From 8533f7c4b431aa27eab5cdfc0f009821e9a3e75f Mon Sep 17 00:00:00 2001 From: aaron <> Date: Mon, 30 Mar 2026 11:56:28 +0800 Subject: [PATCH] update --- .../services/bitget_live_trading_service.py | 29 +- .../app/services/bitget_trading_api_sdk.py | 195 ++- backend/tests/conftest.py | 3 +- backend/tests/test_bitget_balance.py | 2 +- backend/tests/test_bitget_live_integration.py | 1066 ++++++++--------- .../tests/test_bitget_live_trading_service.py | 199 ++- 6 files changed, 786 insertions(+), 708 deletions(-) diff --git a/backend/app/services/bitget_live_trading_service.py b/backend/app/services/bitget_live_trading_service.py index d05f118..80e9e77 100644 --- a/backend/app/services/bitget_live_trading_service.py +++ b/backend/app/services/bitget_live_trading_service.py @@ -55,7 +55,7 @@ class BitgetLiveTradingService: self.settings = get_settings() self.max_total_leverage: float = self.settings.bitget_max_total_leverage self.max_single_position: float = self.settings.bitget_max_single_position - self.circuit_breaker_drawdown: float = self.settings.hyperliquid_circuit_breaker_drawdown + self.circuit_breaker_drawdown: float = self.settings.account_max_drawdown self.trading_api = get_bitget_trading_api() if not self.trading_api: @@ -289,12 +289,12 @@ class BitgetLiveTradingService: type='market', side=side, amount=actual_amount, - params={ + params=self.trading_api._with_account_mode_params({ 'tdMode': 'cross', 'marginCoin': 'USDT', 'holdMode': 'oneWay', **params - } + }) ) if not order: @@ -355,7 +355,7 @@ class BitgetLiveTradingService: side=side, amount=actual_amount, price=price, - params=params + params=self.trading_api._with_account_mode_params(params) ) if not order: @@ -625,9 +625,22 @@ class BitgetLiveTradingService: all_ok = all(r.get('success') for r in results) return {"success": all_ok, "results": results} + def _floor_amount(self, symbol: str, amount: float) -> float: + """根据交易对精度向下取整数量""" + try: + ccxt_symbol = self.trading_api._standardize_symbol(symbol) + market = self.trading_api.exchange.market(ccxt_symbol) + precision = market.get('precision', {}).get('amount') + if precision and precision > 0: + factor = 10 ** precision + return math.floor(amount * factor) / factor + except Exception: + pass + # fallback: 4 位小数 + return math.floor(amount * 10000) / 10000 + def market_close_position(self, symbol: str) -> Dict[str, Any]: """按交易对市价平仓单个持仓""" - import math coin = symbol.replace('USDT', '').replace('/', '').upper() position = self.get_position_for_symbol(coin) @@ -635,7 +648,7 @@ class BitgetLiveTradingService: return {"success": False, "coin": coin, "error": "未找到持仓"} is_long = position['size'] > 0 - coin_amount = math.floor(abs(position['size']) * 10000) / 10000 + coin_amount = self._floor_amount(coin + 'USDT', abs(position['size'])) if coin_amount < 0.0001: logger.warning(f"{coin} 持仓过小 ({coin_amount}),跳过") return {"success": True, "coin": coin, "size": 0} @@ -649,11 +662,11 @@ class BitgetLiveTradingService: symbol=ccxt_symbol, side=side, amount=coin_amount, - params={ + params=self.trading_api._with_account_mode_params({ 'reduceOnly': True, 'tdMode': 'cross', 'marginCoin': 'USDT', - } + }) ) if order: logger.info(f"✅ Bitget 单币种平仓成功: {coin} {side} {coin_amount}") diff --git a/backend/app/services/bitget_trading_api_sdk.py b/backend/app/services/bitget_trading_api_sdk.py index 384d4ff..3e39d2b 100644 --- a/backend/app/services/bitget_trading_api_sdk.py +++ b/backend/app/services/bitget_trading_api_sdk.py @@ -12,6 +12,7 @@ Bitget 实盘交易 API (基于 CCXT SDK) 使用 CCXT 统一交易所接口,提供更稳定的 API 交互。 """ import ccxt +import math from typing import Dict, List, Optional, Any from datetime import datetime from app.utils.logger import logger @@ -23,6 +24,14 @@ class BitgetTradingAPI: DEFAULT_PRODUCT_TYPE = 'USDT-FUTURES' DEFAULT_MARGIN_COIN = 'USDT' + # 合约面值表(张 → 币数量),与 bitget_live_trading_service.CONTRACT_SIZES 保持同步 + CONTRACT_SIZES: Dict[str, float] = { + 'BTC': 0.01, 'ETH': 0.1, 'LTC': 0.1, 'BCH': 0.1, 'BNB': 0.1, + 'SOL': 1.0, 'AVAX': 1.0, 'LINK': 1.0, 'UNI': 1.0, 'ATOM': 1.0, + 'FIL': 1.0, 'DOT': 1.0, 'XRP': 10.0, 'DOGE': 100.0, + 'MATIC': 10.0, 'POL': 10.0, + } + @staticmethod def _pick_first_value(entry: Dict[str, Any], *keys: str, default: str = '0') -> str: """兼容 Bitget UTA 不同字段名,返回第一个非空值。""" @@ -99,47 +108,8 @@ class BitgetTradingAPI: # CCXT 标准化交易对格式 ccxt_symbol = self._standardize_symbol(symbol) - # 手动获取合约规格(不依赖 CCXT 的 contractSize) - # Bitget 永续合约规格 - if 'BTC' in symbol: - contract_size = 0.01 # BTC 每张 0.01 BTC - elif 'ETH' in symbol: - contract_size = 0.1 # ETH 每张 0.1 ETH - elif 'SOL' in symbol: - contract_size = 1 # SOL 每张 1 SOL - elif 'BNB' in symbol: - contract_size = 0.1 # BNB 每张 0.1 BNB - elif 'XRP' in symbol: - contract_size = 10 # XRP 每张 10 XRP - elif 'DOGE' in symbol: - contract_size = 100 # DOGE 每张 100 DOGE - elif 'MATIC' in symbol or 'POL' in symbol: - contract_size = 10 # MATIC 每张 10 MATIC - elif 'AVAX' in symbol: - contract_size = 1 # AVAX 每张 1 AVAX - elif 'LINK' in symbol: - contract_size = 1 # LINK 每张 1 LINK - elif 'UNI' in symbol: - contract_size = 1 # UNI 每张 1 UNI - elif 'ATOM' in symbol: - contract_size = 1 # ATOM 每张 1 ATOM - elif 'LTC' in symbol: - contract_size = 0.1 # LTC 每张 0.1 LTC - elif 'BCH' in symbol: - contract_size = 0.1 # BCH 每张 0.1 BCH - elif 'FIL' in symbol: - contract_size = 1 # FIL 每张 1 FIL - elif 'DOT' in symbol: - contract_size = 1 # DOT 每张 1 DOT - else: - # 默认尝试从市场信息获取 - try: - market = self.exchange.market(ccxt_symbol) - contract_size = market.get('contractSize', 1) - logger.info(f"从市场信息获取合约规格: {contract_size}") - except Exception: - contract_size = 1 - logger.warning(f"无法确定 {symbol} 的合约规格,使用默认值 1") + # 获取合约面值 + contract_size = self._get_contract_size(symbol) # 计算实际下单数量(张数 × 合约规格) # Bitget 的 amount 参数是币的数量,不是张数 @@ -253,7 +223,7 @@ class BitgetTradingAPI: def close_position(self, symbol: str, side: str = None, size: float = None, price: float = None) -> Optional[Dict]: """ - 平仓(双向持仓模式) + 平仓(单向持仓模式) Args: symbol: 交易对 @@ -299,15 +269,15 @@ class BitgetTradingAPI: side = 'sell' if pos_side == 'long' else 'buy' # 如果没有指定平仓数量,则全部平仓 - # 注意:直接使用 BTC 数量,不再通过 place_order 转换 + # 注意:直接使用币数量,不再通过 place_order 转换 close_size_btc = size * self._get_contract_size(symbol) if size else current_size_btc - # 精度处理:向下取整到 0.0001 BTC(Bitget 最小精度) - import math - close_size_btc = math.floor(close_size_btc * 10000) / 10000 + # 精度处理:从市场信息动态获取精度 + close_size_btc = self._floor_amount(ccxt_symbol, close_size_btc) - if close_size_btc < 0.0001: - logger.warning(f"{symbol} 平仓数量 {close_size_btc} BTC 小于最小交易单位 0.0001 BTC") + min_amount = self._get_min_amount(ccxt_symbol) + if close_size_btc < min_amount: + logger.warning(f"{symbol} 平仓数量 {close_size_btc} 小于最小交易单位 {min_amount}") return None logger.info(f"平仓: {symbol} 持仓方向={pos_side}, 平仓方向={side}, 数量={close_size_btc} BTC") @@ -350,7 +320,9 @@ class BitgetTradingAPI: def set_trailing_stop(self, symbol: str, callback_rate: float = None, activation_price: float = None) -> bool: """ - 设置移动止损 + 设置移动止损(UTA V3 兼容) + + 通过下一个 trailing_stop 类型的条件单来实现移动止损。 Args: symbol: 交易对 @@ -370,26 +342,36 @@ class BitgetTradingAPI: return False position = positions[0] - current_size = float(position.get('contracts', 0)) + info = position.get('info', {}) + available = float(info.get('available', 0)) - if current_size == 0: + if available == 0: logger.warning(f"{symbol} 持仓数量为 0") return False - # 使用 CCXT 的私人 API 设置移动止损 - # Bitget 需要通过私人API调用 - params = { - 'symbol': ccxt_symbol, - 'trailingStopCallbackRate': callback_rate, - } - params = self._with_account_mode_params(params) + pos_side = position.get('side', 'long') + close_side = 'sell' if pos_side == 'long' else 'buy' + + # 使用 CCXT 统一接口下 trailing stop 条件单 + params = self._with_account_mode_params({ + 'tdMode': 'cross', + 'marginCoin': 'USDT', + 'reduceOnly': True, + 'trailingPercent': callback_rate * 100 if callback_rate else None, # CCXT 需要百分比 + }) if activation_price: - params['trailingStopActivationPrice'] = activation_price + params['activationPrice'] = activation_price - self.exchange.private_mix_post_modify_contract_trailing_stop(params) + order = self.exchange.create_order( + symbol=ccxt_symbol, + type='trailing_stop_market', + side=close_side, + amount=available, + params=params, + ) - logger.info(f"✅ 设置移动止损成功: {symbol} callback={callback_rate}") + logger.info(f"✅ 设置移动止损成功: {symbol} callback={callback_rate}, order={order.get('id')}") return True except ccxt.BaseError as e: @@ -460,13 +442,11 @@ class BitgetTradingAPI: # 使用独立的止损/止盈计划订单 # 注意:这种方式需要在平仓时也取消这些计划订单 - # CCXT 的 contracts 字段对于 Bitget 实际上已经是 BTC 数量 - # 所以我们直接使用,不需要再乘以 contract_size + # CCXT 的 contracts 字段对于 Bitget 实际上已经是币数量 btc_amount = abs(contracts) - # 精度处理 - import math - btc_amount = math.floor(btc_amount * 10000) / 10000 + # 精度处理:使用动态精度 + btc_amount = self._floor_amount(ccxt_symbol, btc_amount) orders_created = [] @@ -899,46 +879,54 @@ class BitgetTradingAPI: """ 获取合约面值(每张合约对应的币数量) + 优先从硬编码表获取,不存在则查询 CCXT 市场信息。 + Args: symbol: 交易对 Returns: 合约面值 """ - # Bitget 永续合约规格 - if 'BTC' in symbol: - return 0.01 - elif 'ETH' in symbol: - return 0.1 - elif 'SOL' in symbol: - return 1.0 - elif 'BNB' in symbol: - return 0.1 - elif 'XRP' in symbol: - return 10.0 - elif 'DOGE' in symbol: - return 100.0 - elif 'MATIC' in symbol or 'POL' in symbol: - return 10.0 - elif 'AVAX' in symbol: - return 1.0 - elif 'LINK' in symbol: - return 1.0 - elif 'UNI' in symbol: - return 1.0 - elif 'ATOM' in symbol: - return 1.0 - elif 'LTC' in symbol: - return 0.1 - elif 'BCH' in symbol: - return 0.1 - elif 'FIL' in symbol: - return 1.0 - elif 'DOT' in symbol: - return 1.0 - else: + # 提取纯币种名 + coin = symbol.replace('USDT', '').replace('/USDT:USDT', '').replace('/', '').upper() + if coin in self.CONTRACT_SIZES: + return self.CONTRACT_SIZES[coin] + + # fallback: 从 CCXT market info 获取 + try: + ccxt_symbol = self._standardize_symbol(symbol) + market = self.exchange.market(ccxt_symbol) + size = float(market.get('contractSize', 1) or 1) + logger.info(f"从市场信息获取合约面值: {coin} = {size}") + return size + except Exception: + logger.warning(f"无法获取 {coin} 合约面值,使用默认值 1") return 1.0 + def _floor_amount(self, ccxt_symbol: str, amount: float) -> float: + """根据交易对精度向下取整数量""" + try: + market = self.exchange.market(ccxt_symbol) + precision = market.get('precision', {}).get('amount') + if precision and precision > 0: + factor = 10 ** precision + return math.floor(amount * factor) / factor + except Exception: + pass + return math.floor(amount * 10000) / 10000 + + def _get_min_amount(self, ccxt_symbol: str) -> float: + """获取交易对最小下单数量""" + try: + market = self.exchange.market(ccxt_symbol) + limits = market.get('limits', {}).get('amount', {}) + min_amount = limits.get('min') + if min_amount: + return float(min_amount) + except Exception: + pass + return 0.0001 + def test_connection(self) -> bool: """ 测试 API 连接 @@ -983,15 +971,8 @@ def get_bitget_trading_api() -> Optional[BitgetTradingAPI]: """ global _trading_api - # 如果已有实例,检查它是否仍然有效 if _trading_api: - try: - # 尝试获取余额来验证连接是否仍然有效 - _trading_api.get_balance() - return _trading_api - except Exception as e: - logger.warning(f"Bitget API 实例已失效({e}),将重新创建") - _trading_api = None + return _trading_api from app.config import get_settings @@ -999,7 +980,7 @@ def get_bitget_trading_api() -> Optional[BitgetTradingAPI]: # 检查是否配置了 API Key if not settings.bitget_api_key or not settings.bitget_api_secret: - logger.warning("Bitget API Key 未配置,实盘交易功能不可用") + logger.warning("Bitget API Key 未配置��实盘交易功能不可用") return None # 创建实例 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index a768bdf..621ce49 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -12,7 +12,8 @@ def _mock_settings(): s = MagicMock() s.bitget_max_total_leverage = 10.0 s.bitget_max_single_position = 1000.0 - s.hyperliquid_circuit_breaker_drawdown = 0.10 + s.account_max_drawdown = 0.25 + s.hyperliquid_circuit_breaker_drawdown = 0.10 # 保留兼容性 s.bitget_trading_enabled = False return s diff --git a/backend/tests/test_bitget_balance.py b/backend/tests/test_bitget_balance.py index 4dab021..449aa23 100644 --- a/backend/tests/test_bitget_balance.py +++ b/backend/tests/test_bitget_balance.py @@ -221,7 +221,7 @@ def test_uta_none_fields(): balance = api.get_balance() - assert balance['USDT']['available'] == 'None' # str(None) = 'None', float('None') → downstream handles + assert balance['USDT']['available'] == '0' # _pick_first_value 对 None 返回默认值 '0' # ────────────────────────────────────────────── diff --git a/backend/tests/test_bitget_live_integration.py b/backend/tests/test_bitget_live_integration.py index 742b0f5..a73d5dd 100644 --- a/backend/tests/test_bitget_live_integration.py +++ b/backend/tests/test_bitget_live_integration.py @@ -1,625 +1,519 @@ """ -Bitget 真实 API 集成测试 +Bitget UTA V3 真实 API 集成测试 ⚠️ 警告:此测试会使用真实 API 调用和真实订单! -- 确保使用测试网或接受小额手续费 +- 使用最小下单量(1 张 BTC = 0.01 BTC) - 市价单会立即成交,产生实际盈亏 -- 测试后检查是否有残留订单/持仓 +- 测试后自动清理所有订单和持仓 + +覆盖接口: + - [UTA V3] 查询余额 (privateUtaGetV3AccountAssets) + - [UTA V3] 设置杠杆 (privateUtaPostV3AccountSetLeverage) + - [UTA V3] 查询持仓 (fetch_positions + uta:True) + - [UTA V3] 市价开仓 (create_order + uta:True) + - [UTA V3] 限价挂单 (create_order + uta:True) + - [UTA V3] 撤单 (cancel_all_orders + uta:True) + - [UTA V3] 市价平仓 (create_market_order + uta:True + reduceOnly) + - [UTA V3] 止盈止损 (modify_sl_tp + uta:True) + - [Service] 完整交易流程: 账户状态 → 开仓 → 持仓验证 → 平仓 运行方式: cd backend - source venv/bin/activate python3 tests/test_bitget_live_integration.py """ import os import sys import time +import traceback from datetime import datetime # 添加项目路径 sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from dotenv import load_dotenv -load_dotenv('/Users/aaron/source_code/Stock_Agent/.env') +load_dotenv(os.path.join(os.path.dirname(__file__), '..', '..', '.env')) from app.services.bitget_trading_api_sdk import BitgetTradingAPI from app.services.bitget_live_trading_service import BitgetLiveTradingService +from app.config import get_settings -class BitgetIntegrationTest: - """Bitget 真实 API 集成测试""" +# ==================== 测试配置 ==================== + +TEST_SYMBOL = 'BTCUSDT' # 测试交易对 +TEST_CONTRACTS = 1 # 最小下单量 (1 张 = 0.01 BTC) +TEST_LEVERAGE = 5 # 测试杠杆倍数 +LIMIT_OFFSET_PCT = 0.05 # 限价单偏离当前价百分比(5%,确保不成交) + + +class TestResult: + """测试结果收集器""" def __init__(self): - self.api_key = os.getenv('BITGET_API_KEY') - self.api_secret = os.getenv('BITGET_API_SECRET') - self.passphrase = os.getenv('BITGET_PASSPHRASE') - self.use_testnet = os.getenv('BITGET_USE_TESTNET', 'true').lower() == 'true' + self.results = [] - if not all([self.api_key, self.api_secret, self.passphrase]): - raise ValueError("❌ 请在 .env 中配置 BITGET_API_KEY, BITGET_API_SECRET, BITGET_PASSPHRASE") + def record(self, name: str, passed: bool, detail: str = ""): + self.results.append((name, passed, detail)) + status = "✅ PASS" if passed else "❌ FAIL" + print(f" {status}: {name}") + if detail: + print(f" {detail}") + def summary(self): print(f"\n{'='*60}") - print(f"Bitget 真实 API 集成测试") - print(f"{'='*60}") - print(f"API Key: {self.api_key[:10]}...") - print(f"测试网模式: {self.use_testnet}") - print(f"时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") - print(f"{'='*60}\n") - - # 初始化 API - self.api = BitgetTradingAPI( - api_key=self.api_key, - api_secret=self.api_secret, - passphrase=self.passphrase, - use_testnet=self.use_testnet - ) - - # 初始化 Service - self.service = BitgetLiveTradingService.__new__(BitgetLiveTradingService) - self.service.trading_api = self.api - self.service.max_single_position = 100 - self.service.max_total_leverage = 5 - self.service.circuit_breaker_drawdown = 0.10 - self.service._initial_account_state = None - self.service.initial_balance = None # 添加这个属性 - - # 测试状态 - self.limit_order_id = None - self.limit_order_symbol = None - self.market_entry_price = 0.0 - - # ==================== 基础连接测试 ==================== - - def test_01_connection(self): - """测试 1: API 连接""" - print("\n📡 测试 1: API 连接") - print("-" * 40) - - try: - # 获取服务器时间 - server_time = self.api.exchange.fetch_time() - print(f"✅ 服务器时间: {datetime.fromtimestamp(server_time/1000)}") - - # 加载市场信息 - markets = self.api.exchange.load_markets() - btc_market = markets.get('BTC/USDT:USDT', {}) - print(f"✅ BTC 市场ID: {btc_market.get('id', 'N/A')}") - - return True - except Exception as e: - print(f"❌ 连接失败: {e}") - return False - - def test_02_get_balance(self): - """测试 2: 获取账户余额""" - print("\n💰 测试 2: 获取账户余额") - print("-" * 40) - - try: - balance = self.api.get_balance() - if balance: - usdt = balance.get('USDT', {}) - print(f"✅ USDT 可用: {usdt.get('free', 0):.2f}") - print(f"✅ USDT 冻结: {usdt.get('used', 0):.2f}") - print(f"✅ USDT 总额: {usdt.get('total', 0):.2f}") - return True - else: - print("❌ 余额为空") - return False - except Exception as e: - print(f"❌ 获取余额失败: {e}") - return False - - def test_03_get_positions(self): - """测试 3: 获取当前持仓""" - print("\n📊 测试 3: 获取当前持仓") - print("-" * 40) - - try: - positions = self.api.get_position() - if positions: - active_positions = [p for p in positions if float(p.get('contracts', 0)) > 0] - print(f"✅ 当前持仓数: {len(active_positions)}") - for pos in active_positions: - symbol = pos.get('symbol', 'N/A') - contracts = float(pos.get('contracts', 0)) - side = pos.get('side', 'N/A') - entry = float(pos.get('entryPrice', 0)) - print(f" - {symbol}: {side} {contracts} 张 @ ${entry:,.2f}") - else: - print("✅ 当前无持仓") - return True - except Exception as e: - print(f"❌ 获取持仓失败: {e}") - return False - - def test_04_set_leverage(self): - """测试 4: 设置杠杆""" - print("\n⚙️ 测试 4: 设置杠杆") - print("-" * 40) - - try: - symbol = 'BTCUSDT' - leverage = 5 - self.api.set_leverage(symbol, leverage) - print(f"✅ {symbol} 杠杆已设置为 {leverage}x") - return True - except Exception as e: - print(f"❌ 设置杠杆失败: {e}") - return False - - # ==================== 限价单测试 ==================== - - def test_05_place_limit_order(self): - """测试 5: 下限价单(挂单,预期不成交)""" - print("\n📝 测试 5: 下限价单") - print("-" * 40) - - try: - symbol = 'BTCUSDT' - - # 获取当前价格 - ticker = self.api.exchange.fetch_ticker('BTC/USDT:USDT') - current_price = ticker['last'] - print(f" 当前 BTC 价格: ${current_price:,.2f}") - - # 计算挂单价格(当前价格 - 5%,预期不会成交) - limit_price = current_price * 0.95 - print(f" 挂单价格: ${limit_price:,.2f} (低 5%,预期不成交)") - - # 下 1 张多单(最小单位) - order = self.api.place_order( - symbol=symbol, - side='buy', - order_type='limit', - size=1, # 1 张 = 0.01 BTC - price=limit_price - ) - - if order: - self.limit_order_id = order.get('id') - self.limit_order_symbol = symbol - print(f"✅ 限价单已下: {self.limit_order_id}") - print(f" 订单状态: {order.get('status')}") - return True - else: - print("❌ 下单返回空") - return False - - except Exception as e: - print(f"❌ 下限价单失败: {e}") - return False - - def test_06_get_open_orders(self): - """测试 6: 查询挂单""" - print("\n📋 测试 6: 查询挂单") - print("-" * 40) - - try: - symbol = self.limit_order_symbol or 'BTCUSDT' - orders = self.api.get_open_orders(symbol) - - if orders: - print(f"✅ 当前挂单数: {len(orders)}") - for order in orders: - order_id = order.get('id', 'N/A') - side = order.get('side', 'N/A') - price = order.get('price', 0) - amount = order.get('amount', 0) - print(f" - {order_id}: {side} {amount} @ ${price:,.2f}") - else: - print("⚠️ 无挂单") - return True - - except Exception as e: - print(f"❌ 查询挂单失败: {e}") - return False - - def test_07_cancel_order(self): - """测试 7: 撤销挂单""" - print("\n❌ 测试 7: 撤销挂单") - print("-" * 40) - - try: - if not self.limit_order_id: - print("⚠️ 没有需要撤销的订单") - return True - - # 使用 cancel_all_orders 撤销该 symbol 所有挂单 - success = self.api.cancel_all_orders(self.limit_order_symbol) - - if success: - print(f"✅ 已撤销 {self.limit_order_symbol} 的所有挂单") - self.limit_order_id = None - return True - else: - print(f"❌ 撤单失败") - return False - - except Exception as e: - print(f"❌ 撤销订单失败: {e}") - return False - - # ==================== 市价单测试(真实成交!)==================== - - def test_08_market_order_open(self): - """测试 8: 市价开多单(真实成交!)""" - print("\n⚡ 测试 8: 市价开多单") - print("-" * 40) - print("⚠️ 警告:此测试会产生真实订单和手续费!") - - try: - symbol = 'BTCUSDT' - - # 获取当前价格 - ticker = self.api.exchange.fetch_ticker('BTC/USDT:USDT') - current_price = ticker['last'] - print(f" 当前 BTC 价格: ${current_price:,.2f}") - - # 下 1 张市价多单(最小单位 = 0.01 BTC) - print(f" 下市价多单: 1 张 (0.01 BTC)...") - order = self.api.place_order( - symbol=symbol, - side='buy', - order_type='market', - size=1 # 1 张 = 0.01 BTC - ) - - if order: - order_id = order.get('id') - status = order.get('status') - # 安全获取 average 价格 - avg_price = order.get('average') or order.get('price') or 0 - if avg_price: - avg_price = float(avg_price) - print(f"✅ 市价单已成交: {order_id}") - print(f" 订单状态: {status}") - print(f" 成交均价: ${avg_price:,.2f}") - print(f" 成交数量: {order.get('filled', 0)} 张") - - # 保存入场价 - self.market_entry_price = avg_price - return True - else: - print("❌ 下单返回空") - return False - - except Exception as e: - print(f"❌ 市价开仓失败: {e}") - import traceback - traceback.print_exc() - return False - - def test_09_verify_position_opened(self): - """测试 9: 验证持仓已开启""" - print("\n🔍 测试 9: 验证持仓") - print("-" * 40) - - try: - positions = self.api.get_position('BTCUSDT') - - if positions: - for pos in positions: - contracts = float(pos.get('contracts', 0)) - if contracts > 0: - side = pos.get('side', 'N/A') - entry_price = float(pos.get('entryPrice', 0)) - unrealized_pnl = float(pos.get('unrealizedPnl', 0)) - print(f"✅ 持仓已确认:") - print(f" 方向: {side}") - print(f" 数量: {contracts} 张") - print(f" 开仓价: ${entry_price:,.2f}") - print(f" 未实现盈亏: ${unrealized_pnl:,.2f}") - return True - - print("❌ 未找到持仓") - return False - - except Exception as e: - print(f"❌ 验证持仓失败: {e}") - return False - - def test_10_set_tp_sl(self): - """测试 10: 设置止盈止损""" - print("\n🎯 测试 10: 设置止盈止损") - print("-" * 40) - - try: - # 获取当前价格 - ticker = self.api.exchange.fetch_ticker('BTC/USDT:USDT') - current_price = ticker['last'] - - # 设置止盈(+1%)和止损(-0.5%) - tp_price = current_price * 1.01 # +1% - sl_price = current_price * 0.995 # -0.5% - - print(f" 当前价格: ${current_price:,.2f}") - print(f" 设置止盈: ${tp_price:,.2f} (+1%)") - print(f" 设置止损: ${sl_price:,.2f} (-0.5%)") - - result = self.api.modify_sl_tp( - symbol='BTCUSDT', - stop_loss=sl_price, - take_profit=tp_price - ) - - if result: - print(f"✅ 止盈止损已设置") - else: - print(f"⚠️ 止盈止损设置返回 False") - - return True # 无论成功与否都继续测试 - - except Exception as e: - print(f"⚠️ 设置止盈止损失败: {e}") - return True # 不算失败,继续测试 - - def test_11_market_order_close(self): - """测试 11: 市价平仓""" - print("\n🔒 测试 11: 市价平仓") - print("-" * 40) - - try: - # 获取当前价格 - ticker = self.api.exchange.fetch_ticker('BTC/USDT:USDT') - current_price = ticker['last'] - print(f" 当前 BTC 价格: ${current_price:,.2f}") - - # 先撤销止盈止损单 - try: - self.api.cancel_all_orders('BTCUSDT') - print(" 已撤销止盈止损单") - except: - pass - - # 使用 close_position 方法平仓 - print(f" 下市价平仓单...") - order = self.api.close_position( - symbol='BTCUSDT', - side='sell', # 平多 - size=0.01, # 0.01 张 - ) - - if order: - order_id = order.get('id') - avg_price = order.get('average') or order.get('price') or 0 - if avg_price: - avg_price = float(avg_price) - print(f"✅ 平仓成功: {order_id}") - print(f" 平仓均价: ${avg_price:,.2f}") - - # 计算盈亏 - if self.market_entry_price > 0 and avg_price > 0: - pnl = (avg_price - self.market_entry_price) * 0.01 # 1张 = 0.01 BTC - pnl_pct = (avg_price - self.market_entry_price) / self.market_entry_price * 100 - print(f" 实现盈亏: ${pnl:,.2f} ({pnl_pct:+.2f}%)") - - return True - else: - print("❌ 平仓返回空") - return False - - except Exception as e: - print(f"❌ 市价平仓失败: {e}") - import traceback - traceback.print_exc() - return False - - def test_12_verify_position_closed(self): - """测试 12: 验证持仓已平""" - print("\n✅ 测试 12: 验证持仓已平") - print("-" * 40) - - try: - positions = self.api.get_position('BTCUSDT') - - has_position = False - if positions: - for pos in positions: - if float(pos.get('contracts', 0)) > 0: - has_position = True - print(f"⚠️ 仍有持仓: {pos.get('contracts')} 张") - - if not has_position: - print("✅ 持仓已全部平仓") - return True - else: - print("❌ 持仓未完全平仓") - return False - - except Exception as e: - print(f"❌ 验证平仓失败: {e}") - return False - - # ==================== Service 层测试 ==================== - - def test_13_service_get_account_state(self): - """测试 13: Service 层获取账户状态""" - print("\n🏦 测试 13: Service 获取账户状态") - print("-" * 40) - - try: - state = self.service.get_account_state() - print(f"✅ 账户价值: ${state['account_value']:,.2f}") - print(f"✅ 已用保证金: ${state['total_margin_used']:,.2f}") - print(f"✅ 可用余额: ${state['available_balance']:,.2f}") - return True - except Exception as e: - print(f"❌ 获取账户状态失败: {e}") - import traceback - traceback.print_exc() - return False - - def test_14_service_get_positions(self): - """测试 14: Service 层获取持仓""" - print("\n📈 测试 14: Service 获取持仓") - print("-" * 40) - - try: - positions = self.service.get_open_positions() - if positions: - print(f"✅ 当前持仓数: {len(positions)}") - for pos in positions: - # 安全获取字段 - symbol = pos.get('symbol', 'N/A') - side = pos.get('side', 'N/A') - size = pos.get('size', 0) - entry = pos.get('entry_price', 0) - print(f" - {symbol}: {side} {size} @ ${entry:,.2f}") - else: - print("✅ 当前无持仓") - return True - except Exception as e: - print(f"❌ 获取持仓失败: {e}") - import traceback - traceback.print_exc() - return False - - def test_15_service_contract_size(self): - """测试 15: Service 合约面值""" - print("\n📐 测试 15: 合约面值换算") - print("-" * 40) - - try: - # 测试已知币种 - btc_size = self.service.get_contract_size('BTC') - eth_size = self.service.get_contract_size('ETH') - sol_size = self.service.get_contract_size('SOL') - - print(f"✅ BTC 合约面值: {btc_size} BTC/张") - print(f"✅ ETH 合约面值: {eth_size} ETH/张") - print(f"✅ SOL 合约面值: {sol_size} SOL/张") - - # 测试币数转合约数 - contracts = self.service.coins_to_contracts('BTC', 0.05) # 0.05 BTC - print(f"✅ 0.05 BTC = {contracts} 张") - - return True - except Exception as e: - print(f"❌ 合约面值测试失败: {e}") - return False - - def test_16_risk_check(self): - """测试 16: 风控检查""" - print("\n⚠️ 测试 16: 风控检查") - print("-" * 40) - - try: - result = self.service.check_risk_limits() - if result['allowed']: - print(f"✅ 风控检查通过") - else: - print(f"⚠️ 风控检查未通过: {result['reason']}") - return True - except Exception as e: - print(f"⚠️ 风控检查异常: {e}") - return True # 不算失败 - - # ==================== 清理和运行 ==================== - - def cleanup(self): - """清理测试产生的订单和持仓""" - print("\n🧹 清理测试订单...") - print("-" * 40) - - try: - # 撤销所有 BTC 挂单 - success = self.api.cancel_all_orders('BTCUSDT') - if success: - print("✅ 已撤销所有 BTC 挂单") - else: - print("⚠️ 撤销 BTC 挂单返回 False(可能无挂单)") - - # 检查是否有残留持仓 - positions = self.api.get_position('BTCUSDT') - if positions: - for pos in positions: - contracts = float(pos.get('contracts', 0)) - if contracts > 0: - symbol = pos.get('symbol', '') - print(f"⚠️ 发现残留持仓: {symbol} {contracts} 张") - print(f" 请手动平仓!") - - except Exception as e: - print(f"⚠️ 清理失败: {e}") - - def run_all_tests(self): - """运行所有测试""" - tests = [ - self.test_01_connection, - self.test_02_get_balance, - self.test_03_get_positions, - self.test_04_set_leverage, - self.test_05_place_limit_order, - self.test_06_get_open_orders, - self.test_07_cancel_order, - self.test_08_market_order_open, - self.test_09_verify_position_opened, - self.test_10_set_tp_sl, - self.test_11_market_order_close, - self.test_12_verify_position_closed, - self.test_13_service_get_account_state, - self.test_14_service_get_positions, - self.test_15_service_contract_size, - self.test_16_risk_check, - ] - - results = [] - for test in tests: - try: - result = test() - results.append((test.__name__, result)) - time.sleep(0.5) # 避免触发频率限制 - except Exception as e: - print(f"\n❌ 测试异常: {e}") - import traceback - traceback.print_exc() - results.append((test.__name__, False)) - - # 清理 - self.cleanup() - - # 汇总 - print("\n" + "=" * 60) print("测试结果汇总") - print("=" * 60) - - passed = sum(1 for _, r in results if r) - total = len(results) - - for name, result in results: - status = "✅ PASS" if result else "❌ FAIL" - print(f"{status}: {name}") - - print(f"\n总计: {passed}/{total} 通过") - print("=" * 60) - + print(f"{'='*60}") + passed = sum(1 for _, p, _ in self.results if p) + total = len(self.results) + for name, p, detail in self.results: + status = "✅" if p else "❌" + line = f" {status} {name}" + if not p and detail: + line += f" — {detail}" + print(line) + print(f"\n 总���: {passed}/{total} 通过") + print(f"{'='*60}") return passed == total -if __name__ == '__main__': - print("\n" + "!" * 60) - print("⚠️ 警告:此测试将使用真实 API 调用!") - print("!" * 60) - print("请确认:") - print("1. 你已了解这会产生真实 API 调用") - print("2. 你使用的是测试网或接受小额手续费") - print("3. 市价单会立即成交,产生实际盈亏") - print("4. 测试后请检查是否有残留订单/持仓") - print("!" * 60) +# ==================== 初始化 ==================== - confirm = input("\n是否继续?(yes/no): ") +def create_api() -> BitgetTradingAPI: + """创建 BitgetTradingAPI 实例""" + api_key = os.getenv('BITGET_API_KEY', '') + api_secret = os.getenv('BITGET_API_SECRET', '') + passphrase = os.getenv('BITGET_PASSPHRASE', '') - if confirm.lower() != 'yes': - print("已取消测试") - sys.exit(0) + if not api_key or not api_secret: + raise ValueError("请在 .env 中配置 BITGET_API_KEY 和 BITGET_API_SECRET") + return BitgetTradingAPI( + api_key=api_key, + api_secret=api_secret, + passphrase=passphrase, + use_testnet=os.getenv('BITGET_USE_TESTNET', 'true').lower() == 'true' + ) + + +def create_service(api: BitgetTradingAPI) -> BitgetLiveTradingService: + """创建 BitgetLiveTradingService 实例(绕过 __init__ 避免重复调用)""" + service = BitgetLiveTradingService.__new__(BitgetLiveTradingService) + service.trading_api = api + service.settings = get_settings() + service.max_total_leverage = 10.0 + service.max_single_position = 100.0 + service.circuit_breaker_drawdown = 0.25 + service.initial_balance = None + return service + + +# ==================== SDK 层测试 ==================== + +def test_sdk_connection(api: BitgetTradingAPI, r: TestResult): + """SDK: API 连接 + 服务器时间""" try: - tester = BitgetIntegrationTest() - success = tester.run_all_tests() - sys.exit(0 if success else 1) + server_time = api.exchange.fetch_time() + dt = datetime.fromtimestamp(server_time / 1000) + r.record("SDK 连接", True, f"服务器时间: {dt}") except Exception as e: - print(f"\n❌ 测试失败: {e}") - import traceback + r.record("SDK 连接", False, str(e)) + + +def test_sdk_load_markets(api: BitgetTradingAPI, r: TestResult): + """SDK: 加载市场信息(验证 BTC/USDT:USDT 存在)""" + try: + markets = api.exchange.load_markets() + btc = markets.get('BTC/USDT:USDT') + if btc: + contract_size = btc.get('contractSize', 'N/A') + r.record("加载市场", True, f"BTC contractSize={contract_size}") + else: + r.record("加载市场", False, "BTC/USDT:USDT 不在市场列表中") + except Exception as e: + r.record("加载市场", False, str(e)) + + +def test_sdk_get_balance(api: BitgetTradingAPI, r: TestResult): + """SDK: [UTA V3] 查询余额 (privateUtaGetV3AccountAssets)""" + try: + balance = api.get_balance() + usdt = balance.get('USDT', {}) + available = usdt.get('available', '0') + equity = usdt.get('equity', '0') + r.record( + "UTA 查询余额", + float(available) > 0 or float(equity) > 0, + f"可用={available}, 权益={equity}" + ) + except Exception as e: + r.record("UTA 查询余额", False, str(e)) + + +def test_sdk_set_leverage(api: BitgetTradingAPI, r: TestResult): + """SDK: [UTA V3] 设置杠杆 (privateUtaPostV3AccountSetLeverage)""" + try: + success = api.set_leverage(TEST_SYMBOL, TEST_LEVERAGE) + r.record("UTA 设置杠杆", success, f"{TEST_SYMBOL} → {TEST_LEVERAGE}x") + except Exception as e: + r.record("UTA 设置杠杆", False, str(e)) + + +def test_sdk_get_positions(api: BitgetTradingAPI, r: TestResult): + """SDK: [UTA V3] 查询持仓 (fetch_positions + uta:True)""" + try: + positions = api.get_position() + # 只要不报错就算通过,UTA 账户必须能查到 + count = len([p for p in positions if float(p.get('contracts', 0)) > 0]) + r.record("UTA 查询持仓", True, f"当前活跃持仓: {count} 个") + except Exception as e: + r.record("UTA 查询持仓", False, str(e)) + + +def test_sdk_limit_order_and_cancel(api: BitgetTradingAPI, r: TestResult): + """SDK: [UTA V3] 下限价单 + 查询挂单 + 撤单""" + order_id = None + try: + # 1. 获取当前价格 + ticker = api.exchange.fetch_ticker('BTC/USDT:USDT') + price = ticker['last'] + limit_price = round(price * (1 - LIMIT_OFFSET_PCT), 1) # 低 5% 挂单 + + # 2. 下限价单(1 张 BTC = 0.01 BTC) + order = api.place_order( + symbol=TEST_SYMBOL, + side='buy', + order_type='limit', + size=TEST_CONTRACTS, + price=limit_price + ) + if not order: + r.record("UTA 限价挂单", False, "下单返回空") + return + + order_id = order.get('id') + status = order.get('status', 'unknown') + r.record("UTA 限价挂单", order_id is not None, f"id={order_id}, status={status}, price=${limit_price:,.1f}") + + time.sleep(1) + + # 3. 查询挂单 + open_orders = api.get_open_orders(TEST_SYMBOL) + found = any(o.get('id') == order_id for o in open_orders) + r.record("UTA 查询挂单", found or len(open_orders) > 0, f"挂单数={len(open_orders)}") + + # 4. 撤单 + cancel_ok = api.cancel_all_orders(TEST_SYMBOL) + r.record("UTA 撤单", cancel_ok, f"cancel_all_orders → {cancel_ok}") + + time.sleep(1) + + # 5. 验证挂单已撤 + remaining = api.get_open_orders(TEST_SYMBOL) + r.record("验证撤单完成", len(remaining) == 0, f"剩余挂单: {len(remaining)}") + + except Exception as e: + r.record("UTA 限价单流程", False, f"{e}\n{traceback.format_exc()}") + # 清理 + if order_id: + try: + api.cancel_all_orders(TEST_SYMBOL) + except: + pass + + +def test_sdk_market_order_flow(api: BitgetTradingAPI, r: TestResult): + """SDK: [UTA V3] 市价开仓 → 验证持仓 → 止盈止损 → 市价平仓""" + opened = False + try: + # 1. 获取当前价格 + ticker = api.exchange.fetch_ticker('BTC/USDT:USDT') + price = ticker['last'] + print(f"\n 当前 BTC: ${price:,.2f}") + + # 2. 市价开多 1 张 + order = api.place_order( + symbol=TEST_SYMBOL, + side='buy', + order_type='market', + size=TEST_CONTRACTS, + ) + if not order: + r.record("UTA 市价开仓", False, "下单返回空") + return + + avg = order.get('average') or order.get('price') or price + avg = float(avg) if avg else price + r.record("UTA 市价开仓", True, f"id={order.get('id')}, avg=${avg:,.2f}") + opened = True + + time.sleep(2) + + # 3. 验证持仓 + positions = api.get_position(TEST_SYMBOL) + has_pos = any( + float(p.get('info', {}).get('available', 0)) > 0 + for p in positions + ) + r.record("验证持仓存在", has_pos) + + # 4. 设置止盈止损 + tp_price = round(price * 1.02, 1) # +2% + sl_price = round(price * 0.98, 1) # -2% + try: + tp_sl_ok = api.modify_sl_tp(TEST_SYMBOL, stop_loss=sl_price, take_profit=tp_price) + r.record("UTA 止盈止损", tp_sl_ok, f"TP=${tp_price:,.1f}, SL=${sl_price:,.1f}") + except Exception as e: + r.record("UTA 止盈止损", False, str(e)) + + time.sleep(1) + + # 5. 撤销止盈止损挂单 + try: + api.cancel_all_orders(TEST_SYMBOL) + except: + pass + + time.sleep(1) + + # 6. 市价平仓 + close_order = api.close_position(TEST_SYMBOL) + if close_order: + close_avg = close_order.get('average') or close_order.get('price') or 0 + if close_avg: + close_avg = float(close_avg) + r.record("UTA 市价平仓", True, f"平仓价=${close_avg:,.2f}") + opened = False + else: + r.record("UTA 市价平仓", False, "close_position 返回空") + + time.sleep(2) + + # 7. 验证已平仓 + positions_after = api.get_position(TEST_SYMBOL) + still_open = any( + float(p.get('info', {}).get('available', 0)) > 0 + for p in positions_after + ) + r.record("验证已平仓", not still_open) + + except Exception as e: + r.record("市价单流程异常", False, f"{e}\n{traceback.format_exc()}") + finally: + # 确保清理 + if opened: + try: + api.cancel_all_orders(TEST_SYMBOL) + time.sleep(0.5) + api.close_position(TEST_SYMBOL) + print(" 🧹 已自动清理残留持仓") + except Exception as cleanup_err: + print(f" ⚠️ 清理失败,请手动检查: {cleanup_err}") + + +# ==================== Service 层测试 ==================== + +def test_service_account_state(service: BitgetLiveTradingService, r: TestResult): + """Service: 获取账户状态""" + try: + state = service.get_account_state() + av = state['account_value'] + ab = state['available_balance'] + r.record( + "Service 账户状态", + av > 0, + f"权益=${av:,.2f}, 可用=${ab:,.2f}, 已用=${state['total_margin_used']:,.2f}" + ) + except Exception as e: + r.record("Service 账户状态", False, str(e)) + + +def test_service_market_order_flow(service: BitgetLiveTradingService, r: TestResult): + """Service: 完整下单流程 (place_market_order → get_open_positions → market_close_position)""" + opened = False + try: + # 1. 市价开多 1 张 BTC + result = service.place_market_order('BTC', is_buy=True, size=TEST_CONTRACTS) + if not result.get('success'): + r.record("Service 市价开仓", False, result.get('error', '未知')) + return + + r.record("Service 市价开仓", True, f"order_id={result.get('order_id')}") + opened = True + + time.sleep(2) + + # 2. 验证持仓 + positions = service.get_open_positions() + btc_pos = next((p for p in positions if p['coin'] == 'BTC'), None) + r.record("Service 持仓验证", btc_pos is not None, + f"size={btc_pos['size']}, entry=${btc_pos['entry_price']:,.2f}" if btc_pos else "未找到 BTC 持仓") + + # 3. 风控检查 + risk = service.check_risk_limits() + r.record("Service 风控检查", risk['allowed'], risk.get('reason', '通过')) + + time.sleep(1) + + # 4. 市价平仓 + close_result = service.market_close_position('BTC') + r.record("Service 市价平仓", close_result.get('success', False), + close_result.get('error', f"size={close_result.get('size', 'N/A')}")) + if close_result.get('success'): + opened = False + + time.sleep(2) + + # 5. 验证已平仓 + positions_after = service.get_open_positions() + btc_after = next((p for p in positions_after if p['coin'] == 'BTC'), None) + r.record("Service 平仓验证", btc_after is None) + + except Exception as e: + r.record("Service 下单流程异常", False, f"{e}\n{traceback.format_exc()}") + finally: + if opened: + try: + service.market_close_position('BTC') + print(" 🧹 已自动清理残留持仓") + except: + print(" ⚠️ 清理失败,请手动检查") + + +def test_service_limit_order_flow(service: BitgetLiveTradingService, r: TestResult): + """Service: 限价单流程 (place_limit_order → get_open_orders → cancel_order)""" + try: + # 获取当前价格 + ticker = service.trading_api.exchange.fetch_ticker('BTC/USDT:USDT') + price = ticker['last'] + limit_price = round(price * (1 - LIMIT_OFFSET_PCT), 1) + + # 1. 下限价买单 + result = service.place_limit_order('BTC', is_buy=True, size=TEST_CONTRACTS, price=limit_price) + if not result.get('success'): + r.record("Service 限价挂单", False, result.get('error', '未知')) + return + + order_id = result.get('order_id') + r.record("Service 限价挂单", True, f"id={order_id}, status={result.get('order_status')}") + + time.sleep(1) + + # 2. 查询挂单 + orders = service.get_open_orders('BTC') + r.record("Service 查询挂单", len(orders) > 0, f"挂单数={len(orders)}") + + # 3. 撤单 + if order_id: + cancel = service.cancel_order('BTC', order_id) + r.record("Service 撤单", cancel.get('success', False)) + + time.sleep(1) + + # 4. 验证撤单完成 + remaining = service.get_open_orders('BTC') + r.record("Service 验证撤单", len(remaining) == 0, f"剩余={len(remaining)}") + + except Exception as e: + r.record("Service 限价单流程", False, str(e)) + try: + service.cancel_all_orders('BTC') + except: + pass + + +def test_service_leverage_sync(service: BitgetLiveTradingService, r: TestResult): + """Service: 杠杆同步""" + try: + result = service.sync_default_leverage(['BTCUSDT', 'ETHUSDT'], leverage=TEST_LEVERAGE) + r.record( + "Service 杠杆同步", + result['success'], + f"{TEST_LEVERAGE}x → {result.get('results', {})}" + ) + except Exception as e: + r.record("Service 杠杆同步", False, str(e)) + + +# ==================== 主入口 ==================== + +def main(): + print(f"\n{'='*60}") + print(f" Bitget UTA V3 实盘接口集成测试") + print(f"{'='*60}") + print(f" 时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + print(f" 交易对: {TEST_SYMBOL}") + print(f" 下单量: {TEST_CONTRACTS} 张 (0.01 BTC)") + print(f" 杠杆: {TEST_LEVERAGE}x") + print(f"{'='*60}") + + r = TestResult() + + # 初始化 + try: + api = create_api() + service = create_service(api) + print(f" API Key: {api.api_key[:10]}...") + print(f" UTA 模式: {api.use_unified_account}") + print(f" 测试网: {api.use_testnet}") + except Exception as e: + print(f"\n❌ 初始化失败: {e}") traceback.print_exc() sys.exit(1) + + # ---- SDK 层测试 ---- + print(f"\n{'─'*40}") + print(" SDK 层 (BitgetTradingAPI)") + print(f"{'─'*40}") + + test_sdk_connection(api, r) + time.sleep(0.3) + + test_sdk_load_markets(api, r) + time.sleep(0.3) + + test_sdk_get_balance(api, r) + time.sleep(0.3) + + test_sdk_set_leverage(api, r) + time.sleep(0.3) + + test_sdk_get_positions(api, r) + time.sleep(0.3) + + test_sdk_limit_order_and_cancel(api, r) + time.sleep(0.5) + + test_sdk_market_order_flow(api, r) + time.sleep(0.5) + + # ---- Service 层测试 ---- + print(f"\n{'─'*40}") + print(" Service 层 (BitgetLiveTradingService)") + print(f"{'─'*40}") + + test_service_account_state(service, r) + time.sleep(0.3) + + test_service_leverage_sync(service, r) + time.sleep(0.3) + + test_service_limit_order_flow(service, r) + time.sleep(0.5) + + test_service_market_order_flow(service, r) + + # ---- 汇总 ---- + all_passed = r.summary() + sys.exit(0 if all_passed else 1) + + +if __name__ == '__main__': + print("\n⚠️ 此测试会产生真实订单和手续费!") + print(" 使用最小量 1 张 BTC (约 $600-1000 保证金)") + + confirm = input("\n是否继续?(yes/no): ") + if confirm.strip().lower() != 'yes': + print("已取消") + sys.exit(0) + + main() diff --git a/backend/tests/test_bitget_live_trading_service.py b/backend/tests/test_bitget_live_trading_service.py index 94e1b94..fe8b225 100644 --- a/backend/tests/test_bitget_live_trading_service.py +++ b/backend/tests/test_bitget_live_trading_service.py @@ -40,11 +40,13 @@ def make_service(settings_overrides=None): mock_exchange = MagicMock() mock_api.exchange = mock_exchange mock_api._standardize_symbol = lambda s: f"{s.replace('USDT', '')}/USDT:USDT" + mock_api._with_account_mode_params = lambda params=None: {**(params or {}), 'uta': True} + mock_api.use_unified_account = True mock_settings = MagicMock() mock_settings.bitget_max_total_leverage = 10.0 mock_settings.bitget_max_single_position = 1000.0 - mock_settings.hyperliquid_circuit_breaker_drawdown = 0.10 + mock_settings.account_max_drawdown = 0.25 if settings_overrides: for k, v in settings_overrides.items(): setattr(mock_settings, k, v) @@ -54,7 +56,7 @@ def make_service(settings_overrides=None): service.settings = mock_settings service.max_total_leverage = mock_settings.bitget_max_total_leverage service.max_single_position = mock_settings.bitget_max_single_position - service.circuit_breaker_drawdown = mock_settings.hyperliquid_circuit_breaker_drawdown + service.circuit_breaker_drawdown = mock_settings.account_max_drawdown service.trading_api = mock_api service.initial_balance = 10000.0 @@ -532,11 +534,11 @@ class TestCheckRiskLimits: assert result['allowed'] is True def test_circuit_breaker_triggered(self): - """账户余额从 10000 跌至 8900 → 回撤 11% > 10% → 熔断""" + """账户余额从 10000 跌至 7000 → 回撤 30% > 25% → 熔断""" service, mock_api = make_service() service.initial_balance = 10000.0 mock_api.get_balance.return_value = { - 'USDT': {'available': '8900.0', 'frozen': '0.0', 'locked': '0'} + 'USDT': {'available': '7000.0', 'frozen': '0.0', 'locked': '0'} } mock_api.get_position.return_value = [] result = service.check_risk_limits() @@ -668,10 +670,11 @@ class TestGetBitgetLiveServiceFactory: mock_settings.bitget_trading_enabled = True mock_settings.bitget_max_total_leverage = 10.0 mock_settings.bitget_max_single_position = 1000.0 - mock_settings.hyperliquid_circuit_breaker_drawdown = 0.10 + mock_settings.account_max_drawdown = 0.25 mock_api = MagicMock() mock_api._standardize_symbol = lambda s: f"{s}/USDT:USDT" + mock_api._with_account_mode_params = lambda params=None: {**(params or {}), 'uta': True} mock_api.get_balance.return_value = { 'USDT': {'available': '5000', 'frozen': '0', 'locked': '0'} } @@ -877,3 +880,189 @@ class TestInitializeAccount: service._initialize_account() # 不应抛出异常 assert service.initial_balance is None + + +# ==================== TestUTAParams ==================== + +class TestUTAParams: + """验证所有下单/平仓方法都正确传递 UTA 参数(uta: True)""" + + def test_place_market_order_passes_uta(self): + """place_market_order 必须传递 uta: True""" + service, mock_api = make_service() + mock_api.exchange.create_order.return_value = {'id': 'o1', 'status': 'closed'} + service.place_market_order('BTC', is_buy=True, size=1) + + call_kwargs = mock_api.exchange.create_order.call_args[1] + params = call_kwargs.get('params', {}) + assert params.get('uta') is True, f"place_market_order 缺少 uta 参数: {params}" + + def test_place_market_order_uta_with_reduce_only(self): + """reduce_only 模式下也必须传递 uta: True""" + service, mock_api = make_service() + mock_api.exchange.create_order.return_value = {'id': 'o2', 'status': 'closed'} + service.place_market_order('ETH', is_buy=False, size=3, reduce_only=True) + + call_kwargs = mock_api.exchange.create_order.call_args[1] + params = call_kwargs.get('params', {}) + assert params.get('uta') is True + assert params.get('reduceOnly') is True + + def test_place_limit_order_passes_uta(self): + """place_limit_order 必须传递 uta: True""" + service, mock_api = make_service() + mock_api.exchange.create_order.return_value = {'id': 'lim1', 'status': 'open'} + service.place_limit_order('BTC', is_buy=True, size=1, price=50000.0) + + call_kwargs = mock_api.exchange.create_order.call_args[1] + params = call_kwargs.get('params', {}) + assert params.get('uta') is True, f"place_limit_order 缺少 uta 参数: {params}" + + def test_place_limit_order_uta_with_reduce_only(self): + """限价单 reduce_only 模式也必须传递 uta: True""" + service, mock_api = make_service() + mock_api.exchange.create_order.return_value = {'id': 'lim2', 'status': 'open'} + service.place_limit_order('SOL', is_buy=False, size=5, price=150.0, reduce_only=True) + + call_kwargs = mock_api.exchange.create_order.call_args[1] + params = call_kwargs.get('params', {}) + assert params.get('uta') is True + assert params.get('reduceOnly') is True + + def test_market_close_position_passes_uta(self): + """market_close_position 必须传递 uta: True""" + service, mock_api = make_service() + # 构造持仓数据 + mock_api.get_position.return_value = [{ + 'symbol': 'BTC/USDT:USDT', + 'contracts': 2.0, + 'side': 'long', + 'entryPrice': 50000.0, + 'unrealizedPnl': 0.0, + 'leverage': 10, + 'liquidationPrice': 45000.0, + 'info': {'available': '0.02'}, + }] + mock_api.exchange.create_market_order.return_value = {'id': 'close1', 'status': 'closed'} + + service.market_close_position('BTC') + + call_kwargs = mock_api.exchange.create_market_order.call_args[1] + params = call_kwargs.get('params', {}) + assert params.get('uta') is True, f"market_close_position 缺少 uta 参数: {params}" + assert params.get('reduceOnly') is True + + def test_place_market_order_preserves_cross_margin_params(self): + """确保 UTA 参数不覆盖其他必要参数""" + service, mock_api = make_service() + mock_api.exchange.create_order.return_value = {'id': 'o1', 'status': 'closed'} + service.place_market_order('BTC', is_buy=True, size=1) + + call_kwargs = mock_api.exchange.create_order.call_args[1] + params = call_kwargs.get('params', {}) + assert params.get('tdMode') == 'cross' + assert params.get('marginCoin') == 'USDT' + assert params.get('holdMode') == 'oneWay' + assert params.get('uta') is True + + +# ==================== TestFloorAmount ==================== + +class TestFloorAmount: + """测试动态精度向下取整""" + + def test_floor_with_market_precision(self): + """从 market info 获取精度""" + service, mock_api = make_service() + mock_api.exchange.market.return_value = { + 'precision': {'amount': 2} + } + result = service._floor_amount('BTCUSDT', 1.23456) + assert result == pytest.approx(1.23) + + def test_floor_with_4_decimal_precision(self): + service, mock_api = make_service() + mock_api.exchange.market.return_value = { + 'precision': {'amount': 4} + } + result = service._floor_amount('BTCUSDT', 0.12345) + assert result == pytest.approx(0.1234) + + def test_floor_fallback_when_market_unavailable(self): + """market info 失败时回退到 4 位小数""" + service, mock_api = make_service() + mock_api.exchange.market.side_effect = Exception("not found") + result = service._floor_amount('UNKNOWN', 1.23456789) + assert result == pytest.approx(1.2345) + + def test_floor_truncates_not_rounds(self): + """必须向下取整,不能四舍五入""" + service, mock_api = make_service() + mock_api.exchange.market.return_value = { + 'precision': {'amount': 2} + } + result = service._floor_amount('BTCUSDT', 1.999) + assert result == pytest.approx(1.99) + + +# ==================== TestSyncDefaultLeverage ==================== + +class TestSyncDefaultLeverage: + + def test_sync_multiple_symbols(self): + service, mock_api = make_service() + mock_api.set_leverage.return_value = True + service.settings.bitget_default_leverage = 5 + + result = service.sync_default_leverage(['BTCUSDT', 'ETHUSDT'], leverage=5) + assert result['success'] is True + assert result['leverage'] == 5 + assert mock_api.set_leverage.call_count == 2 + + def test_sync_empty_symbols(self): + service, mock_api = make_service() + result = service.sync_default_leverage([]) + assert result['success'] is True + assert result['results'] == {} + + def test_sync_deduplicates_symbols(self): + service, mock_api = make_service() + mock_api.set_leverage.return_value = True + service.settings.bitget_default_leverage = 10 + + result = service.sync_default_leverage(['BTCUSDT', 'BTC', 'BTCUSDT']) + # BTC, BTCUSDT, BTCUSDT 去重后只有 BTC + assert mock_api.set_leverage.call_count == 1 + + def test_sync_partial_failure(self): + service, mock_api = make_service() + mock_api.set_leverage.side_effect = [True, False] + service.settings.bitget_default_leverage = 10 + + result = service.sync_default_leverage(['BTCUSDT', 'ETHUSDT']) + assert result['success'] is False + + +# ==================== TestCancelOrder ==================== + +class TestCancelOrder: + + def test_cancel_success(self): + service, mock_api = make_service() + mock_api.cancel_order.return_value = True + result = service.cancel_order('BTC', 'ord123') + assert result['success'] is True + assert result['order_id'] == 'ord123' + + def test_cancel_failure(self): + service, mock_api = make_service() + mock_api.cancel_order.return_value = False + result = service.cancel_order('BTC', 'ord456') + assert result['success'] is False + + def test_cancel_exception(self): + service, mock_api = make_service() + mock_api.cancel_order.side_effect = Exception("order not found") + result = service.cancel_order('BTC', 'ord789') + assert result['success'] is False + assert 'order not found' in result['error']