This commit is contained in:
aaron 2026-03-25 22:23:38 +08:00
parent 14f1695350
commit 5288bbd4a3
12 changed files with 2119 additions and 1324 deletions

View File

@ -161,15 +161,12 @@ BITGET_PASSPHRASE=your_bitget_passphrase_here
# 是否使用测试网(强烈建议先在测试网测试!) # 是否使用测试网(强烈建议先在测试网测试!)
BITGET_USE_TESTNET=true BITGET_USE_TESTNET=true
# 实盘交易总开关false 时仅模拟交易生效 # Bitget 实盘开关(接入信号/决策层
REAL_TRADING_ENABLED=false BITGET_TRADING_ENABLED=false
# 实盘交易风险控制 # 风险控制
REAL_TRADING_MAX_SINGLE_POSITION=1000 # 单笔最大持仓金额 (USDT) BITGET_MAX_SINGLE_POSITION=1000 # 单笔最大持仓金额 (USDT)
REAL_TRADING_MAX_TOTAL_RATIO=0.5 # 最大总仓位比例账户的50% BITGET_MAX_TOTAL_LEVERAGE=10 # 总杠杆上限(倍数)
REAL_TRADING_DEFAULT_LEVERAGE=10 # 实盘默认杠杆(低于模拟)
REAL_TRADING_RISK_PER_TRADE=0.02 # 每笔交易风险比例2%
REAL_TRADING_MAX_ORDERS=5 # 实盘最大同时持仓数
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
# Agent 模型配置 # Agent 模型配置

View File

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

View File

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

View File

@ -160,16 +160,9 @@ class Settings(BaseSettings):
bitget_passphrase: str = "" # Bitget API Passphrase bitget_passphrase: str = "" # Bitget API Passphrase
bitget_use_testnet: bool = True # 是否使用测试网(测试时设为 true bitget_use_testnet: bool = True # 是否使用测试网(测试时设为 true
# 实盘交易开关 # 实盘风险控制Bitget 实盘共用)
real_trading_enabled: bool = False # 实盘交易总开关false 时仅模拟交易生效) bitget_max_single_position: float = 1000 # 单笔最大持仓金额 (USDT)
bitget_max_total_leverage: float = 10 # 总杠杆上限(倍数)
# 实盘交易风险控制
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 # 实盘最大同时持仓数
# Agent 模型配置 (可选值: zhipu, deepseek) # Agent 模型配置 (可选值: zhipu, deepseek)
smart_agent_model: str = "deepseek" # SmartAgent 使用的模型 smart_agent_model: str = "deepseek" # SmartAgent 使用的模型
@ -207,6 +200,9 @@ class Settings(BaseSettings):
pullback_select_time: str = "09:00" # 选股时间24小时制 pullback_select_time: str = "09:00" # 选股时间24小时制
pullback_sectors_to_check: int = 5 # 检查板块数量 pullback_sectors_to_check: int = 5 # 检查板块数量
# ========== Bitget 实盘交易配置 ==========
bitget_trading_enabled: bool = False # Bitget 实盘交易开关(默认关闭)
# ========== Hyperliquid 交易配置ClawFi 集成)========== # ========== Hyperliquid 交易配置ClawFi 集成)==========
# Hyperliquid 交易开关 # Hyperliquid 交易开关
hyperliquid_trading_enabled: bool = False # Hyperliquid 实盘交易开关(默认关闭) hyperliquid_trading_enabled: bool = False # Hyperliquid 实盘交易开关(默认关闭)

View File

