diff --git a/src/BrighterTrades.py b/src/BrighterTrades.py index 0039500..9e7b703 100644 --- a/src/BrighterTrades.py +++ b/src/BrighterTrades.py @@ -1158,20 +1158,26 @@ class BrighterTrades: result['status'] = 'failure' result['message'] = f'Failed to connect to {exchange_name}.' else: - # Exchange is already connected, check if API keys need updating - # Check if api_keys has actual key/secret values (not just empty dict) + # 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'): - # Get current API keys - current_keys = self.users.get_api_keys(user_name, exchange_name) - - # Compare current keys with provided keys - if current_keys != api_keys: - self.users.update_api_keys(api_keys=api_keys, exchange=exchange_name, user_name=user_name) - result['message'] = f'{exchange_name}: API keys updated.' + # Force reconnection to get fresh ccxt client and balances + reconnect_ok = self.exchanges.connect_exchange( + exchange_name=exchange_name, + user_name=user_name, + api_keys=api_keys + ) + if reconnect_ok: + result['status'] = 'success' + result['message'] = f'{exchange_name}: Reconnected with fresh data.' else: - result['message'] = f'{exchange_name}: API keys unchanged.' + 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).' - result['status'] = 'already_connected' except Exception as e: result['status'] = 'error' result['message'] = f"Failed to connect to {exchange_name} for user '{user_name}': {str(e)}" @@ -1408,6 +1414,9 @@ class BrighterTrades: 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. """ @@ -1535,6 +1544,28 @@ class BrighterTrades: 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 diff --git a/src/DataCache_v3.py b/src/DataCache_v3.py index 7df70c8..8f09c2e 100644 --- a/src/DataCache_v3.py +++ b/src/DataCache_v3.py @@ -1572,14 +1572,8 @@ class IndicatorCache(ServerInteractions): logger.error("EDM client not configured. Cannot fetch candle data for indicators.") return calculated_data - # Look up session_id for authenticated exchange access - session_id = None - if user_name and self.exchanges: - try: - ex = self.exchanges.get_exchange(ename=exchange_name, uname=user_name) - session_id = getattr(ex, 'edm_session_id', None) - except Exception: - pass + # Note: We don't use session_id for candle requests since candle data is public + # and doesn't require authentication. for interval_start, interval_end in missing_intervals: # Ensure interval_start and interval_end are timezone-aware @@ -1594,8 +1588,7 @@ class IndicatorCache(ServerInteractions): symbol=symbol, timeframe=timeframe, start=interval_start, - end=interval_end, - session_id=session_id + end=interval_end ) if ohlc_data.empty or 'close' not in ohlc_data.columns: continue diff --git a/src/Exchange.py b/src/Exchange.py index 1bf4339..7063fbc 100644 --- a/src/Exchange.py +++ b/src/Exchange.py @@ -23,13 +23,19 @@ class Exchange: Parameters: name (str): The name of this exchange instance. - api_keys (Dict[str, str]): Dictionary containing 'key' and 'secret' for API authentication. + api_keys (Dict[str, str]): Dictionary containing API credentials. + Supports: + - key / secret + - optional passphrase (stored as 'passphrase' or legacy 'password') exchange_id (str): The ID of the exchange as recognized by CCXT. Example('binance') testnet (bool): Whether to use testnet/sandbox mode. Defaults to False. """ self.name = name self.api_key = api_keys['key'] if api_keys else None self.api_key_secret = api_keys['secret'] if api_keys else None + self.api_passphrase = ( + api_keys.get('passphrase') or api_keys.get('password') + ) if api_keys else None self.configured = False self.exchange_id = exchange_id self.testnet = testnet @@ -67,6 +73,9 @@ class Exchange: if self.api_key and self.api_key_secret: config['apiKey'] = self.api_key config['secret'] = self.api_key_secret + if self.api_passphrase: + # CCXT uses `password` for exchange passphrases (e.g., KuCoin). + config['password'] = self.api_passphrase client = exchange_class(config) @@ -182,12 +191,20 @@ class Exchange: """ if self.api_key and self.api_key_secret: try: - account_info = self.client.fetch_balance() + # KuCoin has separate accounts (main, trade, margin, futures) + # We need to explicitly request the 'trade' account for spot trading + params = {} + if self.exchange_id.lower() == 'kucoin': + params['type'] = 'trade' + logger.info(f"Fetching KuCoin balance with params: {params}, testnet={self.testnet}") + + account_info = self.client.fetch_balance(params) balances = [] for asset, balance in account_info['total'].items(): asset_balance = float(balance) if asset_balance > 0: balances.append({'asset': asset, 'balance': asset_balance, 'pnl': 0}) + logger.info(f"Fetched balances for {self.exchange_id}: {balances}") return balances except ccxt.BaseError as e: logger.error(f"Error fetching balances: {str(e)}") @@ -258,6 +275,20 @@ class Exchange: """ return self.balances + def refresh_balances(self) -> List[Dict[str, Union[str, float]]]: + """ + Refreshes and returns the balances from the exchange. + Reinitializes the ccxt client to handle pickle corruption issues. + + Returns: + List[Dict[str, Union[str, float]]]: Updated list of balances. + """ + # Reinitialize the ccxt client to fix any pickle corruption + # (ccxt clients don't survive pickle/unpickle properly) + self.client = self._connect_exchange() + self.balances = self._set_balances() + return self.balances + def get_symbol_precision_rule(self, symbol: str) -> int: """ Returns the precision rule for a given symbol. diff --git a/src/ExchangeInterface.py b/src/ExchangeInterface.py index e55d8d0..99a4830 100644 --- a/src/ExchangeInterface.py +++ b/src/ExchangeInterface.py @@ -114,28 +114,18 @@ class ExchangeInterface: :return: True if successful, False otherwise. """ try: - # Remove any existing exchange entry to prevent duplicates - # (get_exchange returns first match, so duplicates cause issues) + existing = None try: + # Preserve existing exchange until the replacement is created successfully. existing = self.get_exchange(exchange_name, user_name) - # Clean up existing EDM session if present - if existing and existing.edm_session_id and self.edm_client: - try: - self.edm_client.delete_session_sync(existing.edm_session_id) - except Exception as e: - logger.warning(f"Failed to delete old EDM session: {e}") - self.cache_manager.remove_row_from_datacache( - cache_name='exchange_data', - filter_vals=[('user', user_name), ('name', exchange_name)] - ) except Exception: - pass # No existing entry to remove, that's fine + pass # No existing entry to replace, that's fine exchange = Exchange(name=exchange_name, api_keys=api_keys, exchange_id=exchange_name.lower(), testnet=testnet) # Create EDM session for authenticated exchange access - if self.edm_client and api_keys: + if self.edm_client and api_keys and api_keys.get('key') and api_keys.get('secret'): try: session_id = self.edm_client.create_session_sync() self.edm_client.add_credentials_sync( @@ -143,6 +133,7 @@ class ExchangeInterface: exchange=exchange_name.lower(), api_key=api_keys['key'], api_secret=api_keys['secret'], + passphrase=api_keys.get('passphrase') or api_keys.get('password'), testnet=testnet ) exchange.edm_session_id = session_id @@ -150,6 +141,19 @@ 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 + self.cache_manager.remove_row_from_datacache( + cache_name='exchange_data', + filter_vals=[('user', user_name), ('name', exchange_name)] + ) + 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) return True except Exception as e: @@ -253,6 +257,45 @@ class ExchangeInterface: # Return a dictionary where exchange 'name' is the key and 'balances' is the value return {row['name']: row['balances'] for _, row in filtered_data.iterrows()} + def refresh_all_balances(self, user_name: str) -> Dict[str, List[Dict[str, Any]]]: + """ + Refresh balances from all connected exchanges for a user. + + :param user_name: The name of the user. + :return: A dictionary containing the refreshed balances of all connected exchanges. + """ + exchanges = self.cache_manager.get_serialized_datacache( + cache_name='exchange_data', + filter_vals=[('user', user_name)] + ) + + if exchanges.empty: + return {} + + result = {} + for _, row in exchanges.iterrows(): + exchange_name = row['name'] + exchange_ref = row['reference'] + try: + # Refresh balances from the exchange + logger.info(f"Refreshing {exchange_name} for user {user_name}, testnet={getattr(exchange_ref, 'testnet', 'unknown')}") + new_balances = exchange_ref.refresh_balances() + result[exchange_name] = new_balances + + # Update the cache with new balances + self.cache_manager.modify_datacache_item( + cache_name='exchange_data', + filter_vals=[('user', user_name), ('name', exchange_name)], + field_names=('balances',), + new_values=(new_balances,) + ) + logger.info(f"Refreshed balances for {exchange_name}: {len(new_balances)} assets") + except Exception as e: + logger.warning(f"Failed to refresh balances for {exchange_name}: {e}") + result[exchange_name] = row.get('balances', []) + + return result + def get_all_activated(self, user_name: str, fetch_type: str = 'trades') -> Dict[str, List[Dict[str, Any]]]: """ Get active trades or open orders for all connected exchanges. diff --git a/src/app.py b/src/app.py index 3775eca..66fae19 100644 --- a/src/app.py +++ b/src/app.py @@ -40,6 +40,28 @@ app = Flask(__name__) # Create a socket in order to receive requests. socketio = SocketIO(app, async_mode='eventlet') + +# Custom Jinja2 filter to format small balances properly (avoid scientific notation) +@app.template_filter('format_balance') +def format_balance(value): + """Format balance to avoid scientific notation for small values.""" + if value is None: + return '0' + try: + val = float(value) + if val == 0: + return '0' + elif abs(val) < 0.00001: + return f'{val:.8f}'.rstrip('0').rstrip('.') + elif abs(val) < 1: + return f'{val:.6f}'.rstrip('0').rstrip('.') + elif abs(val) < 1000: + return f'{val:.4f}'.rstrip('0').rstrip('.') + else: + return f'{val:,.2f}' + except (ValueError, TypeError): + return str(value) + # Create a BrighterTrades object. This the main application that maintains access to the server, local storage, # and manages objects that process trade data. brighter_trades = BrighterTrades(socketio) diff --git a/src/candles.py b/src/candles.py index be3dea7..9f00ae0 100644 --- a/src/candles.py +++ b/src/candles.py @@ -65,20 +65,14 @@ class Candles: if self.edm is None: raise RuntimeError("EDM client not initialized. Cannot fetch candle data.") - # Look up session_id from exchange if not provided - if session_id is None and user_name and self.exchanges: - try: - ex = self.exchanges.get_exchange(ename=exchange, uname=user_name) - session_id = getattr(ex, 'edm_session_id', None) - except Exception: - pass # No configured exchange, use public access - + # Note: We don't pass session_id for candle requests since candle data is public + # and doesn't require authentication. Using session_id can cause issues if the + # session has expired or the exchange connector isn't properly initialized. candles = self.edm.get_candles_sync( exchange=exchange, symbol=asset, timeframe=timeframe, - limit=num_candles, - session_id=session_id + limit=num_candles ) if candles.empty: diff --git a/src/static/brighterStyles.css b/src/static/brighterStyles.css index 705c3ca..4bc80b7 100644 --- a/src/static/brighterStyles.css +++ b/src/static/brighterStyles.css @@ -298,6 +298,9 @@ height: 500px; margin-left: 15px; width: 110px; margin-top: 15px; + height: 80px; + overflow: scroll; + scrollbar-width: none; } /***********************Three Charts ************************/ diff --git a/src/static/exchanges.js b/src/static/exchanges.js index 0db2358..928a8c5 100644 --- a/src/static/exchanges.js +++ b/src/static/exchanges.js @@ -14,9 +14,10 @@ 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()); - // Register handler for exchange connection results + // 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)); + window.UI.data.comms.on('balances_refreshed', this.handleBalancesRefreshed.bind(this)); } } @@ -88,7 +89,7 @@ class Exchanges { location.reload(); }, 1500); } else if (data.status === 'already_connected') { - this.showStatus(data.message || 'Exchange is already connected.', 'error'); + this.showStatus(data.message || 'Exchange is already connected.', 'loading'); } else if (data.status === 'failure' || data.status === 'error') { this.showStatus(data.message || 'Failed to connect exchange.', 'error'); } else { @@ -112,7 +113,11 @@ class Exchanges { let user = window.UI.data.user_name; let key = document.getElementById('api_key').value; let secret_key = document.getElementById('api_secret_key').value; + let passphrase = document.getElementById('api_passphrase').value; let keys = { 'key': key, 'secret': secret_key }; + if (passphrase) { + keys.passphrase = passphrase; + } // Determine if validation is required based on the exchange type. const isPublicExchange = bt_data.public_exchanges.includes(exchange); @@ -123,11 +128,11 @@ class Exchanges { // For non-public exchanges, API keys are required if (isPublicExchange) { // If user provided keys, use them; otherwise connect without keys - if (!key && !secret_key) { + if (!key && !secret_key && !passphrase) { keys = {}; // No keys provided, connect as public } else if (!isKeyValid || !isSecretKeyValid) { // User provided partial keys - warn them - this.showStatus('Please enter both API key and secret, or leave both empty for public access.', 'error'); + this.showStatus('Enter API key and secret together (and passphrase if required), or leave all fields empty for public access.', 'error'); return; } // If both keys are valid, keep them (keys object already set) @@ -137,6 +142,12 @@ class Exchanges { return; // Exit early if validation fails } + // KuCoin requires API passphrase for authenticated requests. + if (exchange.toLowerCase() === 'kucoin' && key && secret_key && !passphrase) { + this.showStatus('KuCoin requires an API passphrase.', 'error'); + return; + } + // Show loading state this.isSubmitting = true; const connectBtn = document.getElementById('exchange_connect_btn'); @@ -162,4 +173,83 @@ class Exchanges { } }, 30000); // 30 second timeout } + + refreshBalances() { + console.log('refreshBalances() called'); + const btn = document.getElementById('refresh_balances_btn'); + if (btn) { + btn.disabled = true; + btn.textContent = '...'; + } + + console.log('Sending refresh_balances message for user:', window.UI.data.user_name); + window.UI.data.comms.sendToApp("refresh_balances", { + user: window.UI.data.user_name + }); + + // Set a timeout in case we don't get a response + setTimeout(() => { + if (btn && btn.disabled) { + btn.disabled = false; + btn.textContent = '↻'; + } + }, 15000); // 15 second timeout + } + + handleBalancesRefreshed(data) { + console.log('handleBalancesRefreshed called with:', data); + const btn = document.getElementById('refresh_balances_btn'); + if (btn) { + btn.disabled = false; + btn.textContent = '↻'; + } + + if (data.success && data.balances) { + console.log('Updating balances table with:', data.balances); + this.updateBalancesTable(data.balances); + } else if (!data.success) { + console.error('Failed to refresh balances:', data.message); + } + } + + updateBalancesTable(balances) { + const tbl = document.getElementById('balances_tbl'); + if (!tbl) return; + + // Build new table HTML + let html = '
| Asset | Balance | Profit & Loss | |
|---|---|---|---|
| ${exchangeName} | |||
| ${balance.asset || ''} | +${this.formatBalance(balance.balance)} | +${this.formatBalance(balance.pnl)} | +|