Fix KuCoin balance fetching and add balance refresh functionality

- Add KuCoin-specific balance fetching with type='trade' parameter
- Reinitialize ccxt client in refresh_balances() to fix pickle corruption
- Force reconnection when exchange is restored from database cache
- Add balance refresh button and socket handler in frontend
- Fix template null check for balances display
- Clean up DataCache and candles imports

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rob 2026-03-06 06:10:31 -04:00
parent 91f51cd71f
commit 21d449d877
12 changed files with 346 additions and 55 deletions

View File

@ -1158,20 +1158,26 @@ class BrighterTrades:
result['status'] = 'failure' result['status'] = 'failure'
result['message'] = f'Failed to connect to {exchange_name}.' result['message'] = f'Failed to connect to {exchange_name}.'
else: else:
# Exchange is already connected, check if API keys need updating # Exchange is in cache but may have been restored from database with broken ccxt client.
# Check if api_keys has actual key/secret values (not just empty dict) # Always reconnect with API keys to ensure fresh connection.
if api_keys and api_keys.get('key') and api_keys.get('secret'): if api_keys and api_keys.get('key') and api_keys.get('secret'):
# Get current API keys # Force reconnection to get fresh ccxt client and balances
current_keys = self.users.get_api_keys(user_name, exchange_name) reconnect_ok = self.exchanges.connect_exchange(
exchange_name=exchange_name,
# Compare current keys with provided keys user_name=user_name,
if current_keys != api_keys: api_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.' if reconnect_ok:
result['status'] = 'success'
result['message'] = f'{exchange_name}: Reconnected with fresh data.'
else: 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['status'] = 'already_connected'
result['message'] = f'{exchange_name}: already connected (public).'
except Exception as e: except Exception as e:
result['status'] = 'error' result['status'] = 'error'
result['message'] = f"Failed to connect to {exchange_name} for user '{user_name}': {str(e)}" 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 no data is found to ensure the WebSocket channel isn't burdened with unnecessary
communication. 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: def standard_reply(reply_msg: str, reply_data: Any) -> dict:
""" Formats a standard reply message. """ """ 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) r_data = self.connect_or_config_exchange(user_name=user, exchange_name=exchange, api_keys=keys)
return standard_reply("Exchange_connection_result", r_data) 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 # Handle backtest operations
if msg_type == 'submit_backtest': if msg_type == 'submit_backtest':
# Validate required fields # Validate required fields

View File

@ -1572,14 +1572,8 @@ class IndicatorCache(ServerInteractions):
logger.error("EDM client not configured. Cannot fetch candle data for indicators.") logger.error("EDM client not configured. Cannot fetch candle data for indicators.")
return calculated_data return calculated_data
# Look up session_id for authenticated exchange access # Note: We don't use session_id for candle requests since candle data is public
session_id = None # and doesn't require authentication.
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
for interval_start, interval_end in missing_intervals: for interval_start, interval_end in missing_intervals:
# Ensure interval_start and interval_end are timezone-aware # Ensure interval_start and interval_end are timezone-aware
@ -1594,8 +1588,7 @@ class IndicatorCache(ServerInteractions):
symbol=symbol, symbol=symbol,
timeframe=timeframe, timeframe=timeframe,
start=interval_start, start=interval_start,
end=interval_end, end=interval_end
session_id=session_id
) )
if ohlc_data.empty or 'close' not in ohlc_data.columns: if ohlc_data.empty or 'close' not in ohlc_data.columns:
continue continue

View File

@ -23,13 +23,19 @@ class Exchange:
Parameters: Parameters:
name (str): The name of this exchange instance. 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') 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. testnet (bool): Whether to use testnet/sandbox mode. Defaults to False.
""" """
self.name = name self.name = name
self.api_key = api_keys['key'] if api_keys else None 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_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.configured = False
self.exchange_id = exchange_id self.exchange_id = exchange_id
self.testnet = testnet self.testnet = testnet
@ -67,6 +73,9 @@ class Exchange:
if self.api_key and self.api_key_secret: if self.api_key and self.api_key_secret:
config['apiKey'] = self.api_key config['apiKey'] = self.api_key
config['secret'] = self.api_key_secret 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) client = exchange_class(config)
@ -182,12 +191,20 @@ class Exchange:
""" """
if self.api_key and self.api_key_secret: if self.api_key and self.api_key_secret:
try: 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 = [] balances = []
for asset, balance in account_info['total'].items(): for asset, balance in account_info['total'].items():
asset_balance = float(balance) asset_balance = float(balance)
if asset_balance > 0: if asset_balance > 0:
balances.append({'asset': asset, 'balance': asset_balance, 'pnl': 0}) balances.append({'asset': asset, 'balance': asset_balance, 'pnl': 0})
logger.info(f"Fetched balances for {self.exchange_id}: {balances}")
return balances return balances
except ccxt.BaseError as e: except ccxt.BaseError as e:
logger.error(f"Error fetching balances: {str(e)}") logger.error(f"Error fetching balances: {str(e)}")
@ -258,6 +275,20 @@ class Exchange:
""" """
return self.balances 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: def get_symbol_precision_rule(self, symbol: str) -> int:
""" """
Returns the precision rule for a given symbol. Returns the precision rule for a given symbol.

View File

@ -114,28 +114,18 @@ class ExchangeInterface:
:return: True if successful, False otherwise. :return: True if successful, False otherwise.
""" """
try: try:
# Remove any existing exchange entry to prevent duplicates existing = None
# (get_exchange returns first match, so duplicates cause issues)
try: try:
# Preserve existing exchange until the replacement is created successfully.
existing = self.get_exchange(exchange_name, user_name) 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: 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(), exchange = Exchange(name=exchange_name, api_keys=api_keys, exchange_id=exchange_name.lower(),
testnet=testnet) testnet=testnet)
# Create EDM session for authenticated exchange access # 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: try:
session_id = self.edm_client.create_session_sync() session_id = self.edm_client.create_session_sync()
self.edm_client.add_credentials_sync( self.edm_client.add_credentials_sync(
@ -143,6 +133,7 @@ class ExchangeInterface:
exchange=exchange_name.lower(), exchange=exchange_name.lower(),
api_key=api_keys['key'], api_key=api_keys['key'],
api_secret=api_keys['secret'], api_secret=api_keys['secret'],
passphrase=api_keys.get('passphrase') or api_keys.get('password'),
testnet=testnet testnet=testnet
) )
exchange.edm_session_id = session_id exchange.edm_session_id = session_id
@ -150,6 +141,19 @@ class ExchangeInterface:
except Exception as e: except Exception as e:
logger.warning(f"Failed to create EDM session for {exchange_name}: {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) self.add_exchange(user_name, exchange)
return True return True
except Exception as e: 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 a dictionary where exchange 'name' is the key and 'balances' is the value
return {row['name']: row['balances'] for _, row in filtered_data.iterrows()} 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]]]: 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. Get active trades or open orders for all connected exchanges.

View File

@ -40,6 +40,28 @@ app = Flask(__name__)
# Create a socket in order to receive requests. # Create a socket in order to receive requests.
socketio = SocketIO(app, async_mode='eventlet') 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, # Create a BrighterTrades object. This the main application that maintains access to the server, local storage,
# and manages objects that process trade data. # and manages objects that process trade data.
brighter_trades = BrighterTrades(socketio) brighter_trades = BrighterTrades(socketio)

View File

@ -65,20 +65,14 @@ class Candles:
if self.edm is None: if self.edm is None:
raise RuntimeError("EDM client not initialized. Cannot fetch candle data.") raise RuntimeError("EDM client not initialized. Cannot fetch candle data.")
# Look up session_id from exchange if not provided # Note: We don't pass session_id for candle requests since candle data is public
if session_id is None and user_name and self.exchanges: # and doesn't require authentication. Using session_id can cause issues if the
try: # session has expired or the exchange connector isn't properly initialized.
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
candles = self.edm.get_candles_sync( candles = self.edm.get_candles_sync(
exchange=exchange, exchange=exchange,
symbol=asset, symbol=asset,
timeframe=timeframe, timeframe=timeframe,
limit=num_candles, limit=num_candles
session_id=session_id
) )
if candles.empty: if candles.empty:

View File

@ -298,6 +298,9 @@ height: 500px;
margin-left: 15px; margin-left: 15px;
width: 110px; width: 110px;
margin-top: 15px; margin-top: 15px;
height: 80px;
overflow: scroll;
scrollbar-width: none;
} }
/***********************Three Charts ************************/ /***********************Three Charts ************************/

View File

@ -14,9 +14,10 @@ class Exchanges {
// Extract the text content from each span and store it in the connected_exchanges array // 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()); 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) { 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('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(); location.reload();
}, 1500); }, 1500);
} else if (data.status === 'already_connected') { } 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') { } else if (data.status === 'failure' || data.status === 'error') {
this.showStatus(data.message || 'Failed to connect exchange.', 'error'); this.showStatus(data.message || 'Failed to connect exchange.', 'error');
} else { } else {
@ -112,7 +113,11 @@ class Exchanges {
let user = window.UI.data.user_name; let user = window.UI.data.user_name;
let key = document.getElementById('api_key').value; let key = document.getElementById('api_key').value;
let secret_key = document.getElementById('api_secret_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 }; let keys = { 'key': key, 'secret': secret_key };
if (passphrase) {
keys.passphrase = passphrase;
}
// Determine if validation is required based on the exchange type. // Determine if validation is required based on the exchange type.
const isPublicExchange = bt_data.public_exchanges.includes(exchange); const isPublicExchange = bt_data.public_exchanges.includes(exchange);
@ -123,11 +128,11 @@ class Exchanges {
// For non-public exchanges, API keys are required // For non-public exchanges, API keys are required
if (isPublicExchange) { if (isPublicExchange) {
// If user provided keys, use them; otherwise connect without keys // 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 keys = {}; // No keys provided, connect as public
} else if (!isKeyValid || !isSecretKeyValid) { } else if (!isKeyValid || !isSecretKeyValid) {
// User provided partial keys - warn them // 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; return;
} }
// If both keys are valid, keep them (keys object already set) // If both keys are valid, keep them (keys object already set)
@ -137,6 +142,12 @@ class Exchanges {
return; // Exit early if validation fails 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 // Show loading state
this.isSubmitting = true; this.isSubmitting = true;
const connectBtn = document.getElementById('exchange_connect_btn'); const connectBtn = document.getElementById('exchange_connect_btn');
@ -162,4 +173,83 @@ class Exchanges {
} }
}, 30000); // 30 second timeout }, 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 = '<table><tr><th>Asset</th><th>Balance</th><th>Profit & Loss</th></tr>';
for (const [exchangeName, exchangeBalances] of Object.entries(balances)) {
html += `<tr><td class="name-row" colspan="4">${exchangeName}</td></tr>`;
if (Array.isArray(exchangeBalances)) {
for (const balance of exchangeBalances) {
html += `<tr>
<td>${balance.asset || ''}</td>
<td>${this.formatBalance(balance.balance)}</td>
<td>${this.formatBalance(balance.pnl)}</td>
</tr>`;
}
}
}
html += '</table>';
tbl.innerHTML = html;
}
formatBalance(value) {
if (value === null || value === undefined) return '0';
const val = parseFloat(value);
if (isNaN(val)) return String(value);
if (val === 0) return '0';
if (Math.abs(val) < 0.00001) {
return val.toFixed(8).replace(/\.?0+$/, '');
} else if (Math.abs(val) < 1) {
return val.toFixed(6).replace(/\.?0+$/, '');
} else if (Math.abs(val) < 1000) {
return val.toFixed(4).replace(/\.?0+$/, '');
} else {
return val.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
}
} }

View File

@ -1,5 +1,5 @@
<!-- Exchange Config Form Popup --> <!-- Exchange Config Form Popup -->
<div class="form-popup" id="exchanges_config_form" style="display: none; overflow: hidden; position: absolute; width: 400px; height: 320px; border-radius: 10px;"> <div class="form-popup" id="exchanges_config_form" style="display: none; overflow: hidden; position: absolute; width: 400px; height: 380px; border-radius: 10px;">
<!-- Draggable Header Section --> <!-- Draggable Header Section -->
<div id="exchange_draggable_header" style="cursor: move; padding: 10px; background-color: #3E3AF2; color: white; border-bottom: 1px solid #ccc;"> <div id="exchange_draggable_header" style="cursor: move; padding: 10px; background-color: #3E3AF2; color: white; border-bottom: 1px solid #ccc;">
@ -30,6 +30,12 @@
<input name="api_secret_key" id="api_secret_key" type="password" style="width: 100%; padding: 6px; box-sizing: border-box;"> <input name="api_secret_key" id="api_secret_key" type="password" style="width: 100%; padding: 6px; box-sizing: border-box;">
</div> </div>
<!-- API Passphrase (KuCoin and similar exchanges) -->
<div style="margin-bottom: 12px;">
<label for="api_passphrase" style="display: block; margin-bottom: 5px;"><b>API Passphrase:</b></label>
<input name="api_passphrase" id="api_passphrase" type="password" style="width: 100%; padding: 6px; box-sizing: border-box;" placeholder="Not required for all exchanges">
</div>
<!-- Status message area --> <!-- Status message area -->
<div id="exchange_status" style="text-align: center; padding: 10px; margin-top: 10px; display: none;"> <div id="exchange_status" style="text-align: center; padding: 10px; margin-top: 10px; display: none;">
<span id="exchange_status_text"></span> <span id="exchange_status_text"></span>

View File

@ -21,7 +21,12 @@
</span> </span>
</div> </div>
<div> <div>
<h3>Balances</h3> <h3 style="display: flex; align-items: center; gap: 10px;">
Balances
<button id="refresh_balances_btn" onclick="UI.exchanges.refreshBalances()"
style="font-size: 12px; padding: 2px 8px; cursor: pointer;"
title="Refresh balances from exchange">&#x21bb;</button>
</h3>
<div id="balances" style="height: 160px"> <div id="balances" style="height: 160px">
<div id="balances_tbl"> <div id="balances_tbl">
<table> <table>
@ -34,13 +39,15 @@
<tr> <tr>
<td class="name-row" colspan="4">{{ name }}</td> <td class="name-row" colspan="4">{{ name }}</td>
</tr> </tr>
{% if balances %}
{% for balance in balances %} {% for balance in balances %}
<tr> <tr>
<td>{{ balance['asset'] }}</td> <td>{{ balance['asset'] }}</td>
<td>{{ balance['balance'] }}</td> <td>{{ balance['balance']|format_balance }}</td>
<td>{{ balance['pnl'] }}</td> <td>{{ balance['pnl']|format_balance }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
{% endif %}
{% endfor %} {% endfor %}
</table> </table>
</div> </div>

View File

@ -149,5 +149,53 @@ class TestBrighterTrades(unittest.TestCase):
user_name='testuser' user_name='testuser'
) )
def test_connect_or_config_exchange_reconnects_when_credentials_change(self):
"""Existing exchange should reconnect immediately when API credentials change."""
connected_cache = MagicMock()
connected_cache.empty = False
self.mock_data_cache.get_serialized_datacache.return_value = connected_cache
old_keys = {'key': 'old_key', 'secret': 'old_secret', 'passphrase': 'old_phrase'}
new_keys = {'key': 'new_key', 'secret': 'new_secret', 'passphrase': 'new_phrase'}
self.mock_users.get_api_keys.return_value = old_keys
self.mock_exchanges.connect_exchange.return_value = True
result = self.brighter_trades.connect_or_config_exchange(
user_name='testuser',
exchange_name='kucoin',
api_keys=new_keys
)
self.assertEqual(result['status'], 'success')
self.mock_exchanges.connect_exchange.assert_called_with(
exchange_name='kucoin',
user_name='testuser',
api_keys=new_keys
)
self.mock_users.update_api_keys.assert_called_with(
api_keys=new_keys,
exchange='kucoin',
user_name='testuser'
)
def test_connect_or_config_exchange_no_reconnect_when_credentials_unchanged(self):
"""Existing exchange should remain connected when submitted credentials are unchanged."""
connected_cache = MagicMock()
connected_cache.empty = False
self.mock_data_cache.get_serialized_datacache.return_value = connected_cache
same_keys = {'key': 'same_key', 'secret': 'same_secret', 'passphrase': 'same_phrase'}
self.mock_users.get_api_keys.return_value = same_keys
result = self.brighter_trades.connect_or_config_exchange(
user_name='testuser',
exchange_name='kucoin',
api_keys=same_keys
)
self.assertEqual(result['status'], 'already_connected')
self.mock_exchanges.connect_exchange.assert_not_called()
self.mock_users.update_api_keys.assert_not_called()
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()

View File

@ -168,6 +168,29 @@ class TestExchange(unittest.TestCase):
self.assertEqual(price, 0.0) self.assertEqual(price, 0.0)
self.mock_client.fetch_ticker.assert_called_with('BTC/USDT') self.mock_client.fetch_ticker.assert_called_with('BTC/USDT')
@patch('ccxt.kucoin')
def test_connect_exchange_uses_password_for_passphrase(self, mock_kucoin):
mock_client = MagicMock()
mock_kucoin.return_value = mock_client
mock_client.fetch_open_orders.return_value = []
mock_client.load_markets.return_value = {}
mock_client.fetch_markets.return_value = []
mock_client.fetch_balance.return_value = {'total': {}}
mock_client.timeframes = {}
api_keys = {
'key': 'kucoin_key',
'secret': 'kucoin_secret',
'passphrase': 'kucoin_passphrase'
}
Exchange(name='KuCoin', api_keys=api_keys, exchange_id='kucoin')
self.assertTrue(mock_kucoin.called)
config = mock_kucoin.call_args.args[0]
self.assertEqual(config.get('apiKey'), 'kucoin_key')
self.assertEqual(config.get('secret'), 'kucoin_secret')
self.assertEqual(config.get('password'), 'kucoin_passphrase')
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()