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 = '
Asset
Balance
Profit & Loss
';
+ 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 += `
+
${balance.asset || ''}
+
${this.formatBalance(balance.balance)}
+
${this.formatBalance(balance.pnl)}
+
`;
+ }
+ }
+
+ if (!hasValidBalances) {
+ html += '
No balances available
';
+ }
+
+ html += '
';
+ 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 = '
Asset
Balance
Profit & Loss
';
+ // Only display the selected exchange's balances
+ let selectedExchange = this.selectedBalanceExchange;
- for (const [exchangeName, exchangeBalances] of Object.entries(balances)) {
- html += `
${exchangeName}
`;
- if (Array.isArray(exchangeBalances)) {
- for (const balance of exchangeBalances) {
- html += `
-
${balance.asset || ''}
-
${this.formatBalance(balance.balance)}
-
${this.formatBalance(balance.pnl)}
-
`;
+ // 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 += '
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 %}