This commit is contained in:
aaron 2026-03-30 11:34:15 +08:00
parent 2fe6a1602b
commit 520428895b
6 changed files with 161 additions and 31 deletions

View File

@ -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:

View File

@ -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}"

View File

@ -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

View File

@ -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")

View File

@ -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)

View File

@ -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': '手动',