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 Backtester’s 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)