From 5288bbd4a3fa9e1b8cb3b1689a94c6e407d5e9f6 Mon Sep 17 00:00:00 2001 From: aaron <> Date: Wed, 25 Mar 2026 22:23:38 +0800 Subject: [PATCH] update --- .env.example | 13 +- backend/app/api/bitget_live.py | 241 +++++ backend/app/api/real_trading.py | 415 --------- backend/app/config.py | 16 +- backend/app/crypto_agent/crypto_agent.py | 459 +++++++++- backend/app/main.py | 10 +- backend/app/models/real_trading.py | 109 --- .../services/bitget_live_trading_service.py | 537 +++++++++++ backend/app/services/real_trading_service.py | 765 ---------------- backend/backend/tests/__init__.py | 0 backend/tests/conftest.py | 34 + .../tests/test_bitget_live_trading_service.py | 844 ++++++++++++++++++ 12 files changed, 2119 insertions(+), 1324 deletions(-) create mode 100644 backend/app/api/bitget_live.py delete mode 100644 backend/app/api/real_trading.py delete mode 100644 backend/app/models/real_trading.py create mode 100644 backend/app/services/bitget_live_trading_service.py delete mode 100644 backend/app/services/real_trading_service.py create mode 100644 backend/backend/tests/__init__.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_bitget_live_trading_service.py diff --git a/.env.example b/.env.example index 8a9ce6e..daff76e 100644 --- a/.env.example +++ b/.env.example @@ -161,15 +161,12 @@ BITGET_PASSPHRASE=your_bitget_passphrase_here # 是否使用测试网(强烈建议先在测试网测试!) BITGET_USE_TESTNET=true -# 实盘交易总开关(false 时仅模拟交易生效) -REAL_TRADING_ENABLED=false +# Bitget 实盘开关(接入信号/决策层) +BITGET_TRADING_ENABLED=false -# 实盘交易风险控制 -REAL_TRADING_MAX_SINGLE_POSITION=1000 # 单笔最大持仓金额 (USDT) -REAL_TRADING_MAX_TOTAL_RATIO=0.5 # 最大总仓位比例(账户的50%) -REAL_TRADING_DEFAULT_LEVERAGE=10 # 实盘默认杠杆(低于模拟) -REAL_TRADING_RISK_PER_TRADE=0.02 # 每笔交易风险比例(2%) -REAL_TRADING_MAX_ORDERS=5 # 实盘最大同时持仓数 +# 风险控制 +BITGET_MAX_SINGLE_POSITION=1000 # 单笔最大持仓金额 (USDT) +BITGET_MAX_TOTAL_LEVERAGE=10 # 总杠杆上限(倍数) # ---------------------------------------------------------------------------- # Agent 模型配置 diff --git a/backend/app/api/bitget_live.py b/backend/app/api/bitget_live.py new file mode 100644 index 0000000..c069f25 --- /dev/null +++ b/backend/app/api/bitget_live.py @@ -0,0 +1,241 @@ +""" +Bitget 实盘交易 API +提供 Bitget 实盘交易数据接口 +""" +from fastapi import APIRouter, HTTPException, Query +from typing import Optional + +from app.services.bitget_live_trading_service import get_bitget_live_service +from app.utils.logger import logger + + +router = APIRouter(prefix="/api/bitget", tags=["Bitget"]) + + +def _get_service(): + service = get_bitget_live_service() + if service is None: + return None + return service + + +@router.get("/account") +async def get_account(): + """获取 Bitget 账户状态""" + try: + service = _get_service() + if service is None: + return {"success": True, "enabled": False, + "message": "Bitget 服务未启用。请在 .env 中设置 bitget_trading_enabled=true"} + + state = service.get_account_state() + positions = service.get_open_positions() + total_position_value = sum(abs(p["size"]) * p["entry_price"] for p in positions) + + if state["account_value"] > 0: + current_leverage = total_position_value / state["account_value"] + else: + current_leverage = 0 + + if service.initial_balance and service.initial_balance > 0: + drawdown = (service.initial_balance - state["account_value"]) / service.initial_balance + else: + drawdown = 0 + + return { + "success": True, + "enabled": True, + "message": "Bitget 服务正常", + "data": { + "account_value": state["account_value"], + "available_balance": state["available_balance"], + "total_margin_used": state["total_margin_used"], + "total_position_value": total_position_value, + "current_total_leverage": current_leverage, + "max_total_leverage": service.max_total_leverage, + "initial_balance": service.initial_balance, + "drawdown_percent": drawdown * 100, + "circuit_breaker_threshold": service.circuit_breaker_drawdown * 100, + } + } + except Exception as e: + logger.error(f"获取 Bitget 账户状态失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/positions") +async def get_positions( + symbol: Optional[str] = Query(None, description="币种筛选,如 BTC") +): + """获取 Bitget 持仓信息""" + try: + service = _get_service() + if service is None: + return {"success": True, "enabled": False, "positions": []} + + all_positions = service.get_open_positions() + + if symbol: + coin = symbol.replace("USDT", "").upper() + all_positions = [p for p in all_positions if p["coin"] == coin] + + positions_data = [] + for pos in all_positions: + coin = pos["coin"] + tp_sl = service.get_tp_sl_prices(coin) + positions_data.append({ + "symbol": f"{coin}USDT", + "side": "long" if pos["size"] > 0 else "short", + "size": abs(pos["size"]), + "entry_price": pos["entry_price"], + "unrealized_pnl": pos["unrealized_pnl"], + "leverage": pos.get("leverage", "N/A"), + "liquidation_price": pos.get("liquidation_price"), + "take_profit": tp_sl.get("take_profit"), + "stop_loss": tp_sl.get("stop_loss"), + }) + + return {"success": True, "enabled": True, "count": len(positions_data), "positions": positions_data} + except Exception as e: + logger.error(f"获取 Bitget 持仓失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/orders") +async def get_orders( + symbol: Optional[str] = Query(None, description="币种筛选,如 BTC") +): + """获取 Bitget 挂单信息""" + try: + service = _get_service() + if service is None: + return {"success": True, "enabled": False, "orders": []} + + coin = symbol.replace("USDT", "").upper() if symbol else None + all_orders = service.get_open_orders(coin) + + entry_orders = [o for o in all_orders if not o.get("is_reduce_only")] + tp_sl_orders = [o for o in all_orders if o.get("is_reduce_only")] + + return { + "success": True, + "enabled": True, + "counts": { + "entry_orders": len(entry_orders), + "tp_sl_orders": len(tp_sl_orders), + "total": len(all_orders), + }, + "entry_orders": entry_orders, + "tp_sl_orders": tp_sl_orders, + } + except Exception as e: + logger.error(f"获取 Bitget 挂单失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/summary") +async def get_summary(): + """获取 Bitget 交易摘要""" + try: + service = _get_service() + if service is None: + return {"success": True, "enabled": False, "message": "Bitget 服务未启用"} + + state = service.get_account_state() + positions = service.get_open_positions() + orders = service.get_open_orders() + total_position_value = sum(abs(p["size"]) * p["entry_price"] for p in positions) + + current_leverage = total_position_value / state["account_value"] if state["account_value"] > 0 else 0 + drawdown = 0 + if service.initial_balance and service.initial_balance > 0: + drawdown = (service.initial_balance - state["account_value"]) / service.initial_balance + + return { + "success": True, + "enabled": True, + "data": { + "account": { + "account_value": state["account_value"], + "available_balance": state["available_balance"], + "total_margin_used": state["total_margin_used"], + }, + "positions": {"count": len(positions), "total_value": total_position_value}, + "orders": { + "count": len(orders), + "entry_orders": len([o for o in orders if not o.get("is_reduce_only")]), + "tp_sl_orders": len([o for o in orders if o.get("is_reduce_only")]), + }, + "risk": { + "current_leverage": current_leverage, + "max_leverage": service.max_total_leverage, + "leverage_utilization": (current_leverage / service.max_total_leverage * 100) if service.max_total_leverage > 0 else 0, + "drawdown": drawdown * 100, + "circuit_breaker_threshold": service.circuit_breaker_drawdown * 100, + }, + } + } + except Exception as e: + logger.error(f"获取 Bitget 摘要失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/orders/cancel") +async def cancel_orders( + symbol: str = Query(..., description="币种,如 BTC") +): + """取消指定币种的所有挂单""" + try: + service = _get_service() + if service is None: + return {"success": False, "message": "Bitget 服务未启用"} + + coin = symbol.replace("USDT", "") + result = service.cancel_all_orders(coin) + if result.get("success"): + return {"success": True, "message": f"已取消 {symbol} 的所有挂单"} + else: + return {"success": False, "message": result.get("error", "取消失败")} + except Exception as e: + logger.error(f"取消 Bitget 挂单失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/positions/close") +async def close_position( + symbol: str = Query(..., description="币种,如 BTC") +): + """市价平仓指定币种(⚠️ 立即以市价平仓)""" + try: + service = _get_service() + if service is None: + return {"success": False, "message": "Bitget 服务未启用"} + + coin = symbol.replace("USDT", "") + position = service.get_position_for_symbol(coin) + if not position: + return {"success": False, "message": f"未找到 {symbol} 的持仓"} + + service.cancel_tp_sl_orders(coin) + + size_in_coins = abs(position["size"]) + is_long = position["size"] > 0 + contracts = service.coins_to_contracts(coin, size_in_coins) + + if contracts < 1: + return {"success": False, "message": f"持仓过小,无法下单 ({size_in_coins} 币)"} + + result = service.place_market_order( + symbol=coin, + is_buy=not is_long, + size=contracts, + reduce_only=True + ) + + if result.get("success"): + return {"success": True, "message": f"已平仓 {symbol} {contracts}张 @ 市价"} + else: + return {"success": False, "message": result.get("error", "平仓失败")} + except Exception as e: + logger.error(f"Bitget 平仓失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/app/api/real_trading.py b/backend/app/api/real_trading.py deleted file mode 100644 index 6c1ce2a..0000000 --- a/backend/app/api/real_trading.py +++ /dev/null @@ -1,415 +0,0 @@ -""" -实盘交易 API -""" -from fastapi import APIRouter, HTTPException, Query -from typing import Optional -from datetime import datetime -from pydantic import BaseModel - -from app.services.real_trading_service import get_real_trading_service -from app.services.bitget_trading_api_sdk import get_bitget_trading_api -from app.utils.logger import logger - - -router = APIRouter(prefix="/api/real-trading", tags=["实盘交易"]) - - -class OrderResponse(BaseModel): - """订单响应""" - success: bool - message: str - data: Optional[dict] = None - - -@router.get("/orders") -async def get_orders( - symbol: Optional[str] = Query(None, description="交易对筛选"), - status: Optional[str] = Query(None, description="数据源: trades=成交记录, orders=历史订单, exchange=历史订单"), - limit: int = Query(100, description="返回数量限制") -): - """ - 获取实盘交易历史数据 - - - symbol: 可选,按交易对筛选 - - status: 可选 - - trades: 交易所成交记录(包含每笔成交和手续费) - - orders: 交易所历史订单(包含订单状态) - - exchange: 交易所历史订单(同 orders) - - limit: 返回数量限制,默认100 - """ - try: - trading_api = get_bitget_trading_api() - - if not trading_api: - return { - "success": False, - "message": "Bitget API 未配置", - "count": 0, - "orders": [] - } - - # 获取成交记录(推荐,包含盈亏信息) - if status == "trades": - orders = trading_api.get_closed_orders(symbol, limit) - return { - "success": True, - "count": len(orders), - "orders": orders, - "source": "trades" - } - - # 获取历史订单 - if status in ["orders", "exchange"]: - try: - if symbol: - ccxt_symbol = trading_api._standardize_symbol(symbol) - orders = trading_api.exchange.fetch_closed_orders(ccxt_symbol, limit=limit) - else: - orders = trading_api.exchange.fetch_closed_orders(limit=limit) - - return { - "success": True, - "count": len(orders), - "orders": orders, - "source": "orders" - } - except Exception as e: - logger.error(f"获取历史订单失败: {e}") - return { - "success": False, - "message": f"获取历史订单失败: {str(e)}", - "count": 0, - "orders": [] - } - - # 默认返回成交记录 - orders = trading_api.get_closed_orders(symbol, limit) - return { - "success": True, - "count": len(orders), - "orders": orders, - "source": "trades" - } - - except Exception as e: - logger.error(f"获取实盘交易历史失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/orders/active") -async def get_active_orders( - symbol: Optional[str] = Query(None, description="交易对筛选") -): - """获取活跃实盘订单""" - try: - service = get_real_trading_service() - - if not service: - return { - "success": False, - "message": "实盘交易服务未启用", - "count": 0, - "orders": [] - } - - orders = service.get_active_orders() - - # 如果指定了交易对,进行过滤 - if symbol: - orders = [o for o in orders if o.get('symbol') == symbol] - - return { - "success": True, - "count": len(orders), - "orders": orders - } - except Exception as e: - logger.error(f"获取活跃实盘订单失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/orders/{order_id}") -async def get_order(order_id: str): - """获取实盘订单详情""" - try: - service = get_real_trading_service() - - if not service: - raise HTTPException(status_code=404, detail="实盘交易服务未启用") - - order = service.get_order(order_id) - - if not order: - raise HTTPException(status_code=404, detail="订单不存在") - - return { - "success": True, - "order": order - } - except HTTPException: - raise - except Exception as e: - logger.error(f"获取实盘订单详情失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/positions") -async def get_positions(): - """获取实盘持仓(从交易所同步)""" - try: - # 即使实盘交易未启用,也可以查看交易所持仓 - trading_api = get_bitget_trading_api() - - if not trading_api: - return { - "success": False, - "message": "Bitget API 未配置", - "positions": [] - } - - positions = trading_api.get_position() - - return { - "success": True, - "count": len(positions), - "positions": positions - } - except Exception as e: - logger.error(f"获取实盘持仓失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/account") -async def get_account_status(): - """获取实盘账户状态(即使实盘交易未启用也可查看)""" - try: - # 直接使用交易 API,不依赖实盘交易服务 - trading_api = get_bitget_trading_api() - - if not trading_api: - return { - "success": False, - "message": "Bitget API 未配置", - "account": None - } - - # 获取账户余额 - balance_info = trading_api.get_balance() - usdt_info = balance_info.get('USDT', {}) - - available = float(usdt_info.get('available', 0)) - frozen = float(usdt_info.get('frozen', 0)) - locked = float(usdt_info.get('locked', 0)) - - # 获取持仓价值 - positions = trading_api.get_position() - total_position_value = sum( - float(p.get('notional', 0)) for p in positions - ) - - account = { - 'current_balance': available + frozen + locked, - 'available': available, - 'used_margin': frozen + locked, - 'total_position_value': total_position_value - } - - return { - "success": True, - "account": account - } - except Exception as e: - logger.error(f"获取实盘账户状态失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.post("/orders/{order_id}/close") -async def close_order(order_id: str, exit_price: float = Query(..., description="平仓价格")): - """ - 手动平仓 - - 注意:实盘交易通常由交易所自动执行止损/止盈, - 此接口主要用于紧急情况下的手动平仓 - """ - try: - service = get_real_trading_service() - - if not service: - raise HTTPException(status_code=404, detail="实盘交易服务未启用") - - # 获取订单 - order = service.get_order(order_id) - if not order: - raise HTTPException(status_code=404, detail="订单不存在") - - # 调用交易所API平仓 - # TODO: 实现手动平仓逻辑 - - return { - "success": True, - "message": "平仓指令已发送", - "order_id": order_id - } - except HTTPException: - raise - except Exception as e: - logger.error(f"实盘手动平仓失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/stats") -async def get_trading_stats(): - """获取实盘交易统计""" - try: - logger.info("[stats] 开始获取统计数据") - - # 获取账户信息 - account = {} - trading_api = get_bitget_trading_api() - logger.info(f"[stats] trading_api: {trading_api}") - - if trading_api: - try: - logger.info("[stats] 开始获取账户信息") - balance_info = trading_api.get_balance() - logger.info(f"[stats] balance_info: {balance_info}") - usdt_info = balance_info.get('USDT', {}) - available = float(usdt_info.get('available', 0)) - frozen = float(usdt_info.get('frozen', 0)) - locked = float(usdt_info.get('locked', 0)) - - # 获取持仓价值 - logger.info("[stats] 开始获取持仓") - positions = trading_api.get_position() - logger.info(f"[stats] positions count: {len(positions)}") - total_position_value = sum( - float(p.get('notional', 0)) for p in positions - ) - - account = { - 'current_balance': available + frozen + locked, - 'available': available, - 'used_margin': frozen + locked, - 'total_position_value': total_position_value - } - logger.info(f"[stats] account: {account}") - except Exception as e: - logger.error(f"[stats] 获取账户信息失败: {e}") - import traceback - logger.error(traceback.format_exc()) - account = {} - - # 尝试从数据库获取统计 - stats = { - "total_trades": 0, - "winning_trades": 0, - "losing_trades": 0, - "win_rate": 0, - "total_pnl": 0, - "current_balance": account.get('current_balance', 0), - "available": account.get('available', 0), - "used_margin": account.get('used_margin', 0), - "total_position_value": account.get('total_position_value', 0), - } - - logger.info(f"[stats] 返回统计数据: {stats}") - - return { - "success": True, - "stats": stats - } - except Exception as e: - logger.error(f"获取实盘交易统计失败: {e}") - import traceback - logger.error(traceback.format_exc()) - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/status") -async def get_service_status(): - """获取实盘交易服务状态""" - try: - from app.config import get_settings - settings = get_settings() - - service = get_real_trading_service() - - status = { - "enabled": settings.real_trading_enabled, - "api_configured": bool(settings.bitget_api_key and settings.bitget_api_secret), - "use_testnet": settings.bitget_use_testnet, - "service_running": service is not None, - "max_single_position": settings.real_trading_max_single_position, - "default_leverage": settings.real_trading_default_leverage, - "max_orders": settings.real_trading_max_orders, - } - - if service: - account = service.get_account_status() - status["account"] = account - status["auto_trading_enabled"] = service.get_auto_trading_status() - - return { - "success": True, - "status": status - } - except Exception as e: - logger.error(f"获取实盘交易服务状态失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.post("/auto-trading") -async def set_auto_trading(enabled: bool = Query(..., description="是否启用自动交易")): - """ - 设置实盘自动交易开关 - - Args: - enabled: true=启用自动交易,false=禁用自动交易 - """ - try: - service = get_real_trading_service() - - if not service: - raise HTTPException(status_code=404, detail="实盘交易服务未初始化,请检查 API 配置") - - success = service.set_auto_trading(enabled) - - if success: - status_text = "启用" if enabled else "禁用" - return { - "success": True, - "message": f"实盘自动交易已{status_text}", - "auto_trading_enabled": enabled - } - else: - raise HTTPException(status_code=500, detail="设置自动交易失败") - - except HTTPException: - raise - except Exception as e: - logger.error(f"设置自动交易失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/auto-trading") -async def get_auto_trading_status(): - """获取实盘自动交易状态""" - try: - service = get_real_trading_service() - - if not service: - return { - "success": False, - "message": "实盘交易服务未初始化", - "auto_trading_enabled": False - } - - enabled = service.get_auto_trading_status() - - return { - "success": True, - "auto_trading_enabled": enabled - } - except Exception as e: - logger.error(f"获取自动交易状态失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/app/config.py b/backend/app/config.py index 249bf3d..45fb57f 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -160,16 +160,9 @@ class Settings(BaseSettings): bitget_passphrase: str = "" # Bitget API Passphrase bitget_use_testnet: bool = True # 是否使用测试网(测试时设为 true) - # 实盘交易开关 - real_trading_enabled: bool = False # 实盘交易总开关(false 时仅模拟交易生效) - - # 实盘交易风险控制 - real_trading_max_single_position: float = 1000 # 单笔最大持仓金额 (USDT) - real_trading_max_total_ratio: float = 0.5 # 最大总仓位比例(账户的50%) - real_trading_default_leverage: int = 10 # 实盘默认杠杆(低于模拟) - real_trading_max_total_leverage: float = 10 # 实盘总杠杆上限(持仓+挂单,倍数) - real_trading_risk_per_trade: float = 0.02 # 每笔交易风险比例(2%) - real_trading_max_orders: int = 5 # 实盘最大同时持仓数 + # 实盘风险控制(Bitget 实盘共用) + bitget_max_single_position: float = 1000 # 单笔最大持仓金额 (USDT) + bitget_max_total_leverage: float = 10 # 总杠杆上限(倍数) # Agent 模型配置 (可选值: zhipu, deepseek) smart_agent_model: str = "deepseek" # SmartAgent 使用的模型 @@ -207,6 +200,9 @@ class Settings(BaseSettings): pullback_select_time: str = "09:00" # 选股时间(24小时制) pullback_sectors_to_check: int = 5 # 检查板块数量 + # ========== Bitget 实盘交易配置 ========== + bitget_trading_enabled: bool = False # Bitget 实盘交易开关(默认关闭) + # ========== Hyperliquid 交易配置(ClawFi 集成)========== # Hyperliquid 交易开关 hyperliquid_trading_enabled: bool = False # Hyperliquid 实盘交易开关(默认关闭) diff --git a/backend/app/crypto_agent/crypto_agent.py b/backend/app/crypto_agent/crypto_agent.py index a7e9ba3..fca43f5 100644 --- a/backend/app/crypto_agent/crypto_agent.py +++ b/backend/app/crypto_agent/crypto_agent.py @@ -70,10 +70,24 @@ class CryptoAgent: else: logger.info(f"📊 Hyperliquid 实盘交易: 未启用(仅模拟盘)") + # Bitget 实盘服务(可选) + from app.services.bitget_live_trading_service import get_bitget_live_service + self.bitget = get_bitget_live_service() + + if self.bitget: + logger.info(f"🔥 Bitget 实盘交易: 已启用") + else: + logger.info(f"📊 Bitget 实盘交易: 未启用(仅模拟盘)") + # 状态管理 self.last_signals: Dict[str, Dict[str, Any]] = {} self.signal_cooldown: Dict[str, datetime] = {} + # 挂单 TP/SL 追踪:挂单成交后自动补设止盈止损 + # key=order_id, value={symbol, is_long, size/contracts, tp_price, sl_price} + self._hl_pending_tp_sl: Dict[str, Dict] = {} + self._bg_pending_tp_sl: Dict[str, Dict] = {} + # 配置 self.symbols = self.settings.crypto_symbols.split(',') @@ -92,6 +106,7 @@ class CryptoAgent: "symbols": self.symbols, "auto_trading_enabled": True, # 模拟交易始终启用 "hyperliquid_enabled": self.hyperliquid is not None, + "bitget_enabled": self.bitget is not None, "analysis_interval": "每5分钟整点" }) @@ -383,6 +398,12 @@ class CryptoAgent: # 发送超时取消通知 await self._notify_expired_orders_cancelled(cancelled) + # 检查实盘挂单是否已成交,补设止盈止损 + if self.hyperliquid: + await self._check_and_set_pending_tp_sl_hyperliquid() + if self.bitget: + await self._check_and_set_pending_tp_sl_bitget() + for symbol in self.symbols: await self.analyze_symbol(symbol) @@ -613,6 +634,7 @@ class CryptoAgent: paper_decision = None hyperliquid_decision = None + bitget_decision = None # 2.1 模拟盘决策 if self.settings.paper_trading_enabled: @@ -642,10 +664,24 @@ class CryptoAgent: else: logger.info(f"⏸️ Hyperliquid 实盘交易未启用") + # 2.3 Bitget 实盘决策(独立) + if self.bitget: + logger.info(f"\n🔥 【Bitget 决策】") + bg_positions, bg_account, bg_pending = self._get_bitget_trading_state() + bg_pending_for_symbol = [o for o in bg_pending if o.get('symbol') == symbol] + + bitget_decision = await self.decision_maker.make_decision( + market_signal, bg_positions, bg_account, current_price, bg_pending_for_symbol + ) + logger.info(f" Bitget 决策: {bitget_decision.get('decision')} - {bitget_decision.get('reasoning', '')}") + await self._send_trading_decision_notification(bitget_decision, market_signal, current_price, prefix="[Bitget]") + else: + logger.info(f"⏸️ Bitget 实盘交易未启用") + # ============================================================ # 第三阶段:执行交易决策(双轨独立) # ============================================================ - await self._execute_decisions(paper_decision, hyperliquid_decision, market_signal, current_price) + await self._execute_decisions(paper_decision, hyperliquid_decision, bitget_decision, market_signal, current_price) except Exception as e: logger.error(f"❌ 分析 {symbol} 出错: {e}") @@ -872,8 +908,9 @@ class CryptoAgent: async def _execute_decisions(self, paper_decision: Dict[str, Any], hyperliquid_decision: Dict[str, Any], + bitget_decision: Dict[str, Any], market_signal: Dict[str, Any], current_price: float): - """执行交易决策(双轨独立)""" + """执行交易决策(三轨独立)""" # 选择最佳信号用于保存 best_signal = self._get_best_signal_from_market(market_signal) @@ -897,6 +934,12 @@ class CryptoAgent: if hyperliquid_decision and self.hyperliquid: await self._execute_hyperliquid_decisions(hyperliquid_decision, market_signal, current_price) + # ============================================================ + # 执行 Bitget 决策 + # ============================================================ + if bitget_decision and self.bitget: + await self._execute_bitget_decisions(bitget_decision, market_signal, current_price) + async def _execute_paper_decisions(self, decision: Dict[str, Any], market_signal: Dict[str, Any], current_price: float): @@ -1551,14 +1594,14 @@ class CryptoAgent: # 默认返回 buy return 'buy' - def _calculate_quantity_by_position_size(self, position_size: str, real_trading: bool = False) -> float: + def _calculate_quantity_by_position_size(self, position_size: str, live_trading: bool = False) -> float: """根据仓位大小计算实际金额""" - if real_trading: + if live_trading: # 实盘交易配置 position_config = { - 'heavy': self.settings.real_trading_max_single_position, - 'medium': self.settings.real_trading_max_single_position * 0.6, - 'light': self.settings.real_trading_max_single_position * 0.3 + 'heavy': self.settings.bitget_max_single_position, + 'medium': self.settings.bitget_max_single_position * 0.6, + 'light': self.settings.bitget_max_single_position * 0.3 } else: # 模拟交易配置 @@ -1908,6 +1951,323 @@ class CryptoAgent: if self.settings.dingtalk_enabled: await self.dingtalk.send_action_card(title, content) + async def _notify_bitget_error(self, symbol: str, operation: str, error: str): + """发送 Bitget 操作失败的飞书/钉钉/Telegram 通知""" + title = f"❌ Bitget 操作失败 - {symbol}" + content = "\n".join([ + f"🔴 **操作**: {operation}", + f"⚠️ **错误**: {error}", + f"🕐 **时间**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", + ]) + logger.error(f"[Bitget] {operation} 失败 | {symbol} | {error}") + if self.settings.feishu_enabled: + await self.feishu.send_card(title, content, "red") + if self.settings.telegram_enabled: + await self.telegram.send_message(f"{title}\n\n{content}") + if self.settings.dingtalk_enabled: + await self.dingtalk.send_action_card(title, content) + + def _get_bitget_trading_state(self) -> tuple: + """ + 获取 Bitget 实盘交易状态(持仓和账户) + + Returns: + (positions, account, pending_orders) + """ + try: + bg_state = self.bitget.get_account_state() + + position_list = [] + for pos in self.bitget.get_open_positions(): + coin = pos["coin"] + size = pos["size"] + if size != 0: + tp_sl = self.bitget.get_tp_sl_prices(coin) + position_list.append({ + 'symbol': f"{coin}USDT", + 'side': 'buy' if size > 0 else 'sell', + 'holding': abs(size), + 'entry_price': pos["entry_price"], + 'unrealized_pnl': pos["unrealized_pnl"], + 'stop_loss': tp_sl.get('stop_loss'), + 'take_profit': tp_sl.get('take_profit'), + }) + + total_position_value = sum( + p['holding'] * p['entry_price'] for p in position_list + ) + account = { + 'current_balance': bg_state["account_value"], + 'initial_balance': self.bitget.initial_balance, + 'used_margin': bg_state["total_margin_used"], + 'available_balance': bg_state["available_balance"], + 'total_position_value': total_position_value, + 'max_total_leverage': self.bitget.max_total_leverage, + } + if account['current_balance'] > 0: + account['current_total_leverage'] = total_position_value / account['current_balance'] + else: + account['current_total_leverage'] = 0 + + all_orders = self.bitget.get_open_orders() + pending_orders = [] + for order in all_orders: + pending_orders.append({ + 'order_id': order.get('order_id'), + 'symbol': f"{order['symbol']}USDT", + 'side': order.get('side', ''), + 'entry_price': order.get('price'), + 'quantity': order.get('size'), + 'entry_type': 'limit', + 'is_reduce_only': order.get('is_reduce_only', False), + }) + + return position_list, account, pending_orders + + except Exception as e: + logger.error(f"获取 Bitget 状态失败: {e}") + return [], {}, [] + + async def _execute_bitget_decisions(self, decision: Dict[str, Any], + market_signal: Dict[str, Any], + current_price: float): + """执行 Bitget 决策""" + decision_type = decision.get('decision', 'HOLD') + symbol = decision.get('symbol', 'UNKNOWN') + + if decision_type == 'HOLD': + reasoning = decision.get('reasoning', '观望') + logger.info(f" Bitget 决策: {reasoning}") + return + + try: + if decision_type in ['OPEN', 'ADD']: + logger.info(f" 准备执行 Bitget 交易...") + result = await self._execute_bitget_trade(decision, market_signal, current_price) + + if result.get('success'): + logger.info(f" ✅ Bitget 交易成功") + order_status = result.get('verified_order_status', 'filled') + await self._send_signal_notification(market_signal, decision, current_price, + prefix="[Bitget]", + hl_order_status=order_status) + if result.get('tp_sl_warning'): + await self._notify_bitget_error(symbol, "设置止盈止损", result['tp_sl_warning']) + else: + error = result.get('error', '未知错误') + logger.error(f" ❌ Bitget 交易失败: {error}") + await self._notify_bitget_error(symbol, decision_type, error) + + elif decision_type == 'CLOSE': + logger.info(f" 准备 Bitget 平仓...") + result = await self._execute_bitget_close(decision, current_price) + + if result.get('success'): + logger.info(f" ✅ Bitget 平仓成功") + await self._send_signal_notification(market_signal, decision, current_price, prefix="[Bitget]") + else: + error = result.get('error', '未知错误') + logger.error(f" ❌ Bitget 平仓失败: {error}") + await self._notify_bitget_error(symbol, "平仓", error) + + elif decision_type == 'CANCEL_PENDING': + logger.info(f" 准备取消 Bitget 挂单...") + result = await self._execute_bitget_cancel(decision) + + if result.get('success'): + logger.info(f" ✅ Bitget 取消成功") + else: + error = result.get('error', '未知错误') + logger.error(f" ❌ Bitget 取消失败: {error}") + await self._notify_bitget_error(symbol, "取消挂单", error) + + except Exception as e: + logger.error(f" ❌ Bitget 执行异常: {e}") + await self._notify_bitget_error(symbol, decision_type, str(e)) + + async def _execute_bitget_trade(self, decision: Dict[str, Any], + market_signal: Dict[str, Any], + current_price: float) -> Dict[str, Any]: + """执行 Bitget 开仓/加仓""" + try: + symbol = decision.get('symbol', '').replace('USDT', '') + action = decision.get('action', '') # buy/sell + entry_type = decision.get('entry_type', 'market') + entry_price = decision.get('entry_price', current_price) + is_buy = (action == 'buy') # 修复:用 action 字段判断方向 + + # 如果是加仓,先取消旧的止盈止损单 + if decision.get('decision') == 'ADD': + self.bitget.cancel_tp_sl_orders(symbol) + + # 计算合约张数 + contracts = self._calculate_bitget_position_size(decision, current_price) + if contracts < 1: + return {"success": False, "error": f"仓位计算结果 {contracts} 张,低于最小下单量 1 张"} + + # 设置杠杆 + leverage = min(decision.get('leverage', 5), 10) + self.bitget.update_leverage(symbol, leverage) + + # 下单 + if entry_type == 'market': + result = self.bitget.place_market_order(symbol, is_buy=is_buy, size=contracts) + else: + result = self.bitget.place_limit_order(symbol, is_buy=is_buy, size=contracts, price=entry_price) + + if not result.get('success'): + return result + + order_status = result.get('order_status', 'filled') + + # 限价挂单中时验证订单是否真实存在 + if entry_type == 'limit' and order_status == 'resting': + order_id = result.get('order_id', '') + open_orders = self.bitget.get_open_orders(symbol) + ids = [str(o.get('order_id', '')) for o in open_orders] + if order_id and order_id not in ids: + logger.warning(f"[Bitget] 挂单 {order_id} 未在挂单列表中,可能已被静默拒绝") + order_status = 'unknown' + + result['verified_order_status'] = order_status + + tp_price = decision.get('take_profit') + sl_price = decision.get('stop_loss') + + if tp_price or sl_price: + if order_status != 'resting': + # 已成交:直接设置止盈止损 + tp_sl_result = self.bitget.set_tp_sl( + symbol=symbol, + is_long=is_buy, + size=contracts, + tp_price=tp_price, + sl_price=sl_price, + ) + if not tp_sl_result.get('success'): + result['tp_sl_warning'] = tp_sl_result.get('error', 'TP/SL 设置失败') + else: + # 挂单中:记录下来,等下次循环检测成交后补设 + order_id = str(result.get('order_id', '')) + if order_id: + self._bg_pending_tp_sl[order_id] = { + 'symbol': symbol, + 'is_long': is_buy, + 'contracts': contracts, + 'tp_price': tp_price, + 'sl_price': sl_price, + } + logger.info(f" 📌 [Bitget] 挂单 TP/SL 已记录 (oid={order_id}),等成交后补设") + + return result + + except Exception as e: + logger.error(f"Bitget 开仓失败: {e}") + return {"success": False, "error": str(e)} + + async def _execute_bitget_close(self, decision: Dict[str, Any], + current_price: float) -> Dict[str, Any]: + """执行 Bitget 市价平仓""" + try: + symbol = decision.get('symbol', '').replace('USDT', '') + + # 清理该 symbol 的挂单 TP/SL 追踪记录 + self._bg_pending_tp_sl = {k: v for k, v in self._bg_pending_tp_sl.items() if v['symbol'] != symbol} + + self.bitget.cancel_tp_sl_orders(symbol) + logger.info(f" 取消 Bitget 止盈止损订单") + + position = self.bitget.get_position_for_symbol(symbol) + if not position: + return {"success": False, "error": "未找到持仓"} + + size_in_coins = abs(position["size"]) + is_long = position["size"] > 0 + contracts = self.bitget.coins_to_contracts(symbol, size_in_coins) + + if contracts < 1: + return {"success": False, "error": f"持仓过小,无法下单({size_in_coins} 币 = {contracts} 张)"} + + result = self.bitget.place_market_order( + symbol=symbol, + is_buy=not is_long, + size=contracts, + reduce_only=True + ) + return result + + except Exception as e: + logger.error(f"Bitget 平仓失败: {e}") + return {"success": False, "error": str(e)} + + async def _execute_bitget_cancel(self, decision: Dict[str, Any]) -> Dict[str, Any]: + """执行 Bitget 取消挂单""" + try: + symbol = decision.get('symbol', '').replace('USDT', '') + # 清理该 symbol 的挂单 TP/SL 追踪记录 + self._bg_pending_tp_sl = {k: v for k, v in self._bg_pending_tp_sl.items() if v['symbol'] != symbol} + result = self.bitget.cancel_all_orders(symbol) + return result + except Exception as e: + logger.error(f"Bitget 取消挂单失败: {e}") + return {"success": False, "error": str(e)} + + def _calculate_bitget_position_size(self, decision: Dict[str, Any], current_price: float) -> int: + """ + 计算 Bitget 仓位大小(整数合约张数) + + Returns: + 可开仓合约数(整数张),0 表示不可开仓 + """ + try: + account_state = self.bitget.get_account_state() + current_balance = account_state["account_value"] + available_balance = account_state["available_balance"] + + total_position_value = sum( + abs(p["size"]) * p["entry_price"] + for p in self.bitget.get_open_positions() + ) + + leverage = min(decision.get('leverage', 5), 10) + + max_by_config = self.bitget.max_single_position + max_by_available = available_balance * leverage + max_by_total_leverage = ( + current_balance * self.bitget.max_total_leverage - total_position_value + ) + max_position_usd = min(max_by_config, max_by_available, max_by_total_leverage) + max_position_usd = min(max_position_usd, current_balance * 0.5) + + if max_position_usd <= 0: + logger.warning(f"⚠️ Bitget 可用保证金不足,无法开仓 (balance={current_balance:.2f})") + return 0 + + symbol = decision.get('symbol', '').replace('USDT', '') + contract_size = self.bitget.get_contract_size(symbol) + if contract_size <= 0 or current_price <= 0: + return 0 + + # notional → coins → contracts(向下取整) + coin_amount = max_position_usd / current_price + contracts = math.floor(coin_amount / contract_size) + + if contracts < 1: + logger.warning( + f"⚠️ Bitget 仓位计算 {coin_amount:.4f} 币 = {contracts} 张,低于最小 1 张" + ) + return 0 + + logger.info( + f"💰 Bitget 仓位: 最大{max_position_usd:.0f}USD → {coin_amount:.4f}{symbol} " + f"→ {contracts}张 (合约面值={contract_size}) @ ${current_price:.2f}" + ) + return contracts + + except Exception as e: + logger.error(f"Bitget 计算仓位大小失败: {e}") + return 0 + async def _execute_hyperliquid_decisions(self, decision: Dict[str, Any], market_signal: Dict[str, Any], current_price: float): @@ -1973,9 +2333,10 @@ class CryptoAgent: """执行 Hyperliquid 开仓/加仓""" try: symbol = decision.get('symbol', '').replace('USDT', '') # BTCUSDT → BTC - side = decision.get('side') + action = decision.get('action', '') # buy/sell entry_type = decision.get('entry_type', 'market') entry_price = decision.get('entry_price', current_price) + is_buy = (action == 'buy') # 修复:用 action 字段判断方向 # 计算仓位大小(基于可用保证金和风控) size = self._calculate_hyperliquid_position_size(decision, current_price) @@ -1997,13 +2358,13 @@ class CryptoAgent: if entry_type == 'market': result = self.hyperliquid.place_market_order( symbol=symbol, - is_buy=(side == 'buy'), + is_buy=is_buy, size=size ) else: # limit result = self.hyperliquid.place_limit_order( symbol=symbol, - is_buy=(side == 'buy'), + is_buy=is_buy, size=size, price=entry_price ) @@ -2038,10 +2399,9 @@ class CryptoAgent: if tp_price or sl_price: # 只有已成交的订单才设置止盈止损(挂单中的不设,等成交后再设) if order_status != 'resting': - is_long = (side == 'buy') tp_sl_result = self.hyperliquid.set_tp_sl( symbol=symbol, - is_long=is_long, + is_long=is_buy, size=size, tp_price=tp_price, sl_price=sl_price @@ -2050,6 +2410,18 @@ class CryptoAgent: if not tp_sl_result.get('success'): logger.warning(f" ⚠️ 设置止盈止损失败: {tp_sl_result.get('error')}") result['tp_sl_warning'] = tp_sl_result.get('error', '设置止盈止损失败') + else: + # 挂单中:记录下来,等下次循环检测成交后补设 + order_id = str(result.get('order_id', '')) + if order_id: + self._hl_pending_tp_sl[order_id] = { + 'symbol': symbol, + 'is_long': is_buy, + 'size': size, + 'tp_price': tp_price, + 'sl_price': sl_price, + } + logger.info(f" 📌 [Hyperliquid] 挂单 TP/SL 已记录 (oid={order_id}),等成交后补设") return result @@ -2063,6 +2435,9 @@ class CryptoAgent: try: symbol = decision.get('symbol', '').replace('USDT', '') + # 清理该 symbol 的挂单 TP/SL 追踪记录 + self._hl_pending_tp_sl = {k: v for k, v in self._hl_pending_tp_sl.items() if v['symbol'] != symbol} + # 先取消所有止盈止损订单 self.hyperliquid.cancel_tp_sl_orders(symbol) logger.info(f" 取消止盈止损订单") @@ -2094,12 +2469,72 @@ class CryptoAgent: """执行 Hyperliquid 取消挂单""" try: symbol = decision.get('symbol', '').replace('USDT', '') + # 清理该 symbol 的挂单 TP/SL 追踪记录 + self._hl_pending_tp_sl = {k: v for k, v in self._hl_pending_tp_sl.items() if v['symbol'] != symbol} result = self.hyperliquid.cancel_all_orders(symbol) return result except Exception as e: logger.error(f"Hyperliquid 取消挂单失败: {e}") return {"success": False, "error": str(e)} + async def _check_and_set_pending_tp_sl_hyperliquid(self): + """检查 Hyperliquid 挂单是否已成交,若成交则补设止盈止损""" + if not self._hl_pending_tp_sl: + return + try: + for order_id, info in list(self._hl_pending_tp_sl.items()): + symbol = info['symbol'] + open_orders = self.hyperliquid.get_open_orders(symbol) + still_open = any(str(o.get('order_id')) == order_id for o in open_orders) + if not still_open: + # 订单已不在挂单列表 → 已成交,补设 TP/SL + tp_price = info.get('tp_price') + sl_price = info.get('sl_price') + logger.info(f"[Hyperliquid] 挂单 {order_id} ({symbol}) 已成交,补设 TP/SL...") + tp_sl_result = self.hyperliquid.set_tp_sl( + symbol=symbol, + is_long=info['is_long'], + size=info['size'], + tp_price=tp_price, + sl_price=sl_price, + ) + if tp_sl_result.get('success'): + logger.info(f"[Hyperliquid] ✅ TP/SL 补设成功: {symbol} TP={tp_price} SL={sl_price}") + else: + logger.warning(f"[Hyperliquid] ⚠️ TP/SL 补设失败: {tp_sl_result.get('error')}") + del self._hl_pending_tp_sl[order_id] + except Exception as e: + logger.error(f"[Hyperliquid] 检查挂单 TP/SL 补设异常: {e}") + + async def _check_and_set_pending_tp_sl_bitget(self): + """检查 Bitget 挂单是否已成交,若成交则补设止盈止损""" + if not self._bg_pending_tp_sl: + return + try: + for order_id, info in list(self._bg_pending_tp_sl.items()): + symbol = info['symbol'] + open_orders = self.bitget.get_open_orders(symbol) + still_open = any(str(o.get('order_id')) == order_id for o in open_orders) + if not still_open: + # 订单已不在挂单列表 → 已成交,补设 TP/SL + tp_price = info.get('tp_price') + sl_price = info.get('sl_price') + logger.info(f"[Bitget] 挂单 {order_id} ({symbol}) 已成交,补设 TP/SL...") + tp_sl_result = self.bitget.set_tp_sl( + symbol=symbol, + is_long=info['is_long'], + size=info['contracts'], + tp_price=tp_price, + sl_price=sl_price, + ) + if tp_sl_result.get('success'): + logger.info(f"[Bitget] ✅ TP/SL 补设成功: {symbol} TP={tp_price} SL={sl_price}") + else: + logger.warning(f"[Bitget] ⚠️ TP/SL 补设失败: {tp_sl_result.get('error')}") + del self._bg_pending_tp_sl[order_id] + except Exception as e: + logger.error(f"[Bitget] 检查挂单 TP/SL 补设异常: {e}") + def _calculate_hyperliquid_position_size(self, decision: Dict[str, Any], current_price: float) -> float: """ 计算 Hyperliquid 仓位大小(基于可用保证金和风控限制) diff --git a/backend/app/main.py b/backend/app/main.py index 76686d6..49df71d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -9,7 +9,7 @@ from fastapi.responses import FileResponse from contextlib import asynccontextmanager from app.config import get_settings from app.utils.logger import logger -from app.api import chat, stock, skills, llm, auth, admin, paper_trading, stocks, signals, system, real_trading, news, astock, hyperliquid +from app.api import chat, stock, skills, llm, auth, admin, paper_trading, stocks, signals, system, news, astock, hyperliquid, bitget_live from app.utils.error_handler import setup_global_exception_handler, init_error_notifier from app.utils.system_status import get_system_monitor import os @@ -672,8 +672,8 @@ app.include_router(stock.router, prefix="/api/stock", tags=["股票数据"]) app.include_router(skills.router, prefix="/api/skills", tags=["技能管理"]) app.include_router(llm.router, tags=["LLM模型"]) app.include_router(paper_trading.router, tags=["交易"]) -app.include_router(real_trading.router, tags=["实盘交易"]) app.include_router(hyperliquid.router, tags=["Hyperliquid"]) +app.include_router(bitget_live.router, tags=["Bitget"]) app.include_router(stocks.router, prefix="/api/stocks", tags=["美股分析"]) app.include_router(astock.router, prefix="/api/astock", tags=["A股分析"]) app.include_router(signals.router, tags=["信号管理"]) @@ -714,9 +714,9 @@ async def trading_page(): return FileResponse(page_path) return {"message": "页面不存在"} -@app.get("/real-trading") -async def real_trading_page(): - """实盘交易页面""" +@app.get("/bitget-trading") +async def bitget_trading_page(): + """Bitget 实盘交易页面""" page_path = os.path.join(frontend_path, "real-trading.html") if os.path.exists(page_path): return FileResponse(page_path) diff --git a/backend/app/models/real_trading.py b/backend/app/models/real_trading.py deleted file mode 100644 index 641a083..0000000 --- a/backend/app/models/real_trading.py +++ /dev/null @@ -1,109 +0,0 @@ -""" -实盘交易数据模型 - -与模拟交易使用相同的订单状态和方向枚举 -""" -from datetime import datetime -from sqlalchemy import Column, Integer, String, Float, DateTime, JSON, Text, Enum as SQLEnum -from app.models.database import Base -from app.models.paper_trading import OrderStatus, OrderSide, SignalGrade, EntryType - - -class RealOrder(Base): - """实盘交易订单表""" - __tablename__ = "real_orders" - - id = Column(Integer, primary_key=True, index=True) - - # 订单标识 - order_id = Column(String(64), unique=True, nullable=False, index=True) # 本地订单ID - exchange_order_id = Column(String(64), nullable=True, index=True) # 交易所订单ID - client_order_id = Column(String(64), nullable=True, index=True) # 自定义订单ID - - # 交易对信息 - symbol = Column(String(20), nullable=False, index=True) - side = Column(SQLEnum(OrderSide), nullable=False) - - # 价格信息 - entry_price = Column(Float, nullable=False) # 目标入场价 - stop_loss = Column(Float, nullable=False) # 止损价 - take_profit = Column(Float, nullable=False) # 止盈价 - filled_price = Column(Float, nullable=True) # 实际成交价 - exit_price = Column(Float, nullable=True) # 出场价 - - # 仓位信息 - quantity = Column(Float, default=1000) # 仓位大小 (USDT) - leverage = Column(Integer, default=10) # 杠杆倍数 - size = Column(Float, nullable=True) # 合约数量(张数) - - # 信号信息 - signal_grade = Column(SQLEnum(SignalGrade), default=SignalGrade.D) - signal_type = Column(String(20), default="swing") # swing / short_term - confidence = Column(Float, default=0) # 置信度 (0-100) - trend = Column(String(20), nullable=True) # 趋势方向 - entry_type = Column(SQLEnum(EntryType, values_callable=lambda x: [e.value for e in x]), default=EntryType.MARKET) - - # 订单状态 - status = Column(SQLEnum(OrderStatus), default=OrderStatus.PENDING, index=True) - - # 盈亏信息 - pnl_amount = Column(Float, default=0) # 盈亏金额 (USDT) - pnl_percent = Column(Float, default=0) # 盈亏百分比 - fee_amount = Column(Float, default=0) # 手续费 - - # 风险指标 - max_drawdown = Column(Float, default=0) # 持仓期间最大回撤 - max_profit = Column(Float, default=0) # 持仓期间最大盈利 - - # 移动止损相关 - breakeven_triggered = Column(Integer, default=0) # 保本止损是否已触发 - trailing_stop_triggered = Column(Integer, default=0) # 移动止损是否已触发 - trailing_stop_base_profit = Column(Float, default=0) # 移动止损基准盈利 - - # 时间戳 - created_at = Column(DateTime, default=datetime.now, index=True) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - filled_at = Column(DateTime, nullable=True) # 成交时间 - closed_at = Column(DateTime, nullable=True) # 平仓时间 - - # 额外数据 - extra_data = Column(JSON, nullable=True) # 存储额外信息(metadata是保留字) - notes = Column(Text, nullable=True) # 备注 - - def to_dict(self) -> dict: - """转换为字典""" - return { - 'id': self.id, - 'order_id': self.order_id, - 'exchange_order_id': self.exchange_order_id, - 'client_order_id': self.client_order_id, - 'symbol': self.symbol, - 'side': self.side.value if self.side else None, - 'entry_price': self.entry_price, - 'stop_loss': self.stop_loss, - 'take_profit': self.take_profit, - 'filled_price': self.filled_price, - 'exit_price': self.exit_price, - 'quantity': self.quantity, - 'leverage': self.leverage, - 'size': self.size, - 'signal_grade': self.signal_grade.value if self.signal_grade else None, - 'signal_type': self.signal_type, - 'confidence': self.confidence, - 'trend': self.trend, - 'entry_type': self.entry_type.value if self.entry_type else None, - 'status': self.status.value if self.status else None, - 'pnl_amount': self.pnl_amount, - 'pnl_percent': self.pnl_percent, - 'fee_amount': self.fee_amount, - 'max_drawdown': self.max_drawdown, - 'max_profit': self.max_profit, - 'breakeven_triggered': bool(self.breakeven_triggered), - 'trailing_stop_triggered': bool(self.trailing_stop_triggered), - 'created_at': self.created_at.isoformat() if self.created_at else None, - 'updated_at': self.updated_at.isoformat() if self.updated_at else None, - 'filled_at': self.filled_at.isoformat() if self.filled_at else None, - 'closed_at': self.closed_at.isoformat() if self.closed_at else None, - 'extra_data': self.extra_data, - 'notes': self.notes - } diff --git a/backend/app/services/bitget_live_trading_service.py b/backend/app/services/bitget_live_trading_service.py new file mode 100644 index 0000000..4140c16 --- /dev/null +++ b/backend/app/services/bitget_live_trading_service.py @@ -0,0 +1,537 @@ +""" +Bitget 实盘交易服务 + +提供与 HyperliquidTradingService 一致的接口,底层调用 BitgetTradingAPI(ccxt)。 +供 crypto_agent.py 的决策执行层使用。 +""" +import math +from typing import Dict, List, Optional, Any + +from app.config import get_settings +from app.services.bitget_trading_api_sdk import get_bitget_trading_api +from app.utils.logger import logger + +# 合约面值表(张 → 币数量) +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, +} + + +class BitgetLiveTradingService: + """ + Bitget 实盘交易服务 + + 接口与 HyperliquidTradingService 保持一致,方便 crypto_agent.py 统一调用。 + """ + + def __init__(self): + 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.trading_api = get_bitget_trading_api() + if not self.trading_api: + raise RuntimeError("Bitget 交易 API 初始化失败,请检查 API Key 配置") + + # 初始余额(用于回撤计算) + self.initial_balance: Optional[float] = None + self._initialize_account() + + logger.info( + f"✅ BitgetLiveTradingService 初始化完成 " + f"(最大总杠杆: {self.max_total_leverage}x, " + f"单笔上限: ${self.max_single_position}, " + f"熔断阈值: {self.circuit_breaker_drawdown * 100:.0f}%)" + ) + + def _initialize_account(self): + """初始化账户状态,记录初始余额""" + try: + state = self.get_account_state() + self.initial_balance = state["account_value"] + logger.info(f"Bitget 初始账户余额: ${self.initial_balance:.2f}") + except Exception as e: + logger.warning(f"Bitget 初始化账户余额失败: {e}") + + # ==================== 账户 ==================== + + def get_account_state(self) -> Dict[str, Any]: + """ + 获取账户状态 + + Returns: + { + "account_value": float, # 账户总价值(USDT) + "total_margin_used": float, # 已用保证金 + "available_balance": float, # 可用余额 + } + """ + balance = self.trading_api.get_balance() + + usdt = balance.get('USDT', {}) + available = float(usdt.get('available', 0) or 0) + frozen = float(usdt.get('frozen', 0) or 0) + account_value = available + frozen + + return { + "account_value": account_value, + "total_margin_used": frozen, + "available_balance": available, + } + + def check_risk_limits(self) -> Dict[str, Any]: + """ + 检查风控限制 + + Returns: + {"allowed": bool, "reason": str} + """ + try: + state = self.get_account_state() + account_value = state["account_value"] + + # 1. 熔断检查 + if self.initial_balance and self.initial_balance > 0: + drawdown = (self.initial_balance - account_value) / self.initial_balance + if drawdown >= self.circuit_breaker_drawdown: + return { + "allowed": False, + "reason": f"熔断触发: 回撤 {drawdown * 100:.1f}% >= 阈值 {self.circuit_breaker_drawdown * 100:.0f}%" + } + + # 2. 总杠杆检查 + positions = self.get_open_positions() + total_position_value = sum( + abs(p["size"]) * p["entry_price"] for p in positions + ) + if account_value > 0: + current_leverage = total_position_value / account_value + if current_leverage >= self.max_total_leverage: + return { + "allowed": False, + "reason": f"总杠杆超限: {current_leverage:.1f}x >= {self.max_total_leverage}x" + } + + return {"allowed": True, "reason": ""} + except Exception as e: + logger.error(f"Bitget 风控检查异常: {e}") + return {"allowed": False, "reason": f"风控检查异常: {e}"} + + # ==================== 持仓 ==================== + + def get_open_positions(self) -> List[Dict[str, Any]]: + """ + 获取所有持仓 + + Returns: + [ + { + "coin": "BTC", + "size": float, # 正数=多, 负数=空(以币为单位) + "entry_price": float, + "unrealized_pnl": float, + "leverage": int, + "liquidation_price": Optional[float], + "position": dict, # 原始数据 + } + ] + """ + raw_positions = self.trading_api.get_position() + result = [] + for pos in raw_positions: + contracts = float(pos.get('contracts', 0)) + if contracts == 0: + continue + + symbol_raw = pos.get('symbol', '') # e.g. "BTC/USDT:USDT" + coin = symbol_raw.split('/')[0] if '/' in symbol_raw else symbol_raw + + contract_size = self.get_contract_size(coin) + coin_amount = contracts * contract_size + + side = pos.get('side', 'long') + size = coin_amount if side == 'long' else -coin_amount + + result.append({ + "coin": coin, + "size": size, + "entry_price": float(pos.get('entryPrice', 0) or 0), + "unrealized_pnl": float(pos.get('unrealizedPnl', 0) or 0), + "leverage": int(float(pos.get('leverage', 1) or 1)), + "liquidation_price": float(pos.get('liquidationPrice', 0) or 0) or None, + "position": pos, + }) + return result + + def get_position_for_symbol(self, symbol: str) -> Optional[Dict[str, Any]]: + """获取指定币种的持仓,无持仓返回 None""" + coin = symbol.replace('USDT', '').replace('/', '').upper() + for pos in self.get_open_positions(): + if pos['coin'] == coin: + return pos + return None + + # ==================== 下单 ==================== + + def place_market_order( + self, + symbol: str, + is_buy: bool, + size: int, + reduce_only: bool = False + ) -> Dict[str, Any]: + """ + 市价下单 + + Args: + symbol: 币种,如 "BTC" + is_buy: True=买入/做多, False=卖出/做空 + size: 合约数量(张) + reduce_only: 是否只减仓 + Returns: + {"success": bool, "order_id": str, "symbol": str, "side": str, "size": int, "error"?: str} + """ + try: + side = 'buy' if is_buy else 'sell' + params = {} + if reduce_only: + params['reduceOnly'] = True + + ccxt_symbol = self.trading_api._standardize_symbol(symbol) + contract_size = self.get_contract_size(symbol) + actual_amount = size * contract_size + + order = self.trading_api.exchange.create_order( + symbol=ccxt_symbol, + type='market', + side=side, + amount=actual_amount, + params={ + 'tdMode': 'cross', + 'marginCoin': 'USDT', + 'holdMode': 'oneWay', + **params + } + ) + + if not order: + return {"success": False, "error": "下单返回空", "symbol": symbol, "side": side, "size": size} + + logger.info(f"✅ Bitget 市价单成功: {symbol} {side} {size}张") + return { + "success": True, + "order_id": str(order.get('id', '')), + "symbol": symbol, + "side": side, + "size": size, + } + except Exception as e: + logger.error(f"❌ Bitget 市价单失败: {symbol} {e}") + return {"success": False, "error": str(e), "symbol": symbol, "side": "buy" if is_buy else "sell", "size": size} + + def place_limit_order( + self, + symbol: str, + is_buy: bool, + size: int, + price: float, + reduce_only: bool = False + ) -> Dict[str, Any]: + """ + 限价下单 + + Returns: + { + "success": bool, + "order_status": "resting" | "filled", + "order_id": str, + "symbol": str, + "side": str, + "size": int, + "price": float, + "error"?: str + } + """ + try: + side = 'buy' if is_buy else 'sell' + params = { + 'tdMode': 'cross', + 'marginCoin': 'USDT', + 'holdMode': 'oneWay', + } + if reduce_only: + params['reduceOnly'] = True + + ccxt_symbol = self.trading_api._standardize_symbol(symbol) + contract_size = self.get_contract_size(symbol) + actual_amount = size * contract_size + + order = self.trading_api.exchange.create_order( + symbol=ccxt_symbol, + type='limit', + side=side, + amount=actual_amount, + price=price, + params=params + ) + + if not order: + return { + "success": False, + "error": "下单返回空", + "symbol": symbol, + "side": side, + "size": size, + "price": price, + } + + # 判断订单状态:open → resting(挂单中),closed → filled(立即成交) + raw_status = order.get('status', 'open') + order_status = 'filled' if raw_status == 'closed' else 'resting' + + logger.info(f"✅ Bitget 限价单: {symbol} {side} {size}张 @ {price} [{order_status}]") + return { + "success": True, + "order_status": order_status, + "order_id": str(order.get('id', '')), + "symbol": symbol, + "side": side, + "size": size, + "price": price, + } + except Exception as e: + logger.error(f"❌ Bitget 限价单失败: {symbol} {e}") + return { + "success": False, + "error": str(e), + "symbol": symbol, + "side": "buy" if is_buy else "sell", + "size": size, + "price": price, + } + + # ==================== 止盈止损 ==================== + + def set_tp_sl( + self, + symbol: str, + is_long: bool, + size: int, + tp_price: Optional[float] = None, + sl_price: Optional[float] = None + ) -> Dict[str, Any]: + """ + 设置止盈止损 + + Returns: + {"success": bool, "results": [...], "error"?: str} + """ + try: + success = self.trading_api.modify_sl_tp( + symbol=symbol, + stop_loss=sl_price, + take_profit=tp_price, + ) + if success: + logger.info(f"✅ Bitget TP/SL 设置成功: {symbol} TP={tp_price} SL={sl_price}") + return { + "success": True, + "results": [ + {"type": "take_profit", "price": tp_price}, + {"type": "stop_loss", "price": sl_price}, + ] + } + else: + return {"success": False, "error": "modify_sl_tp 返回 False", "results": []} + except Exception as e: + logger.error(f"❌ Bitget 设置 TP/SL 失败: {symbol} {e}") + return {"success": False, "error": str(e), "results": []} + + def get_tp_sl_prices(self, symbol: str) -> Dict[str, Optional[float]]: + """ + 从挂单中读取止盈止损价格 + + Returns: + {"take_profit": float|None, "stop_loss": float|None} + """ + result = {"take_profit": None, "stop_loss": None} + try: + orders = self.trading_api.get_open_orders(symbol) + for order in orders: + if not order.get('reduceOnly'): + continue + order_side = order.get('side', '') + price = float(order.get('price', 0) or 0) + order_type = order.get('type', '') + + # stop 类型通常是止损,limit 类型通常是止盈 + if 'stop' in order_type.lower(): + result['stop_loss'] = price + elif order_type == 'limit' and price: + result['take_profit'] = price + except Exception as e: + logger.warning(f"Bitget 获取 TP/SL 价格失败: {symbol} {e}") + return result + + # ==================== 挂单管理 ==================== + + def get_open_orders(self, symbol: Optional[str] = None) -> List[Dict[str, Any]]: + """ + 获取挂单列表 + + Returns: + [{"order_id", "symbol", "side", "size", "price", "is_reduce_only"}] + """ + raw_orders = self.trading_api.get_open_orders(symbol) + result = [] + for order in raw_orders: + sym_raw = order.get('symbol', '') + coin = sym_raw.split('/')[0] if '/' in sym_raw else sym_raw + contracts = float(order.get('amount', 0) or 0) + contract_size = self.get_contract_size(coin) + size_in_coins = contracts # ccxt amount 已是币数量 + + result.append({ + "order_id": str(order.get('id', '')), + "symbol": coin, + "side": order.get('side', ''), + "size": size_in_coins, + "price": float(order.get('price', 0) or 0), + "is_reduce_only": bool(order.get('reduceOnly', False)), + "order_type": order.get('type', ''), + }) + return result + + def cancel_all_orders(self, symbol: Optional[str] = None) -> Dict[str, Any]: + """ + 撤销指定币种的所有挂单 + + Returns: + {"success": bool, "cancelled": int, "error"?: str} + """ + try: + success = self.trading_api.cancel_all_orders(symbol or '') + if success: + logger.info(f"✅ Bitget 撤销挂单成功: {symbol or '全部'}") + return {"success": True, "cancelled": 1} + else: + return {"success": False, "cancelled": 0, "error": "cancel_all_orders 返回 False"} + except Exception as e: + logger.error(f"❌ Bitget 撤销挂单失败: {symbol} {e}") + return {"success": False, "cancelled": 0, "error": str(e)} + + def cancel_tp_sl_orders(self, symbol: str) -> Dict[str, Any]: + """撤销止盈止损单(reduce-only 挂单)""" + return self.cancel_all_orders(symbol) + + # ==================== 杠杆 ==================== + + def update_leverage(self, symbol: str, leverage: int): + """设置杠杆倍数""" + try: + self.trading_api.set_leverage(symbol, leverage) + except Exception as e: + logger.warning(f"Bitget 设置杠杆失败: {symbol} {leverage}x: {e}") + + # ==================== 辅助方法 ==================== + + def get_contract_size(self, symbol: str) -> float: + """ + 获取合约面值(1张合约对应的币数量) + + 优先从硬编码表获取,不存在则查询 ccxt 市场信息。 + """ + coin = symbol.replace('USDT', '').replace('/', '').upper() + if coin in CONTRACT_SIZES: + return CONTRACT_SIZES[coin] + + # fallback: 从 ccxt market info 获取 + try: + ccxt_symbol = self.trading_api._standardize_symbol(symbol) + market = self.trading_api.exchange.market(ccxt_symbol) + size = float(market.get('contractSize', 1) or 1) + logger.info(f"Bitget 从市场信息获取合约面值: {coin} = {size}") + return size + except Exception: + logger.warning(f"Bitget 无法获取 {coin} 合约面值,使用默认值 1") + return 1.0 + + def coins_to_contracts(self, symbol: str, coin_amount: float, price: float = 1.0) -> int: + """ + 将币数量转换为合约张数(向下取整) + + Args: + symbol: 币种 + coin_amount: 要转换的币数量 + price: 当前价格(未使用,保留接口兼容) + Returns: + 整数合约张数 + """ + contract_size = self.get_contract_size(symbol) + if contract_size <= 0: + return 0 + return math.floor(coin_amount / contract_size) + + def market_close_all(self) -> Dict[str, Any]: + """市价平仓所有持仓""" + results = [] + positions = self.get_open_positions() + for pos in positions: + coin = pos['coin'] + is_long = pos['size'] > 0 + contracts = self.coins_to_contracts(coin, abs(pos['size'])) + if contracts < 1: + continue + result = self.place_market_order(coin, is_buy=not is_long, size=contracts, reduce_only=True) + results.append(result) + all_ok = all(r.get('success') for r in results) + return {"success": all_ok, "results": results} + + +# ==================== 单例工厂 ==================== + +_bitget_live_service: Optional[BitgetLiveTradingService] = None + + +def get_bitget_live_service() -> Optional[BitgetLiveTradingService]: + """ + 获取 BitgetLiveTradingService 单例。 + + bitget_trading_enabled=False 时返回 None(功能关闭)。 + """ + global _bitget_live_service + + settings = get_settings() + if not settings.bitget_trading_enabled: + return None + + if _bitget_live_service is None: + try: + _bitget_live_service = BitgetLiveTradingService() + except Exception as e: + logger.error(f"❌ BitgetLiveTradingService 初始化失败: {e}") + return None + + return _bitget_live_service + + +def reset_bitget_live_service(): + """重置单例(测试用)""" + global _bitget_live_service + _bitget_live_service = None diff --git a/backend/app/services/real_trading_service.py b/backend/app/services/real_trading_service.py deleted file mode 100644 index 3e11d3f..0000000 --- a/backend/app/services/real_trading_service.py +++ /dev/null @@ -1,765 +0,0 @@ -""" -实盘交易服务 - Bitget 合约交易 - -提供与模拟交易服务类似的接口,但执行的是真实交易 -集成 LLM 仓位管理决策 -""" -import uuid -from datetime import datetime, timedelta -from typing import Dict, Any, List, Optional - -from app.models.real_trading import RealOrder -from app.models.paper_trading import OrderStatus, OrderSide, SignalGrade, EntryType -from app.services.db_service import db_service -from app.services.position_manager import calculate_real_position -from app.config import get_settings -from app.utils.logger import logger - - -class RealTradingService: - """实盘交易服务""" - - def __init__(self): - """初始化实盘交易服务""" - self.settings = get_settings() - self.active_orders: Dict[str, RealOrder] = {} # 内存缓存活跃订单 - - # 实盘交易配置 - self.max_single_position = self.settings.real_trading_max_single_position - self.max_total_ratio = self.settings.real_trading_max_total_ratio - self.default_leverage = self.settings.real_trading_default_leverage - self.max_total_leverage = self.settings.real_trading_max_total_leverage # 总杠杆上限 - self.risk_per_trade = self.settings.real_trading_risk_per_trade - self.max_orders = self.settings.real_trading_max_orders - - # 自动交易开关(从数据库加载) - self.auto_trading_enabled = self._load_auto_trading_status() - - # 获取交易 API (使用 CCXT SDK 版本) - from app.services.bitget_trading_api_sdk import get_bitget_trading_api - self.trading_api = get_bitget_trading_api() - - if not self.trading_api: - logger.error("Bitget 交易 API 未初始化,实盘交易功能不可用") - return - - # 确保表已创建 - self._ensure_table_exists() - - # 加载活跃订单 - self._load_active_orders() - - logger.info(f"实盘交易服务初始化完成(最大单笔: ${self.max_single_position}," - f"杠杆: {self.default_leverage}x,最大持仓: {self.max_orders}," - f"自动交易: {'启用' if self.auto_trading_enabled else '禁用'})") - - def _ensure_table_exists(self): - """确保数据表已创建""" - from app.models.real_trading import RealOrder - from app.models.database import Base - from sqlalchemy import text - Base.metadata.create_all(bind=db_service.engine) - - # 创建自动交易开关表(使用简单的文本检查而不是 ORM) - db = db_service.get_session() - try: - # 检查表是否存在 - result = db.execute(text(""" - SELECT name FROM sqlite_master WHERE type='table' AND name='real_trading_settings' - """)).fetchone() - - if not result: - # 表不存在,创建表 - db.execute(text(""" - CREATE TABLE real_trading_settings ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - """)) - db.commit() - - # 初始化自动交易开关 - db.execute(text(""" - INSERT INTO real_trading_settings (key, value) - VALUES ('auto_trading_enabled', '0') - """)) - db.commit() - logger.info("创建实盘交易设置表") - except Exception as e: - logger.warning(f"创建设置表失败: {e}") - db.rollback() - finally: - db.close() - - def _load_auto_trading_status(self) -> bool: - """从数据库加载自动交易开关状态""" - db = db_service.get_session() - try: - from sqlalchemy import text - result = db.execute(text("SELECT value FROM real_trading_settings WHERE key = 'auto_trading_enabled'")).fetchone() - if result: - return result[0] == '1' - return False - except Exception as e: - logger.warning(f"加载自动交易状态失败: {e}") - return False - finally: - db.close() - - def set_auto_trading(self, enabled: bool) -> bool: - """设置自动交易开关""" - db = db_service.get_session() - try: - from sqlalchemy import text - db.execute(text(""" - UPDATE real_trading_settings - SET value = :value, updated_at = CURRENT_TIMESTAMP - WHERE key = 'auto_trading_enabled' - """), {'value': '1' if enabled else '0'}) - db.commit() - - self.auto_trading_enabled = enabled - logger.info(f"实盘自动交易已{'启用' if enabled else '禁用'}") - return True - except Exception as e: - logger.error(f"设置自动交易失败: {e}") - db.rollback() - return False - finally: - db.close() - - def get_auto_trading_status(self) -> bool: - """获取自动交易状态""" - return self.auto_trading_enabled - - def _load_active_orders(self): - """从数据库加载活跃订单""" - db = db_service.get_session() - try: - orders = db.query(RealOrder).filter( - RealOrder.status.in_([OrderStatus.PENDING, OrderStatus.OPEN]) - ).all() - - for order in orders: - self.active_orders[order.order_id] = order - - logger.info(f"实盘交易: 加载了 {len(orders)} 个活跃订单") - - finally: - db.close() - - def get_position_info_for_llm(self) -> Dict[str, Any]: - """ - 获取当前持仓信息供 LLM 分析参考 - - Returns: - 持仓信息字典,包含账户余额、持仓列表、当前杠杆等 - """ - try: - # 获取账户状态 - account = self.get_account_status() - balance = account.get('current_balance', 0) - total_position_value = account.get('total_position_value', 0) - used_margin = account.get('used_margin', 0) - available = account.get('available', 0) - - # 计算当前杠杆倍数(全仓模式) - current_leverage = (total_position_value / balance) if balance > 0 else 0 - - # 获取所有活跃持仓(本地记录) - positions = [] - for order in self.active_orders.values(): - if order.status in [OrderStatus.OPEN, OrderStatus.PENDING]: - positions.append({ - 'order_id': order.order_id, - 'symbol': order.symbol, - 'side': 'long' if order.side == OrderSide.LONG else 'short', - 'status': order.status.value, - 'entry_price': order.filled_price or order.entry_price, - 'quantity': order.quantity, - 'pnl_percent': order.pnl_percent or 0, - 'leverage': order.leverage - }) - - # 从交易所获取最新的持仓数据 - exchange_positions = [] - try: - if self.trading_api: - exchange_pos_list = self.trading_api.get_position() - for pos in exchange_pos_list: - contracts = float(pos.get('contracts', 0)) - if contracts != 0: - exchange_positions.append({ - 'symbol': pos.get('symbol'), - 'side': 'long' if pos.get('side') == 'long' else 'short', - 'contracts': contracts, - 'entryPrice': pos.get('entryPrice'), - 'markPrice': pos.get('markPrice'), - 'unrealizedPnl': pos.get('unrealizedPnl', 0), - 'leverage': pos.get('leverage', 1) - }) - except Exception as e: - logger.warning(f"获取交易所持仓失败: {e}") - - return { - 'account_balance': balance, - 'available': available, - 'total_position_value': total_position_value, - 'used_margin': used_margin, - 'current_leverage': current_leverage, - 'max_leverage': 20, # 全仓模式最大 20 倍杠杆 - 'positions': positions, - 'exchange_positions': exchange_positions - } - except Exception as e: - logger.error(f"获取持仓信息失败: {e}") - import traceback - logger.error(traceback.format_exc()) - return { - 'account_balance': 0, - 'total_position_value': 0, - 'current_leverage': 0, - 'max_leverage': 20, - 'positions': [], - 'exchange_positions': [] - } - - def create_order_from_signal(self, signal: Dict[str, Any], current_price: float = None) -> Dict[str, Any]: - """ - 从信号创建实盘订单(集成 LLM 仓位管理) - - Args: - signal: LLM 分析信号 - - symbol: 交易对 - - side: 'long' or 'short' - - entry_type: 'market' or 'limit' - - entry_price: 入场价 - - stop_loss: 止损价 - - take_profit: 止盈价 - - grade: 信号等级 - - confidence: 置信度 - - position_size: LLM 建议的仓位大小 ('heavy', 'medium', 'light') - current_price: 当前价格 - - Returns: - 创建结果 - """ - # 检查自动交易开关 - if not self.auto_trading_enabled: - logger.info(f"实盘自动交易已禁用,跳过信号执行") - return { - 'success': False, - 'message': '实盘自动交易已禁用', - 'skipped': True - } - - if not self.trading_api: - return { - 'success': False, - 'message': '交易 API 未初始化' - } - - db = db_service.get_session() - result = { - 'success': False, - 'message': '', - 'order_id': None - } - - try: - # 检查实盘交易是否启用 - if not self.settings.real_trading_enabled: - return { - 'success': False, - 'message': '实盘交易未启用,请检查配置 REAL_TRADING_ENABLED=true' - } - - # 获取信号信息 - symbol = signal.get('symbol') - side = signal.get('side') # 'long' or 'short' - entry_type = signal.get('entry_type', 'market') # 'market' or 'limit' - entry_price = signal.get('entry_price') - stop_loss = signal.get('stop_loss') - take_profit = signal.get('take_profit') - grade = signal.get('grade', 'D') - confidence = signal.get('confidence', 0) - position_size = signal.get('position_size', 'light') # LLM 建议的仓位大小 - - # 验证必需参数 - if not all([symbol, side, stop_loss, take_profit]): - return { - 'success': False, - 'message': '信号缺少必需参数' - } - - # 获取当前价格 - if not current_price: - from app.services.bitget_service import bitget_service - current_price = bitget_service.get_current_price(symbol) - - if not current_price: - return { - 'success': False, - 'message': '无法获取当前价格' - } - - # 设置入场价 - if entry_type == 'market': - entry_price = current_price - elif not entry_price: - entry_price = current_price - - # 风险检查 - 检查订单数量 - if len(self.active_orders) >= self.max_orders: - return { - 'success': False, - 'message': f'已达最大持仓数 {self.max_orders}' - } - - # 获取账户状态 - account = self.get_account_status() - balance = account['current_balance'] - available = account['available'] - used_margin = account['used_margin'] - total_position_value = account['total_position_value'] - - if available < 10: - return { - 'success': False, - 'message': f'可用余额不足 (${available:.2f})' - } - - # === 使用 LLM 建议的仓位大小计算仓位 === - # 检查当前杠杆,确保加仓后不超过配置的总杠杆上限 - current_leverage = (total_position_value / balance) if balance > 0 else 0 - max_total_leverage = self.max_total_leverage # 使用配置的总杠杆上限 - available_leverage = max_total_leverage - current_leverage - - if available_leverage <= 0: - return { - 'success': False, - 'message': f'当前杠杆已达 {current_leverage:.1f}x,已超最大限制 {max_total_leverage}x,无法开仓' - } - - # 优先使用信号中的 quantity(LLM 决策的保证金金额) - quantity_from_signal = signal.get('quantity') - if quantity_from_signal is not None and quantity_from_signal > 0: - # LLM 决策的 quantity 是保证金金额 - margin = float(quantity_from_signal) - # 计算持仓价值(保证金 × 杠杆) - position_value = margin * self.default_leverage - logger.info(f"使用 LLM 决策保证金: ${margin:.2f}, 持仓价值: ${position_value:.2f}") - - # 验证:加仓后的总杠杆不超过配置的上限 - new_total_value = total_position_value + position_value - new_leverage = new_total_value / balance if balance > 0 else 0 - if new_leverage > max_total_leverage: - return { - 'success': False, - 'message': f'LLM 决策会导致总杠杆 {new_leverage:.1f}x 超过限制 {max_total_leverage}x (保证金 ${margin:.2f}, 持仓价值 ${position_value:.2f})' - } - else: - # 回退到动态仓位计算 - # 根据可用杠杆空间动态调整仓位比例 - custom_ratios = { - 'heavy': 0.12, # heavy: 12% 可用杠杆空间 - 'medium': 0.06, # medium: 6% 可用杠杆空间 - 'light': 0.03 # light: 3% 可用杠杆空间 - } - - # 计算仓位(使用统一的仓位管理器) - margin, position_value = calculate_real_position( - balance=balance, - used_margin=used_margin, - total_position_value=total_position_value, - position_size=position_size, - symbol=symbol, - max_leverage=int(available_leverage), # 使用可用杠杆空间 - custom_ratios=custom_ratios - ) - - if margin <= 0 or position_value <= 0: - return { - 'success': False, - 'message': '无法开仓:仓位计算失败或已达杠杆限制' - } - - # 再次验证:加仓后的总杠杆不超过配置的上限 - new_total_value = total_position_value + position_value - new_leverage = new_total_value / balance if balance > 0 else 0 - if new_leverage > max_total_leverage: - # 调整仓位大小到安全范围内 - safe_position_value = balance * max_total_leverage - total_position_value - if safe_position_value > 0: - position_value = safe_position_value - margin = position_value / self.default_leverage - logger.warning(f"仓位已调整,确保总杠杆不超过 {max_total_leverage}x") - else: - return { - 'success': False, - 'message': f'当前杠杆 {current_leverage:.1f}x,无法再加仓' - } - - quantity = position_value # 订单数量(以 USDT 计价) - - # 最小仓位限制(100 美金测试,最小 5 USDT) - min_quantity = 5 - if quantity < min_quantity: - return { - 'success': False, - 'message': f'计算仓位 ${quantity:.2f} 小于最小值 ${min_quantity}' - } - - # 创建订单对象 - order_id = str(uuid.uuid4()) - order = RealOrder( - order_id=order_id, - symbol=symbol, - side=OrderSide.LONG if side == 'long' else OrderSide.SHORT, - entry_price=entry_price, - stop_loss=stop_loss, - take_profit=take_profit, - quantity=quantity, - leverage=self.default_leverage, - signal_grade=SignalGrade[grade.upper()] if grade.upper() in ['A', 'B', 'C', 'D'] else SignalGrade.D, - signal_type=signal.get('signal_type', 'swing'), - confidence=confidence, - trend=signal.get('trend'), - entry_type=EntryType.MARKET if entry_type == 'market' else EntryType.LIMIT, - status=OrderStatus.OPEN if entry_type == 'market' else OrderStatus.PENDING - ) - - db.add(order) - db.commit() - db.refresh(order) - - # 调用 Bitget API 下单 - exchange_result = self._place_bitget_order(order, current_price) - - if exchange_result['success']: - # 更新订单状态 - order.exchange_order_id = exchange_result.get('order_id') - order.client_order_id = exchange_result.get('client_order_id') - order.filled_price = exchange_result.get('filled_price', entry_price) - order.filled_at = datetime.now() - order.status = OrderStatus.OPEN - - db.commit() - - # 添加到内存缓存 - self.active_orders[order_id] = order - - result['success'] = True - result['message'] = f'实盘订单创建成功 (仓位: {position_size})' - result['order_id'] = order_id - result['exchange_order_id'] = exchange_result.get('order_id') - result['position_size'] = position_size - result['quantity'] = quantity - - logger.info(f"✅ 实盘订单创建成功: {symbol} {side} ${entry_price} | " - f"仓位: {position_size} | 数量: ${quantity:.2f} | " - f"-> {exchange_result.get('order_id')}") - else: - # 下单失败,删除记录 - db.delete(order) - db.commit() - - result['message'] = f"下单失败: {exchange_result.get('message', '未知错误')}" - - logger.error(f"❌ 实盘订单下单失败: {exchange_result.get('message')}") - - except Exception as e: - db.rollback() - result['message'] = f"创建订单失败: {str(e)}" - logger.error(f"创建实盘订单失败: {e}") - - finally: - db.close() - - return result - - def _place_bitget_order(self, order: RealOrder, current_price: float) -> Dict: - """ - 调用 Bitget API 下单 (使用 CCXT SDK) - - Returns: - {'success': bool, 'order_id': str, 'client_order_id': str, 'filled_price': float, 'message': str} - """ - try: - # CCXT 使用标准的 buy/sell 方向 - # 对于合约:buy = 开多/平空,sell = 开空/平多 - # 这里我们简化为:long = buy, short = sell - side_map = { - OrderSide.LONG: 'buy', - OrderSide.SHORT: 'sell' - } - - # 映射订单类型 - order_type = 'market' if order.entry_type == EntryType.MARKET else 'limit' - - # 计算合约数量(张数) - # Bitget U本位合约:1张 = 1 USDT(大多数情况) - size = order.quantity # 简化处理,实际应该根据合约规格计算 - - # 生成自定义订单ID - client_order_id = f"real_{order.order_id[:8]}" - - # 调用 API 下单 - result = self.trading_api.place_order( - symbol=order.symbol, - side=side_map[order.side], - order_type=order_type, - size=size, - price=order.entry_price if order_type == 'limit' else None, - client_order_id=client_order_id - ) - - if result: - # CCXT 返回的订单对象格式 - # result['id'] 是交易所订单ID - # result['price'] 是委托价格 - # result['average'] 是成交均价(如果已成交) - order_id = result.get('id') - filled_price = float(result.get('average', 0)) or current_price - - return { - 'success': True, - 'order_id': order_id, - 'client_order_id': client_order_id, - 'filled_price': filled_price - } - else: - return { - 'success': False, - 'message': 'API 调用失败' - } - - except Exception as e: - logger.error(f"Bitget 下单失败: {e}") - return { - 'success': False, - 'message': str(e) - } - - def get_active_orders(self) -> List[Dict]: - """获取活跃订单列表""" - return [order.to_dict() for order in self.active_orders.values()] - - def get_order(self, order_id: str) -> Optional[Dict]: - """获取指定订单""" - order = self.active_orders.get(order_id) - if order: - return order.to_dict() - return None - - def cancel_order(self, order_id: str) -> Dict[str, Any]: - """ - 取消挂单 - - Args: - order_id: 订单ID - - Returns: - 取消结果字典 - """ - if order_id not in self.active_orders: - return { - 'success': False, - 'message': f'订单不存在: {order_id}' - } - - order = self.active_orders[order_id] - - # 只能取消挂单状态的订单 - if order.status != OrderStatus.PENDING: - return { - 'success': False, - 'message': f'只能取消挂单状态的订单,当前状态: {order.status.value}' - } - - # 调用交易所 API 取消订单 - if not self.trading_api: - return { - 'success': False, - 'message': '交易 API 未初始化' - } - - try: - # 获取原始订单ID(如果有) - original_order_id = order.original_order_id or order_id - - # 调用交易所取消订单API - success = self.trading_api.cancel_order(symbol=order.symbol, order_id=original_order_id) - - if success: - # 更新本地订单状态 - order.status = OrderStatus.CANCELLED - order.closed_at = datetime.utcnow() - - # 保存到数据库 - db = db_service.get_session() - try: - db_order = db.query(RealOrder).filter(RealOrder.order_id == order_id).first() - if db_order: - db_order.status = OrderStatus.CANCELLED - db_order.closed_at = datetime.utcnow() - db.merge(db_order) - db.commit() - except Exception as e: - logger.error(f"更新数据库订单状态失败: {e}") - db.rollback() - finally: - db.close() - - # 从活跃订单缓存中移除 - if order_id in self.active_orders: - del self.active_orders[order_id] - - logger.info(f"实盘挂单已取消: {order_id} | {order.symbol}") - - return { - 'success': True, - 'order_id': order_id, - 'message': '挂单已取消' - } - else: - return { - 'success': False, - 'message': '交易所取消订单失败' - } - - except Exception as e: - logger.error(f"取消实盘挂单失败: {e}") - return { - 'success': False, - 'message': f'取消订单异常: {e}' - } - - def sync_positions_from_exchange(self) -> List[Dict]: - """ - 从交易所同步持仓状态 - - Returns: - 同步后的持仓列表 - """ - if not self.trading_api: - return [] - - try: - # 获取交易所实际持仓 - positions = self.trading_api.get_position() - - logger.info(f"从交易所同步了 {len(positions)} 个持仓") - return positions - - except Exception as e: - logger.error(f"同步持仓失败: {e}") - return [] - - def get_account_status(self) -> Dict: - """获取账户状态""" - if not self.trading_api: - return { - 'current_balance': 0, - 'used_margin': 0, - 'total_position_value': 0, - 'available': 0 - } - - try: - balance_info = self.trading_api.get_balance() - usdt_info = balance_info.get('USDT', {}) - - available = float(usdt_info.get('available', 0)) - frozen = float(usdt_info.get('frozen', 0)) - locked = float(usdt_info.get('locked', 0)) - - # 计算持仓价值 - total_position_value = 0 - for order in self.active_orders.values(): - if order.status == OrderStatus.OPEN: - total_position_value += order.quantity - - return { - 'current_balance': available + frozen + locked, - 'available': available, - 'used_margin': frozen + locked, - 'total_position_value': total_position_value - } - - except Exception as e: - logger.error(f"获取账户状态失败: {e}") - return { - 'current_balance': 0, - 'used_margin': 0, - 'total_position_value': 0, - 'available': 0 - } - - def get_position_info(self) -> Dict[str, Any]: - """ - 获取当前持仓信息(供 LLM 分析使用) - - Returns: - 持仓信息字典 - """ - account = self.get_account_status() - active_orders = self.get_active_orders() - - # 计算当前杠杆 - balance = account['current_balance'] - total_position_value = account['total_position_value'] - current_leverage = total_position_value / balance if balance > 0 else 0 - - # 格式化持仓列表 - positions = [] - for order in active_orders: - positions.append({ - 'symbol': order.get('symbol'), - 'side': order.get('side'), - 'status': order.get('status'), - 'entry_price': order.get('filled_price') or order.get('entry_price'), - 'quantity': order.get('quantity'), - 'pnl_percent': order.get('pnl_percent', 0) - }) - - return { - 'account_balance': balance, - 'total_position_value': total_position_value, - 'current_leverage': current_leverage, - 'max_leverage': self.default_leverage, - 'active_order_count': len(active_orders), - 'max_orders': self.max_orders, - 'positions': positions - } - - -# 全局实例 -_real_trading_service: Optional[RealTradingService] = None - - -def get_real_trading_service() -> Optional[RealTradingService]: - """ - 获取实盘交易服务实例(单例) - - 注意:不再检查 REAL_TRADING_ENABLED 配置 - 只要 API 配置了就初始化服务,自动交易可以单独控制 - - Returns: - RealTradingService 实例或 None(如果未配置 API) - """ - global _real_trading_service - - if _real_trading_service: - return _real_trading_service - - settings = get_settings() - - # 检查是否配置了 API Key(不再检查 REAL_TRADING_ENABLED) - if not settings.bitget_api_key or not settings.bitget_api_secret: - logger.warning("Bitget API Key 未配置,实盘交易功能不可用") - return None - - _real_trading_service = RealTradingService() - return _real_trading_service diff --git a/backend/backend/tests/__init__.py b/backend/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..a768bdf --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,34 @@ +""" +conftest.py - pytest 全局 mock 配置 + +在任何 app.* 模块加载前,把 app.config 和 app.utils.logger 替换为 mock, +避免 pydantic_settings / 数据库 / 真实网络依赖。 +""" +import sys +from unittest.mock import MagicMock + + +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.bitget_trading_enabled = False + return s + + +# ---- mock app.config ---- +mock_config_module = MagicMock() +mock_config_module.get_settings = _mock_settings +sys.modules['app.config'] = mock_config_module + +# ---- mock app.utils.logger ---- +mock_logger_module = MagicMock() +mock_logger_module.logger = MagicMock() +sys.modules['app.utils'] = MagicMock() +sys.modules['app.utils.logger'] = mock_logger_module + +# ---- mock app.services.bitget_trading_api_sdk (避免 ccxt import) ---- +mock_sdk_module = MagicMock() +mock_sdk_module.get_bitget_trading_api = MagicMock(return_value=MagicMock()) +sys.modules['app.services.bitget_trading_api_sdk'] = mock_sdk_module diff --git a/backend/tests/test_bitget_live_trading_service.py b/backend/tests/test_bitget_live_trading_service.py new file mode 100644 index 0000000..e9b1086 --- /dev/null +++ b/backend/tests/test_bitget_live_trading_service.py @@ -0,0 +1,844 @@ +""" +BitgetLiveTradingService 单元测试 + +使用 pytest + unittest.mock,mock BitgetTradingAPI 实例(不调用真实 ccxt/网络)。 + +测试覆盖: + - get_account_state + - get_open_positions + - get_position_for_symbol + - place_market_order + - place_limit_order + - set_tp_sl + - cancel_all_orders + - get_open_orders + - get_tp_sl_prices + - update_leverage + - check_risk_limits + - get_contract_size / coins_to_contracts +""" +import math +import sys +import os +import pytest +from unittest.mock import MagicMock, patch, PropertyMock + +# 将 backend 目录加入 path,使 app.* 可以导入 +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + + +# ==================== Fixtures ==================== + +def make_service(settings_overrides=None): + """ + 创建 BitgetLiveTradingService 实例,直接用 __new__ 绕过 __init__, + 手动注入 mock trading_api 和 settings,无需真实网络/数据库。 + """ + from app.services.bitget_live_trading_service import BitgetLiveTradingService + + mock_api = MagicMock() + mock_exchange = MagicMock() + mock_api.exchange = mock_exchange + mock_api._standardize_symbol = lambda s: f"{s.replace('USDT', '')}/USDT:USDT" + + 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 + if settings_overrides: + for k, v in settings_overrides.items(): + setattr(mock_settings, k, v) + + # 用 __new__ 跳过 __init__(避免真实 API/数据库调用),手动设置所有属性 + service = BitgetLiveTradingService.__new__(BitgetLiveTradingService) + 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.trading_api = mock_api + service.initial_balance = 10000.0 + + return service, mock_api + + +# ==================== TestGetAccountState ==================== + +class TestGetAccountState: + + def test_normal(self): + service, mock_api = make_service() + mock_api.get_balance.return_value = { + 'USDT': {'available': '8000.0', 'frozen': '2000.0', 'locked': '0'} + } + state = service.get_account_state() + assert state['account_value'] == pytest.approx(10000.0) + assert state['available_balance'] == pytest.approx(8000.0) + assert state['total_margin_used'] == pytest.approx(2000.0) + + def test_usdt_not_present(self): + service, mock_api = make_service() + mock_api.get_balance.return_value = {} + state = service.get_account_state() + assert state['account_value'] == pytest.approx(0.0) + assert state['available_balance'] == pytest.approx(0.0) + assert state['total_margin_used'] == pytest.approx(0.0) + + def test_none_values_treated_as_zero(self): + service, mock_api = make_service() + mock_api.get_balance.return_value = { + 'USDT': {'available': None, 'frozen': None, 'locked': '0'} + } + state = service.get_account_state() + assert state['account_value'] == pytest.approx(0.0) + + def test_api_exception_propagates(self): + service, mock_api = make_service() + mock_api.get_balance.side_effect = Exception("network error") + with pytest.raises(Exception, match="network error"): + service.get_account_state() + + +# ==================== TestGetOpenPositions ==================== + +class TestGetOpenPositions: + + def _make_raw_position(self, coin='BTC', contracts=1.0, side='long', + entry_price=50000.0, pnl=100.0, leverage=10, + liq_price=45000.0): + return { + 'symbol': f'{coin}/USDT:USDT', + 'contracts': contracts, + 'side': side, + 'entryPrice': entry_price, + 'unrealizedPnl': pnl, + 'leverage': leverage, + 'liquidationPrice': liq_price, + } + + def test_long_position(self): + service, mock_api = make_service() + mock_api.get_position.return_value = [ + self._make_raw_position('BTC', 2.0, 'long', 50000.0) + ] + positions = service.get_open_positions() + assert len(positions) == 1 + pos = positions[0] + assert pos['coin'] == 'BTC' + # 2 张 × 0.01 BTC/张 = 0.02 BTC,多仓为正 + assert pos['size'] == pytest.approx(0.02) + assert pos['entry_price'] == pytest.approx(50000.0) + + def test_short_position(self): + service, mock_api = make_service() + mock_api.get_position.return_value = [ + self._make_raw_position('ETH', 5.0, 'short', 3000.0) + ] + positions = service.get_open_positions() + assert len(positions) == 1 + pos = positions[0] + assert pos['coin'] == 'ETH' + # 5 张 × 0.1 ETH/张 = 0.5 ETH,空仓为负 + assert pos['size'] == pytest.approx(-0.5) + + def test_zero_contracts_filtered(self): + service, mock_api = make_service() + mock_api.get_position.return_value = [ + self._make_raw_position('BTC', 0.0, 'long', 50000.0) + ] + positions = service.get_open_positions() + assert positions == [] + + def test_empty_positions(self): + service, mock_api = make_service() + mock_api.get_position.return_value = [] + positions = service.get_open_positions() + assert positions == [] + + def test_multiple_positions(self): + service, mock_api = make_service() + mock_api.get_position.return_value = [ + self._make_raw_position('BTC', 1.0, 'long', 50000.0), + self._make_raw_position('ETH', 3.0, 'short', 3000.0), + ] + positions = service.get_open_positions() + assert len(positions) == 2 + coins = [p['coin'] for p in positions] + assert 'BTC' in coins + assert 'ETH' in coins + + def test_api_exception_returns_empty(self): + service, mock_api = make_service() + mock_api.get_position.side_effect = Exception("timeout") + # get_open_positions 内部调用 trading_api.get_position() 会抛出 + # service 应让异常传播(不静默吞掉),由上层处理 + with pytest.raises(Exception): + service.get_open_positions() + + +# ==================== TestGetPositionForSymbol ==================== + +class TestGetPositionForSymbol: + + def test_found(self): + service, mock_api = make_service() + mock_api.get_position.return_value = [{ + 'symbol': 'BTC/USDT:USDT', + 'contracts': 1.0, + 'side': 'long', + 'entryPrice': 50000.0, + 'unrealizedPnl': 0.0, + 'leverage': 10, + 'liquidationPrice': 45000.0, + }] + pos = service.get_position_for_symbol('BTC') + assert pos is not None + assert pos['coin'] == 'BTC' + + def test_not_found(self): + service, mock_api = make_service() + mock_api.get_position.return_value = [] + pos = service.get_position_for_symbol('SOL') + assert pos is None + + def test_usdt_suffix_stripped(self): + service, mock_api = make_service() + mock_api.get_position.return_value = [{ + 'symbol': 'ETH/USDT:USDT', + 'contracts': 5.0, + 'side': 'long', + 'entryPrice': 3000.0, + 'unrealizedPnl': 0.0, + 'leverage': 5, + 'liquidationPrice': 2500.0, + }] + pos = service.get_position_for_symbol('ETHUSDT') + assert pos is not None + assert pos['coin'] == 'ETH' + + +# ==================== TestPlaceMarketOrder ==================== + +class TestPlaceMarketOrder: + + def _mock_order(self, order_id='ord001', status='closed'): + return {'id': order_id, 'status': status} + + def test_buy_success(self): + service, mock_api = make_service() + mock_api.exchange.create_order.return_value = self._mock_order('o1') + result = service.place_market_order('BTC', is_buy=True, size=1) + assert result['success'] is True + assert result['side'] == 'buy' + assert result['size'] == 1 + + call_kwargs = mock_api.exchange.create_order.call_args + assert call_kwargs[1]['type'] == 'market' or call_kwargs[0][1] == 'market' + + def test_sell_success(self): + service, mock_api = make_service() + mock_api.exchange.create_order.return_value = self._mock_order('o2') + result = service.place_market_order('ETH', is_buy=False, size=5) + assert result['success'] is True + assert result['side'] == 'sell' + + def test_reduce_only(self): + service, mock_api = make_service() + mock_api.exchange.create_order.return_value = self._mock_order('o3') + result = service.place_market_order('BTC', is_buy=False, size=1, reduce_only=True) + assert result['success'] is True + # 验证 reduceOnly 被传入 params + call_params = mock_api.exchange.create_order.call_args[1].get('params', {}) + assert call_params.get('reduceOnly') is True + + def test_api_returns_none(self): + service, mock_api = make_service() + mock_api.exchange.create_order.return_value = None + result = service.place_market_order('BTC', is_buy=True, size=1) + assert result['success'] is False + assert 'error' in result + + def test_api_exception(self): + service, mock_api = make_service() + mock_api.exchange.create_order.side_effect = Exception("insufficient margin") + result = service.place_market_order('BTC', is_buy=True, size=100) + assert result['success'] is False + assert 'insufficient margin' in result['error'] + + def test_contract_size_applied_btc(self): + """BTC 1张 = 0.01 BTC,传给 create_order 的 amount 应为 0.01""" + service, mock_api = make_service() + mock_api.exchange.create_order.return_value = self._mock_order() + service.place_market_order('BTC', is_buy=True, size=2) + call_args = mock_api.exchange.create_order.call_args + amount = call_args[1].get('amount') or call_args[0][3] + assert amount == pytest.approx(0.02) # 2张 × 0.01 + + def test_contract_size_applied_eth(self): + """ETH 3张 = 0.3 ETH""" + service, mock_api = make_service() + mock_api.exchange.create_order.return_value = self._mock_order() + service.place_market_order('ETH', is_buy=True, size=3) + call_args = mock_api.exchange.create_order.call_args + amount = call_args[1].get('amount') or call_args[0][3] + assert amount == pytest.approx(0.3) # 3张 × 0.1 + + +# ==================== TestPlaceLimitOrder ==================== + +class TestPlaceLimitOrder: + + def test_resting_order(self): + """限价单未立即成交 → order_status = resting""" + service, mock_api = make_service() + mock_api.exchange.create_order.return_value = {'id': 'lim001', 'status': 'open'} + result = service.place_limit_order('BTC', is_buy=True, size=1, price=49000.0) + assert result['success'] is True + assert result['order_status'] == 'resting' + assert result['order_id'] == 'lim001' + assert result['price'] == pytest.approx(49000.0) + + def test_filled_order(self): + """限价单立即成交(status=closed)→ order_status = filled""" + service, mock_api = make_service() + mock_api.exchange.create_order.return_value = {'id': 'lim002', 'status': 'closed'} + result = service.place_limit_order('ETH', is_buy=False, size=2, price=3100.0) + assert result['success'] is True + assert result['order_status'] == 'filled' + + def test_api_returns_none(self): + service, mock_api = make_service() + mock_api.exchange.create_order.return_value = None + result = service.place_limit_order('BTC', is_buy=True, size=1, price=50000.0) + assert result['success'] is False + assert 'error' in result + + def test_api_exception(self): + service, mock_api = make_service() + mock_api.exchange.create_order.side_effect = Exception("price out of range") + result = service.place_limit_order('BTC', is_buy=True, size=1, price=1.0) + assert result['success'] is False + assert 'price out of range' in result['error'] + + def test_reduce_only_flag(self): + service, mock_api = make_service() + mock_api.exchange.create_order.return_value = {'id': 'lim003', 'status': 'open'} + result = service.place_limit_order('BTC', is_buy=False, size=1, price=55000.0, reduce_only=True) + assert result['success'] is True + call_params = mock_api.exchange.create_order.call_args[1].get('params', {}) + assert call_params.get('reduceOnly') is True + + +# ==================== TestSetTpSl ==================== + +class TestSetTpSl: + + def test_tp_and_sl_success(self): + service, mock_api = make_service() + mock_api.modify_sl_tp.return_value = True + result = service.set_tp_sl('BTC', is_long=True, size=1, tp_price=55000.0, sl_price=47000.0) + assert result['success'] is True + mock_api.modify_sl_tp.assert_called_once_with( + symbol='BTC', stop_loss=47000.0, take_profit=55000.0 + ) + + def test_only_sl(self): + service, mock_api = make_service() + mock_api.modify_sl_tp.return_value = True + result = service.set_tp_sl('ETH', is_long=False, size=2, tp_price=None, sl_price=3200.0) + assert result['success'] is True + + def test_modify_sl_tp_returns_false(self): + service, mock_api = make_service() + mock_api.modify_sl_tp.return_value = False + result = service.set_tp_sl('BTC', is_long=True, size=1, tp_price=55000.0, sl_price=47000.0) + assert result['success'] is False + assert 'error' in result + + def test_api_exception(self): + service, mock_api = make_service() + mock_api.modify_sl_tp.side_effect = Exception("order rejected") + result = service.set_tp_sl('BTC', is_long=True, size=1, tp_price=55000.0, sl_price=47000.0) + assert result['success'] is False + assert 'order rejected' in result['error'] + + +# ==================== TestCancelAllOrders ==================== + +class TestCancelAllOrders: + + def test_cancel_success(self): + service, mock_api = make_service() + mock_api.cancel_all_orders.return_value = True + result = service.cancel_all_orders('BTC') + assert result['success'] is True + assert result['cancelled'] == 1 + + def test_cancel_returns_false(self): + service, mock_api = make_service() + mock_api.cancel_all_orders.return_value = False + result = service.cancel_all_orders('ETH') + assert result['success'] is False + assert result['cancelled'] == 0 + + def test_cancel_no_symbol(self): + """不传 symbol 时撤销全部挂单""" + service, mock_api = make_service() + mock_api.cancel_all_orders.return_value = True + result = service.cancel_all_orders() + assert result['success'] is True + + def test_api_exception(self): + service, mock_api = make_service() + mock_api.cancel_all_orders.side_effect = Exception("connection refused") + result = service.cancel_all_orders('BTC') + assert result['success'] is False + assert 'connection refused' in result['error'] + + +# ==================== TestGetOpenOrders ==================== + +class TestGetOpenOrders: + + def _make_ccxt_order(self, order_id, symbol, side, amount, price, reduce_only=False, order_type='limit'): + return { + 'id': order_id, + 'symbol': symbol, + 'side': side, + 'amount': amount, + 'price': price, + 'reduceOnly': reduce_only, + 'type': order_type, + } + + def test_returns_formatted_orders(self): + service, mock_api = make_service() + mock_api.get_open_orders.return_value = [ + self._make_ccxt_order('o1', 'BTC/USDT:USDT', 'buy', 0.01, 49000.0), + self._make_ccxt_order('o2', 'ETH/USDT:USDT', 'sell', 0.1, 3200.0, reduce_only=True), + ] + orders = service.get_open_orders() + assert len(orders) == 2 + assert orders[0]['order_id'] == 'o1' + assert orders[0]['symbol'] == 'BTC' + assert orders[0]['is_reduce_only'] is False + assert orders[1]['is_reduce_only'] is True + + def test_empty_orders(self): + service, mock_api = make_service() + mock_api.get_open_orders.return_value = [] + orders = service.get_open_orders() + assert orders == [] + + def test_with_symbol_filter(self): + service, mock_api = make_service() + mock_api.get_open_orders.return_value = [ + self._make_ccxt_order('o1', 'BTC/USDT:USDT', 'buy', 0.01, 49000.0), + ] + orders = service.get_open_orders('BTC') + mock_api.get_open_orders.assert_called_with('BTC') + assert len(orders) == 1 + + +# ==================== TestGetTpSlPrices ==================== + +class TestGetTpSlPrices: + + def test_has_tp_and_sl(self): + service, mock_api = make_service() + mock_api.get_open_orders.return_value = [ + {'id': 'tp1', 'symbol': 'BTC/USDT:USDT', 'side': 'sell', 'price': 55000.0, + 'amount': 0.01, 'reduceOnly': True, 'type': 'limit'}, + {'id': 'sl1', 'symbol': 'BTC/USDT:USDT', 'side': 'sell', 'price': 47000.0, + 'amount': 0.01, 'reduceOnly': True, 'type': 'stop'}, + ] + result = service.get_tp_sl_prices('BTC') + assert result['take_profit'] == pytest.approx(55000.0) + assert result['stop_loss'] == pytest.approx(47000.0) + + def test_no_positions(self): + service, mock_api = make_service() + mock_api.get_open_orders.return_value = [] + result = service.get_tp_sl_prices('BTC') + assert result['take_profit'] is None + assert result['stop_loss'] is None + + def test_api_exception_returns_none(self): + service, mock_api = make_service() + mock_api.get_open_orders.side_effect = Exception("timeout") + result = service.get_tp_sl_prices('BTC') + assert result['take_profit'] is None + assert result['stop_loss'] is None + + +# ==================== TestUpdateLeverage ==================== + +class TestUpdateLeverage: + + def test_success(self): + service, mock_api = make_service() + mock_api.set_leverage.return_value = True + service.update_leverage('BTC', 10) # 不应抛出异常 + mock_api.set_leverage.assert_called_once_with('BTC', 10) + + def test_failure_logged_not_raised(self): + """set_leverage 失败时记录 warning,不抛出异常""" + service, mock_api = make_service() + mock_api.set_leverage.side_effect = Exception("leverage rejected") + service.update_leverage('BTC', 20) # 不应抛出,只 warning + + +# ==================== TestCheckRiskLimits ==================== + +class TestCheckRiskLimits: + + def test_normal_allowed(self): + service, mock_api = make_service() + mock_api.get_balance.return_value = { + 'USDT': {'available': '9000.0', 'frozen': '1000.0', 'locked': '0'} + } + mock_api.get_position.return_value = [] # 无持仓 → 总杠杆 0 + result = service.check_risk_limits() + assert result['allowed'] is True + + def test_circuit_breaker_triggered(self): + """账户余额从 10000 跌至 8900 → 回撤 11% > 10% → 熔断""" + 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'} + } + mock_api.get_position.return_value = [] + result = service.check_risk_limits() + assert result['allowed'] is False + assert '熔断' in result['reason'] + + def test_leverage_limit_exceeded(self): + """持仓价值 = 110000, 账户 = 10000 → 杠杆 11x > 10x → 拒绝""" + service, mock_api = make_service() + service.initial_balance = 10000.0 + mock_api.get_balance.return_value = { + 'USDT': {'available': '10000.0', 'frozen': '0.0', 'locked': '0'} + } + # 构造一个持仓:BTC 多仓,2张=0.02 BTC,入场价 55000 → 价值 1100 USDT + # 为使总杠杆超限,entry_price 设很大 + mock_api.get_position.return_value = [{ + 'symbol': 'BTC/USDT:USDT', + 'contracts': 200.0, # 200张 × 0.01 = 2 BTC + 'side': 'long', + 'entryPrice': 55000.0, + 'unrealizedPnl': 0.0, + 'leverage': 10, + 'liquidationPrice': 50000.0, + }] + result = service.check_risk_limits() + assert result['allowed'] is False + assert '杠杆' in result['reason'] + + def test_no_initial_balance_skips_circuit_breaker(self): + """initial_balance 为 None 时,跳过熔断检查""" + service, mock_api = make_service() + service.initial_balance = None + mock_api.get_balance.return_value = { + 'USDT': {'available': '5000.0', 'frozen': '0.0', 'locked': '0'} + } + mock_api.get_position.return_value = [] + result = service.check_risk_limits() + assert result['allowed'] is True + + +# ==================== TestContractSize ==================== + +class TestContractSize: + + @pytest.mark.parametrize("coin,expected", [ + ('BTC', 0.01), + ('ETH', 0.1), + ('SOL', 1.0), + ('LTC', 0.1), + ('XRP', 10.0), + ('DOGE', 100.0), + ('AVAX', 1.0), + ]) + def test_known_coins(self, coin, expected): + service, _ = make_service() + assert service.get_contract_size(coin) == pytest.approx(expected) + + def test_unknown_coin_from_market(self): + """未知币种从 ccxt market info 获取""" + service, mock_api = make_service() + mock_api.exchange.market.return_value = {'contractSize': 5.0} + size = service.get_contract_size('NEWCOIN') + assert size == pytest.approx(5.0) + + def test_unknown_coin_fallback(self): + """ccxt market info 也失败时,默认 1.0""" + service, mock_api = make_service() + mock_api.exchange.market.side_effect = Exception("market not found") + size = service.get_contract_size('UNKNOWNCOIN') + assert size == pytest.approx(1.0) + + +# ==================== TestCoinsToContracts ==================== + +class TestCoinsToContracts: + + def test_btc(self): + service, _ = make_service() + # 0.05 BTC / 0.01 BTC/张 = 5 张 + assert service.coins_to_contracts('BTC', 0.05) == 5 + + def test_eth(self): + service, _ = make_service() + # 0.35 ETH / 0.1 ETH/张 = 3 张(向下取整 3.5 → 3) + assert service.coins_to_contracts('ETH', 0.35) == 3 + + def test_sol(self): + service, _ = make_service() + # 7.9 SOL / 1 SOL/张 = 7 张 + assert service.coins_to_contracts('SOL', 7.9) == 7 + + def test_floor_not_round(self): + """必须向下取整,不能四舍五入""" + service, _ = make_service() + # 0.099 / 0.01 = 9.9 → 应为 9,不是 10 + assert service.coins_to_contracts('BTC', 0.099) == 9 + + def test_zero_amount(self): + service, _ = make_service() + assert service.coins_to_contracts('BTC', 0.0) == 0 + + def test_less_than_one_contract(self): + service, _ = make_service() + # 0.005 BTC < 0.01 BTC/张 → 0 张 + assert service.coins_to_contracts('BTC', 0.005) == 0 + + +# ==================== TestGetBitgetLiveService (factory) ==================== + +class TestGetBitgetLiveServiceFactory: + + def test_returns_none_when_disabled(self): + from app.services.bitget_live_trading_service import get_bitget_live_service, reset_bitget_live_service + reset_bitget_live_service() + + mock_settings = MagicMock() + mock_settings.bitget_trading_enabled = False + + with patch('app.services.bitget_live_trading_service.get_settings', return_value=mock_settings): + result = get_bitget_live_service() + assert result is None + + def test_returns_service_when_enabled(self): + from app.services.bitget_live_trading_service import get_bitget_live_service, reset_bitget_live_service + reset_bitget_live_service() + + mock_settings = MagicMock() + 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_api = MagicMock() + mock_api._standardize_symbol = lambda s: f"{s}/USDT:USDT" + mock_api.get_balance.return_value = { + 'USDT': {'available': '5000', 'frozen': '0', 'locked': '0'} + } + + with patch('app.services.bitget_live_trading_service.get_settings', return_value=mock_settings), \ + patch('app.services.bitget_live_trading_service.get_bitget_trading_api', return_value=mock_api): + result = get_bitget_live_service() + + assert result is not None + reset_bitget_live_service() + + def test_init_failure_returns_none(self): + from app.services.bitget_live_trading_service import get_bitget_live_service, reset_bitget_live_service + reset_bitget_live_service() + + mock_settings = MagicMock() + mock_settings.bitget_trading_enabled = True + + with patch('app.services.bitget_live_trading_service.get_settings', return_value=mock_settings), \ + patch('app.services.bitget_live_trading_service.get_bitget_trading_api', return_value=None): + result = get_bitget_live_service() + + assert result is None + reset_bitget_live_service() + + +# ==================== TestCancelTpSlOrders ==================== + +class TestCancelTpSlOrders: + """cancel_tp_sl_orders 是 cancel_all_orders 的别名,验证调用链正确""" + + def test_delegates_to_cancel_all_orders(self): + service, mock_api = make_service() + mock_api.cancel_all_orders.return_value = True + result = service.cancel_tp_sl_orders('BTC') + assert result['success'] is True + mock_api.cancel_all_orders.assert_called_once_with('BTC') + + def test_cancel_returns_false(self): + service, mock_api = make_service() + mock_api.cancel_all_orders.return_value = False + result = service.cancel_tp_sl_orders('ETH') + assert result['success'] is False + + def test_api_exception(self): + service, mock_api = make_service() + mock_api.cancel_all_orders.side_effect = Exception("network error") + result = service.cancel_tp_sl_orders('SOL') + assert result['success'] is False + assert 'network error' in result['error'] + + +# ==================== TestMarketCloseAll ==================== + +class TestMarketCloseAll: + + def _make_position(self, coin, size, entry_price=50000.0): + return { + 'coin': coin, + 'size': size, + 'entry_price': entry_price, + 'unrealized_pnl': 0.0, + 'leverage': 10, + 'liquidation_price': None, + 'position': {}, + } + + def test_close_single_long(self): + """单个多仓:发出方向相反(sell)的市价单""" + 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, + }] + mock_api.exchange.create_order.return_value = {'id': 'close1', 'status': 'closed'} + + result = service.market_close_all() + assert result['success'] is True + assert len(result['results']) == 1 + # 多仓平仓用 sell + call_args = mock_api.exchange.create_order.call_args + assert call_args[1].get('side') or call_args[0][2] == 'sell' + + def test_close_single_short(self): + """单个空仓:发出 buy 方向的市价单""" + service, mock_api = make_service() + mock_api.get_position.return_value = [{ + 'symbol': 'ETH/USDT:USDT', + 'contracts': 5.0, + 'side': 'short', + 'entryPrice': 3000.0, + 'unrealizedPnl': 0.0, + 'leverage': 5, + 'liquidationPrice': 3500.0, + }] + mock_api.exchange.create_order.return_value = {'id': 'close2', 'status': 'closed'} + + result = service.market_close_all() + assert result['success'] is True + call_args = mock_api.exchange.create_order.call_args + assert call_args[1].get('side') or call_args[0][2] == 'buy' + + def test_close_multiple_positions(self): + """多个持仓,全部成功""" + service, mock_api = make_service() + mock_api.get_position.return_value = [ + {'symbol': 'BTC/USDT:USDT', 'contracts': 1.0, 'side': 'long', + 'entryPrice': 50000.0, 'unrealizedPnl': 0.0, 'leverage': 10, 'liquidationPrice': None}, + {'symbol': 'ETH/USDT:USDT', 'contracts': 3.0, 'side': 'short', + 'entryPrice': 3000.0, 'unrealizedPnl': 0.0, 'leverage': 5, 'liquidationPrice': None}, + ] + mock_api.exchange.create_order.return_value = {'id': 'x', 'status': 'closed'} + + result = service.market_close_all() + assert result['success'] is True + assert len(result['results']) == 2 + + def test_no_positions_returns_success(self): + """无持仓时,成功返回空结果""" + service, mock_api = make_service() + mock_api.get_position.return_value = [] + result = service.market_close_all() + assert result['success'] is True + assert result['results'] == [] + + def test_partial_failure(self): + """一个平仓失败,success 应为 False""" + service, mock_api = make_service() + mock_api.get_position.return_value = [ + {'symbol': 'BTC/USDT:USDT', 'contracts': 1.0, 'side': 'long', + 'entryPrice': 50000.0, 'unrealizedPnl': 0.0, 'leverage': 10, 'liquidationPrice': None}, + {'symbol': 'ETH/USDT:USDT', 'contracts': 3.0, 'side': 'long', + 'entryPrice': 3000.0, 'unrealizedPnl': 0.0, 'leverage': 5, 'liquidationPrice': None}, + ] + # 第一次下单成功,第二次失败 + mock_api.exchange.create_order.side_effect = [ + {'id': 'ok1', 'status': 'closed'}, + Exception("rate limit"), + ] + result = service.market_close_all() + assert result['success'] is False + + def test_position_too_small_skipped(self): + """持仓量小于 1 张时跳过,不报错""" + service, mock_api = make_service() + # BTC 合约面值 0.01,持仓 0.005 BTC → 0 张 → 跳过 + mock_api.get_position.return_value = [{ + 'symbol': 'BTC/USDT:USDT', + 'contracts': 0.5, # 0.5张 × 0.01 = 0.005 BTC → floor(0.005/0.01) = 0张 + 'side': 'long', + 'entryPrice': 50000.0, + 'unrealizedPnl': 0.0, + 'leverage': 10, + 'liquidationPrice': None, + }] + result = service.market_close_all() + assert result['success'] is True + assert result['results'] == [] + mock_api.exchange.create_order.assert_not_called() + + +# ==================== TestInitializeAccount ==================== + +class TestInitializeAccount: + + def test_sets_initial_balance(self): + """成功时 initial_balance 被赋值""" + from app.services.bitget_live_trading_service import BitgetLiveTradingService + + mock_api = MagicMock() + mock_api._standardize_symbol = lambda s: f"{s}/USDT:USDT" + mock_api.get_balance.return_value = { + 'USDT': {'available': '8000.0', 'frozen': '2000.0', 'locked': '0'} + } + + service = BitgetLiveTradingService.__new__(BitgetLiveTradingService) + service.trading_api = mock_api + service.initial_balance = None + + service._initialize_account() + + assert service.initial_balance == pytest.approx(10000.0) + + def test_api_failure_leaves_none(self): + """get_balance 抛出异常时,initial_balance 保持 None,不传播异常""" + from app.services.bitget_live_trading_service import BitgetLiveTradingService + + mock_api = MagicMock() + mock_api.get_balance.side_effect = Exception("timeout") + + service = BitgetLiveTradingService.__new__(BitgetLiveTradingService) + service.trading_api = mock_api + service.initial_balance = None + + service._initialize_account() # 不应抛出异常 + + assert service.initial_balance is None