diff --git a/backend/app/services/bitget_live_trading_service.py b/backend/app/services/bitget_live_trading_service.py index ff755fb..fcbf357 100644 --- a/backend/app/services/bitget_live_trading_service.py +++ b/backend/app/services/bitget_live_trading_service.py @@ -84,6 +84,7 @@ 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) @@ -91,6 +92,11 @@ class BitgetLiveTradingService: equity = float(usdt.get('equity', 0) or 0) account_value = equity if equity > 0 else (available + frozen) + logger.info( + f"[Bitget] 账户状态: available=${available:.2f}, " + f"frozen=${frozen:.2f}, equity=${equity:.2f}, account_value=${account_value:.2f}" + ) + return { "account_value": account_value, "current_balance": account_value, diff --git a/backend/app/services/bitget_trading_api_sdk.py b/backend/app/services/bitget_trading_api_sdk.py index 6fe2f35..57cb507 100644 --- a/backend/app/services/bitget_trading_api_sdk.py +++ b/backend/app/services/bitget_trading_api_sdk.py @@ -699,13 +699,43 @@ class BitgetTradingAPI: 查询账户余额 Returns: - 余额信息 {USDT: {available: "...", frozen: "...", locked: "..."}} + 余额信息 {USDT: {available: "...", frozen: "...", locked: "...", equity: "..."}} + + 查询策略: + 1. 优先使用 ccxt 统一接口 fetch_balance(),兼容 UTA / 经典账户 + 2. fetch_balance() 失败时,按 use_unified_account 回退到原始 API """ + # ---------- 策略 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: 原始 API(UTA / 合约账户) ---------- 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: @@ -718,12 +748,13 @@ class BitgetTradingAPI: 'locked': str(entry.get('locked', '0')), 'equity': str(entry.get('equity', entry.get('balance', '0'))), } - logger.debug(f"账户余额: {result}") + logger.debug(f"[UTA API] 账户余额: {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 []: @@ -737,7 +768,7 @@ class BitgetTradingAPI: 'equity': str(entry.get('accountEquity', entry.get('usdtEquity', '0'))), } - logger.debug(f"账户余额: {result}") + logger.debug(f"[合约 API] 账户余额: {result}") return result except ccxt.BaseError as e: diff --git a/backend/tests/test_bitget_balance.py b/backend/tests/test_bitget_balance.py new file mode 100644 index 0000000..85dacbb --- /dev/null +++ b/backend/tests/test_bitget_balance.py @@ -0,0 +1,356 @@ +""" +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 端到端解析 +""" +import importlib.util +import os +import sys +import types +from pathlib import Path +from unittest.mock import MagicMock, patch + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + + +def load_bitget_sdk_class(): + sdk_path = ( + Path(__file__).resolve().parents[1] + / 'app' / 'services' / 'bitget_trading_api_sdk.py' + ) + + if 'ccxt' not in sys.modules: + ccxt_module = types.ModuleType('ccxt') + ccxt_module.BaseError = Exception + 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() + sys.modules['app.utils.logger'] = mock_log + + module_name = 'app.services.bitget_trading_api_sdk_bal_test' + spec = importlib.util.spec_from_file_location(module_name, sdk_path) + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module.BitgetTradingAPI + + +# ────────────────────────────────────────────── +# 测试 1: fetch_balance 成功,直接返回 +# ────────────────────────────────────────────── +def test_fetch_balance_success(): + """fetch_balance 返回 USDT,不走原始 API""" + 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', + 'locked': '0', + 'equity': '1975.00', + } + ], + }, + } + + balance = api.get_balance() + + assert balance['USDT']['available'] == '1975.00' + assert balance['USDT']['equity'] == '1975.00' + api.exchange.privateUtaGetV3AccountAssets.assert_called_once() + + +# ────────────────────────────────────────────── +# 测试 4: fetch_balance 无 USDT → 回退合约 API +# ────────────────────────────────────────────── +def test_fetch_balance_no_usdt_fallback_contract(): + """use_unified_account=False → 回退到合约 API""" + 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': [ + { + 'marginCoin': 'USDT', + 'available': '800.50', + 'locked': '100.00', + 'accountEquity': '900.50', + } + ], + } + + balance = api.get_balance() + + assert balance['USDT']['available'] == '800.50' + assert balance['USDT']['frozen'] == '100.00' + assert balance['USDT']['equity'] == '900.50' + + +# ────────────────────────────────────────────── +# 测试 5: fetch_balance 抛异常 → 回退 UTA +# ────────────────────────────────────────────── +def test_fetch_balance_exception_fallback(): + """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("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") + + balance = api.get_balance() + + assert balance == {} + + +# ────────────────────────────────────────────── +# 测试 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 → 回退到 {} +# ────────────────────────────────────────────── +def test_uta_empty_assets(): + """UTA API 返回空 assets 列表""" + BitgetTradingAPI = load_bitget_sdk_class() + api = BitgetTradingAPI.__new__(BitgetTradingAPI) + api.exchange = MagicMock() + api.use_unified_account = True + + api.exchange.fetch_balance.return_value = {} + api.exchange.privateUtaGetV3AccountAssets.return_value = { + 'code': '00000', + 'data': {'assets': []}, + } + + balance = api.get_balance() + + assert balance == {} + + +# ────────────────────────────────────────────── +# 测试 10: 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', + 'frozen': '24.50', + 'locked': '24.50', + 'equity': '2000.00', + } + } + + # 直接验证解析逻辑(不初始化完整 service) + usdt = balance_return.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) + account_value = equity if equity > 0 else (available + frozen) + + assert available == 1975.50 + assert frozen == 24.50 + assert equity == 2000.00 + assert account_value == 2000.00 + + +# ────────────────────────────────────────────── +# 测试 11: balance 返回 {} 时 get_account_state 各字段为 0 +# ────────────────────────────────────────────── +def test_get_account_state_empty_balance(): + """balance 返回空 → 全部为 0""" + balance_return = {} + + usdt = balance_return.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) + account_value = equity if equity > 0 else (available + frozen) + + assert available == 0.0 + assert frozen == 0.0 + assert account_value == 0.0 + + +# ────────────────────────────────────────────── +# 测试 12: fetch_balance 返回字符串数值 +# ────────────────────────────────────────────── +def test_fetch_balance_string_values(): + """ccxt 有时返回字符串类型的余额""" + 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'}, + } + + balance = api.get_balance() + + assert balance['USDT']['available'] == '1975.50' + assert balance['USDT']['equity'] == '2000.00'