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['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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
22
src/app.py
22
src/app.py
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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 ************************/
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">↻</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>
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue