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:
parent
91f51cd71f
commit
21d449d877
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
22
src/app.py
22
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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -298,6 +298,9 @@ height: 500px;
|
|||
margin-left: 15px;
|
||||
width: 110px;
|
||||
margin-top: 15px;
|
||||
height: 80px;
|
||||
overflow: scroll;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
/***********************Three Charts ************************/
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = '<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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<!-- 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 -->
|
||||
<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;">
|
||||
</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 -->
|
||||
<div id="exchange_status" style="text-align: center; padding: 10px; margin-top: 10px; display: none;">
|
||||
<span id="exchange_status_text"></span>
|
||||
|
|
|
|||
|
|
@ -21,7 +21,12 @@
|
|||
</span>
|
||||
</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">↻</button>
|
||||
</h3>
|
||||
<div id="balances" style="height: 160px">
|
||||
<div id="balances_tbl">
|
||||
<table>
|
||||
|
|
@ -34,13 +39,15 @@
|
|||
<tr>
|
||||
<td class="name-row" colspan="4">{{ name }}</td>
|
||||
</tr>
|
||||
{% if balances %}
|
||||
{% for balance in balances %}
|
||||
<tr>
|
||||
<td>{{ balance['asset'] }}</td>
|
||||
<td>{{ balance['balance'] }}</td>
|
||||
<td>{{ balance['pnl'] }}</td>
|
||||
<td>{{ balance['balance']|format_balance }}</td>
|
||||
<td>{{ balance['pnl']|format_balance }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -149,5 +149,53 @@ class TestBrighterTrades(unittest.TestCase):
|
|||
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__':
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -168,6 +168,29 @@ class TestExchange(unittest.TestCase):
|
|||
self.assertEqual(price, 0.0)
|
||||
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__':
|
||||
unittest.main()
|
||||
|
|
|
|||
Loading…
Reference in New Issue