@ -70,10 +70,24 @@ class CryptoAgent:
else: else:
logger.info(f"📊 Hyperliquid 实盘交易: 未启用(仅模拟盘)") 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.last_signals: Dict[str, Dict[str, Any]] = {}
self.signal_cooldown: Dict[str, datetime] = {} 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(',') self.symbols = self.settings.crypto_symbols.split(',')
@ -92,6 +106,7 @@ class CryptoAgent:
"symbols": self.symbols, "symbols": self.symbols,
"auto_trading_enabled": True, # 模拟交易始终启用 "auto_trading_enabled": True, # 模拟交易始终启用
"hyperliquid_enabled": self.hyperliquid is not None, "hyperliquid_enabled": self.hyperliquid is not None,
"bitget_enabled": self.bitget is not None,
"analysis_interval": "每5分钟整点" "analysis_interval": "每5分钟整点"
}) })
@ -383,6 +398,12 @@ class CryptoAgent:
# 发送超时取消通知 # 发送超时取消通知
await self._notify_expired_orders_cancelled(cancelled) 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: for symbol in self.symbols:
await self.analyze_symbol(symbol) await self.analyze_symbol(symbol)
@ -613,6 +634,7 @@ class CryptoAgent:
paper_decision = None paper_decision = None
hyperliquid_decision = None hyperliquid_decision = None
bitget_decision = None
# 2.1 模拟盘决策 # 2.1 模拟盘决策
if self.settings.paper_trading_enabled: if self.settings.paper_trading_enabled:
@ -642,10 +664,24 @@ class CryptoAgent:
else: else:
logger.info(f"⏸️ Hyperliquid 实盘交易未启用") 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: except Exception as e:
logger.error(f"❌ 分析 {symbol} 出错: {e}") logger.error(f"❌ 分析 {symbol} 出错: {e}")
@ -872,8 +908,9 @@ class CryptoAgent:
async def _execute_decisions(self, paper_decision: Dict[str, Any], async def _execute_decisions(self, paper_decision: Dict[str, Any],
hyperliquid_decision: Dict[str, Any], hyperliquid_decision: Dict[str, Any],
bitget_decision: Dict[str, Any],
market_signal: Dict[str, Any], current_price: float): market_signal: Dict[str, Any], current_price: float):
"""执行交易决策(轨独立)""" """执行交易决策(轨独立)"""
# 选择最佳信号用于保存 # 选择最佳信号用于保存
best_signal = self._get_best_signal_from_market(market_signal) best_signal = self._get_best_signal_from_market(market_signal)
@ -897,6 +934,12 @@ class CryptoAgent:
if hyperliquid_decision and self.hyperliquid: if hyperliquid_decision and self.hyperliquid:
await self._execute_hyperliquid_decisions(hyperliquid_decision, market_signal, current_price) 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], async def _execute_paper_decisions(self, decision: Dict[str, Any],
market_signal: Dict[str, Any], market_signal: Dict[str, Any],
current_price: float): current_price: float):
@ -1551,14 +1594,14 @@ class CryptoAgent:
# 默认返回 buy # 默认返回 buy
return '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 = { position_config = {
'heavy': self.settings.real_trading_max_single_position, 'heavy': self.settings.bitget_max_single_position,
'medium': self.settings.real_trading_max_single_position * 0.6, 'medium': self.settings.bitget_max_single_position * 0.6,
'light': self.settings.real_trading_max_single_position * 0.3 'light': self.settings.bitget_max_single_position * 0.3
} }
else: else:
# 模拟交易配置 # 模拟交易配置
@ -1908,6 +1951,323 @@ class CryptoAgent:
if self.settings.dingtalk_enabled: if self.settings.dingtalk_enabled:
await self.dingtalk.send_action_card(title, content) 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], async def _execute_hyperliquid_decisions(self, decision: Dict[str, Any],
market_signal: Dict[str, Any], market_signal: Dict[str, Any],
current_price: float): current_price: float):
@ -1973,9 +2333,10 @@ class CryptoAgent:
"""执行 Hyperliquid 开仓/加仓""" """执行 Hyperliquid 开仓/加仓"""
try: try:
symbol = decision.get('symbol', '').replace('USDT', '') # BTCUSDT → BTC 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_type = decision.get('entry_type', 'market')
entry_price = decision.get('entry_price', current_price) entry_price = decision.get('entry_price', current_price)
is_buy = (action == 'buy') # 修复:用 action 字段判断方向
# 计算仓位大小(基于可用保证金和风控) # 计算仓位大小(基于可用保证金和风控)
size = self._calculate_hyperliquid_position_size(decision, current_price) size = self._calculate_hyperliquid_position_size(decision, current_price)
@ -1997,13 +2358,13 @@ class CryptoAgent:
if entry_type == 'market': if entry_type == 'market':
result = self.hyperliquid.place_market_order( result = self.hyperliquid.place_market_order(
symbol=symbol, symbol=symbol,
is_buy=(side == 'buy'), is_buy=is_buy,
size=size size=size
) )
else: # limit else: # limit
result = self.hyperliquid.place_limit_order( result = self.hyperliquid.place_limit_order(
symbol=symbol, symbol=symbol,
is_buy=(side == 'buy'), is_buy=is_buy,
size=size, size=size,
price=entry_price price=entry_price
) )
@ -2038,10 +2399,9 @@ class CryptoAgent:
if tp_price or sl_price: if tp_price or sl_price:
# 只有已成交的订单才设置止盈止损(挂单中的不设,等成交后再设) # 只有已成交的订单才设置止盈止损(挂单中的不设,等成交后再设)
if order_status != 'resting': if order_status != 'resting':
is_long = (side == 'buy')
tp_sl_result = self.hyperliquid.set_tp_sl( tp_sl_result = self.hyperliquid.set_tp_sl(
symbol=symbol, symbol=symbol,
is_long=is_long, is_long=is_buy,
size=size, size=size,
tp_price=tp_price, tp_price=tp_price,
sl_price=sl_price sl_price=sl_price
@ -2050,6 +2410,18 @@ class CryptoAgent:
if not tp_sl_result.get('success'): if not tp_sl_result.get('success'):
logger.warning(f" ⚠️ 设置止盈止损失败: {tp_sl_result.get('error')}") logger.warning(f" ⚠️ 设置止盈止损失败: {tp_sl_result.get('error')}")
result['tp_sl_warning'] = 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 return result
@ -2063,6 +2435,9 @@ class CryptoAgent:
try: try:
symbol = decision.get('symbol', '').replace('USDT', '') 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) self.hyperliquid.cancel_tp_sl_orders(symbol)
logger.info(f" 取消止盈止损订单") logger.info(f" 取消止盈止损订单")
@ -2094,12 +2469,72 @@ class CryptoAgent:
"""执行 Hyperliquid 取消挂单""" """执行 Hyperliquid 取消挂单"""
try: try:
symbol = decision.get('symbol', '').replace('USDT', '') 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) result = self.hyperliquid.cancel_all_orders(symbol)
return result return result
except Exception as e: except Exception as e:
logger.error(f"Hyperliquid 取消挂单失败: {e}") logger.error(f"Hyperliquid 取消挂单失败: {e}")
return {"success": False, "error": str(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: def _calculate_hyperliquid_position_size(self, decision: Dict[str, Any], current_price: float) -> float:
""" """
计算 Hyperliquid 仓位大小基于可用保证金和风控限制 计算 Hyperliquid 仓位大小基于可用保证金和风控限制

View File

@ -9,7 +9,7 @@ from fastapi.responses import FileResponse
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from app.config import get_settings from app.config import get_settings
from app.utils.logger import logger 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.error_handler import setup_global_exception_handler, init_error_notifier
from app.utils.system_status import get_system_monitor from app.utils.system_status import get_system_monitor
import os 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(skills.router, prefix="/api/skills", tags=["技能管理"])
app.include_router(llm.router, tags=["LLM模型"]) app.include_router(llm.router, tags=["LLM模型"])
app.include_router(paper_trading.router, tags=["交易"]) app.include_router(paper_trading.router, tags=["交易"])
app.include_router(real_trading.router, tags=["实盘交易"])
app.include_router(hyperliquid.router, tags=["Hyperliquid"]) 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(stocks.router, prefix="/api/stocks", tags=["美股分析"])
app.include_router(astock.router, prefix="/api/astock", tags=["A股分析"]) app.include_router(astock.router, prefix="/api/astock", tags=["A股分析"])
app.include_router(signals.router, tags=["信号管理"]) app.include_router(signals.router, tags=["信号管理"])
@ -714,9 +714,9 @@ async def trading_page():
return FileResponse(page_path) return FileResponse(page_path)
return {"message": "页面不存在"} return {"message": "页面不存在"}
@app.get("/real-trading") @app.get("/bitget-trading")
async def real_trading_page(): async def bitget_trading_page():
"""实盘交易页面""" """Bitget 实盘交易页面"""
page_path = os.path.join(frontend_path, "real-trading.html") page_path = os.path.join(frontend_path, "real-trading.html")
if os.path.exists(page_path): if os.path.exists(page_path):
return FileResponse(page_path) return FileResponse(page_path)

View File

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

View File

@ -0,0 +1,537 @@
"""
Bitget 实盘交易服务
提供与 HyperliquidTradingService 一致的接口底层调用 BitgetTradingAPIccxt
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

View File

@ -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无法开仓'
}
# 优先使用信号中的 quantityLLM 决策的保证金金额)
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

View File

34
backend/tests/conftest.py Normal file
View File

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

View File

@ -0,0 +1,844 @@
"""
BitgetLiveTradingService 单元测试
使用 pytest + unittest.mockmock 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