diff --git a/src/BrighterTrades.py b/src/BrighterTrades.py index c98567b..7eeacc8 100644 --- a/src/BrighterTrades.py +++ b/src/BrighterTrades.py @@ -752,6 +752,44 @@ class BrighterTrades: # This ensures subscribers run with the creator's indicator definitions indicator_owner_id = creator_id if is_subscribed and not is_owner else None + # Early exchange requirements validation + 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: @@ -1140,9 +1178,9 @@ class BrighterTrades: 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 + # 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) + 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) @@ -1166,10 +1204,12 @@ class BrighterTrades: ) # 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 + api_keys=api_keys, + testnet=False ) if reconnect_ok: # Update stored credentials if they changed @@ -1621,7 +1661,13 @@ class BrighterTrades: if 'error' in resp: # If there's an error, send a backtest_error message - return standard_reply("backtest_error", {"message": resp['error']}) + # 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) diff --git a/src/DataCache_v3.py b/src/DataCache_v3.py index 43ec29c..9e7a3b0 100644 --- a/src/DataCache_v3.py +++ b/src/DataCache_v3.py @@ -1129,9 +1129,20 @@ class DatabaseInteractions(SnapshotDataCache): if len(rows) > 1: raise ValueError(f"Multiple rows found for {filter_vals}. Please provide more specific filter.") - # Update the DataFrame with the new values - for field_name, new_value in zip(field_names, new_values): - rows[field_name] = new_value + # Types that don't need serialization (same as serialized_datacache_insert) + excluded_objects = (str, int, float, bool, type(None), bytes) + + # Serialize non-primitive values for database storage + serialized_values = [] + for new_value in new_values: + if not isinstance(new_value, excluded_objects): + serialized_values.append(pickle.dumps(new_value)) + else: + serialized_values.append(new_value) + + # Update the DataFrame with the serialized values (for cache consistency) + for field_name, serialized_value in zip(field_names, serialized_values): + rows[field_name] = serialized_value # Get the cache instance cache = self.get_cache(cache_name) @@ -1146,11 +1157,11 @@ class DatabaseInteractions(SnapshotDataCache): else: raise ValueError(f"Unsupported cache type for {cache_name}") - # Update the values in the database + # Update the values in the database with serialized values set_clause = ", ".join([f"{field} = ?" for field in field_names]) where_clause = " AND ".join([f"{col} = ?" for col, _ in filter_vals]) sql_update = f"UPDATE {cache_name} SET {set_clause} WHERE {where_clause}" - params = list(new_values) + [val for _, val in filter_vals] + params = serialized_values + [val for _, val in filter_vals] # Execute the SQL update to modify the database self.db.execute_sql(sql_update, params) diff --git a/src/ExchangeInterface.py b/src/ExchangeInterface.py index 99a4830..7961b3c 100644 --- a/src/ExchangeInterface.py +++ b/src/ExchangeInterface.py @@ -114,13 +114,19 @@ class ExchangeInterface: :return: True if successful, False otherwise. """ try: + # Get existing exchange to preserve EDM session ID for cleanup existing = None + old_session_id = None try: - # Preserve existing exchange until the replacement is created successfully. existing = self.get_exchange(exchange_name, user_name) + old_session_id = existing.edm_session_id if hasattr(existing, 'edm_session_id') else None + old_testnet = getattr(existing, 'testnet', 'unknown') + logger.info(f"Replacing existing {exchange_name} for {user_name} (old testnet={old_testnet}, new testnet={testnet})") except Exception: - pass # No existing entry to replace, that's fine + pass # No existing entry, that's fine + # Create new exchange with explicit testnet setting + logger.info(f"Creating {exchange_name} for {user_name} with testnet={testnet}") exchange = Exchange(name=exchange_name, api_keys=api_keys, exchange_id=exchange_name.lower(), testnet=testnet) @@ -141,20 +147,29 @@ class ExchangeInterface: except Exception as e: logger.warning(f"Failed to create EDM session for {exchange_name}: {e}") - # Replace existing entry only after new exchange initialization. - if existing is not None: - old_session_id = existing.edm_session_id if hasattr(existing, 'edm_session_id') else None + # ALWAYS try to remove existing entry before adding new one + # This prevents duplicate entries even if get_exchange failed + # Use tbl_key for precise targeting, with fallback to user+name filter + tbl_key = f"{user_name}:{exchange_name}" + try: self.cache_manager.remove_row_from_datacache( cache_name='exchange_data', - filter_vals=[('user', user_name), ('name', exchange_name)] + filter_vals=[('user', user_name), ('name', exchange_name)], + key=tbl_key ) - if old_session_id and self.edm_client: - try: - self.edm_client.delete_session_sync(old_session_id) - except Exception as e: - logger.warning(f"Failed to delete old EDM session: {e}") + logger.info(f"Removed old exchange entry for {user_name}/{exchange_name}") + except Exception as e: + logger.debug(f"No existing entry to remove for {user_name}/{exchange_name}: {e}") + + # Clean up old EDM session if we had one + if old_session_id and self.edm_client: + try: + self.edm_client.delete_session_sync(old_session_id) + except Exception as e: + logger.warning(f"Failed to delete old EDM session: {e}") self.add_exchange(user_name, exchange) + logger.info(f"Connected {exchange_name} for {user_name} (testnet={testnet}, balances={len(exchange.balances)} assets)") return True except Exception as e: logger.error(f"Failed to connect user '{user_name}' to exchange '{exchange_name}': {str(e)}") @@ -168,6 +183,9 @@ class ExchangeInterface: :param exchange: The Exchange object to add. """ try: + # Generate a unique tbl_key to prevent duplicates + tbl_key = f"{user_name}:{exchange.name}" + row_data = { 'user': user_name, 'name': exchange.name, @@ -181,7 +199,8 @@ class ExchangeInterface: row = pd.DataFrame([row_data]) - self.cache_manager.serialized_datacache_insert(cache_name='exchange_data', data=row) + # Pass key to let serialized_datacache_insert add the tbl_key column + self.cache_manager.serialized_datacache_insert(cache_name='exchange_data', data=row, key=tbl_key) except Exception as e: logger.error(f"Couldn't create an instance of the exchange! {str(e)}") raise diff --git a/src/Signals.py b/src/Signals.py index 2096ed0..f1932d3 100644 --- a/src/Signals.py +++ b/src/Signals.py @@ -3,7 +3,7 @@ import logging import uuid import datetime as dt from dataclasses import dataclass -from typing import Any +from typing import Any, Dict import pandas as pd from DataCache_v3 import DataCache @@ -12,6 +12,18 @@ from DataCache_v3 import DataCache logger = logging.getLogger(__name__) +class IndicatorWrapper: + """ + Wrapper to make indicator dict data accessible via .properties attribute. + + This bridges the gap between get_indicator_list() return format + (flat dict with properties merged in) and the expected access pattern + (indicator.properties.get(prop_name)). + """ + def __init__(self, data: dict): + self.properties = data + + @dataclass() class Signal: """Class for individual signal properties and state.""" @@ -507,8 +519,30 @@ class Signals: :return: Dictionary of signals that changed state. """ state_changes = {} + + # Cache indicator data per user to avoid repeated lookups + user_indicator_cache: Dict[int, dict] = {} + for signal in self.signals: - change_in_state = self.process_signal(signal, indicators) + # Get or fetch indicator data for this signal's creator + user_id = signal.creator + if user_id not in user_indicator_cache: + try: + # Fetch indicator list for this user + indicator_list = indicators.get_indicator_list(user_id=user_id) + # Wrap each indicator's data so it has a .properties attribute + user_indicator_cache[user_id] = { + name: IndicatorWrapper(data) + for name, data in indicator_list.items() + } + except Exception as e: + logger.debug(f"Could not fetch indicators for user {user_id}: {e}") + user_indicator_cache[user_id] = {} + + # Get the wrapped indicators for this user + user_indicators = user_indicator_cache.get(user_id, {}) + + change_in_state = self.process_signal(signal, user_indicators) if change_in_state: state_changes[signal.name] = signal.state # Persist state change to database @@ -521,12 +555,12 @@ class Signals: ) return state_changes - def process_signal(self, signal: Signal, indicators, candles=None) -> bool: + def process_signal(self, signal: Signal, indicator_data: dict, candles=None) -> bool: """ Process a signal by comparing indicator values. :param signal: The signal to process. - :param indicators: The Indicators instance with calculated values. + :param indicator_data: Dict mapping indicator names to IndicatorWrapper objects. :param candles: Optional candles for recalculation. :return: True if the signal state changed, False otherwise. """ @@ -534,8 +568,8 @@ class Signals: # Get the source of the first signal source_1 = signal.source1 # Ask the indicator for the last result - if source_1 in indicators.indicators: - signal.value1 = indicators.indicators[source_1].properties.get(signal.prop1) + if source_1 in indicator_data: + signal.value1 = indicator_data[source_1].properties.get(signal.prop1) else: logger.debug(f'Could not calculate signal: source indicator "{source_1}" not found.') return False @@ -550,8 +584,8 @@ class Signals: signal.value2 = signal.prop2 else: # Ask the indicator for the last result - if source_2 in indicators.indicators: - signal.value2 = indicators.indicators[source_2].properties.get(signal.prop2) + if source_2 in indicator_data: + signal.value2 = indicator_data[source_2].properties.get(signal.prop2) else: logger.debug(f'Could not calculate signal: source2 indicator "{source_2}" not found.') return False diff --git a/src/Strategies.py b/src/Strategies.py index 9599160..664da90 100644 --- a/src/Strategies.py +++ b/src/Strategies.py @@ -1116,6 +1116,20 @@ class Strategies: return strategy_row + def get_required_exchanges(self, strategy_tbl_key: str) -> set[str]: + """ + Get the set of exchange names required by a strategy. + + Extracts unique exchange names from the strategy's data sources + and default source settings. + + :param strategy_tbl_key: The unique identifier of the strategy. + :return: Set of canonicalized exchange names required by the strategy. + """ + from exchange_validation import extract_required_exchanges + strategy = self.get_strategy_by_tbl_key(strategy_tbl_key) + return extract_required_exchanges(strategy) + def update_strategy_stats(self, strategy_id: str, profit_loss: float) -> None: """ Updates the strategy's statistics based on the latest profit or loss. diff --git a/src/backtesting.py b/src/backtesting.py index 9dfd652..4557b32 100644 --- a/src/backtesting.py +++ b/src/backtesting.py @@ -781,6 +781,27 @@ class Backtester: # For subscribed strategies, use creator's indicators indicator_owner_id = creator_id if is_subscribed and not is_owner else None + # Validate exchange requirements for backtest + from exchange_validation import extract_required_exchanges, validate_for_backtest + required_exchanges = extract_required_exchanges(strategy) + + if required_exchanges and self.edm_client: + try: + edm_available = self.edm_client.get_exchanges_sync() + validation_result = validate_for_backtest(required_exchanges, edm_available) + if not validation_result.valid: + return { + "error": validation_result.message, + "error_code": validation_result.error_code.value if validation_result.error_code else None, + "missing_exchanges": list(validation_result.missing_exchanges) + } + except Exception as e: + logger.warning(f"Could not validate EDM exchanges: {e}") + return { + "error": "Cannot validate exchange availability - EDM unreachable", + "error_code": "edm_unreachable" + } + if not backtest_name: # If backtest_name is not provided, generate a unique name backtest_name = f"{tbl_key}_backtest" diff --git a/src/exchange_validation.py b/src/exchange_validation.py new file mode 100644 index 0000000..f6f0886 --- /dev/null +++ b/src/exchange_validation.py @@ -0,0 +1,314 @@ +""" +Exchange requirements validation for strategies. + +Centralized validator with structured error codes for validating that users +have access to required exchanges before running strategies in different +trading modes (backtest, paper, live). +""" + +import logging +from typing import Set, List, Dict, Any +from enum import Enum + +logger = logging.getLogger(__name__) + + +class ValidationErrorCode(Enum): + """Structured error codes for exchange validation failures.""" + MISSING_EDM_DATA = "missing_edm_data" + MISSING_CONFIG = "missing_config" + INVALID_EXCHANGE = "invalid_exchange" + INVALID_KEYS = "invalid_keys" + EDM_UNREACHABLE = "edm_unreachable" + + +class ExchangeValidationResult: + """Structured validation result.""" + + def __init__( + self, + valid: bool, + error_code: ValidationErrorCode = None, + missing_exchanges: Set[str] = None, + message: str = None + ): + self.valid = valid + self.error_code = error_code + self.missing_exchanges = missing_exchanges or set() + self.message = message + + def to_dict(self) -> Dict[str, Any]: + """Convert result to dictionary for JSON serialization.""" + result = {"valid": self.valid} + if not self.valid: + result["error_code"] = self.error_code.value if self.error_code else None + result["missing_exchanges"] = list(self.missing_exchanges) + result["message"] = self.message + return result + + +# Exchange name canonicalization map +# Maps variations to canonical names. Names not in map pass through lowercased. +EXCHANGE_ALIASES = { + 'binance': 'binance', + 'binanceus': 'binanceus', + 'binanceusdm': 'binanceusdm', + 'binancecoinm': 'binancecoinm', + 'kucoin': 'kucoin', + 'kucoinfutures': 'kucoinfutures', + 'kraken': 'kraken', + 'krakenfutures': 'krakenfutures', + 'coinbase': 'coinbase', + 'coinbasepro': 'coinbasepro', + 'bybit': 'bybit', + 'okx': 'okx', + 'okex': 'okx', # Alias: okex -> okx + 'gateio': 'gateio', + 'gate': 'gateio', # Alias: gate -> gateio + 'htx': 'htx', + 'huobi': 'htx', # Alias: huobi -> htx (rebranded) +} + + +def canonicalize_exchange(name: str) -> str: + """ + Normalize exchange name to canonical form. + + Handles case normalization and common aliases. + + :param name: Exchange name in any case + :return: Canonical lowercase exchange name + """ + if not name: + return '' + lower = name.lower().strip() + return EXCHANGE_ALIASES.get(lower, lower) + + +def extract_required_exchanges(strategy: dict) -> Set[str]: + """ + Extract unique exchange names required by a strategy. + + Single canonical implementation - use everywhere to avoid drift. + + Parses strategy_components.data_sources to identify all exchanges + the strategy needs for execution. Falls back to default_source if + data_sources is empty. + + :param strategy: Strategy dictionary from database + :return: Set of canonicalized exchange names + """ + if not strategy: + return set() + + components = strategy.get('strategy_components', {}) + if isinstance(components, str): + # Handle case where components is JSON string + import json + try: + components = json.loads(components) + except (json.JSONDecodeError, TypeError): + components = {} + + data_sources = components.get('data_sources', []) + + exchanges = set() + + # Extract from data_sources (list of tuples or dicts) + for source in data_sources: + if isinstance(source, (list, tuple)) and len(source) >= 1: + exchange_name = source[0] + if exchange_name: + exchanges.add(canonicalize_exchange(exchange_name)) + elif isinstance(source, dict) and source.get('exchange'): + exchanges.add(canonicalize_exchange(source['exchange'])) + + # Also check default_source as fallback + default_source = strategy.get('default_source', {}) + if isinstance(default_source, str): + import json + try: + default_source = json.loads(default_source) + except (json.JSONDecodeError, TypeError): + default_source = {} + + if default_source and default_source.get('exchange'): + exchanges.add(canonicalize_exchange(default_source['exchange'])) + + return exchanges + + +def get_valid_ccxt_exchanges() -> Set[str]: + """ + Get set of valid ccxt exchange names. + + Used to validate that an exchange is supported for paper trading. + + :return: Set of lowercase exchange names supported by ccxt + """ + import ccxt + return {ex.lower() for ex in ccxt.exchanges} + + +def _extract_exchange_name(ex) -> str: + """ + Extract exchange name from EDM response item. + + EDM may return either strings or dicts with a 'name' key. + + :param ex: Exchange item (str or dict) + :return: Exchange name string + """ + if isinstance(ex, dict): + return ex.get('name', '') + return str(ex) if ex else '' + + +def validate_for_backtest( + required_exchanges: Set[str], + edm_available_exchanges: List[str] +) -> ExchangeValidationResult: + """ + Validate exchanges for backtest mode. + + Backtest requires historical data from EDM. All required exchanges + must be available in EDM's exchange list. + + :param required_exchanges: Set of canonicalized exchange names + :param edm_available_exchanges: List of exchanges available in EDM (strings or dicts) + :return: Validation result + """ + if not required_exchanges: + return ExchangeValidationResult(valid=True) + + edm_available = { + canonicalize_exchange(_extract_exchange_name(ex)) + for ex in (edm_available_exchanges or []) + } + missing = required_exchanges - edm_available + + if missing: + return ExchangeValidationResult( + valid=False, + error_code=ValidationErrorCode.MISSING_EDM_DATA, + missing_exchanges=missing, + message=f"Historical data not available for: {', '.join(sorted(missing))}" + ) + return ExchangeValidationResult(valid=True) + + +def validate_for_paper( + required_exchanges: Set[str], + edm_available_exchanges: List[str] +) -> ExchangeValidationResult: + """ + Validate exchanges for paper mode. + + Paper mode uses ccxt public endpoints for price fetching, so exchanges + just need to be valid ccxt exchanges. Warns if not in EDM since some + data fetching may fail. + + :param required_exchanges: Set of canonicalized exchange names + :param edm_available_exchanges: List of exchanges available in EDM + :return: Validation result + """ + if not required_exchanges: + return ExchangeValidationResult(valid=True) + + # Paper mode uses ccxt public endpoints - just need valid ccxt exchange + ccxt_exchanges = get_valid_ccxt_exchanges() + invalid = required_exchanges - ccxt_exchanges + + if invalid: + return ExchangeValidationResult( + valid=False, + error_code=ValidationErrorCode.INVALID_EXCHANGE, + missing_exchanges=invalid, + message=f"Unknown exchanges: {', '.join(sorted(invalid))}" + ) + + # Warn if not in EDM (price fetching may use ccxt fallback) + edm_available = { + canonicalize_exchange(_extract_exchange_name(ex)) + for ex in (edm_available_exchanges or []) + } + not_in_edm = required_exchanges - edm_available + if not_in_edm: + logger.warning( + f"Exchanges not in EDM (will use ccxt fallback): {not_in_edm}" + ) + + return ExchangeValidationResult(valid=True) + + +def validate_for_live( + required_exchanges: Set[str], + user_configured_exchanges: List[str] +) -> ExchangeValidationResult: + """ + Validate exchanges for live mode. + + Live mode requires user to have API keys configured for each exchange. + This is an early check - full validation (including key validity) happens + later in start_strategy when it attempts to connect. + + :param required_exchanges: Set of canonicalized exchange names + :param user_configured_exchanges: Exchanges with API keys configured + :return: Validation result + """ + if not required_exchanges: + return ExchangeValidationResult(valid=True) + + configured = { + canonicalize_exchange(ex) + for ex in (user_configured_exchanges or []) + } + missing = required_exchanges - configured + + if missing: + return ExchangeValidationResult( + valid=False, + error_code=ValidationErrorCode.MISSING_CONFIG, + missing_exchanges=missing, + message=f"API keys required for: {', '.join(sorted(missing))}" + ) + return ExchangeValidationResult(valid=True) + + +def validate_exchange_requirements( + required_exchanges: Set[str], + user_configured_exchanges: List[str], + edm_available_exchanges: List[str], + mode: str +) -> ExchangeValidationResult: + """ + Main validation entrypoint. Routes to mode-specific validator. + + Use this from both start_strategy and backtest entry points. + + :param required_exchanges: Set of exchange names required by strategy + :param user_configured_exchanges: Exchanges with API keys configured + :param edm_available_exchanges: Exchanges available in EDM + :param mode: Trading mode ('backtest', 'paper', 'live') + :return: Validation result with error details if invalid + """ + # Canonicalize all required exchanges + required = {canonicalize_exchange(ex) for ex in required_exchanges if ex} + + if not required: + return ExchangeValidationResult(valid=True) + + if mode == 'live': + return validate_for_live(required, user_configured_exchanges) + elif mode == 'paper': + return validate_for_paper(required, edm_available_exchanges) + elif mode == 'backtest': + return validate_for_backtest(required, edm_available_exchanges) + else: + # Unknown mode - reject explicitly + return ExchangeValidationResult( + valid=False, + error_code=ValidationErrorCode.INVALID_EXCHANGE, + missing_exchanges=set(), + message=f"Invalid trading mode: {mode}" + ) diff --git a/src/static/Strategies.js b/src/static/Strategies.js index 16feaeb..f5ce6a3 100644 --- a/src/static/Strategies.js +++ b/src/static/Strategies.js @@ -2000,8 +2000,38 @@ class Strategies { * @param {Object} data - Error data from server. */ handleStrategyRunError(data) { - console.error("Strategy run error:", data.message); - alert(`Failed to start strategy: ${data.message}`); + console.error("Strategy run error:", data.message, data); + + const errorCode = data.error_code; + const missing = data.missing_exchanges; + + // Handle exchange requirement errors with detailed messages + if (missing && missing.length > 0) { + const exchanges = missing.join(', '); + let message; + + switch (errorCode) { + case 'missing_edm_data': + message = `Historical data not available for these exchanges:\n\n${exchanges}\n\n` + + `These exchanges may not be supported by the Exchange Data Manager.`; + break; + case 'missing_config': + message = `Please configure API keys for:\n\n${exchanges}\n\n` + + `Go to Exchange Settings to add your credentials.`; + break; + case 'invalid_exchange': + message = `Unknown or unsupported exchanges:\n\n${exchanges}`; + break; + case 'edm_unreachable': + message = `Cannot validate exchange availability - data service unreachable.`; + break; + default: + message = `This strategy requires: ${exchanges}`; + } + alert(message); + } else { + alert(`Failed to start strategy: ${data.message || data.error || 'Unknown error'}`); + } } /** diff --git a/src/static/backtesting.js b/src/static/backtesting.js index 02d85df..27e29d0 100644 --- a/src/static/backtesting.js +++ b/src/static/backtesting.js @@ -101,7 +101,7 @@ class Backtesting { } handleBacktestError(data) { - console.error("Backtest error:", data.message); + console.error("Backtest error:", data.message || data.error, data); const test = this.tests.find(t => t.name === this.currentTest); if (test) { @@ -110,7 +110,29 @@ class Backtesting { this.updateHTML(); } - this.displayMessage(`Backtest error: ${data.message}`, 'red'); + // Build error message with exchange requirement details if present + let errorMessage; + const errorCode = data.error_code; + const missing = data.missing_exchanges; + + if (missing && missing.length > 0) { + const exchanges = missing.join(', '); + switch (errorCode) { + case 'missing_edm_data': + errorMessage = `Historical data not available for: ${exchanges}. ` + + `These exchanges may not be supported.`; + break; + case 'edm_unreachable': + errorMessage = `Cannot validate exchange availability - data service unreachable.`; + break; + default: + errorMessage = `This strategy requires exchanges: ${exchanges}`; + } + } else { + errorMessage = data.message || data.error || 'Unknown error'; + } + + this.displayMessage(`Backtest error: ${errorMessage}`, 'red'); // Hide progress bar and results this.hideElement(this.progressContainer); diff --git a/src/static/exchanges.js b/src/static/exchanges.js index 928a8c5..6e0005b 100644 --- a/src/static/exchanges.js +++ b/src/static/exchanges.js @@ -1,8 +1,9 @@ class Exchanges { constructor() { this.exchanges = {}; - this.balances = {}; + this.balances = {}; // All balances by exchange name this.connected_exchanges = []; + this.selectedBalanceExchange = null; // Currently selected exchange for balance display this.isSubmitting = false; } @@ -14,6 +15,15 @@ class Exchanges { // Extract the text content from each span and store it in the connected_exchanges array this.connected_exchanges = Array.from(spans).map(span => span.textContent.trim()); + // Get the currently selected exchange from the selector (defaults to chart view exchange) + const selector = document.getElementById('balance_exchange_selector'); + if (selector && selector.value) { + this.selectedBalanceExchange = selector.value; + } else { + // No selector or no options - will show empty state + this.selectedBalanceExchange = null; + } + // Register handlers for exchange events if (window.UI && window.UI.data && window.UI.data.comms) { window.UI.data.comms.on('Exchange_connection_result', this.handleConnectionResult.bind(this)); @@ -21,6 +31,45 @@ class Exchanges { } } + onBalanceExchangeChange(exchangeName) { + this.selectedBalanceExchange = exchangeName; + // If we have cached balances for this exchange, display them + if (this.balances[exchangeName]) { + this.displaySingleExchangeBalances(exchangeName, this.balances[exchangeName]); + } else { + // No cached balances, show empty state + this.displaySingleExchangeBalances(exchangeName, []); + } + } + + displaySingleExchangeBalances(exchangeName, balanceList) { + const tbl = document.getElementById('balances_tbl'); + if (!tbl) return; + + let html = ''; + let hasValidBalances = false; + + if (Array.isArray(balanceList) && balanceList.length > 0) { + for (const balance of balanceList) { + // Skip N/A placeholder entries + if (balance.asset === 'N/A' && balance.balance === 0) continue; + hasValidBalances = true; + html += ` + + + + `; + } + } + + if (!hasValidBalances) { + html += ''; + } + + html += '
AssetBalanceProfit & Loss
${balance.asset || ''}${this.formatBalance(balance.balance)}${this.formatBalance(balance.pnl)}
No balances available
'; + tbl.innerHTML = html; + } + status() { // Reset form state when opening this.resetFormState(); @@ -205,7 +254,10 @@ class Exchanges { } if (data.success && data.balances) { - console.log('Updating balances table with:', data.balances); + console.log('Updating balances cache with:', data.balances); + // Store all balances in cache + this.balances = data.balances; + // Display only the selected exchange's balances this.updateBalancesTable(data.balances); } else if (!data.success) { console.error('Failed to refresh balances:', data.message); @@ -213,27 +265,47 @@ class Exchanges { } updateBalancesTable(balances) { - const tbl = document.getElementById('balances_tbl'); - if (!tbl) return; + // Update the selector with available exchanges + this.updateBalanceExchangeSelector(Object.keys(balances)); - // Build new table HTML - let html = ''; + // Only display the selected exchange's balances + let selectedExchange = this.selectedBalanceExchange; - for (const [exchangeName, exchangeBalances] of Object.entries(balances)) { - html += ``; - if (Array.isArray(exchangeBalances)) { - for (const balance of exchangeBalances) { - html += ` - - - - `; + // If no exchange is selected or selected doesn't exist, pick the first one + if (!selectedExchange || !balances[selectedExchange]) { + const exchanges = Object.keys(balances); + if (exchanges.length > 0) { + selectedExchange = exchanges[0]; + this.selectedBalanceExchange = selectedExchange; + const selector = document.getElementById('balance_exchange_selector'); + if (selector) { + selector.value = selectedExchange; } } } - html += '
AssetBalanceProfit & Loss
${exchangeName}
${balance.asset || ''}${this.formatBalance(balance.balance)}${this.formatBalance(balance.pnl)}
'; - tbl.innerHTML = html; + const exchangeBalances = balances[selectedExchange] || []; + this.displaySingleExchangeBalances(selectedExchange, exchangeBalances); + } + + updateBalanceExchangeSelector(exchanges) { + const selector = document.getElementById('balance_exchange_selector'); + if (!selector) return; + + // Get current selection + const currentSelection = selector.value; + + // Rebuild options + selector.innerHTML = ''; + for (const exchange of exchanges) { + const option = document.createElement('option'); + option.value = exchange; + option.textContent = exchange; + if (exchange === currentSelection || exchange === this.selectedBalanceExchange) { + option.selected = true; + } + selector.appendChild(option); + } } formatBalance(value) { diff --git a/src/templates/exchange_info_hud.html b/src/templates/exchange_info_hud.html index 1db6ac3..be30b72 100644 --- a/src/templates/exchange_info_hud.html +++ b/src/templates/exchange_info_hud.html @@ -23,6 +23,16 @@

Balances + {% set balance_exchanges = my_balances.keys()|list %} + {% set default_balance_exchange = selected_exchange if selected_exchange in balance_exchanges else (balance_exchanges[0] if balance_exchanges else '') %} + {% if balance_exchanges %} + + {% endif %} @@ -35,20 +45,21 @@ Balance Profit & Loss - {% for name, balances in my_balances.items() %} - - {{ name }} - - {% if balances %} - {% for balance in balances %} + {% set selected_balances = my_balances.get(default_balance_exchange, []) %} + {% set valid_balances = selected_balances|selectattr('asset', 'ne', 'N/A')|list if selected_balances else [] %} + {% if valid_balances %} + {% for balance in valid_balances %} {{ balance['asset'] }} {{ balance['balance']|format_balance }} {{ balance['pnl']|format_balance }} {% endfor %} - {% endif %} - {% endfor %} + {% else %} + + No balances available + + {% endif %}

diff --git a/tests/test_BrighterTrades.py b/tests/test_BrighterTrades.py index 0298c36..7067c39 100644 --- a/tests/test_BrighterTrades.py +++ b/tests/test_BrighterTrades.py @@ -170,7 +170,8 @@ class TestBrighterTrades(unittest.TestCase): self.mock_exchanges.connect_exchange.assert_called_with( exchange_name='kucoin', user_name='testuser', - api_keys=new_keys + api_keys=new_keys, + testnet=False ) self.mock_users.update_api_keys.assert_called_with( api_keys=new_keys, diff --git a/tests/test_exchange_validation.py b/tests/test_exchange_validation.py new file mode 100644 index 0000000..ecf693f --- /dev/null +++ b/tests/test_exchange_validation.py @@ -0,0 +1,425 @@ +""" +Unit tests for exchange_validation module. + +Tests cover: +- Exchange extraction from strategy data +- Mode-specific validation (backtest, paper, live) +- Exchange name canonicalization +- Error code generation +- Edge cases (empty data, EDM unavailable, etc.) +""" + +import pytest +import sys +import os + +# Add src to path for imports +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src')) + +from exchange_validation import ( + canonicalize_exchange, + extract_required_exchanges, + validate_for_backtest, + validate_for_paper, + validate_for_live, + validate_exchange_requirements, + ValidationErrorCode, + ExchangeValidationResult, +) + + +class TestCanonicalizeExchange: + """Tests for exchange name canonicalization.""" + + def test_lowercase_normalization(self): + """Test that exchange names are normalized to lowercase.""" + assert canonicalize_exchange('BINANCE') == 'binance' + assert canonicalize_exchange('Binance') == 'binance' + assert canonicalize_exchange('binance') == 'binance' + + def test_alias_mapping(self): + """Test that known aliases are mapped correctly.""" + assert canonicalize_exchange('okex') == 'okx' + assert canonicalize_exchange('OKEX') == 'okx' + assert canonicalize_exchange('huobi') == 'htx' + assert canonicalize_exchange('gate') == 'gateio' + + def test_passthrough_unknown(self): + """Test that unknown exchange names pass through lowercased.""" + assert canonicalize_exchange('someexchange') == 'someexchange' + assert canonicalize_exchange('NEWEXCHANGE') == 'newexchange' + + def test_empty_string(self): + """Test handling of empty string.""" + assert canonicalize_exchange('') == '' + assert canonicalize_exchange(' ') == '' + + def test_whitespace_stripping(self): + """Test that whitespace is stripped.""" + assert canonicalize_exchange(' binance ') == 'binance' + assert canonicalize_exchange('\tkucoin\n') == 'kucoin' + + +class TestExtractRequiredExchanges: + """Tests for extracting required exchanges from strategy data.""" + + def test_empty_strategy(self): + """Test handling of empty/None strategy.""" + assert extract_required_exchanges(None) == set() + assert extract_required_exchanges({}) == set() + + def test_extract_from_data_sources_tuple(self): + """Test extraction from data_sources as tuples.""" + strategy = { + 'strategy_components': { + 'data_sources': [ + ('binance', 'BTC/USDT', '1h'), + ('kucoin', 'ETH/USDT', '5m'), + ] + } + } + result = extract_required_exchanges(strategy) + assert result == {'binance', 'kucoin'} + + def test_extract_from_data_sources_list(self): + """Test extraction from data_sources as lists.""" + strategy = { + 'strategy_components': { + 'data_sources': [ + ['binance', 'BTC/USDT', '1h'], + ] + } + } + result = extract_required_exchanges(strategy) + assert result == {'binance'} + + def test_extract_from_data_sources_dict(self): + """Test extraction from data_sources as dicts.""" + strategy = { + 'strategy_components': { + 'data_sources': [ + {'exchange': 'binance', 'symbol': 'BTC/USDT', 'timeframe': '1h'}, + ] + } + } + result = extract_required_exchanges(strategy) + assert result == {'binance'} + + def test_extract_from_default_source(self): + """Test extraction from default_source.""" + strategy = { + 'default_source': { + 'exchange': 'kucoin', + 'symbol': 'BTC/USDT', + 'timeframe': '15m' + } + } + result = extract_required_exchanges(strategy) + assert result == {'kucoin'} + + def test_extract_combined_sources(self): + """Test extraction from both data_sources and default_source.""" + strategy = { + 'strategy_components': { + 'data_sources': [ + ('binance', 'BTC/USDT', '1h'), + ] + }, + 'default_source': { + 'exchange': 'kucoin', + 'symbol': 'ETH/USDT', + } + } + result = extract_required_exchanges(strategy) + assert result == {'binance', 'kucoin'} + + def test_extract_with_canonicalization(self): + """Test that extracted exchanges are canonicalized.""" + strategy = { + 'strategy_components': { + 'data_sources': [ + ('BINANCE', 'BTC/USDT', '1h'), + ('okex', 'ETH/USDT', '5m'), # Should become 'okx' + ] + } + } + result = extract_required_exchanges(strategy) + assert result == {'binance', 'okx'} + + def test_extract_deduplication(self): + """Test that duplicate exchanges are deduplicated.""" + strategy = { + 'strategy_components': { + 'data_sources': [ + ('binance', 'BTC/USDT', '1h'), + ('Binance', 'ETH/USDT', '5m'), + ('BINANCE', 'LTC/USDT', '15m'), + ] + } + } + result = extract_required_exchanges(strategy) + assert result == {'binance'} + + def test_extract_json_string_components(self): + """Test extraction when strategy_components is JSON string.""" + import json + strategy = { + 'strategy_components': json.dumps({ + 'data_sources': [ + ['binance', 'BTC/USDT', '1h'], + ] + }) + } + result = extract_required_exchanges(strategy) + assert result == {'binance'} + + +class TestValidateForBacktest: + """Tests for backtest mode validation.""" + + def test_empty_requirements(self): + """Test that empty requirements are valid.""" + result = validate_for_backtest(set(), ['binance', 'kucoin']) + assert result.valid is True + + def test_all_available(self): + """Test validation when all exchanges are available.""" + result = validate_for_backtest( + {'binance', 'kucoin'}, + ['binance', 'kucoin', 'kraken'] + ) + assert result.valid is True + + def test_missing_exchanges(self): + """Test validation when exchanges are missing.""" + result = validate_for_backtest( + {'binance', 'kucoin', 'unknown_exchange'}, + ['binance', 'kucoin'] + ) + assert result.valid is False + assert result.error_code == ValidationErrorCode.MISSING_EDM_DATA + assert result.missing_exchanges == {'unknown_exchange'} + assert 'unknown_exchange' in result.message + + def test_empty_edm_list(self): + """Test validation when EDM list is empty.""" + result = validate_for_backtest({'binance'}, []) + assert result.valid is False + assert result.error_code == ValidationErrorCode.MISSING_EDM_DATA + + def test_none_edm_list(self): + """Test validation when EDM list is None.""" + result = validate_for_backtest({'binance'}, None) + assert result.valid is False + assert result.error_code == ValidationErrorCode.MISSING_EDM_DATA + + def test_canonicalization_in_comparison(self): + """Test that EDM exchanges are canonicalized during comparison.""" + result = validate_for_backtest( + {'binance'}, + ['BINANCE'] # Should match after canonicalization + ) + assert result.valid is True + + def test_edm_returns_dicts(self): + """Test that EDM response with dicts (containing 'name' key) is handled.""" + result = validate_for_backtest( + {'binance', 'kucoin'}, + [{'name': 'binance', 'timeframes': ['1m', '1h']}, + {'name': 'kucoin', 'timeframes': ['5m', '15m']}] + ) + assert result.valid is True + + def test_edm_returns_dicts_missing(self): + """Test that missing exchanges are detected with dict format.""" + result = validate_for_backtest( + {'binance', 'kraken'}, + [{'name': 'binance'}] + ) + assert result.valid is False + assert result.missing_exchanges == {'kraken'} + + +class TestValidateForPaper: + """Tests for paper mode validation.""" + + def test_empty_requirements(self): + """Test that empty requirements are valid.""" + result = validate_for_paper(set(), []) + assert result.valid is True + + def test_valid_ccxt_exchange(self): + """Test validation with valid ccxt exchange.""" + result = validate_for_paper({'binance'}, []) + assert result.valid is True + + def test_invalid_exchange(self): + """Test validation with invalid exchange name.""" + result = validate_for_paper({'totally_fake_exchange_xyz'}, []) + assert result.valid is False + assert result.error_code == ValidationErrorCode.INVALID_EXCHANGE + assert 'totally_fake_exchange_xyz' in result.missing_exchanges + + def test_mixed_valid_invalid(self): + """Test validation with mix of valid and invalid exchanges.""" + result = validate_for_paper({'binance', 'fake_exchange'}, []) + assert result.valid is False + assert result.missing_exchanges == {'fake_exchange'} + + +class TestValidateForLive: + """Tests for live mode validation.""" + + def test_empty_requirements(self): + """Test that empty requirements are valid.""" + result = validate_for_live(set(), []) + assert result.valid is True + + def test_configured_exchange(self): + """Test validation when exchange is configured.""" + result = validate_for_live({'binance'}, ['binance', 'kucoin']) + assert result.valid is True + + def test_missing_config(self): + """Test validation when exchange is not configured.""" + result = validate_for_live({'kraken'}, ['binance', 'kucoin']) + assert result.valid is False + assert result.error_code == ValidationErrorCode.MISSING_CONFIG + assert result.missing_exchanges == {'kraken'} + assert 'API keys' in result.message + + def test_multiple_missing(self): + """Test validation with multiple missing exchanges.""" + result = validate_for_live( + {'binance', 'kraken', 'bybit'}, + ['binance'] + ) + assert result.valid is False + assert result.missing_exchanges == {'kraken', 'bybit'} + + def test_canonicalization_in_comparison(self): + """Test that configured exchanges are canonicalized.""" + result = validate_for_live({'binance'}, ['BINANCE']) + assert result.valid is True + + +class TestValidateExchangeRequirements: + """Tests for main validation entrypoint.""" + + def test_routes_to_backtest(self): + """Test that backtest mode routes correctly.""" + result = validate_exchange_requirements( + required_exchanges={'binance'}, + user_configured_exchanges=[], + edm_available_exchanges=['binance'], + mode='backtest' + ) + assert result.valid is True + + def test_routes_to_paper(self): + """Test that paper mode routes correctly.""" + result = validate_exchange_requirements( + required_exchanges={'binance'}, + user_configured_exchanges=[], + edm_available_exchanges=[], + mode='paper' + ) + assert result.valid is True # binance is valid ccxt exchange + + def test_routes_to_live(self): + """Test that live mode routes correctly.""" + result = validate_exchange_requirements( + required_exchanges={'binance'}, + user_configured_exchanges=['binance'], + edm_available_exchanges=[], + mode='live' + ) + assert result.valid is True + + def test_empty_requirements_all_modes(self): + """Test that empty requirements are valid for all modes.""" + for mode in ['backtest', 'paper', 'live']: + result = validate_exchange_requirements( + required_exchanges=set(), + user_configured_exchanges=[], + edm_available_exchanges=[], + mode=mode + ) + assert result.valid is True, f"Failed for mode: {mode}" + + def test_unknown_mode_rejected(self): + """Test that unknown mode is rejected.""" + result = validate_exchange_requirements( + required_exchanges={'binance'}, + user_configured_exchanges=[], + edm_available_exchanges=[], + mode='invalid_mode' + ) + assert result.valid is False + assert 'Invalid trading mode' in result.message + + +class TestExchangeValidationResult: + """Tests for ExchangeValidationResult class.""" + + def test_valid_result_to_dict(self): + """Test serialization of valid result.""" + result = ExchangeValidationResult(valid=True) + d = result.to_dict() + assert d == {"valid": True} + + def test_invalid_result_to_dict(self): + """Test serialization of invalid result.""" + result = ExchangeValidationResult( + valid=False, + error_code=ValidationErrorCode.MISSING_CONFIG, + missing_exchanges={'binance', 'kucoin'}, + message="API keys required" + ) + d = result.to_dict() + assert d["valid"] is False + assert d["error_code"] == "missing_config" + assert set(d["missing_exchanges"]) == {'binance', 'kucoin'} + assert d["message"] == "API keys required" + + +class TestEdgeCases: + """Tests for edge cases and error handling.""" + + def test_none_values_in_data_sources(self): + """Test handling of None values in data sources.""" + strategy = { + 'strategy_components': { + 'data_sources': [ + (None, 'BTC/USDT', '1h'), + ('binance', 'BTC/USDT', '1h'), + ] + } + } + result = extract_required_exchanges(strategy) + assert result == {'binance'} + + def test_empty_tuple_in_data_sources(self): + """Test handling of empty tuples in data sources.""" + strategy = { + 'strategy_components': { + 'data_sources': [ + (), + ('binance', 'BTC/USDT', '1h'), + ] + } + } + result = extract_required_exchanges(strategy) + assert result == {'binance'} + + def test_single_element_tuple(self): + """Test handling of single element tuple (just exchange).""" + strategy = { + 'strategy_components': { + 'data_sources': [ + ('binance',), + ] + } + } + result = extract_required_exchanges(strategy) + assert result == {'binance'} diff --git a/tests/test_strategy_execution.py b/tests/test_strategy_execution.py index fc13e4b..43e3028 100644 --- a/tests/test_strategy_execution.py +++ b/tests/test_strategy_execution.py @@ -47,6 +47,16 @@ class TestStartStrategyValidation: bt.exchanges.get_exchange = MagicMock(return_value=mock_exchange) bt.exchanges.connect_exchange = MagicMock(return_value=True) + # Mock EDM client for exchange validation + bt.edm_client = MagicMock() + bt.edm_client.get_exchanges_sync = MagicMock(return_value=['binance', 'kucoin']) + + # Mock strategies.get_strategy_by_tbl_key for exchange validation + bt.strategies.get_strategy_by_tbl_key = MagicMock(return_value={ + 'strategy_components': {}, + 'default_source': {} + }) + return bt def test_start_strategy_invalid_mode(self, mock_brighter_trades): @@ -517,6 +527,10 @@ class TestLiveModeWarning: bt.exchanges.get_exchange = MagicMock(return_value=mock_exchange) bt.exchanges.connect_exchange = MagicMock(return_value=True) + # Mock EDM client for exchange validation + bt.edm_client = MagicMock() + bt.edm_client.get_exchanges_sync = MagicMock(return_value=['binance', 'kucoin']) + # Set up valid strategy mock_strategy = pd.DataFrame([{ 'tbl_key': 'test-strategy', @@ -531,6 +545,12 @@ class TestLiveModeWarning: strategy_name='Test Strategy' ) + # Mock strategies.get_strategy_by_tbl_key for exchange validation + bt.strategies.get_strategy_by_tbl_key = MagicMock(return_value={ + 'strategy_components': {}, + 'default_source': {} + }) + return bt def test_live_mode_returns_success(self, mock_brighter_trades):