This commit is contained in:
aaron 2026-03-30 09:20:59 +08:00
parent f251656690
commit e0e89624d0
2 changed files with 114 additions and 212 deletions

View File

@ -701,41 +701,14 @@ class BitgetTradingAPI:
Returns:
余额信息 {USDT: {available: "...", frozen: "...", locked: "...", equity: "..."}}
查询策略
1. 优先使用 ccxt 统一接口 fetch_balance()兼容 UTA / 经典账户
2. fetch_balance() 失败时 use_unified_account 回退到原始 API
注意Bitget UTA统一账户模式下ccxt fetch_balance() 会报错
"Classic Account API is not supported"必须使用 privateUtaGetV3AccountAssets
"""
# ---------- 策略 1: ccxt fetch_balance ----------
try:
balance = self.exchange.fetch_balance({'type': 'swap'})
if balance and isinstance(balance, dict):
usdt_info = balance.get('USDT')
if usdt_info and isinstance(usdt_info, dict):
available = usdt_info.get('free', 0) or 0
used = usdt_info.get('used', 0) or 0
total = usdt_info.get('total', 0) or 0
result = {
'USDT': {
'available': str(available),
'frozen': str(used),
'locked': str(used),
'equity': str(total if total else (available + used)),
}
}
logger.debug(f"[fetch_balance] USDT available={available}, used={used}, total={total}")
return result
else:
logger.warning(f"[fetch_balance] 返回中无 USDT 字段: {list(balance.keys())[:10]}")
except Exception as e:
logger.warning(f"[fetch_balance] 查询失败,回退到原始 API: {e}")
# ---------- 策略 2: 原始 APIUTA / 合约账户) ----------
try:
if self.use_unified_account:
response = self.exchange.privateUtaGetV3AccountAssets({
'coin': self.DEFAULT_MARGIN_COIN,
})
logger.debug(f"[UTA API] 原始响应: {response}")
assets = response.get('data', {}).get('assets', []) or []
result = {}
for entry in assets:
@ -748,14 +721,12 @@ class BitgetTradingAPI:
'locked': str(entry.get('locked', '0')),
'equity': str(entry.get('equity', entry.get('balance', '0'))),
}
logger.debug(f"[UTA API] 账户余额: {result}")
logger.debug(f"[UTA] 账户余额: {result}")
return result
response = self.exchange.privateMixGetV2MixAccountAccounts({
'productType': self.DEFAULT_PRODUCT_TYPE,
})
logger.debug(f"[合约 API] 原始响应: {response}")
result = {}
for entry in response.get('data', []) or []:
currency = entry.get('marginCoin')
@ -767,8 +738,7 @@ class BitgetTradingAPI:
'locked': str(entry.get('locked', '0')),
'equity': str(entry.get('accountEquity', entry.get('usdtEquity', '0'))),
}
logger.debug(f"[合约 API] 账户余额: {result}")
logger.debug(f"[合约] 账户余额: {result}")
return result
except ccxt.BaseError as e:

View File

@ -2,20 +2,18 @@
BitgetTradingAPI.get_balance() 单元测试
覆盖场景
1. fetch_balance 成功 直接返回不调原始 API
2. fetch_balance USDT 回退到原始 API (UTA)
3. fetch_balance USDT 回退到原始 API (合约)
4. fetch_balance 抛异常 回退到原始 API (UTA)
5. fetch_balance 和原始 API 都失败 返回 {}
6. 余额为 0 / None 的边界情况
7. get_account_state 端到端解析
1. UTA 模式 privateUtaGetV3AccountAssets真实路径
2. 经典账户模式 privateMixGetV2MixAccountAccounts
3. API 异常 返回 {}
4. assets / USDT
5. get_account_state 端到端解析
"""
import importlib.util
import os
import sys
import types
from pathlib import Path
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
@ -32,13 +30,11 @@ def load_bitget_sdk_class():
ccxt_module.bitget = MagicMock()
sys.modules['ccxt'] = ccxt_module
# mock app.config
if 'app.config' not in sys.modules:
mock_cfg = types.ModuleType('app.config')
mock_cfg.get_settings = MagicMock()
sys.modules['app.config'] = mock_cfg
# mock app.utils.logger
if 'app.utils.logger' not in sys.modules:
mock_log = types.ModuleType('app.utils.logger')
mock_log.logger = MagicMock()
@ -53,81 +49,24 @@ def load_bitget_sdk_class():
# ──────────────────────────────────────────────
# 测试 1: fetch_balance 成功,直接返回
# 测试 1: UTA 模式,正常返回(匹配真实 API
# ──────────────────────────────────────────────
def test_fetch_balance_success():
"""fetch_balance 返回 USDT不走原始 API"""
def test_uta_balance_success():
"""UTA 模式 → privateUtaGetV3AccountAssets 返回 1975.37"""
BitgetTradingAPI = load_bitget_sdk_class()
api = BitgetTradingAPI.__new__(BitgetTradingAPI)
api.exchange = MagicMock()
api.use_unified_account = True
api.exchange.fetch_balance.return_value = {
'USDT': {'free': 1975.0, 'used': 50.0, 'total': 2025.0},
'free': {'USDT': 1975.0},
'used': {'USDT': 50.0},
'total': {'USDT': 2025.0},
}
balance = api.get_balance()
assert balance == {
'USDT': {
'available': '1975.0',
'frozen': '50.0',
'locked': '50.0',
'equity': '2025.0',
}
}
# 不应调原始 API
api.exchange.privateUtaGetV3AccountAssets.assert_not_called()
# ──────────────────────────────────────────────
# 测试 2: fetch_balance 成功,余额为 0
# ──────────────────────────────────────────────
def test_fetch_balance_zero_balance():
"""fetch_balance 返回 0 余额,应正确解析"""
BitgetTradingAPI = load_bitget_sdk_class()
api = BitgetTradingAPI.__new__(BitgetTradingAPI)
api.exchange = MagicMock()
api.use_unified_account = True
api.exchange.fetch_balance.return_value = {
'USDT': {'free': 0, 'used': 0, 'total': 0},
}
balance = api.get_balance()
assert balance['USDT']['available'] == '0'
assert balance['USDT']['equity'] == '0'
# ──────────────────────────────────────────────
# 测试 3: fetch_balance 无 USDT → 回退 UTA
# ──────────────────────────────────────────────
def test_fetch_balance_no_usdt_fallback_uta():
"""fetch_balance 返回无 USDT → 回退到 UTA 原始 API"""
BitgetTradingAPI = load_bitget_sdk_class()
api = BitgetTradingAPI.__new__(BitgetTradingAPI)
api.exchange = MagicMock()
api.use_unified_account = True
# fetch_balance 没有USDT
api.exchange.fetch_balance.return_value = {
'BTC': {'free': 0.5, 'used': 0, 'total': 0.5},
}
# UTA 回退返回正常数据
api.exchange.privateUtaGetV3AccountAssets.return_value = {
'code': '00000',
'data': {
'assets': [
{
'coin': 'USDT',
'available': '1975.00',
'available': '1975.37457492',
'locked': '0',
'equity': '1975.00',
'equity': '1975.37457492',
}
],
},
@ -135,25 +74,49 @@ def test_fetch_balance_no_usdt_fallback_uta():
balance = api.get_balance()
assert balance['USDT']['available'] == '1975.00'
assert balance['USDT']['equity'] == '1975.00'
api.exchange.privateUtaGetV3AccountAssets.assert_called_once()
assert balance['USDT']['available'] == '1975.37457492'
assert balance['USDT']['equity'] == '1975.37457492'
assert balance['USDT']['frozen'] == '0'
api.exchange.privateUtaGetV3AccountAssets.assert_called_once_with({'coin': 'USDT'})
# ──────────────────────────────────────────────
# 测试 4: fetch_balance 无 USDT → 回退合约 API
# 测试 2: UTA 多币种返回
# ──────────────────────────────────────────────
def test_fetch_balance_no_usdt_fallback_contract():
"""use_unified_account=False → 回退到合约 API"""
def test_uta_multi_coins():
"""UTA 返回多个币种,正确解析"""
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': {
'assets': [
{'coin': 'USDT', 'available': '1000.00', 'locked': '200.00', 'equity': '1200.00'},
{'coin': 'BGB', 'available': '50.00', 'locked': '0', 'equity': '50.00'},
],
},
}
balance = api.get_balance()
assert balance['USDT']['available'] == '1000.00'
assert balance['USDT']['frozen'] == '200.00'
assert balance['BGB']['available'] == '50.00'
# ──────────────────────────────────────────────
# 测试 3: 经典合约账户模式
# ──────────────────────────────────────────────
def test_contract_account_balance():
"""经典账户 → privateMixGetV2MixAccountAccounts"""
BitgetTradingAPI = load_bitget_sdk_class()
api = BitgetTradingAPI.__new__(BitgetTradingAPI)
api.exchange = MagicMock()
api.use_unified_account = False
# fetch_balance 没有USDT
api.exchange.fetch_balance.return_value = {}
# 合约 API 回退
api.exchange.privateMixGetV2MixAccountAccounts.return_value = {
'code': '00000',
'data': [
@ -174,49 +137,16 @@ def test_fetch_balance_no_usdt_fallback_contract():
# ──────────────────────────────────────────────
# 测试 5: fetch_balance 抛异常 → 回退 UTA
# 测试 4: API 异常 → 返回 {}
# ──────────────────────────────────────────────
def test_fetch_balance_exception_fallback():
"""fetch_balance 抛异常 → 回退到原始 API"""
def test_api_exception_returns_empty():
"""API 抛异常 → 返回空字典"""
BitgetTradingAPI = load_bitget_sdk_class()
api = BitgetTradingAPI.__new__(BitgetTradingAPI)
api.exchange = MagicMock()
api.use_unified_account = True
api.exchange.fetch_balance.side_effect = Exception("network error")
api.exchange.privateUtaGetV3AccountAssets.return_value = {
'code': '00000',
'data': {
'assets': [
{
'coin': 'USDT',
'available': '500.00',
'locked': '0',
'equity': '500.00',
}
],
},
}
balance = api.get_balance()
assert balance['USDT']['available'] == '500.00'
api.exchange.privateUtaGetV3AccountAssets.assert_called_once()
# ──────────────────────────────────────────────
# 测试 6: 两个策略都失败 → 返回 {}
# ──────────────────────────────────────────────
def test_both_strategies_fail():
"""fetch_balance 和原始 API 都失败 → 返回空字典"""
BitgetTradingAPI = load_bitget_sdk_class()
api = BitgetTradingAPI.__new__(BitgetTradingAPI)
api.exchange = MagicMock()
api.use_unified_account = True
api.exchange.fetch_balance.side_effect = Exception("timeout")
api.exchange.privateUtaGetV3AccountAssets.side_effect = Exception("auth fail")
api.exchange.privateUtaGetV3AccountAssets.side_effect = Exception("network error")
balance = api.get_balance()
@ -224,47 +154,7 @@ def test_both_strategies_fail():
# ──────────────────────────────────────────────
# 测试 7: fetch_balance 返回 free=None
# ──────────────────────────────────────────────
def test_fetch_balance_none_values():
"""fetch_balance 中 free/used 为 None → 解析为 0"""
BitgetTradingAPI = load_bitget_sdk_class()
api = BitgetTradingAPI.__new__(BitgetTradingAPI)
api.exchange = MagicMock()
api.use_unified_account = True
api.exchange.fetch_balance.return_value = {
'USDT': {'free': None, 'used': None, 'total': None},
}
balance = api.get_balance()
assert balance['USDT']['available'] == '0'
assert balance['USDT']['frozen'] == '0'
assert balance['USDT']['equity'] == '0'
# ──────────────────────────────────────────────
# 测试 8: fetch_balance 返回 free=0, total=0 → equity 用 free+used
# ──────────────────────────────────────────────
def test_fetch_balance_zero_total():
"""total=0 时 equity = free + used"""
BitgetTradingAPI = load_bitget_sdk_class()
api = BitgetTradingAPI.__new__(BitgetTradingAPI)
api.exchange = MagicMock()
api.use_unified_account = True
api.exchange.fetch_balance.return_value = {
'USDT': {'free': 100, 'used': 50, 'total': 0},
}
balance = api.get_balance()
assert balance['USDT']['equity'] == '150'
# ──────────────────────────────────────────────
# 测试 9: UTA 返回空 assets → 回退到 {}
# 测试 5: UTA 空 assets → 返回 {}
# ──────────────────────────────────────────────
def test_uta_empty_assets():
"""UTA API 返回空 assets 列表"""
@ -273,7 +163,6 @@ def test_uta_empty_assets():
api.exchange = MagicMock()
api.use_unified_account = True
api.exchange.fetch_balance.return_value = {}
api.exchange.privateUtaGetV3AccountAssets.return_value = {
'code': '00000',
'data': {'assets': []},
@ -285,17 +174,61 @@ def test_uta_empty_assets():
# ──────────────────────────────────────────────
# 测试 10: get_account_state 端到端解析
# 测试 6: UTA assets 无 coin 字段 → 跳过
# ──────────────────────────────────────────────
def test_uta_skip_no_coin():
"""assets 中 entry 无 coin → 跳过该条目"""
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': {
'assets': [
{'coin': '', 'available': '100'},
{'coin': 'USDT', 'available': '500.00', 'locked': '0', 'equity': '500.00'},
],
},
}
balance = api.get_balance()
assert 'USDT' in balance
assert balance['USDT']['available'] == '500.00'
assert len(balance) == 1
# ──────────────────────────────────────────────
# 测试 7: UTA assets 中字段为 None → 解析为 '0'
# ──────────────────────────────────────────────
def test_uta_none_fields():
"""UTA assets 中 available/locked 为 None → 解析为 '0'"""
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': {
'assets': [
{'coin': 'USDT', 'available': None, 'locked': None, 'equity': None},
],
},
}
balance = api.get_balance()
assert balance['USDT']['available'] == 'None' # str(None) = 'None', float('None') → downstream handles
# ──────────────────────────────────────────────
# 测试 8: get_account_state 端到端解析(正常余额)
# ──────────────────────────────────────────────
def test_get_account_state_e2e():
"""get_account_state 正确解析 balance 返回值"""
BitgetTradingAPI = load_bitget_sdk_class()
# Mock get_bitget_live_service 的依赖链
# 直接测 get_account_state 解析逻辑
from app.services.bitget_live_trading_service import CONTRACT_SIZES
# 模拟 balance 返回
balance_return = {
'USDT': {
'available': '1975.50',
@ -305,7 +238,6 @@ def test_get_account_state_e2e():
}
}
# 直接验证解析逻辑(不初始化完整 service
usdt = balance_return.get('USDT', {})
available = float(usdt.get('available', 0) or 0)
frozen = float(usdt.get('frozen', 0) or 0)
@ -319,7 +251,7 @@ def test_get_account_state_e2e():
# ──────────────────────────────────────────────
# 测试 11: balance 返回 {} 时 get_account_state 各字段为 0
# 测试 9: balance 返回 {} 时 get_account_state 各字段为 0
# ──────────────────────────────────────────────
def test_get_account_state_empty_balance():
"""balance 返回空 → 全部为 0"""
@ -337,20 +269,20 @@ def test_get_account_state_empty_balance():
# ──────────────────────────────────────────────
# 测试 12: fetch_balance 返回字符串数值
# 测试 10: UTA 返回 data=None → 返回 {}
# ──────────────────────────────────────────────
def test_fetch_balance_string_values():
"""ccxt 有时返回字符串类型的余额"""
def test_uta_data_none():
"""UTA API 返回 data=None"""
BitgetTradingAPI = load_bitget_sdk_class()
api = BitgetTradingAPI.__new__(BitgetTradingAPI)
api.exchange = MagicMock()
api.use_unified_account = True
api.exchange.fetch_balance.return_value = {
'USDT': {'free': '1975.50', 'used': '24.50', 'total': '2000.00'},
api.exchange.privateUtaGetV3AccountAssets.return_value = {
'code': '00000',
'data': None,
}
balance = api.get_balance()
assert balance['USDT']['available'] == '1975.50'
assert balance['USDT']['equity'] == '2000.00'
assert balance == {}