diff --git a/backend/app/crypto_agent/crypto_agent.py b/backend/app/crypto_agent/crypto_agent.py index 1a26e25..f061be0 100644 --- a/backend/app/crypto_agent/crypto_agent.py +++ b/backend/app/crypto_agent/crypto_agent.py @@ -1137,10 +1137,10 @@ class CryptoAgent: next_decision = decision.get('next_decision') if decision_type == 'HOLD': - reasoning = decision.get('reasoning', decision.get('reason', '观望')) - logger.info(f"\n📊 交易决策: {reasoning}") + hold_reason = decision.get('reason', decision.get('reasoning', '观望')) + logger.info(f"\n📊 交易决策: {hold_reason}") await self._notify_signal_not_executed( - market_signal, decision, current_price, reason=f"[模拟盘] {reasoning}", prefix="[模拟盘]" + market_signal, decision, current_price, reason=f"[模拟盘] {hold_reason}", prefix="[模拟盘]" ) return @@ -2221,8 +2221,14 @@ class CryptoAgent: available = account.get('available', account.get('available_balance', 0)) balance = account.get('current_balance', 0) - if available <= 0 or balance <= 0: - return 0, "账户余额无效" + logger.info(f" 仓位计算: available=${available}, balance={balance}, account_keys={list(account.keys())}") + + if balance <= 0: + logger.warning(f" ❌ 账户权益无效: available={available}, balance={balance}") + return 0, "账户权益无效" + if available <= 0: + logger.warning(f" ❌ 可用保证金无效: available={available}, balance={balance}") + return 0, "可用保证金不足或账户可用余额读取失败" # 应用平台规则 rules = self.PLATFORM_RULES.get(platform_name, {}) @@ -2583,10 +2589,10 @@ class CryptoAgent: next_decision = decision.get('next_decision') if decision_type == 'HOLD': - reasoning = decision.get('reasoning', decision.get('reason', '观望')) - logger.info(f" Bitget 决策: {reasoning}") + hold_reason = decision.get('reason', decision.get('reasoning', '观望')) + logger.info(f" Bitget 决策: {hold_reason}") await self._notify_signal_not_executed( - market_signal, decision, current_price, reason=f"[Bitget] {reasoning}", prefix="[Bitget]" + market_signal, decision, current_price, reason=f"[Bitget] {hold_reason}", prefix="[Bitget]" ) return @@ -2925,10 +2931,10 @@ class CryptoAgent: next_decision = decision.get('next_decision') if decision_type == 'HOLD': - reasoning = decision.get('reasoning', decision.get('reason', '观望')) - logger.info(f" Hyperliquid 决策: {reasoning}") + hold_reason = decision.get('reason', decision.get('reasoning', '观望')) + logger.info(f" Hyperliquid 决策: {hold_reason}") await self._notify_signal_not_executed( - market_signal, decision, current_price, reason=f"[Hyperliquid] {reasoning}", prefix="[Hyperliquid]" + market_signal, decision, current_price, reason=f"[Hyperliquid] {hold_reason}", prefix="[Hyperliquid]" ) return @@ -3446,11 +3452,14 @@ class CryptoAgent: # 决策信息 decision_type = decision.get('decision', 'HOLD') + decision_reason = decision.get('reason', '') decision_reasoning = decision.get('reasoning', '') # 如果有外部传入的 reason(订单创建失败的具体原因),优先使用 if reason: final_reason = reason + elif decision_reason: + final_reason = decision_reason elif decision_reasoning: final_reason = decision_reasoning else: diff --git a/backend/app/services/bitget_live_trading_service.py b/backend/app/services/bitget_live_trading_service.py index fcbf357..d05f118 100644 --- a/backend/app/services/bitget_live_trading_service.py +++ b/backend/app/services/bitget_live_trading_service.py @@ -40,6 +40,17 @@ class BitgetLiveTradingService: 接口与 HyperliquidTradingService 保持一致,方便 crypto_agent.py 统一调用。 """ + @staticmethod + def _safe_float(*values: Any) -> float: + for value in values: + if value in (None, ''): + continue + try: + return float(value) + except (TypeError, ValueError): + continue + return 0.0 + def __init__(self): self.settings = get_settings() self.max_total_leverage: float = self.settings.bitget_max_total_leverage @@ -86,12 +97,45 @@ class BitgetLiveTradingService: balance = self.trading_api.get_balance() logger.debug(f"[Bitget] get_balance 原始返回: {balance}") - usdt = balance.get('USDT', {}) - available = float(usdt.get('available', 0) or 0) - frozen = float(usdt.get('frozen', 0) or 0) - equity = float(usdt.get('equity', 0) or 0) + usdt = balance.get('USDT') or balance.get('usdt') or {} + if not usdt: + for currency, asset in balance.items(): + if str(currency).upper() == 'USDT': + usdt = asset + break + + available = self._safe_float( + usdt.get('available'), + usdt.get('availableBalance'), + usdt.get('crossedMaxAvailable'), + usdt.get('maxTransferOut'), + usdt.get('free'), + ) + frozen = self._safe_float( + usdt.get('frozen'), + usdt.get('locked'), + usdt.get('occupied'), + usdt.get('used'), + ) + equity = self._safe_float( + usdt.get('equity'), + usdt.get('accountEquity'), + usdt.get('usdtEquity'), + usdt.get('balance'), + usdt.get('accountBalance'), + usdt.get('totalEquity'), + ) account_value = equity if equity > 0 else (available + frozen) + if available <= 0 and account_value > 0: + inferred_available = max(account_value - frozen, 0.0) + if inferred_available > 0: + logger.warning( + f"[Bitget] 可用余额字段缺失,使用 account_value - frozen 回退: " + f"${account_value:.2f} - ${frozen:.2f} = ${inferred_available:.2f}" + ) + available = inferred_available + logger.info( f"[Bitget] 账户状态: available=${available:.2f}, " f"frozen=${frozen:.2f}, equity=${equity:.2f}, account_value=${account_value:.2f}" diff --git a/backend/app/services/bitget_trading_api_sdk.py b/backend/app/services/bitget_trading_api_sdk.py index 50b0bf6..384d4ff 100644 --- a/backend/app/services/bitget_trading_api_sdk.py +++ b/backend/app/services/bitget_trading_api_sdk.py @@ -23,6 +23,15 @@ class BitgetTradingAPI: DEFAULT_PRODUCT_TYPE = 'USDT-FUTURES' DEFAULT_MARGIN_COIN = 'USDT' + @staticmethod + def _pick_first_value(entry: Dict[str, Any], *keys: str, default: str = '0') -> str: + """兼容 Bitget UTA 不同字段名,返回第一个非空值。""" + for key in keys: + value = entry.get(key) + if value not in (None, ''): + return str(value) + return default + def __init__(self, api_key: str, api_secret: str, passphrase: str = "", use_testnet: bool = True): """ 初始化 Bitget 交易 API @@ -709,17 +718,49 @@ class BitgetTradingAPI: response = self.exchange.privateUtaGetV3AccountAssets({ 'coin': self.DEFAULT_MARGIN_COIN, }) - assets = response.get('data', {}).get('assets', []) or [] + data = response.get('data', {}) + if isinstance(data, list): + assets = data + else: + assets = data.get('assets') or data.get('assetList') or data.get('list') or [] result = {} for entry in assets: - currency = entry.get('coin') + currency = entry.get('coin') or entry.get('marginCoin') or entry.get('asset') if not currency: continue + currency = str(currency).upper() result[currency] = { - 'available': str(entry.get('available', '0')), - 'frozen': str(entry.get('locked', '0')), - 'locked': str(entry.get('locked', '0')), - 'equity': str(entry.get('equity', entry.get('balance', '0'))), + 'available': self._pick_first_value( + entry, + 'available', + 'availableBalance', + 'crossedMaxAvailable', + 'maxTransferOut', + 'free', + ), + 'frozen': self._pick_first_value( + entry, + 'locked', + 'frozen', + 'occupied', + 'used', + ), + 'locked': self._pick_first_value( + entry, + 'locked', + 'frozen', + 'occupied', + 'used', + ), + 'equity': self._pick_first_value( + entry, + 'equity', + 'accountEquity', + 'usdtEquity', + 'balance', + 'accountBalance', + 'totalEquity', + ), } logger.debug(f"[UTA] 账户余额: {result}") return result diff --git a/backend/tests/test_bitget_live_trading_service.py b/backend/tests/test_bitget_live_trading_service.py index dc875e7..94e1b94 100644 --- a/backend/tests/test_bitget_live_trading_service.py +++ b/backend/tests/test_bitget_live_trading_service.py @@ -99,6 +99,20 @@ class TestGetAccountState: state = service.get_account_state() assert state['account_value'] == pytest.approx(0.0) + def test_alternative_fields_and_inferred_available_supported(self): + service, mock_api = make_service() + mock_api.get_balance.return_value = { + 'USDT': { + 'availableBalance': None, + 'locked': '10.0', + 'accountEquity': '210.0', + } + } + state = service.get_account_state() + assert state['account_value'] == pytest.approx(210.0) + assert state['available_balance'] == pytest.approx(200.0) + assert state['total_margin_used'] == pytest.approx(10.0) + def test_api_exception_propagates(self): service, mock_api = make_service() mock_api.get_balance.side_effect = Exception("network error") diff --git a/backend/tests/test_bitget_trading_api_sdk_unit.py b/backend/tests/test_bitget_trading_api_sdk_unit.py index dd43f80..a69726b 100644 --- a/backend/tests/test_bitget_trading_api_sdk_unit.py +++ b/backend/tests/test_bitget_trading_api_sdk_unit.py @@ -67,6 +67,37 @@ def test_get_balance_uses_usdt_futures_account_endpoint(): } +def test_get_balance_supports_alternative_uta_asset_fields(): + BitgetTradingAPI = load_bitget_sdk_class() + api = BitgetTradingAPI.__new__(BitgetTradingAPI) + api.exchange = MagicMock() + api.use_unified_account = True + api.exchange.privateUtaGetV3AccountAssets.return_value = { + 'code': '00000', + 'data': { + 'assetList': [ + { + 'asset': 'USDT', + 'availableBalance': '222.2', + 'occupied': '11.1', + 'accountEquity': '233.3', + } + ], + }, + } + + balance = api.get_balance() + + assert balance == { + 'USDT': { + 'available': '222.2', + 'frozen': '11.1', + 'locked': '11.1', + 'equity': '233.3', + } + } + + def test_set_leverage_uses_mix_contract_endpoint(): BitgetTradingAPI = load_bitget_sdk_class() api = BitgetTradingAPI.__new__(BitgetTradingAPI) diff --git a/frontend/trading.html b/frontend/trading.html index a77611b..64e1d9f 100644 --- a/frontend/trading.html +++ b/frontend/trading.html @@ -782,8 +782,7 @@ signal_grade_text: order.signal_grade || '-', signal_type_text: this.getSignalTypeText(order.signal_type), confidence_text: this.formatConfidence(order.confidence), - entry_type_text: this.getEntryTypeText(order.entry_type), - reason_preview: this.getPendingReasonPreview(order) + entry_type_text: this.getEntryTypeText(order.entry_type) }; }); }, @@ -1134,14 +1133,6 @@ return map[entryType] || entryType || ''; }, - getPendingReasonPreview(order) { - const reasons = Array.isArray(order.entry_reasons) - ? order.entry_reasons.filter(Boolean) - : []; - const baseText = reasons.length > 0 ? reasons[0] : '暂无入场理由'; - return baseText.length > 58 ? `${baseText.slice(0, 58)}...` : baseText; - }, - getCloseReason(reason) { const map = { 'manual': '手动',