brighter-trading/src/BrighterTrades.py

2012 lines
90 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import json
import logging
from typing import Any
from Users import Users
from DataCache_v3 import DataCache
from Strategies import Strategies
from backtesting import Backtester
from candles import Candles
from Configuration import Configuration
from ExchangeInterface import ExchangeInterface
from indicators import Indicators
from Signals import Signals
from Formations import Formations
from ExternalSources import ExternalSources
from ExternalIndicators import ExternalIndicatorsManager
from trade import Trades
from edm_client import EdmClient, EdmWebSocketClient
from wallet import WalletManager
from utils import sanitize_for_json
# Configure logging
logger = logging.getLogger(__name__)
class BrighterTrades:
def __init__(self, socketio):
# Object that interacts with the persistent data.
self.data = DataCache()
# Configuration for the app
self.config = Configuration()
# Initialize EDM clients for candle data fetching
self.edm_client = None
self.edm_ws = None
if self.config.is_edm_enabled():
try:
edm_config = self.config.get_edm_config()
self.edm_client = EdmClient(config=edm_config)
self.edm_ws = EdmWebSocketClient(config=edm_config)
logger.info(f"EDM client initialized: {edm_config.rest_url}")
except Exception as e:
logger.warning(f"Failed to initialize EDM client: {e}")
# Object that interacts and maintains exchange_interface and account data
self.exchanges = ExchangeInterface(self.data, edm_client=self.edm_client)
# Set the exchange and EDM client for datacache to use
self.data.set_exchange(self.exchanges)
self.data.set_edm_client(self.edm_client)
# The object that manages users in the system.
self.users = Users(data_cache=self.data)
# Object that maintains signals.
self.signals = Signals(self.data)
# Object that maintains chart formations (trendlines, channels, patterns).
self.formations = Formations(self.data)
# Object that maintains external data sources (custom signal types).
self.external_sources = ExternalSources(self.data)
# Object that maintains external indicators (API-based indicators with historical data).
self.external_indicators = ExternalIndicatorsManager(self.data)
# Object that maintains candlestick and price data.
self.candles = Candles(users=self.users, exchanges=self.exchanges, datacache=self.data,
config=self.config, edm_client=self.edm_client)
# Object that interacts with and maintains data from available indicators
self.indicators = Indicators(self.candles, self.users, self.data)
# Object that maintains the trades data
self.trades = Trades(self.users, data_cache=self.data)
# The Trades object needs to connect to an exchange_interface.
self.trades.connect_exchanges(exchanges=self.exchanges)
# Object that maintains the strategies data
self.strategies = Strategies(self.data, self.trades, self.indicators, edm_client=self.edm_client)
# Object responsible for testing trade and strategies data.
self.backtester = Backtester(data_cache=self.data, strategies=self.strategies,
indicators=self.indicators, socketio=socketio,
edm_client=self.edm_client,
external_indicators=self.external_indicators)
self.backtests = {} # In-memory storage for backtests (replace with DB access in production)
# Wallet manager for Bitcoin wallets and credits ledger
wallet_config = self.config.get_setting('wallet') or {}
wallet_keys = wallet_config.get('encryption_keys', {1: 'default_dev_key_change_in_production'})
# Ensure keys are int -> str mapping
wallet_keys = {int(k): v for k, v in wallet_keys.items()}
self.wallet_manager = WalletManager(
database=self.data.db,
encryption_keys=wallet_keys,
default_network=wallet_config.get('bitcoin_network', 'testnet')
)
logger.info(f"Wallet manager initialized (network: {wallet_config.get('bitcoin_network', 'testnet')})")
@staticmethod
def _coerce_user_id(user_id: Any) -> int | None:
if user_id is None or user_id == '':
return None
try:
return int(user_id)
except (TypeError, ValueError):
return None
def resolve_user_name(self, msg_data: dict | None) -> str | None:
"""
Resolve a username from payload fields, accepting both legacy and migrated key shapes.
"""
if not isinstance(msg_data, dict):
return None
user_name = msg_data.get('user_name') or msg_data.get('user')
if user_name:
return user_name
user_id = self._coerce_user_id(msg_data.get('user_id') or msg_data.get('userId'))
if user_id is None:
return None
try:
return self.users.get_username(user_id=user_id)
except Exception:
logger.warning(f"Unable to resolve user_name from user id '{user_id}'.")
return None
def resolve_user_id(self, msg_data: dict | None, user_name: str | None = None) -> int | None:
"""
Resolve a user id from payload fields, accepting both legacy and migrated key shapes.
"""
if isinstance(msg_data, dict):
user_id = self._coerce_user_id(msg_data.get('user_id') or msg_data.get('userId'))
if user_id is not None:
return user_id
if user_name:
try:
return self.get_user_info(user_name=user_name, info='User_id')
except Exception:
logger.warning(f"Unable to resolve user_id from user_name '{user_name}'.")
return None
return None
def create_new_user(self, email: str, username: str, password: str) -> bool:
"""
Creates a new user and logs the user in.
:param email: User's email address.
:param username: User's user_name.
:param password: User's password.
:return: bool - True on successful creation and log in.
"""
if not email or not username or not password:
raise ValueError("Missing required arguments for 'create_new_user'")
try:
self.users.create_new_user(email=email, username=username, password=password)
login_successful = self.users.log_in_user(username=username, password=password)
return login_successful
except Exception as e:
# Handle specific exceptions or log the error
raise ValueError("Error creating a new user: " + str(e))
def log_user_in_out(self, user_name: str, cmd: str, password: str = None):
"""
Logs the user in or out based on the provided command.
:param user_name: The user_name.
:param cmd: The command indicating the action to perform ('logout' or 'login').
:param password: The password for logging in. Required if cmd is 'login'.
:return: True if the action was successful, False otherwise.
"""
if cmd not in ['login', 'logout']:
raise ValueError("Invalid command. Expected 'login' or 'logout'.")
try:
if cmd == 'logout':
return self.users.log_out_user(username=user_name)
elif cmd == 'login':
if password is None:
raise ValueError("Password is required for login.")
return self.users.log_in_user(username=user_name, password=password)
except Exception as e:
# Handle specific exceptions or log the error
raise ValueError("Error during user login/logout: " + str(e))
def get_user_info(self, user_name: str, info: str) -> Any | None:
"""
Returns specified user info.
:param user_name: The user_name.
:param info: The information being requested.('Chart View','Is logged in?', 'User_id')
:return: The requested info or None.
:raises ValueError: If the provided info is invalid.
"""
if info == 'Chart View':
try:
return self.users.get_chart_view(user_name=user_name)
except Exception as e:
# Handle specific exceptions or log the error
raise ValueError("Error retrieving chart view information: " + str(e))
elif info == 'Is logged in?':
try:
return self.users.is_logged_in(user_name=user_name)
except Exception as e:
# Handle specific exceptions or log the error
raise ValueError("Error checking logged in status: " + str(e))
elif info == 'User_id':
try:
return self.users.get_id(user_name=user_name)
except Exception as e:
# Handle specific exceptions or log the error
raise ValueError("Error fetching id: " + str(e))
else:
raise ValueError("Invalid information requested: " + info)
def get_market_info(self, info: str, **kwargs) -> Any:
"""
Request market information from the application.
:param info: str - The information requested.
:param kwargs: arguments required depending on the info requested.
:return: The info requested.
"""
if info == 'Candle History':
chart_view = kwargs.get('chart_view', {})
num_records = kwargs.get('num_records', 10)
symbol = chart_view.get('market')
timeframe = chart_view.get('timeframe')
exchange_name = chart_view.get('exchange')
user_name = kwargs.get('user_name')
if symbol and timeframe and exchange_name and user_name:
return self.candles.get_candle_history(num_records=num_records,
symbol=symbol,
interval=timeframe,
exchange_name=exchange_name,
user_name=user_name)
else:
missing_args = [arg for arg in ['symbol', 'timeframe', 'exchange', 'user_name'] if arg not in kwargs]
raise ValueError(f"Missing required arguments for 'Candle History': {', '.join(missing_args)}")
elif info == 'Something Else':
# Add code or action for 'Something Else'
pass
else:
raise ValueError(f"Unknown or missing argument for get_market_info(): {info}")
return None
def get_indicator_data(self, user_name: str, source: dict, start_ts: float = None, num_results: int = None) -> dict:
"""
Fetches indicator data for a specific user.
:param user_name: The name of the user making the request.
:param source: A dictionary containing values specific to the type of indicator.
:param start_ts: The optional timestamp to start fetching the data from.
:param num_results: The optional number of results requested.
:return: dict - A dictionary of timestamp indexed indicator data.
:raises: ValueError if user_name or source is invalid.
"""
if not user_name:
raise ValueError("Invalid user_name provided.")
if not source:
raise ValueError("Invalid source provided.")
# Additional validation checks for start_ts and num_results if needed
return self.indicators.get_indicator_data(user_name=user_name, source=source, start_ts=start_ts,
num_results=num_results)
def connect_user_to_exchange(self, user_name: str, default_exchange: str, default_keys: dict = None) -> bool:
"""
Connects an exchange if it is not already connected.
:param user_name: str - The user executing the action.
:param default_exchange: - The name of the default exchange to connect.
:param default_keys: default API keys.
:return: bool - True on success.
"""
active_exchanges = self.users.get_exchanges(user_name, category='active_exchanges')
success = False
for exchange in active_exchanges:
keys = self.users.get_api_keys(user_name, exchange)
result = self.connect_or_config_exchange(user_name=user_name,
exchange_name=exchange,
api_keys=keys)
if (result['status'] == 'success') or (result['status'] == 'already_connected'):
success = True
if not success:
# If no active exchange was successfully connected, connect to the default exchange
result = self.connect_or_config_exchange(user_name=user_name,
exchange_name=default_exchange,
api_keys=default_keys)
if result['status'] == 'success':
success = True
return success
def get_js_init_data(self, user_name: str) -> dict:
"""
Returns a JSON object of initialization data.
This is passed into the frontend HTML template for the javascript to access in the rendered HTML.
:param user_name: str - The name of the user making the query.
"""
chart_view = self.users.get_chart_view(user_name=user_name)
indicator_types = self.indicators.get_available_indicator_types()
available_indicators = self.indicators.get_indicator_list(user_name)
exchange_name = chart_view.get('exchange', '') if chart_view else ''
# Try to get exchange locally first, then fall back to EDM for public exchanges
symbols = []
intervals = []
try:
exchange = self.exchanges.get_exchange(ename=exchange_name, uname=user_name)
symbols = exchange.get_symbols() if exchange else []
intervals = exchange.intervals if exchange else []
except ValueError:
# Exchange not connected locally - get data from EDM for public exchanges
if self.edm_client and exchange_name:
try:
symbols = self.edm_client.get_symbols_sync(exchange_name)
edm_exchanges = self.edm_client.get_exchanges_sync()
for ex in edm_exchanges:
if ex.get('name') == exchange_name:
intervals = ex.get('timeframes', [])
break
except Exception as e:
print(f"Error getting data from EDM for '{exchange_name}': {e}")
if not chart_view:
chart_view = {'timeframe': '', 'exchange': '', 'market': ''}
if not indicator_types:
indicator_types = []
if not available_indicators:
available_indicators = []
# Get EDM URL from config
edm_settings = self.config.get_setting('edm') or {}
edm_url = edm_settings.get('rest_url', 'http://localhost:8080')
js_data = {
'i_types': indicator_types,
'indicators': available_indicators,
'timeframe': chart_view.get('timeframe'),
'exchange_name': chart_view.get('exchange'),
'trading_pair': chart_view.get('market'),
'user_name': user_name,
'public_exchanges': self.exchanges.get_public_exchanges(),
'intervals': intervals,
'symbols': symbols,
'edm_url': edm_url
}
return js_data
def get_rendered_data(self, user_name: str) -> dict:
"""
Returns data required to render the HTML template of the application's frontend.
:param user_name: The name of the user executing the request.
:return: A dictionary containing the requested data.
"""
chart_view = self.users.get_chart_view(user_name=user_name)
exchange_name = chart_view.get('exchange', '')
# Try to get exchange locally first, then fall back to EDM for public exchanges
exchange = None
symbols = []
intervals = []
try:
exchange = self.exchanges.get_exchange(ename=exchange_name, uname=user_name)
symbols = exchange.get_symbols() if exchange else []
intervals = exchange.intervals if exchange else []
except ValueError:
# Exchange not connected locally - get data from EDM for public exchanges
if self.edm_client and exchange_name:
try:
symbols = self.edm_client.get_symbols_sync(exchange_name)
# Get timeframes from EDM exchanges endpoint
edm_exchanges = self.edm_client.get_exchanges_sync()
for ex in edm_exchanges:
if ex.get('name') == exchange_name:
intervals = ex.get('timeframes', [])
break
except Exception as e:
print(f"Error getting data from EDM for '{exchange_name}': {e}")
# noinspection PyDictCreation
r_data = {}
r_data['title'] = self.config.get_setting('application_title')
r_data['chart_interval'] = chart_view.get('timeframe', '')
r_data['selected_exchange'] = exchange_name
r_data['intervals'] = intervals
r_data['symbols'] = symbols
r_data['available_exchanges'] = self.exchanges.get_available_exchanges() or []
r_data['connected_exchanges'] = self.exchanges.get_connected_exchanges(user_name) or []
r_data['configured_exchanges'] = self.users.get_exchanges(
user_name, category='configured_exchanges') or []
# Chart exchanges: public exchanges + user's configured private exchanges
public_exchanges = self.exchanges.get_public_exchanges() or []
configured = r_data['configured_exchanges']
# Combine and deduplicate, keeping configured exchanges at the top
chart_exchanges = list(configured) + [ex for ex in public_exchanges if ex not in configured]
r_data['chart_exchanges'] = chart_exchanges
r_data['my_balances'] = self.exchanges.get_all_balances(user_name) or {}
r_data['indicator_types'] = self.indicators.get_available_indicator_types() or []
r_data['indicator_list'] = self.indicators.get_indicator_list(user_name) or []
r_data['enabled_indicators'] = self.indicators.get_indicator_list(user_name, only_enabled=True) or []
r_data['ma_vals'] = self.indicators.MV_AVERAGE_ENUM
r_data['active_trades'] = self.exchanges.get_all_activated(user_name, fetch_type='trades') or {}
r_data['open_orders'] = self.exchanges.get_all_activated(user_name, fetch_type='orders') or {}
return r_data
def received_cdata(self, cdata: dict) -> dict | None:
"""
Processes the received candle data and updates the indicators, signals, trades, and strategies.
:param cdata: Dictionary containing the most recent price data.
:return: Dictionary of updates to be passed onto the UI or None if received duplicate data.
"""
# Return if this candle is the same as the last candle received, except when it's the first candle received.
if not self.candles.cached_last_candle:
self.candles.cached_last_candle = cdata
elif cdata['time'] == self.candles.cached_last_candle['time']:
return None
self.candles.set_new_candle(cdata)
# i_updates = self.indicators.update_indicators()
state_changes = self.signals.process_all_signals(
self.indicators, self.external_sources, self.external_indicators
)
# Refresh external sources if needed
self.external_sources.refresh_all_sources()
# Build price updates dict: trades.update expects {symbol: price}
symbol = cdata.get('symbol', cdata.get('market', 'BTC/USDT'))
price_updates = {symbol: float(cdata['close'])}
trade_updates = self.trades.update(price_updates)
# Debug: log trade updates
if self.trades.active_trades:
logger.debug(f"Active trades: {list(self.trades.active_trades.keys())}")
logger.debug(f"Price updates for symbol '{symbol}': {price_updates}")
logger.debug(f"Trade updates returned: {trade_updates}")
# Update all active strategy instances with new candle data
stg_updates = self.strategies.update(candle_data=cdata)
updates = {}
# if i_updates:
# updates['i_updates'] = i_updates
if state_changes:
updates['s_updates'] = state_changes
if trade_updates:
updates['trade_updts'] = trade_updates
if stg_updates:
updates['stg_updts'] = stg_updates
# Log any errors from strategy execution
for event in stg_updates:
if event.get('type') == 'error':
logger.warning(f"Strategy error: {event.get('message')} "
f"(user={event.get('user_id')}, strategy={event.get('strategy_id')})")
return updates
def received_new_signal(self, data: dict, user_id: int = None) -> dict:
"""
Handles the creation of a new signal based on the provided data.
:param data: A dictionary containing the attributes of the new signal.
:param user_id: The ID of the user creating the signal.
:return: A dictionary containing success or failure information.
"""
# Validate required fields
required_fields = ['name', 'source1', 'prop1', 'source2', 'prop2', 'operator']
missing_fields = [field for field in required_fields if field not in data]
if missing_fields:
return {"success": False, "message": f"Missing fields: {', '.join(missing_fields)}"}
# Add creator field
if user_id is not None:
data['creator'] = user_id
# Save the signal
result = self.signals.new_signal(data)
return result
def received_edit_signal(self, data: dict, user_id: int = None) -> dict:
"""
Handles editing an existing signal based on the provided data.
:param data: A dictionary containing the attributes of the signal to edit.
:param user_id: The ID of the user editing the signal.
:return: A dictionary containing success or failure information.
"""
if not data.get('tbl_key'):
return {"success": False, "message": "Signal tbl_key not provided."}
# Verify user has permission to edit
if user_id is not None:
signal = self.signals.get_signal_by_tbl_key(data['tbl_key'])
if signal and signal.creator != user_id and not signal.public:
return {"success": False, "message": "You don't have permission to edit this signal."}
data['creator'] = user_id
result = self.signals.edit_signal(data)
return result
def received_new_strategy(self, data: dict) -> dict:
"""
Handles the creation of a new strategy based on the provided data.
:param data: A dictionary containing the attributes of the new strategy.
:return: A dictionary indicating success or failure with an appropriate message.
"""
# Validate presence of required fields
required_fields = ['user_name', 'name', 'workspace', 'code']
missing_fields = [field for field in required_fields if field not in data]
if missing_fields:
return {"success": False, "message": f"Missing fields: {', '.join(missing_fields)}"}
# Extract user_name and get user_id
user_name = data.get('user_name')
user_id = self.get_user_info(user_name=user_name, info='User_id')
if not user_id:
return {"success": False, "message": "User ID not found"}
# Validate data types and contents
if not isinstance(data['name'], str) or not data['name'].strip():
return {"success": False, "message": "Invalid or empty strategy name"}
if not isinstance(data['workspace'], str) or not data['workspace'].strip():
return {"success": False, "message": "Invalid or empty workspace data"}
if not isinstance(data['code'], (dict, str)) or not data['code']:
return {"success": False, "message": "Invalid or empty strategy code"}
try:
# Ensure 'code' is serialized as a JSON string
code_json = json.dumps(data['code']) if isinstance(data['code'], dict) else data['code']
except (TypeError, ValueError) as e:
return {"success": False, "message": f"Invalid strategy code: {str(e)}"}
# The default source for undefined sources in the strategy
# Use explicit source from frontend if provided, otherwise fall back to chart view
try:
if 'default_source' in data and isinstance(data['default_source'], dict):
default_source = data['default_source']
logger.info(f"Using explicit default_source from frontend: {default_source}")
else:
default_source = self.users.get_chart_view(user_name=user_name)
logger.info(f"Using chart view as default_source: {default_source}")
except Exception as e:
return {"success": False, "message": f"Error determining default source: {str(e)}"}
# Prepare the strategy data for insertion
try:
strategy_data = {
"creator": int(user_id), # Convert numpy.int64 to native int for SQLite
"name": data['name'].strip(),
"workspace": data['workspace'].strip(),
"code": code_json,
"stats": data.get('stats', {}),
"public": int(data.get('public', 0)),
"fee": float(data.get('fee', 0.0)),
"default_source": default_source # Save the default source with the strategy
}
except Exception as e:
return {"success": False, "message": f"Error preparing strategy data: {str(e)}"}
# Save the new strategy (in both cache and database) and handle the result
try:
result = self.strategies.new_strategy(strategy_data, default_source)
if result.get("success"):
return {
"success": True,
"strategy": result.get("strategy"), # Strategy object without `strategy_components`
"updated_at": result.get("updated_at"),
"message": result.get("message", "Strategy created successfully")
}
else:
return {"success": False, "message": result.get("message", "Failed to create strategy")}
except Exception as e:
# Log unexpected exceptions for debugging
logger.error(f"Error creating new strategy: {e}", exc_info=True)
return {"success": False, "message": "An unexpected error occurred while creating the strategy"}
def received_edit_strategy(self, data: dict) -> dict:
"""
Handles editing an existing strategy based on the provided data.
:param data: A dictionary containing the attributes of the strategy to edit.
:return: A dictionary containing success or failure information.
"""
# Extract user_name and strategy name from the data
user_name = data.get('user_name')
strategy_name = data.get('name')
if not user_name:
return {"success": False, "message": "User not specified"}
if not strategy_name:
return {"success": False, "message": "Strategy name not specified"}
# Fetch the user_id using the user_name
user_id = self.get_user_info(user_name=user_name, info='User_id')
if not user_id:
return {"success": False, "message": "User ID not found"}
# Retrieve the tbl_key using user_id and strategy name
filter_conditions = [('creator', user_id), ('name', strategy_name)]
strategy_row = self.data.get_rows_from_datacache(
cache_name='strategies',
filter_vals=filter_conditions,
include_tbl_key=True # Include tbl_key in the result
)
if strategy_row.empty:
return {"success": False, "message": "Strategy not found"}
# Ensure only one strategy is found
if len(strategy_row) > 1:
return {"success": False, "message": "Multiple strategies found. Please provide more specific information."}
# Extract the tbl_key
tbl_key = strategy_row.iloc[0]['tbl_key']
# The default source for undefined sources in the strategy
# Use explicit source from frontend if provided, otherwise fall back to chart view
try:
if 'default_source' in data and isinstance(data['default_source'], dict):
default_source = data['default_source']
logger.info(f"Using explicit default_source from frontend: {default_source}")
else:
default_source = self.users.get_chart_view(user_name=user_name)
logger.info(f"Using chart view as default_source: {default_source}")
except Exception as e:
return {"success": False, "message": f"Error determining default source: {str(e)}"}
# Prepare the updated strategy data
strategy_data = {
"creator": user_id,
"name": strategy_name,
"workspace": data.get('workspace'),
"code": data.get('code'),
"stats": data.get('stats', {}),
"public": data.get('public', 0),
"fee": data.get('fee', None),
"tbl_key": tbl_key, # Include the tbl_key to identify the strategy
"default_source": default_source # Save the default source with the strategy
}
# Call the edit_strategy method to update the strategy
try:
result = self.strategies.edit_strategy(strategy_data, default_source)
if result.get("success"):
return {
"success": True,
"strategy": result.get("strategy"), # Strategy object without `strategy_components`
"updated_at": result.get("updated_at"),
"message": result.get("message", "Strategy updated successfully")
}
else:
return {"success": False, "message": result.get("message", "Failed to update strategy")}
except Exception as e:
# Log unexpected exceptions
logger.error(f"Error editing strategy: {e}", exc_info=True)
return {"success": False, "message": "An unexpected error occurred while editing the strategy"}
def delete_strategy(self, data: dict, user_id: int = None) -> dict:
"""
Deletes the specified strategy identified by tbl_key from the strategies instance.
Security: Ownership is verified before deletion to prevent unauthorized access.
:param data: Dictionary containing 'tbl_key'.
:param user_id: The authenticated user ID (for ownership verification).
:return: A dictionary indicating success or failure with an appropriate message and the tbl_key.
"""
# Validate tbl_key
tbl_key = data.get('tbl_key')
if not tbl_key:
return {"success": False, "message": "tbl_key not provided", "tbl_key": None}
# Get user_id from data if not passed directly (backwards compatibility)
if user_id is None:
user_id = data.get('user_id') or data.get('userId')
# Call the delete_strategy method to remove the strategy (with ownership check)
result = self.strategies.delete_strategy(tbl_key=tbl_key, user_id=user_id)
# Return the result with tbl_key included
if result.get('success'):
return {
"success": True,
"message": result.get('message'),
"tbl_key": tbl_key # Include tbl_key in the response
}
else:
return {
"success": False,
"message": result.get('message'),
"tbl_key": tbl_key # Include tbl_key even on failure for debugging
}
def start_strategy(
self,
user_id: int,
strategy_id: str,
mode: str,
initial_balance: float = 10000.0,
commission: float = 0.001,
exchange_name: str = None,
testnet: bool = True,
max_position_pct: float = 0.5,
circuit_breaker_pct: float = -0.10,
) -> dict:
"""
Start a strategy in the specified mode (paper or live).
:param user_id: User identifier.
:param strategy_id: Strategy tbl_key.
:param mode: Trading mode ('paper' or 'live').
:param initial_balance: Starting balance for paper trading.
:param commission: Commission rate.
:param exchange_name: Exchange name for live trading (required for live mode).
:param testnet: Use testnet for live trading (default True for safety).
:param max_position_pct: Maximum position size as % of balance for live trading.
:param circuit_breaker_pct: Drawdown % to halt trading for live trading.
:return: Dictionary with success status and details.
"""
from brokers import TradingMode
import uuid
import config
# Validate mode
if mode not in [TradingMode.PAPER, TradingMode.LIVE]:
return {"success": False, "message": f"Invalid mode '{mode}'. Use 'paper' or 'live'."}
# For live mode, we now use LiveStrategyInstance
effective_mode = mode
# Get the strategy data
strategy_data = self.strategies.data_cache.get_rows_from_datacache(
cache_name='strategies',
filter_vals=[('tbl_key', strategy_id)],
include_tbl_key=True
)
if strategy_data.empty:
return {"success": False, "message": "Strategy not found."}
strategy_row = strategy_data.iloc[0]
strategy_name = strategy_row.get('name', 'Unknown')
# Authorization check: user must own the strategy OR be subscribed to it
strategy_creator = strategy_row.get('creator')
try:
creator_id = int(strategy_creator) if strategy_creator is not None else None
except (ValueError, TypeError):
creator_id = None
is_owner = (creator_id == user_id) if (creator_id is not None and user_id is not None) else False
is_subscribed = self.strategies.is_subscribed(user_id, strategy_id)
# Must be owner OR subscribed to run
if not is_owner and not is_subscribed:
return {
"success": False,
"message": "Subscribe to this strategy first"
}
# For subscribed strategies, use creator's indicators
# This ensures subscribers run with the creator's indicator definitions
indicator_owner_id = creator_id if is_subscribed and not is_owner else None
# Check for strategy fees (only for non-owners running in paper/live mode)
strategy_fee = float(strategy_row.get('fee', 0.0))
has_fee = strategy_fee > 0 and not is_owner and mode in ['paper', 'live']
strategy_run_id = None # Will be set if fee accumulation is started
# Early exchange requirements validation (BEFORE fee accumulation to avoid orphaned fees)
from exchange_validation import extract_required_exchanges, validate_exchange_requirements
strategy_full = self.strategies.get_strategy_by_tbl_key(strategy_id)
required_exchanges = extract_required_exchanges(strategy_full)
if required_exchanges:
# Get user's configured exchanges
try:
user_name = self.users.get_username(user_id=user_id)
user_configured = self.users.get_exchanges(user_name, category='configured_exchanges') or []
except Exception:
user_configured = []
# Get EDM available exchanges (List[str])
edm_available = []
if self.edm_client:
try:
edm_available = self.edm_client.get_exchanges_sync()
except Exception as e:
logger.warning(f"Could not fetch EDM exchanges: {e}")
# For backtest mode, fail if EDM unreachable (can't proceed without data)
# Paper/live can continue since they use ccxt/exchange directly
validation_result = validate_exchange_requirements(
required_exchanges=required_exchanges,
user_configured_exchanges=user_configured,
edm_available_exchanges=edm_available,
mode=mode
)
if not validation_result.valid:
return {
"success": False,
"message": validation_result.message,
"error_code": validation_result.error_code.value if validation_result.error_code else None,
"missing_exchanges": list(validation_result.missing_exchanges)
}
# Check if already running
instance_key = (user_id, strategy_id, effective_mode)
if instance_key in self.strategies.active_instances:
return {
"success": False,
"message": f"Strategy '{strategy_name}' is already running in {effective_mode} mode."
}
# Get the generated code from strategy_components
try:
import json
components = json.loads(strategy_row.get('strategy_components', '{}'))
# Key is 'generated_code' not 'code' - matches PythonGenerator output
generated_code = components.get('generated_code', '')
if not generated_code:
return {"success": False, "message": "Strategy has no generated code."}
except (json.JSONDecodeError, TypeError) as e:
return {"success": False, "message": f"Invalid strategy components: {e}"}
# Create the strategy instance
try:
# For live mode, we need to get the exchange instance FIRST
# (before creating instance ID, to use resolved exchange name)
exchange = None
actual_testnet = testnet
resolved_exchange_name = exchange_name
if mode == TradingMode.LIVE:
# Get the user's username for exchange lookup
try:
user_name = self.users.get_username(user_id=user_id)
except Exception:
return {"success": False, "message": "Could not resolve username for exchange access."}
# Determine which exchange to use
if not resolved_exchange_name:
# Try to get the user's active exchange
active_exchanges = self.users.get_exchanges(user_name, category='active_exchanges')
if active_exchanges:
resolved_exchange_name = active_exchanges[0]
else:
return {
"success": False,
"message": "No exchange specified and no active exchange found. Please configure an exchange."
}
# Determine actual testnet mode (config can override to force testnet)
if config.TESTNET_MODE:
actual_testnet = True
# Hard production gate using effective mode after config overrides.
if not actual_testnet and not config.ALLOW_LIVE_PRODUCTION:
logger.warning(
f"Production trading blocked: BRIGHTER_ALLOW_LIVE_PROD not set. "
f"User {user_id} attempted production trading."
)
return {
"success": False,
"message": "Production trading is disabled. Set BRIGHTER_ALLOW_LIVE_PROD=true to enable."
}
# Get the exchange instance (may not exist yet)
try:
exchange = self.exchanges.get_exchange(ename=resolved_exchange_name, uname=user_name)
except ValueError:
exchange = None # Exchange doesn't exist yet, will be created below
# CRITICAL: Verify exchange testnet mode matches requested mode
if exchange:
# Use bool() to normalize the comparison (handles mock objects)
exchange_is_testnet = bool(getattr(exchange, 'testnet', False))
if exchange_is_testnet != actual_testnet:
# Exchange mode mismatch - need to create new exchange with correct mode
logger.warning(
f"Exchange '{resolved_exchange_name}' is in "
f"{'testnet' if exchange_is_testnet else 'production'} mode, "
f"but requested {'testnet' if actual_testnet else 'production'}. "
f"Creating new exchange connection."
)
# Get API keys and reconnect with correct mode
api_keys = self.users.get_api_keys(user_name, resolved_exchange_name)
self.exchanges.connect_exchange(
exchange_name=resolved_exchange_name,
user_name=user_name,
api_keys=api_keys,
testnet=actual_testnet
)
exchange = self.exchanges.get_exchange(ename=resolved_exchange_name, uname=user_name)
# If exchange doesn't exist or isn't configured, try to load API keys from database
if not exchange or not exchange.configured:
logger.info(f"Exchange '{resolved_exchange_name}' not configured, loading API keys from database...")
api_keys = self.users.get_api_keys(user_name, resolved_exchange_name)
if api_keys:
logger.info(f"Found API keys for {resolved_exchange_name}, reconnecting with testnet={actual_testnet}...")
success = self.exchanges.connect_exchange(
exchange_name=resolved_exchange_name,
user_name=user_name,
api_keys=api_keys,
testnet=actual_testnet
)
if success:
exchange = self.exchanges.get_exchange(ename=resolved_exchange_name, uname=user_name)
logger.info(f"Reconnected exchange: configured={exchange.configured}, testnet={exchange.testnet}")
else:
logger.error(f"Failed to reconnect exchange '{resolved_exchange_name}'")
else:
logger.warning(f"No API keys found in database for {user_name}/{resolved_exchange_name}")
# Check again after attempting to load keys
if not exchange or not exchange.configured:
return {
"success": False,
"message": f"Exchange '{resolved_exchange_name}' is not configured with valid API keys. "
f"Please configure your API keys in the exchange settings."
}
# Final verification: exchange mode MUST match requested mode
exchange_is_testnet = bool(getattr(exchange, 'testnet', False))
if exchange_is_testnet != actual_testnet:
return {
"success": False,
"message": f"Exchange mode mismatch: exchange is {'testnet' if exchange_is_testnet else 'production'}, "
f"but requested {'testnet' if actual_testnet else 'production'}."
}
# Safety warning for production mode
if not actual_testnet:
logger.warning(
f"Starting LIVE PRODUCTION strategy '{strategy_name}' for user {user_id} "
f"on exchange '{resolved_exchange_name}'. Real money will be used!"
)
# Create deterministic instance ID for live mode AFTER exchange resolution
# (enables restart-safe state recovery with correct exchange name)
if mode == TradingMode.LIVE:
# Use resolved exchange name (not 'default')
testnet_suffix = 'testnet' if actual_testnet else 'prod'
strategy_instance_id = f"live:{user_id}:{strategy_id}:{resolved_exchange_name}:{testnet_suffix}"
else:
# Paper mode: random UUID since paper state is ephemeral
strategy_instance_id = str(uuid.uuid4())
# Start fee accumulation only after all startup validation has passed.
if has_fee and creator_id is not None:
strategy_run_id = f"{strategy_id}_{user_id}_{uuid.uuid4().hex[:8]}"
fee_result = self.wallet_manager.start_fee_accumulation(
strategy_run_id=strategy_run_id,
user_id=user_id,
creator_user_id=creator_id,
fee_percent=int(strategy_fee), # 1-100% of exchange commission
estimated_trades=10 # Check for ~10 trades worth of credits
)
if not fee_result['success']:
return {
'success': False,
'message': fee_result.get('error', 'Failed to start fee accumulation'),
'balance_available': fee_result.get('available', 0),
'recommended_minimum': fee_result.get('recommended_minimum', 10000),
'need_deposit': True
}
logger.info(f"Started fee accumulation for strategy {strategy_id}: run_id={strategy_run_id}")
instance = self.strategies.create_strategy_instance(
mode=mode,
strategy_instance_id=strategy_instance_id,
strategy_id=strategy_id,
strategy_name=strategy_name,
user_id=user_id,
generated_code=generated_code,
initial_balance=initial_balance,
commission=commission,
price_provider=lambda symbol: self.exchanges.get_price(symbol),
exchange=exchange,
testnet=actual_testnet,
max_position_pct=max_position_pct,
circuit_breaker_pct=circuit_breaker_pct,
indicator_owner_id=indicator_owner_id, # For subscribed strategies, use creator's indicators
)
# Store fee tracking info on the instance
if strategy_run_id:
instance.strategy_run_id = strategy_run_id
instance.has_fee = True
instance.wallet_manager = self.wallet_manager
else:
instance.strategy_run_id = None
instance.has_fee = False
# Store the active instance
self.strategies.active_instances[instance_key] = instance
logger.info(f"Started strategy '{strategy_name}' for user {user_id} in {mode} mode")
result = {
"success": True,
"message": f"Strategy '{strategy_name}' started in {mode} mode.",
"strategy_id": strategy_id,
"strategy_name": strategy_name,
"instance_id": strategy_instance_id,
"mode": mode,
"actual_mode": effective_mode,
"initial_balance": initial_balance,
}
# Add live-specific info
if mode == TradingMode.LIVE:
result["exchange"] = resolved_exchange_name
result["testnet"] = actual_testnet
result["max_position_pct"] = max_position_pct
result["circuit_breaker_pct"] = circuit_breaker_pct
if actual_testnet:
result["warning"] = "Running in TESTNET mode. No real money at risk."
else:
result["warning"] = "PRODUCTION MODE: Real money is at risk!"
return result
except Exception as e:
if strategy_run_id:
self.wallet_manager.cancel_fee_accumulation(strategy_run_id)
logger.error(f"Failed to create strategy instance: {e}", exc_info=True)
return {"success": False, "message": f"Failed to start strategy: {str(e)}"}
def stop_strategy(
self,
user_id: int,
strategy_id: str,
mode: str,
) -> dict:
"""
Stop a running strategy.
:param user_id: User identifier.
:param strategy_id: Strategy tbl_key.
:param mode: Trading mode.
:return: Dictionary with success status.
"""
from brokers import TradingMode
instance_key = (user_id, strategy_id, mode)
instance = self.strategies.active_instances.get(instance_key)
# Compatibility for live mode fallback.
if instance is None and mode == TradingMode.LIVE:
fallback_key = (user_id, strategy_id, TradingMode.PAPER)
instance = self.strategies.active_instances.get(fallback_key)
if instance is not None:
instance_key = fallback_key
if instance is None:
return {
"success": False,
"message": f"No running strategy found for this user/strategy/mode combination."
}
self.strategies.active_instances.pop(instance_key, None)
actual_mode = instance_key[2]
strategy_name = instance.strategy_name
# Get final stats if available
final_stats = {}
if hasattr(instance, 'broker') and hasattr(instance.broker, 'get_balance'):
final_stats['final_balance'] = instance.broker.get_balance()
final_stats['available_balance'] = instance.broker.get_available_balance()
if hasattr(instance, 'trade_history'):
final_stats['total_trades'] = len(instance.trade_history)
# Settle accumulated fees if this was a paid strategy
fee_settlement = None
if hasattr(instance, 'strategy_run_id') and instance.strategy_run_id:
settle_result = self.wallet_manager.settle_accumulated_fees(instance.strategy_run_id)
fee_settlement = {
'fees_settled': settle_result.get('settled', 0),
'trades_charged': settle_result.get('trades', 0)
}
logger.info(f"Settled {settle_result.get('settled', 0)} sats for "
f"{settle_result.get('trades', 0)} trades on strategy {strategy_id}")
logger.info(f"Stopped strategy '{strategy_name}' for user {user_id} in {mode} mode")
result = {
"success": True,
"message": f"Strategy '{strategy_name}' stopped.",
"strategy_id": strategy_id,
"strategy_name": strategy_name,
"mode": mode,
"actual_mode": actual_mode,
"final_stats": final_stats,
}
if fee_settlement:
result["fee_settlement"] = fee_settlement
return result
def get_strategy_status(
self,
user_id: int,
strategy_id: str = None,
mode: str = None,
) -> dict:
"""
Get the status of running strategies for a user.
:param user_id: User identifier.
:param strategy_id: Optional strategy ID to filter.
:param mode: Optional mode to filter.
:return: Dictionary with strategy statuses.
"""
running_strategies = []
for (uid, sid, m), instance in self.strategies.active_instances.items():
if uid != user_id:
continue
if strategy_id and sid != strategy_id:
continue
if mode and m != mode:
continue
status = {
"strategy_id": sid,
"strategy_name": instance.strategy_name,
"mode": m,
"instance_id": instance.strategy_instance_id,
}
# Add broker stats if available
if hasattr(instance, 'broker'):
status['balance'] = instance.broker.get_balance()
status['available_balance'] = instance.broker.get_available_balance()
# Get positions
if hasattr(instance.broker, 'get_all_positions'):
positions = instance.broker.get_all_positions()
status['positions'] = [
{
'symbol': p.symbol,
'size': p.size,
'entry_price': p.entry_price,
'unrealized_pnl': p.unrealized_pnl,
}
for p in positions
]
if hasattr(instance, 'trade_history'):
status['trade_count'] = len(instance.trade_history)
# Live-specific status
if hasattr(instance, 'is_testnet'):
status['testnet'] = instance.is_testnet
if hasattr(instance, 'circuit_breaker_status'):
status['circuit_breaker'] = instance.circuit_breaker_status
running_strategies.append(status)
return {
"success": True,
"running_strategies": running_strategies,
"count": len(running_strategies),
}
def delete_signal(self, data: dict, user_id: int = None) -> dict:
"""
Deletes a signal from the signals instance.
:param data: Dictionary containing 'tbl_key' or 'name' of the signal to delete.
:param user_id: The ID of the user deleting the signal (for permission check).
:return: A dictionary indicating success or failure.
"""
tbl_key = data.get('tbl_key')
signal_name = data.get('name')
# If only name provided, find the tbl_key
if not tbl_key and signal_name:
signal = self.signals.get_signal_by_name(signal_name)
if signal:
tbl_key = signal.tbl_key
if not tbl_key:
return {"success": False, "message": "Signal not found.", "tbl_key": None}
# Verify user has permission to delete
if user_id is not None:
signal = self.signals.get_signal_by_tbl_key(tbl_key)
if signal and signal.creator != user_id:
return {"success": False, "message": "You don't have permission to delete this signal."}
# Delete the signal
return self.signals.delete_signal(tbl_key)
def get_signals_json(self, user_id: int = None) -> list:
"""
Retrieve signals visible to the user (their own + public signals) and return as a list.
:param user_id: The ID of the user making the request.
:return: list - A list of signal dictionaries.
"""
return self.signals.get_all_signals(user_id, 'dict')
def get_strategies_json(self, user_id) -> list:
"""
Retrieve strategies that the user owns or is subscribed to.
Returns owned strategies with full data and subscribed strategies with redacted internals.
:param user_id: The user's ID.
:return: list - A list of dictionaries, each representing a strategy.
"""
return self.strategies.get_user_strategies(user_id)
def connect_or_config_exchange(self, user_name: str, exchange_name: str, api_keys: dict = None) -> dict:
"""
Connects to an exchange if not already connected, or configures the exchange connection for a single user.
:param user_name: str - The name of the user.
:param exchange_name: str - The name of the exchange.
:param api_keys: dict - The API keys for the exchange.
:return: dict - A dictionary containing the result of the operation.
"""
result = {
'exchange': exchange_name,
'status': '',
'message': ''
}
# If no API keys provided, try to load from database
if not api_keys:
api_keys = self.users.get_api_keys(user_name, exchange_name)
try:
if self.data.get_serialized_datacache(cache_name='exchange_data',
filter_vals=([('user', user_name), ('name', exchange_name)])).empty:
# Exchange is not connected, try to connect (always use production mode, not testnet)
success = self.exchanges.connect_exchange(exchange_name=exchange_name, user_name=user_name,
api_keys=api_keys, testnet=False)
if success:
self.users.active_exchange(exchange=exchange_name, user_name=user_name, cmd='set')
# Check if api_keys has actual key/secret values (not just empty dict)
if api_keys and api_keys.get('key') and api_keys.get('secret'):
self.users.update_api_keys(api_keys=api_keys, exchange=exchange_name, user_name=user_name)
result['status'] = 'success'
result['message'] = f'Successfully connected to {exchange_name}.'
else:
result['status'] = 'failure'
result['message'] = f'Failed to connect to {exchange_name}.'
else:
# Exchange is in cache but may have been restored from database with broken ccxt client.
# Always reconnect with API keys to ensure fresh connection.
if api_keys and api_keys.get('key') and api_keys.get('secret'):
# Check if credentials have changed
stored_keys = self.users.get_api_keys(user_name, exchange_name)
credentials_changed = (
stored_keys.get('key') != api_keys.get('key') or
stored_keys.get('secret') != api_keys.get('secret') or
stored_keys.get('passphrase') != api_keys.get('passphrase')
)
# Force reconnection to get fresh ccxt client and balances
# Always use production mode (testnet=False) unless explicitly requested
reconnect_ok = self.exchanges.connect_exchange(
exchange_name=exchange_name,
user_name=user_name,
api_keys=api_keys,
testnet=False
)
if reconnect_ok:
# Update stored credentials if they changed
if credentials_changed:
self.users.update_api_keys(api_keys=api_keys, exchange=exchange_name, user_name=user_name)
result['status'] = 'success'
result['message'] = f'{exchange_name}: Reconnected with fresh data.'
else:
result['status'] = 'failure'
result['message'] = f'{exchange_name}: Failed to reconnect.'
else:
# No API keys - just mark as already connected (public exchange)
result['status'] = 'already_connected'
result['message'] = f'{exchange_name}: already connected (public).'
except Exception as e:
result['status'] = 'error'
result['message'] = f"Failed to connect to {exchange_name} for user '{user_name}': {str(e)}"
return result
def close_trade(self, trade_id: str, current_price: float = None) -> dict:
"""
Closes a trade identified by the given trade ID.
:param trade_id: The ID of the trade to be closed.
:param current_price: Optional current price for settlement.
:return: Dict with success status and trade info.
"""
if not self.trades.is_valid_trade_id(trade_id):
logger.warning(f"Invalid trade ID: {trade_id}. Unable to close the trade.")
return {"success": False, "message": f"Invalid trade ID: {trade_id}"}
result = self.trades.close_trade(trade_id, current_price=current_price)
if result.get('success'):
logger.info(f"Trade {trade_id} has been closed.")
else:
logger.warning(f"Failed to close trade {trade_id}: {result.get('message')}")
return result
def received_new_trade(self, data: dict, user_id: int = None) -> dict:
"""
Called when a new trade has been defined and created in the UI.
:param data: A dictionary containing the attributes of the trade.
:param user_id: The ID of the user creating the trade.
:return: Dict with success status and trade data.
"""
def get_value(attr, default=None):
"""
Gets a value from data, casting numeric strings to float where appropriate.
"""
val = data.get(attr, default)
if val is None or val == '':
return default
# Try to cast to float for numeric fields
if attr in ['price', 'quantity']:
try:
return float(val)
except (ValueError, TypeError):
return val
return val
# Get trade parameters
target = get_value('target') or get_value('exchange_name', 'test_exchange')
exchange = get_value('exchange') # Actual exchange for price data
symbol = get_value('symbol') or get_value('trading_pair')
price = get_value('price', 0.0)
side = get_value('side', 'buy')
order_type = get_value('orderType') or get_value('order_type', 'MARKET')
quantity = get_value('quantity', 0.0)
strategy_id = get_value('strategy_id')
testnet = data.get('testnet', False)
# Validate required fields
if not symbol:
return {"success": False, "message": "Symbol is required."}
if not quantity or float(quantity) <= 0:
return {"success": False, "message": "Quantity must be greater than 0."}
# Forward the request to trades
status, result = self.trades.new_trade(
target=target,
exchange=exchange,
symbol=symbol,
price=price,
side=side,
order_type=order_type,
qty=quantity,
user_id=user_id,
strategy_id=strategy_id,
testnet=testnet
)
if status == 'Error':
logger.warning(f'Error placing the trade: {result}')
return {"success": False, "message": result}
mode_str = 'paper' if target == 'test_exchange' else ('testnet' if testnet else 'production')
logger.info(f'Trade order received: target={target}, symbol={symbol}, '
f'side={side}, type={order_type}, quantity={quantity}, price={price}, mode={mode_str}')
# Get the created trade
trade_obj = self.trades.get_trade_by_id(result)
if trade_obj:
return {
"success": True,
"message": "Trade created successfully.",
"trade": trade_obj.to_json()
}
else:
return {"success": False, "message": "Trade created but could not be retrieved."}
def get_trades(self, user_id: int = None):
"""
Return a JSON object of all the trades in the trades instance.
:param user_id: Optional user ID to filter trades.
:return: List of trade dictionaries.
"""
if user_id is not None:
return self.trades.get_trades_for_user(user_id, 'json')
return self.trades.get_trades('json')
def delete_backtest(self, msg_data):
""" Delete an existing backtest by interacting with the Backtester. """
backtest_name = msg_data.get('name')
user_name = self.resolve_user_name(msg_data)
if not backtest_name or not user_name:
return {"success": False, "message": "Missing backtest name or user name."}
# Construct the backtest_key based on Backtesters naming convention
backtest_key = f"backtest:{user_name}:{backtest_name}"
try:
# Delegate the deletion to the Backtester
self.backtester.remove_backtest(backtest_key)
return {"success": True, "message": f"Backtest '{backtest_name}' deleted successfully.",
"name": backtest_name}
except KeyError:
return {"success": False, "message": f"Backtest '{backtest_name}' not found."}
except Exception as e:
return {"success": False, "message": f"Error deleting backtest: {str(e)}"}
def adjust_setting(self, user_name: str, setting: str, params: Any):
"""
Adjusts the specified setting for a user.
:param user_name: The name of the user.
:param setting: The setting to adjust.
:param params: The parameters for the setting adjustment.
:raises ValueError: If the provided setting is not supported.
"""
print(f"[SETTINGS()] MODIFYING({user_name, setting})")
if setting == 'interval':
interval_state = params['timeframe']
self.users.set_chart_view(values=interval_state, specific_property='timeframe', user_name=user_name)
elif setting == 'trading_pair':
trading_pair = params['symbol']
self.users.set_chart_view(values=trading_pair, specific_property='market', user_name=user_name)
elif setting == 'exchange':
exchange_name = params['exchange_name']
# Get the list of available symbols (markets) for the specified exchange.
# First try locally connected exchange, then fall back to EDM for public exchanges.
markets = []
try:
exchange = self.exchanges.get_exchange(ename=exchange_name, uname=user_name)
markets = exchange.get_symbols() if exchange else []
except ValueError:
# Exchange not connected locally - try getting symbols from EDM
if self.edm_client:
try:
markets = self.edm_client.get_symbols_sync(exchange_name)
except Exception as e:
print(f"Error getting symbols from EDM for '{exchange_name}': {e}")
# Check if the markets list is empty
if not markets:
# If no markets are available, exit without changing the chart view.
print(f"No available markets found for exchange '{exchange_name}'. Chart view remains unchanged.")
return
# Get the currently viewed market for the user.
current_symbol = self.users.get_chart_view(user_name=user_name, prop='market')
# Determine the market to display based on availability.
if current_symbol not in markets:
# If the current market is not available, default to the first available market.
market = markets[0]
else:
# Otherwise, continue displaying the current market.
market = current_symbol
# Update the user's chart view to reflect the new exchange and default market.
self.users.set_chart_view(values=exchange_name, specific_property='exchange_name',
user_name=user_name, default_market=market)
elif setting == 'toggle_indicator':
# Get indicator names - can be a list (from form checkboxes) or JSON string
indicator_param = params.get('indicator', [])
if isinstance(indicator_param, list):
# Check if it's a list with a single JSON string element (from JavaScript FormData)
if len(indicator_param) == 1 and isinstance(indicator_param[0], str):
try:
# Try to parse as JSON array
parsed = json.loads(indicator_param[0])
if isinstance(parsed, list):
indicators_to_toggle = parsed
else:
indicators_to_toggle = indicator_param
except json.JSONDecodeError:
# Not JSON, use as-is (single indicator name)
indicators_to_toggle = indicator_param
else:
# Multiple checkbox values from standard form submission
indicators_to_toggle = indicator_param
elif isinstance(indicator_param, str):
# Try to parse as JSON for backwards compatibility
try:
indicators_to_toggle = json.loads(indicator_param)
except json.JSONDecodeError:
# If not JSON, treat as single indicator name
indicators_to_toggle = [indicator_param] if indicator_param else []
else:
indicators_to_toggle = []
user_id = self.get_user_info(user_name=user_name, info='User_id')
self.indicators.toggle_indicators(user_id=user_id, indicator_names=indicators_to_toggle)
elif setting == 'edit_indicator':
self.indicators.edit_indicator(user_name=user_name, params=params)
elif setting == 'delete_indicator':
self.indicators.delete_indicator(user_name=user_name, indicator_name=params)
elif setting == 'new_indicator':
# Frontend sends indicator data nested under 'indicator' key
indicator_params = params.get('indicator', params)
self.indicators.new_indicator(user_name=user_name, params=indicator_params)
else:
print(f'ERROR SETTING VALUE')
print(f'The string received by the server was: /n{params}')
return
def process_incoming_message(
self,
msg_type: str,
msg_data: dict,
socket_conn_id: str,
authenticated_user_id: int = None
) -> dict | None:
"""
Processes an incoming message and performs the corresponding actions based on the message type and data.
:param msg_type: The type of the incoming message.
:param msg_data: The data associated with the incoming message.
:param socket_conn_id: The WebSocket connection to send updates back to the client.
:param authenticated_user_id: Server-verified user ID from socket mapping. If provided, this takes
precedence over any user identity in msg_data.
:return: dict|None - A dictionary containing the response message and data, or None if no response is needed or
no data is found to ensure the WebSocket channel isn't burdened with unnecessary
communication.
"""
# Debug log for all incoming messages
if msg_type not in ('candle_data',): # Skip noisy candle_data messages
logger.info(f"[SOCKET] Received message type: {msg_type}")
def standard_reply(reply_msg: str, reply_data: Any) -> dict:
""" Formats a standard reply message with JSON-safe data. """
return {"reply": reply_msg, "data": sanitize_for_json(reply_data)}
# Use authenticated_user_id if provided (from secure socket mapping)
# Otherwise fall back to resolving from msg_data (for backwards compatibility)
if authenticated_user_id is not None:
user_id = authenticated_user_id
user_name = self.users.get_username(user_id=authenticated_user_id)
else:
user_name = self.resolve_user_name(msg_data)
user_id = self.resolve_user_id(msg_data, user_name=user_name)
if user_name:
msg_data.setdefault('user_name', user_name)
msg_data.setdefault('user', user_name)
if user_id is not None:
msg_data.setdefault('user_id', user_id)
msg_data.setdefault('userId', user_id)
if msg_type == 'candle_data':
if r_data := self.received_cdata(msg_data):
return standard_reply("updates", r_data)
if msg_type == 'request':
request_for = msg_data.get('request')
if request_for == 'signals':
signals = self.get_signals_json(user_id)
return standard_reply("signals", signals if signals else [])
elif request_for == 'strategies':
if user_id is None:
return standard_reply("strategy_error", {"message": "User not specified"})
# Always return response, even if empty list
strategies = self.get_strategies_json(user_id)
return standard_reply("strategies", strategies or [])
elif request_for == 'trades':
trades = self.get_trades(user_id)
return standard_reply("trades", trades if trades else [])
else:
print('Warning: Unhandled request!')
print(msg_data)
# Processing commands
if msg_type == 'delete_signal':
result = self.delete_signal(msg_data, user_id)
if result.get('success'):
return standard_reply("signal_deleted", {
"message": result.get('message'),
"tbl_key": result.get('tbl_key'),
"name": result.get('name')
})
else:
return standard_reply("signal_error", {
"message": result.get('message'),
"tbl_key": result.get('tbl_key')
})
if msg_type == 'delete_strategy':
# Pass authenticated user_id for ownership verification
result = self.delete_strategy(msg_data, user_id=user_id)
if result.get('success'):
return standard_reply("strategy_deleted", {
"message": result.get('message'),
"tbl_key": result.get('tbl_key') # Include tbl_key in the response
})
else:
return standard_reply("strategy_error", {
"message": result.get('message'),
"tbl_key": result.get('tbl_key') # Include tbl_key for debugging purposes
})
if msg_type == 'close_trade':
trade_id = msg_data.get('trade_id') or msg_data.get('unique_id') or msg_data
if isinstance(trade_id, dict):
trade_id = trade_id.get('trade_id') or trade_id.get('unique_id')
result = self.close_trade(str(trade_id))
if result.get('success'):
return standard_reply("trade_closed", result)
else:
return standard_reply("trade_error", result)
if msg_type == 'new_signal':
result = self.received_new_signal(msg_data, user_id)
if result.get('success'):
return standard_reply("signal_created", result)
else:
return standard_reply("signal_error", result)
if msg_type == 'edit_signal':
result = self.received_edit_signal(msg_data, user_id)
if result.get('success'):
return standard_reply("signal_updated", result)
else:
return standard_reply("signal_error", result)
if msg_type == 'new_strategy':
try:
if r_data := self.received_new_strategy(msg_data):
return standard_reply("strategy_created", r_data)
except Exception as e:
logger.error(f"Error processing new_strategy: {e}", exc_info=True)
return standard_reply("strategy_error", {"message": "Failed to create strategy."})
if msg_type == 'edit_strategy':
try:
if r_data := self.received_edit_strategy(msg_data):
return standard_reply("strategy_updated", r_data)
except Exception as e:
# Log the error for debugging
logger.error(f"Error processing edit_strategy: {e}", exc_info=True)
return standard_reply("strategy_error", {"message": "Failed to edit strategy."})
if msg_type == 'new_trade':
result = self.received_new_trade(msg_data, user_id=user_id)
if result.get('success'):
return standard_reply("trade_created", result)
else:
return standard_reply("trade_error", result)
if msg_type == 'config_exchange':
user = msg_data.get('user') or user_name
exchange = msg_data.get('exch') or msg_data.get('exchange') or msg_data.get('exchange_name')
keys = msg_data.get('keys')
if not user or not exchange:
return standard_reply("Exchange_connection_result", {
"exchange": exchange or '',
"status": "error",
"message": "Missing user or exchange in request."
})
r_data = self.connect_or_config_exchange(user_name=user, exchange_name=exchange, api_keys=keys)
return standard_reply("Exchange_connection_result", r_data)
if msg_type == 'refresh_balances':
user = msg_data.get('user') or user_name
if not user:
return standard_reply("balances_refreshed", {
"success": False,
"message": "Missing user in request."
})
try:
logger.info(f"Refreshing balances for user: {user}")
balances = self.exchanges.refresh_all_balances(user)
logger.info(f"Refreshed balances: {balances}")
return standard_reply("balances_refreshed", {
"success": True,
"balances": balances
})
except Exception as e:
logger.error(f"Failed to refresh balances: {e}")
return standard_reply("balances_refreshed", {
"success": False,
"message": str(e)
})
# Handle backtest operations
if msg_type == 'submit_backtest':
# Validate required fields
required_fields = ['strategy', 'start_date', 'capital', 'commission']
if not all(field in msg_data for field in required_fields):
return standard_reply("backtest_error", {"message": "Missing required fields."})
if not user_name:
return standard_reply("backtest_error", {"message": "Missing user identity."})
try:
# Delegate backtest handling to the Backtester
resp = self.backtester.handle_backtest_message(
user_id=user_id if user_id is not None else self.get_user_info(user_name=user_name, info='User_id'),
msg_data=msg_data,
socket_conn_id=socket_conn_id
)
if 'error' in resp:
# If there's an error, send a backtest_error message
# Preserve structured error fields (error_code, missing_exchanges) if present
error_data = {"message": resp['error']}
if 'error_code' in resp:
error_data['error_code'] = resp['error_code']
if 'missing_exchanges' in resp:
error_data['missing_exchanges'] = resp['missing_exchanges']
return standard_reply("backtest_error", error_data)
else:
# If successful, send a backtest_submitted message
return standard_reply("backtest_submitted", resp)
except Exception as e:
# Catch any unexpected exceptions and send a backtest_error message
logger.error(f"Unhandled exception during backtest submission: {e}", exc_info=True)
return standard_reply("backtest_error",
{"message": "An unexpected error occurred during backtest submission."})
if msg_type == 'delete_backtest':
response = self.delete_backtest(msg_data)
return standard_reply("backtest_deleted", response)
if msg_type == 'run_strategy':
# Run a strategy in paper or live mode
required_fields = ['strategy_id', 'mode']
if not all(field in msg_data for field in required_fields):
return standard_reply("strategy_run_error", {"message": "Missing required fields (strategy_id, mode)."})
strategy_id = msg_data.get('strategy_id')
mode = msg_data.get('mode', 'paper').lower()
try:
# Parse numeric values safely inside try block
initial_balance = float(msg_data.get('initial_balance', 10000.0))
commission = float(msg_data.get('commission', 0.001))
# Live trading specific parameters
exchange_name = msg_data.get('exchange_name') or msg_data.get('exchange')
testnet = msg_data.get('testnet', True)
if isinstance(testnet, str):
testnet = testnet.lower() == 'true'
max_position_pct = float(msg_data.get('max_position_pct', 0.5))
circuit_breaker_pct = float(msg_data.get('circuit_breaker_pct', -0.10))
# Validate numeric ranges
if initial_balance <= 0:
return standard_reply("strategy_run_error", {"message": "Initial balance must be positive."})
if commission < 0 or commission > 1:
return standard_reply("strategy_run_error", {"message": "Commission must be between 0 and 1."})
if not (0 < max_position_pct <= 1):
return standard_reply("strategy_run_error", {"message": "max_position_pct must be between 0 and 1."})
if circuit_breaker_pct >= 0:
return standard_reply("strategy_run_error", {"message": "circuit_breaker_pct must be negative (e.g., -0.10 for -10%)."})
result = self.start_strategy(
user_id=user_id,
strategy_id=strategy_id,
mode=mode,
initial_balance=initial_balance,
commission=commission,
exchange_name=exchange_name,
testnet=testnet,
max_position_pct=max_position_pct,
circuit_breaker_pct=circuit_breaker_pct,
)
if result.get('success'):
return standard_reply("strategy_started", result)
else:
return standard_reply("strategy_run_error", result)
except ValueError as e:
return standard_reply("strategy_run_error", {"message": f"Invalid numeric value: {str(e)}"})
except Exception as e:
logger.error(f"Error starting strategy: {e}", exc_info=True)
return standard_reply("strategy_run_error", {"message": f"Failed to start strategy: {str(e)}"})
if msg_type == 'stop_strategy':
strategy_id = msg_data.get('strategy_id')
mode = msg_data.get('mode', 'paper').lower()
if not strategy_id:
return standard_reply("strategy_stop_error", {"message": "Missing strategy_id."})
try:
result = self.stop_strategy(
user_id=user_id,
strategy_id=strategy_id,
mode=mode,
)
if result.get('success'):
return standard_reply("strategy_stopped", result)
else:
return standard_reply("strategy_stop_error", result)
except Exception as e:
logger.error(f"Error stopping strategy: {e}", exc_info=True)
return standard_reply("strategy_stop_error", {"message": f"Failed to stop strategy: {str(e)}"})
if msg_type == 'get_strategy_status':
strategy_id = msg_data.get('strategy_id')
mode = msg_data.get('mode')
try:
result = self.get_strategy_status(
user_id=user_id,
strategy_id=strategy_id,
mode=mode,
)
return standard_reply("strategy_status", result)
except Exception as e:
logger.error(f"Error getting strategy status: {e}", exc_info=True)
return standard_reply("strategy_status_error", {"message": f"Failed to get status: {str(e)}"})
# ===== Strategy Subscription Handlers =====
if msg_type == 'subscribe_strategy':
strategy_tbl_key = msg_data.get('strategy_tbl_key') or msg_data.get('tbl_key')
if not strategy_tbl_key:
return standard_reply("subscription_error", {"message": "Strategy not specified"})
result = self.strategies.subscribe_to_strategy(user_id, strategy_tbl_key)
if result.get('success'):
return standard_reply("strategy_subscribed", result)
else:
return standard_reply("subscription_error", result)
if msg_type == 'unsubscribe_strategy':
strategy_tbl_key = msg_data.get('strategy_tbl_key') or msg_data.get('tbl_key')
if not strategy_tbl_key:
return standard_reply("subscription_error", {"message": "Strategy not specified"})
result = self.strategies.unsubscribe_from_strategy(user_id, strategy_tbl_key)
if result.get('success'):
return standard_reply("strategy_unsubscribed", result)
else:
return standard_reply("subscription_error", result)
if msg_type == 'get_public_strategies':
# Returns all public strategies for the browse dialog
try:
strategies = self.strategies.get_public_strategies_catalog(user_id)
return standard_reply("public_strategies", {"strategies": strategies or []})
except Exception as e:
logger.error(f"Error getting public strategies: {e}", exc_info=True)
return standard_reply("public_strategies_error", {"message": str(e)})
# ===== Formation Handlers =====
if msg_type == 'request_formations':
# Get formations for current chart scope
exchange = msg_data.get('exchange')
market = msg_data.get('market')
timeframe = msg_data.get('timeframe')
if not all([exchange, market, timeframe]):
return standard_reply("formation_error", {"message": "Missing scope parameters"})
formations = self.formations.get_for_scope(user_id, exchange, market, timeframe)
return standard_reply("formations", {"formations": formations})
if msg_type == 'new_formation':
result = self.formations.create(user_id, msg_data)
if result.get('success'):
return standard_reply("formation_created", result)
else:
return standard_reply("formation_error", result)
if msg_type == 'edit_formation':
result = self.formations.update(user_id, msg_data)
if result.get('success'):
return standard_reply("formation_updated", result)
else:
return standard_reply("formation_error", result)
if msg_type == 'delete_formation':
tbl_key = msg_data.get('tbl_key')
if not tbl_key:
return standard_reply("formation_error", {"message": "Missing tbl_key"})
result = self.formations.delete(user_id, tbl_key)
if result.get('success'):
return standard_reply("formation_deleted", result)
else:
return standard_reply("formation_error", result)
if msg_type == 'reply':
# If the message is a reply log the response to the terminal.
print(f"\napp.py:Received reply: {msg_data}")
# ===== Wallet Methods =====
def create_user_wallet(self, user_id: int, user_sweep_address: str = None) -> dict:
"""
Create BTC wallet for registered user (two-address model).
Args:
user_id: User ID to create wallet for.
user_sweep_address: Optional user-provided sweep address.
Returns:
Dict with success status and wallet details.
"""
user = self.users.get_user_by_id(user_id)
if not user:
return {'success': False, 'error': 'User not found'}
# Check if user is a guest (guests can't have wallets)
username = user.get('user_name', '')
if username == 'guest' or user.get('is_guest', False):
return {'success': False, 'error': 'Only registered users can create wallets'}
return self.wallet_manager.create_wallet(user_id, user_sweep_address=user_sweep_address)
def get_user_wallet(self, user_id: int) -> dict:
"""
Get user's wallet info (without private key).
Args:
user_id: User ID to look up.
Returns:
Dict with wallet info or None.
"""
return self.wallet_manager.get_wallet(user_id)
def get_credits_balance(self, user_id: int) -> int:
"""
Get user's spendable credits balance.
Args:
user_id: User ID to look up.
Returns:
Balance in satoshis.
"""
return self.wallet_manager.get_credits_balance(user_id)
def request_withdrawal(self, user_id: int, amount_satoshis: int,
destination_address: str) -> dict:
"""
Request BTC withdrawal.
Args:
user_id: User ID requesting withdrawal.
amount_satoshis: Amount to withdraw.
destination_address: Bitcoin address to send to.
Returns:
Dict with success status.
"""
return self.wallet_manager.request_withdrawal(
user_id, amount_satoshis, destination_address
)
def get_transaction_history(self, user_id: int, limit: int = 20) -> list:
"""
Get user's recent ledger transactions.
Args:
user_id: User ID to look up.
limit: Maximum number of transactions.
Returns:
List of transaction dicts.
"""
return self.wallet_manager.get_transaction_history(user_id, limit)