update
This commit is contained in:
parent
14f1695350
commit
5288bbd4a3
13
.env.example
13
.env.example
@ -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 模型配置
|
||||||
|
|||||||
241
backend/app/api/bitget_live.py
Normal file
241
backend/app/api/bitget_live.py
Normal 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))
|
||||||
@ -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))
|
|
||||||
@ -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 实盘交易开关(默认关闭)
|
||||||
|
|||||||
@ -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 仓位大小(基于可用保证金和风控限制)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
537
backend/app/services/bitget_live_trading_service.py
Normal file
537
backend/app/services/bitget_live_trading_service.py
Normal file
@ -0,0 +1,537 @@
|
|||||||
|
"""
|
||||||
|
Bitget 实盘交易服务
|
||||||
|
|
||||||
|
提供与 HyperliquidTradingService 一致的接口,底层调用 BitgetTradingAPI(ccxt)。
|
||||||
|
供 crypto_agent.py 的决策执行层使用。
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
from typing import Dict, List, Optional, Any
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.services.bitget_trading_api_sdk import get_bitget_trading_api
|
||||||
|
from app.utils.logger import logger
|
||||||
|
|
||||||
|
# 合约面值表(张 → 币数量)
|
||||||
|
CONTRACT_SIZES: Dict[str, float] = {
|
||||||
|
'BTC': 0.01,
|
||||||
|
'ETH': 0.1,
|
||||||
|
'LTC': 0.1,
|
||||||
|
'BCH': 0.1,
|
||||||
|
'BNB': 0.1,
|
||||||
|
'SOL': 1.0,
|
||||||
|
'AVAX': 1.0,
|
||||||
|
'LINK': 1.0,
|
||||||
|
'UNI': 1.0,
|
||||||
|
'ATOM': 1.0,
|
||||||
|
'FIL': 1.0,
|
||||||
|
'DOT': 1.0,
|
||||||
|
'XRP': 10.0,
|
||||||
|
'DOGE': 100.0,
|
||||||
|
'MATIC': 10.0,
|
||||||
|
'POL': 10.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class BitgetLiveTradingService:
|
||||||
|
"""
|
||||||
|
Bitget 实盘交易服务
|
||||||
|
|
||||||
|
接口与 HyperliquidTradingService 保持一致,方便 crypto_agent.py 统一调用。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.settings = get_settings()
|
||||||
|
self.max_total_leverage: float = self.settings.bitget_max_total_leverage
|
||||||
|
self.max_single_position: float = self.settings.bitget_max_single_position
|
||||||
|
self.circuit_breaker_drawdown: float = self.settings.hyperliquid_circuit_breaker_drawdown
|
||||||
|
|
||||||
|
self.trading_api = get_bitget_trading_api()
|
||||||
|
if not self.trading_api:
|
||||||
|
raise RuntimeError("Bitget 交易 API 初始化失败,请检查 API Key 配置")
|
||||||
|
|
||||||
|
# 初始余额(用于回撤计算)
|
||||||
|
self.initial_balance: Optional[float] = None
|
||||||
|
self._initialize_account()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"✅ BitgetLiveTradingService 初始化完成 "
|
||||||
|
f"(最大总杠杆: {self.max_total_leverage}x, "
|
||||||
|
f"单笔上限: ${self.max_single_position}, "
|
||||||
|
f"熔断阈值: {self.circuit_breaker_drawdown * 100:.0f}%)"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _initialize_account(self):
|
||||||
|
"""初始化账户状态,记录初始余额"""
|
||||||
|
try:
|
||||||
|
state = self.get_account_state()
|
||||||
|
self.initial_balance = state["account_value"]
|
||||||
|
logger.info(f"Bitget 初始账户余额: ${self.initial_balance:.2f}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Bitget 初始化账户余额失败: {e}")
|
||||||
|
|
||||||
|
# ==================== 账户 ====================
|
||||||
|
|
||||||
|
def get_account_state(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
获取账户状态
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
"account_value": float, # 账户总价值(USDT)
|
||||||
|
"total_margin_used": float, # 已用保证金
|
||||||
|
"available_balance": float, # 可用余额
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
balance = self.trading_api.get_balance()
|
||||||
|
|
||||||
|
usdt = balance.get('USDT', {})
|
||||||
|
available = float(usdt.get('available', 0) or 0)
|
||||||
|
frozen = float(usdt.get('frozen', 0) or 0)
|
||||||
|
account_value = available + frozen
|
||||||
|
|
||||||
|
return {
|
||||||
|
"account_value": account_value,
|
||||||
|
"total_margin_used": frozen,
|
||||||
|
"available_balance": available,
|
||||||
|
}
|
||||||
|
|
||||||
|
def check_risk_limits(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
检查风控限制
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{"allowed": bool, "reason": str}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
state = self.get_account_state()
|
||||||
|
account_value = state["account_value"]
|
||||||
|
|
||||||
|
# 1. 熔断检查
|
||||||
|
if self.initial_balance and self.initial_balance > 0:
|
||||||
|
drawdown = (self.initial_balance - account_value) / self.initial_balance
|
||||||
|
if drawdown >= self.circuit_breaker_drawdown:
|
||||||
|
return {
|
||||||
|
"allowed": False,
|
||||||
|
"reason": f"熔断触发: 回撤 {drawdown * 100:.1f}% >= 阈值 {self.circuit_breaker_drawdown * 100:.0f}%"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. 总杠杆检查
|
||||||
|
positions = self.get_open_positions()
|
||||||
|
total_position_value = sum(
|
||||||
|
abs(p["size"]) * p["entry_price"] for p in positions
|
||||||
|
)
|
||||||
|
if account_value > 0:
|
||||||
|
current_leverage = total_position_value / account_value
|
||||||
|
if current_leverage >= self.max_total_leverage:
|
||||||
|
return {
|
||||||
|
"allowed": False,
|
||||||
|
"reason": f"总杠杆超限: {current_leverage:.1f}x >= {self.max_total_leverage}x"
|
||||||
|
}
|
||||||
|
|
||||||
|
return {"allowed": True, "reason": ""}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Bitget 风控检查异常: {e}")
|
||||||
|
return {"allowed": False, "reason": f"风控检查异常: {e}"}
|
||||||
|
|
||||||
|
# ==================== 持仓 ====================
|
||||||
|
|
||||||
|
def get_open_positions(self) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
获取所有持仓
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"coin": "BTC",
|
||||||
|
"size": float, # 正数=多, 负数=空(以币为单位)
|
||||||
|
"entry_price": float,
|
||||||
|
"unrealized_pnl": float,
|
||||||
|
"leverage": int,
|
||||||
|
"liquidation_price": Optional[float],
|
||||||
|
"position": dict, # 原始数据
|
||||||
|
}
|
||||||
|
]
|
||||||
|
"""
|
||||||
|
raw_positions = self.trading_api.get_position()
|
||||||
|
result = []
|
||||||
|
for pos in raw_positions:
|
||||||
|
contracts = float(pos.get('contracts', 0))
|
||||||
|
if contracts == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
symbol_raw = pos.get('symbol', '') # e.g. "BTC/USDT:USDT"
|
||||||
|
coin = symbol_raw.split('/')[0] if '/' in symbol_raw else symbol_raw
|
||||||
|
|
||||||
|
contract_size = self.get_contract_size(coin)
|
||||||
|
coin_amount = contracts * contract_size
|
||||||
|
|
||||||
|
side = pos.get('side', 'long')
|
||||||
|
size = coin_amount if side == 'long' else -coin_amount
|
||||||
|
|
||||||
|
result.append({
|
||||||
|
"coin": coin,
|
||||||
|
"size": size,
|
||||||
|
"entry_price": float(pos.get('entryPrice', 0) or 0),
|
||||||
|
"unrealized_pnl": float(pos.get('unrealizedPnl', 0) or 0),
|
||||||
|
"leverage": int(float(pos.get('leverage', 1) or 1)),
|
||||||
|
"liquidation_price": float(pos.get('liquidationPrice', 0) or 0) or None,
|
||||||
|
"position": pos,
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
def get_position_for_symbol(self, symbol: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""获取指定币种的持仓,无持仓返回 None"""
|
||||||
|
coin = symbol.replace('USDT', '').replace('/', '').upper()
|
||||||
|
for pos in self.get_open_positions():
|
||||||
|
if pos['coin'] == coin:
|
||||||
|
return pos
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ==================== 下单 ====================
|
||||||
|
|
||||||
|
def place_market_order(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
is_buy: bool,
|
||||||
|
size: int,
|
||||||
|
reduce_only: bool = False
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
市价下单
|
||||||
|
|
||||||
|
Args:
|
||||||
|
symbol: 币种,如 "BTC"
|
||||||
|
is_buy: True=买入/做多, False=卖出/做空
|
||||||
|
size: 合约数量(张)
|
||||||
|
reduce_only: 是否只减仓
|
||||||
|
Returns:
|
||||||
|
{"success": bool, "order_id": str, "symbol": str, "side": str, "size": int, "error"?: str}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
side = 'buy' if is_buy else 'sell'
|
||||||
|
params = {}
|
||||||
|
if reduce_only:
|
||||||
|
params['reduceOnly'] = True
|
||||||
|
|
||||||
|
ccxt_symbol = self.trading_api._standardize_symbol(symbol)
|
||||||
|
contract_size = self.get_contract_size(symbol)
|
||||||
|
actual_amount = size * contract_size
|
||||||
|
|
||||||
|
order = self.trading_api.exchange.create_order(
|
||||||
|
symbol=ccxt_symbol,
|
||||||
|
type='market',
|
||||||
|
side=side,
|
||||||
|
amount=actual_amount,
|
||||||
|
params={
|
||||||
|
'tdMode': 'cross',
|
||||||
|
'marginCoin': 'USDT',
|
||||||
|
'holdMode': 'oneWay',
|
||||||
|
**params
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if not order:
|
||||||
|
return {"success": False, "error": "下单返回空", "symbol": symbol, "side": side, "size": size}
|
||||||
|
|
||||||
|
logger.info(f"✅ Bitget 市价单成功: {symbol} {side} {size}张")
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"order_id": str(order.get('id', '')),
|
||||||
|
"symbol": symbol,
|
||||||
|
"side": side,
|
||||||
|
"size": size,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Bitget 市价单失败: {symbol} {e}")
|
||||||
|
return {"success": False, "error": str(e), "symbol": symbol, "side": "buy" if is_buy else "sell", "size": size}
|
||||||
|
|
||||||
|
def place_limit_order(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
is_buy: bool,
|
||||||
|
size: int,
|
||||||
|
price: float,
|
||||||
|
reduce_only: bool = False
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
限价下单
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
"success": bool,
|
||||||
|
"order_status": "resting" | "filled",
|
||||||
|
"order_id": str,
|
||||||
|
"symbol": str,
|
||||||
|
"side": str,
|
||||||
|
"size": int,
|
||||||
|
"price": float,
|
||||||
|
"error"?: str
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
side = 'buy' if is_buy else 'sell'
|
||||||
|
params = {
|
||||||
|
'tdMode': 'cross',
|
||||||
|
'marginCoin': 'USDT',
|
||||||
|
'holdMode': 'oneWay',
|
||||||
|
}
|
||||||
|
if reduce_only:
|
||||||
|
params['reduceOnly'] = True
|
||||||
|
|
||||||
|
ccxt_symbol = self.trading_api._standardize_symbol(symbol)
|
||||||
|
contract_size = self.get_contract_size(symbol)
|
||||||
|
actual_amount = size * contract_size
|
||||||
|
|
||||||
|
order = self.trading_api.exchange.create_order(
|
||||||
|
symbol=ccxt_symbol,
|
||||||
|
type='limit',
|
||||||
|
side=side,
|
||||||
|
amount=actual_amount,
|
||||||
|
price=price,
|
||||||
|
params=params
|
||||||
|
)
|
||||||
|
|
||||||
|
if not order:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": "下单返回空",
|
||||||
|
"symbol": symbol,
|
||||||
|
"side": side,
|
||||||
|
"size": size,
|
||||||
|
"price": price,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 判断订单状态:open → resting(挂单中),closed → filled(立即成交)
|
||||||
|
raw_status = order.get('status', 'open')
|
||||||
|
order_status = 'filled' if raw_status == 'closed' else 'resting'
|
||||||
|
|
||||||
|
logger.info(f"✅ Bitget 限价单: {symbol} {side} {size}张 @ {price} [{order_status}]")
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"order_status": order_status,
|
||||||
|
"order_id": str(order.get('id', '')),
|
||||||
|
"symbol": symbol,
|
||||||
|
"side": side,
|
||||||
|
"size": size,
|
||||||
|
"price": price,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Bitget 限价单失败: {symbol} {e}")
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": str(e),
|
||||||
|
"symbol": symbol,
|
||||||
|
"side": "buy" if is_buy else "sell",
|
||||||
|
"size": size,
|
||||||
|
"price": price,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ==================== 止盈止损 ====================
|
||||||
|
|
||||||
|
def set_tp_sl(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
is_long: bool,
|
||||||
|
size: int,
|
||||||
|
tp_price: Optional[float] = None,
|
||||||
|
sl_price: Optional[float] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
设置止盈止损
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{"success": bool, "results": [...], "error"?: str}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
success = self.trading_api.modify_sl_tp(
|
||||||
|
symbol=symbol,
|
||||||
|
stop_loss=sl_price,
|
||||||
|
take_profit=tp_price,
|
||||||
|
)
|
||||||
|
if success:
|
||||||
|
logger.info(f"✅ Bitget TP/SL 设置成功: {symbol} TP={tp_price} SL={sl_price}")
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"results": [
|
||||||
|
{"type": "take_profit", "price": tp_price},
|
||||||
|
{"type": "stop_loss", "price": sl_price},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {"success": False, "error": "modify_sl_tp 返回 False", "results": []}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Bitget 设置 TP/SL 失败: {symbol} {e}")
|
||||||
|
return {"success": False, "error": str(e), "results": []}
|
||||||
|
|
||||||
|
def get_tp_sl_prices(self, symbol: str) -> Dict[str, Optional[float]]:
|
||||||
|
"""
|
||||||
|
从挂单中读取止盈止损价格
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{"take_profit": float|None, "stop_loss": float|None}
|
||||||
|
"""
|
||||||
|
result = {"take_profit": None, "stop_loss": None}
|
||||||
|
try:
|
||||||
|
orders = self.trading_api.get_open_orders(symbol)
|
||||||
|
for order in orders:
|
||||||
|
if not order.get('reduceOnly'):
|
||||||
|
continue
|
||||||
|
order_side = order.get('side', '')
|
||||||
|
price = float(order.get('price', 0) or 0)
|
||||||
|
order_type = order.get('type', '')
|
||||||
|
|
||||||
|
# stop 类型通常是止损,limit 类型通常是止盈
|
||||||
|
if 'stop' in order_type.lower():
|
||||||
|
result['stop_loss'] = price
|
||||||
|
elif order_type == 'limit' and price:
|
||||||
|
result['take_profit'] = price
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Bitget 获取 TP/SL 价格失败: {symbol} {e}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
# ==================== 挂单管理 ====================
|
||||||
|
|
||||||
|
def get_open_orders(self, symbol: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
获取挂单列表
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
[{"order_id", "symbol", "side", "size", "price", "is_reduce_only"}]
|
||||||
|
"""
|
||||||
|
raw_orders = self.trading_api.get_open_orders(symbol)
|
||||||
|
result = []
|
||||||
|
for order in raw_orders:
|
||||||
|
sym_raw = order.get('symbol', '')
|
||||||
|
coin = sym_raw.split('/')[0] if '/' in sym_raw else sym_raw
|
||||||
|
contracts = float(order.get('amount', 0) or 0)
|
||||||
|
contract_size = self.get_contract_size(coin)
|
||||||
|
size_in_coins = contracts # ccxt amount 已是币数量
|
||||||
|
|
||||||
|
result.append({
|
||||||
|
"order_id": str(order.get('id', '')),
|
||||||
|
"symbol": coin,
|
||||||
|
"side": order.get('side', ''),
|
||||||
|
"size": size_in_coins,
|
||||||
|
"price": float(order.get('price', 0) or 0),
|
||||||
|
"is_reduce_only": bool(order.get('reduceOnly', False)),
|
||||||
|
"order_type": order.get('type', ''),
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
def cancel_all_orders(self, symbol: Optional[str] = None) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
撤销指定币种的所有挂单
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{"success": bool, "cancelled": int, "error"?: str}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
success = self.trading_api.cancel_all_orders(symbol or '')
|
||||||
|
if success:
|
||||||
|
logger.info(f"✅ Bitget 撤销挂单成功: {symbol or '全部'}")
|
||||||
|
return {"success": True, "cancelled": 1}
|
||||||
|
else:
|
||||||
|
return {"success": False, "cancelled": 0, "error": "cancel_all_orders 返回 False"}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Bitget 撤销挂单失败: {symbol} {e}")
|
||||||
|
return {"success": False, "cancelled": 0, "error": str(e)}
|
||||||
|
|
||||||
|
def cancel_tp_sl_orders(self, symbol: str) -> Dict[str, Any]:
|
||||||
|
"""撤销止盈止损单(reduce-only 挂单)"""
|
||||||
|
return self.cancel_all_orders(symbol)
|
||||||
|
|
||||||
|
# ==================== 杠杆 ====================
|
||||||
|
|
||||||
|
def update_leverage(self, symbol: str, leverage: int):
|
||||||
|
"""设置杠杆倍数"""
|
||||||
|
try:
|
||||||
|
self.trading_api.set_leverage(symbol, leverage)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Bitget 设置杠杆失败: {symbol} {leverage}x: {e}")
|
||||||
|
|
||||||
|
# ==================== 辅助方法 ====================
|
||||||
|
|
||||||
|
def get_contract_size(self, symbol: str) -> float:
|
||||||
|
"""
|
||||||
|
获取合约面值(1张合约对应的币数量)
|
||||||
|
|
||||||
|
优先从硬编码表获取,不存在则查询 ccxt 市场信息。
|
||||||
|
"""
|
||||||
|
coin = symbol.replace('USDT', '').replace('/', '').upper()
|
||||||
|
if coin in CONTRACT_SIZES:
|
||||||
|
return CONTRACT_SIZES[coin]
|
||||||
|
|
||||||
|
# fallback: 从 ccxt market info 获取
|
||||||
|
try:
|
||||||
|
ccxt_symbol = self.trading_api._standardize_symbol(symbol)
|
||||||
|
market = self.trading_api.exchange.market(ccxt_symbol)
|
||||||
|
size = float(market.get('contractSize', 1) or 1)
|
||||||
|
logger.info(f"Bitget 从市场信息获取合约面值: {coin} = {size}")
|
||||||
|
return size
|
||||||
|
except Exception:
|
||||||
|
logger.warning(f"Bitget 无法获取 {coin} 合约面值,使用默认值 1")
|
||||||
|
return 1.0
|
||||||
|
|
||||||
|
def coins_to_contracts(self, symbol: str, coin_amount: float, price: float = 1.0) -> int:
|
||||||
|
"""
|
||||||
|
将币数量转换为合约张数(向下取整)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
symbol: 币种
|
||||||
|
coin_amount: 要转换的币数量
|
||||||
|
price: 当前价格(未使用,保留接口兼容)
|
||||||
|
Returns:
|
||||||
|
整数合约张数
|
||||||
|
"""
|
||||||
|
contract_size = self.get_contract_size(symbol)
|
||||||
|
if contract_size <= 0:
|
||||||
|
return 0
|
||||||
|
return math.floor(coin_amount / contract_size)
|
||||||
|
|
||||||
|
def market_close_all(self) -> Dict[str, Any]:
|
||||||
|
"""市价平仓所有持仓"""
|
||||||
|
results = []
|
||||||
|
positions = self.get_open_positions()
|
||||||
|
for pos in positions:
|
||||||
|
coin = pos['coin']
|
||||||
|
is_long = pos['size'] > 0
|
||||||
|
contracts = self.coins_to_contracts(coin, abs(pos['size']))
|
||||||
|
if contracts < 1:
|
||||||
|
continue
|
||||||
|
result = self.place_market_order(coin, is_buy=not is_long, size=contracts, reduce_only=True)
|
||||||
|
results.append(result)
|
||||||
|
all_ok = all(r.get('success') for r in results)
|
||||||
|
return {"success": all_ok, "results": results}
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 单例工厂 ====================
|
||||||
|
|
||||||
|
_bitget_live_service: Optional[BitgetLiveTradingService] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_bitget_live_service() -> Optional[BitgetLiveTradingService]:
|
||||||
|
"""
|
||||||
|
获取 BitgetLiveTradingService 单例。
|
||||||
|
|
||||||
|
bitget_trading_enabled=False 时返回 None(功能关闭)。
|
||||||
|
"""
|
||||||
|
global _bitget_live_service
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.bitget_trading_enabled:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if _bitget_live_service is None:
|
||||||
|
try:
|
||||||
|
_bitget_live_service = BitgetLiveTradingService()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ BitgetLiveTradingService 初始化失败: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return _bitget_live_service
|
||||||
|
|
||||||
|
|
||||||
|
def reset_bitget_live_service():
|
||||||
|
"""重置单例(测试用)"""
|
||||||
|
global _bitget_live_service
|
||||||
|
_bitget_live_service = None
|
||||||
@ -1,765 +0,0 @@
|
|||||||
"""
|
|
||||||
实盘交易服务 - Bitget 合约交易
|
|
||||||
|
|
||||||
提供与模拟交易服务类似的接口,但执行的是真实交易
|
|
||||||
集成 LLM 仓位管理决策
|
|
||||||
"""
|
|
||||||
import uuid
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from typing import Dict, Any, List, Optional
|
|
||||||
|
|
||||||
from app.models.real_trading import RealOrder
|
|
||||||
from app.models.paper_trading import OrderStatus, OrderSide, SignalGrade, EntryType
|
|
||||||
from app.services.db_service import db_service
|
|
||||||
from app.services.position_manager import calculate_real_position
|
|
||||||
from app.config import get_settings
|
|
||||||
from app.utils.logger import logger
|
|
||||||
|
|
||||||
|
|
||||||
class RealTradingService:
|
|
||||||
"""实盘交易服务"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
"""初始化实盘交易服务"""
|
|
||||||
self.settings = get_settings()
|
|
||||||
self.active_orders: Dict[str, RealOrder] = {} # 内存缓存活跃订单
|
|
||||||
|
|
||||||
# 实盘交易配置
|
|
||||||
self.max_single_position = self.settings.real_trading_max_single_position
|
|
||||||
self.max_total_ratio = self.settings.real_trading_max_total_ratio
|
|
||||||
self.default_leverage = self.settings.real_trading_default_leverage
|
|
||||||
self.max_total_leverage = self.settings.real_trading_max_total_leverage # 总杠杆上限
|
|
||||||
self.risk_per_trade = self.settings.real_trading_risk_per_trade
|
|
||||||
self.max_orders = self.settings.real_trading_max_orders
|
|
||||||
|
|
||||||
# 自动交易开关(从数据库加载)
|
|
||||||
self.auto_trading_enabled = self._load_auto_trading_status()
|
|
||||||
|
|
||||||
# 获取交易 API (使用 CCXT SDK 版本)
|
|
||||||
from app.services.bitget_trading_api_sdk import get_bitget_trading_api
|
|
||||||
self.trading_api = get_bitget_trading_api()
|
|
||||||
|
|
||||||
if not self.trading_api:
|
|
||||||
logger.error("Bitget 交易 API 未初始化,实盘交易功能不可用")
|
|
||||||
return
|
|
||||||
|
|
||||||
# 确保表已创建
|
|
||||||
self._ensure_table_exists()
|
|
||||||
|
|
||||||
# 加载活跃订单
|
|
||||||
self._load_active_orders()
|
|
||||||
|
|
||||||
logger.info(f"实盘交易服务初始化完成(最大单笔: ${self.max_single_position},"
|
|
||||||
f"杠杆: {self.default_leverage}x,最大持仓: {self.max_orders},"
|
|
||||||
f"自动交易: {'启用' if self.auto_trading_enabled else '禁用'})")
|
|
||||||
|
|
||||||
def _ensure_table_exists(self):
|
|
||||||
"""确保数据表已创建"""
|
|
||||||
from app.models.real_trading import RealOrder
|
|
||||||
from app.models.database import Base
|
|
||||||
from sqlalchemy import text
|
|
||||||
Base.metadata.create_all(bind=db_service.engine)
|
|
||||||
|
|
||||||
# 创建自动交易开关表(使用简单的文本检查而不是 ORM)
|
|
||||||
db = db_service.get_session()
|
|
||||||
try:
|
|
||||||
# 检查表是否存在
|
|
||||||
result = db.execute(text("""
|
|
||||||
SELECT name FROM sqlite_master WHERE type='table' AND name='real_trading_settings'
|
|
||||||
""")).fetchone()
|
|
||||||
|
|
||||||
if not result:
|
|
||||||
# 表不存在,创建表
|
|
||||||
db.execute(text("""
|
|
||||||
CREATE TABLE real_trading_settings (
|
|
||||||
key TEXT PRIMARY KEY,
|
|
||||||
value TEXT NOT NULL,
|
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
)
|
|
||||||
"""))
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
# 初始化自动交易开关
|
|
||||||
db.execute(text("""
|
|
||||||
INSERT INTO real_trading_settings (key, value)
|
|
||||||
VALUES ('auto_trading_enabled', '0')
|
|
||||||
"""))
|
|
||||||
db.commit()
|
|
||||||
logger.info("创建实盘交易设置表")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"创建设置表失败: {e}")
|
|
||||||
db.rollback()
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
def _load_auto_trading_status(self) -> bool:
|
|
||||||
"""从数据库加载自动交易开关状态"""
|
|
||||||
db = db_service.get_session()
|
|
||||||
try:
|
|
||||||
from sqlalchemy import text
|
|
||||||
result = db.execute(text("SELECT value FROM real_trading_settings WHERE key = 'auto_trading_enabled'")).fetchone()
|
|
||||||
if result:
|
|
||||||
return result[0] == '1'
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"加载自动交易状态失败: {e}")
|
|
||||||
return False
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
def set_auto_trading(self, enabled: bool) -> bool:
|
|
||||||
"""设置自动交易开关"""
|
|
||||||
db = db_service.get_session()
|
|
||||||
try:
|
|
||||||
from sqlalchemy import text
|
|
||||||
db.execute(text("""
|
|
||||||
UPDATE real_trading_settings
|
|
||||||
SET value = :value, updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE key = 'auto_trading_enabled'
|
|
||||||
"""), {'value': '1' if enabled else '0'})
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
self.auto_trading_enabled = enabled
|
|
||||||
logger.info(f"实盘自动交易已{'启用' if enabled else '禁用'}")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"设置自动交易失败: {e}")
|
|
||||||
db.rollback()
|
|
||||||
return False
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
def get_auto_trading_status(self) -> bool:
|
|
||||||
"""获取自动交易状态"""
|
|
||||||
return self.auto_trading_enabled
|
|
||||||
|
|
||||||
def _load_active_orders(self):
|
|
||||||
"""从数据库加载活跃订单"""
|
|
||||||
db = db_service.get_session()
|
|
||||||
try:
|
|
||||||
orders = db.query(RealOrder).filter(
|
|
||||||
RealOrder.status.in_([OrderStatus.PENDING, OrderStatus.OPEN])
|
|
||||||
).all()
|
|
||||||
|
|
||||||
for order in orders:
|
|
||||||
self.active_orders[order.order_id] = order
|
|
||||||
|
|
||||||
logger.info(f"实盘交易: 加载了 {len(orders)} 个活跃订单")
|
|
||||||
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
def get_position_info_for_llm(self) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
获取当前持仓信息供 LLM 分析参考
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
持仓信息字典,包含账户余额、持仓列表、当前杠杆等
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# 获取账户状态
|
|
||||||
account = self.get_account_status()
|
|
||||||
balance = account.get('current_balance', 0)
|
|
||||||
total_position_value = account.get('total_position_value', 0)
|
|
||||||
used_margin = account.get('used_margin', 0)
|
|
||||||
available = account.get('available', 0)
|
|
||||||
|
|
||||||
# 计算当前杠杆倍数(全仓模式)
|
|
||||||
current_leverage = (total_position_value / balance) if balance > 0 else 0
|
|
||||||
|
|
||||||
# 获取所有活跃持仓(本地记录)
|
|
||||||
positions = []
|
|
||||||
for order in self.active_orders.values():
|
|
||||||
if order.status in [OrderStatus.OPEN, OrderStatus.PENDING]:
|
|
||||||
positions.append({
|
|
||||||
'order_id': order.order_id,
|
|
||||||
'symbol': order.symbol,
|
|
||||||
'side': 'long' if order.side == OrderSide.LONG else 'short',
|
|
||||||
'status': order.status.value,
|
|
||||||
'entry_price': order.filled_price or order.entry_price,
|
|
||||||
'quantity': order.quantity,
|
|
||||||
'pnl_percent': order.pnl_percent or 0,
|
|
||||||
'leverage': order.leverage
|
|
||||||
})
|
|
||||||
|
|
||||||
# 从交易所获取最新的持仓数据
|
|
||||||
exchange_positions = []
|
|
||||||
try:
|
|
||||||
if self.trading_api:
|
|
||||||
exchange_pos_list = self.trading_api.get_position()
|
|
||||||
for pos in exchange_pos_list:
|
|
||||||
contracts = float(pos.get('contracts', 0))
|
|
||||||
if contracts != 0:
|
|
||||||
exchange_positions.append({
|
|
||||||
'symbol': pos.get('symbol'),
|
|
||||||
'side': 'long' if pos.get('side') == 'long' else 'short',
|
|
||||||
'contracts': contracts,
|
|
||||||
'entryPrice': pos.get('entryPrice'),
|
|
||||||
'markPrice': pos.get('markPrice'),
|
|
||||||
'unrealizedPnl': pos.get('unrealizedPnl', 0),
|
|
||||||
'leverage': pos.get('leverage', 1)
|
|
||||||
})
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"获取交易所持仓失败: {e}")
|
|
||||||
|
|
||||||
return {
|
|
||||||
'account_balance': balance,
|
|
||||||
'available': available,
|
|
||||||
'total_position_value': total_position_value,
|
|
||||||
'used_margin': used_margin,
|
|
||||||
'current_leverage': current_leverage,
|
|
||||||
'max_leverage': 20, # 全仓模式最大 20 倍杠杆
|
|
||||||
'positions': positions,
|
|
||||||
'exchange_positions': exchange_positions
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"获取持仓信息失败: {e}")
|
|
||||||
import traceback
|
|
||||||
logger.error(traceback.format_exc())
|
|
||||||
return {
|
|
||||||
'account_balance': 0,
|
|
||||||
'total_position_value': 0,
|
|
||||||
'current_leverage': 0,
|
|
||||||
'max_leverage': 20,
|
|
||||||
'positions': [],
|
|
||||||
'exchange_positions': []
|
|
||||||
}
|
|
||||||
|
|
||||||
def create_order_from_signal(self, signal: Dict[str, Any], current_price: float = None) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
从信号创建实盘订单(集成 LLM 仓位管理)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
signal: LLM 分析信号
|
|
||||||
- symbol: 交易对
|
|
||||||
- side: 'long' or 'short'
|
|
||||||
- entry_type: 'market' or 'limit'
|
|
||||||
- entry_price: 入场价
|
|
||||||
- stop_loss: 止损价
|
|
||||||
- take_profit: 止盈价
|
|
||||||
- grade: 信号等级
|
|
||||||
- confidence: 置信度
|
|
||||||
- position_size: LLM 建议的仓位大小 ('heavy', 'medium', 'light')
|
|
||||||
current_price: 当前价格
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
创建结果
|
|
||||||
"""
|
|
||||||
# 检查自动交易开关
|
|
||||||
if not self.auto_trading_enabled:
|
|
||||||
logger.info(f"实盘自动交易已禁用,跳过信号执行")
|
|
||||||
return {
|
|
||||||
'success': False,
|
|
||||||
'message': '实盘自动交易已禁用',
|
|
||||||
'skipped': True
|
|
||||||
}
|
|
||||||
|
|
||||||
if not self.trading_api:
|
|
||||||
return {
|
|
||||||
'success': False,
|
|
||||||
'message': '交易 API 未初始化'
|
|
||||||
}
|
|
||||||
|
|
||||||
db = db_service.get_session()
|
|
||||||
result = {
|
|
||||||
'success': False,
|
|
||||||
'message': '',
|
|
||||||
'order_id': None
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 检查实盘交易是否启用
|
|
||||||
if not self.settings.real_trading_enabled:
|
|
||||||
return {
|
|
||||||
'success': False,
|
|
||||||
'message': '实盘交易未启用,请检查配置 REAL_TRADING_ENABLED=true'
|
|
||||||
}
|
|
||||||
|
|
||||||
# 获取信号信息
|
|
||||||
symbol = signal.get('symbol')
|
|
||||||
side = signal.get('side') # 'long' or 'short'
|
|
||||||
entry_type = signal.get('entry_type', 'market') # 'market' or 'limit'
|
|
||||||
entry_price = signal.get('entry_price')
|
|
||||||
stop_loss = signal.get('stop_loss')
|
|
||||||
take_profit = signal.get('take_profit')
|
|
||||||
grade = signal.get('grade', 'D')
|
|
||||||
confidence = signal.get('confidence', 0)
|
|
||||||
position_size = signal.get('position_size', 'light') # LLM 建议的仓位大小
|
|
||||||
|
|
||||||
# 验证必需参数
|
|
||||||
if not all([symbol, side, stop_loss, take_profit]):
|
|
||||||
return {
|
|
||||||
'success': False,
|
|
||||||
'message': '信号缺少必需参数'
|
|
||||||
}
|
|
||||||
|
|
||||||
# 获取当前价格
|
|
||||||
if not current_price:
|
|
||||||
from app.services.bitget_service import bitget_service
|
|
||||||
current_price = bitget_service.get_current_price(symbol)
|
|
||||||
|
|
||||||
if not current_price:
|
|
||||||
return {
|
|
||||||
'success': False,
|
|
||||||
'message': '无法获取当前价格'
|
|
||||||
}
|
|
||||||
|
|
||||||
# 设置入场价
|
|
||||||
if entry_type == 'market':
|
|
||||||
entry_price = current_price
|
|
||||||
elif not entry_price:
|
|
||||||
entry_price = current_price
|
|
||||||
|
|
||||||
# 风险检查 - 检查订单数量
|
|
||||||
if len(self.active_orders) >= self.max_orders:
|
|
||||||
return {
|
|
||||||
'success': False,
|
|
||||||
'message': f'已达最大持仓数 {self.max_orders}'
|
|
||||||
}
|
|
||||||
|
|
||||||
# 获取账户状态
|
|
||||||
account = self.get_account_status()
|
|
||||||
balance = account['current_balance']
|
|
||||||
available = account['available']
|
|
||||||
used_margin = account['used_margin']
|
|
||||||
total_position_value = account['total_position_value']
|
|
||||||
|
|
||||||
if available < 10:
|
|
||||||
return {
|
|
||||||
'success': False,
|
|
||||||
'message': f'可用余额不足 (${available:.2f})'
|
|
||||||
}
|
|
||||||
|
|
||||||
# === 使用 LLM 建议的仓位大小计算仓位 ===
|
|
||||||
# 检查当前杠杆,确保加仓后不超过配置的总杠杆上限
|
|
||||||
current_leverage = (total_position_value / balance) if balance > 0 else 0
|
|
||||||
max_total_leverage = self.max_total_leverage # 使用配置的总杠杆上限
|
|
||||||
available_leverage = max_total_leverage - current_leverage
|
|
||||||
|
|
||||||
if available_leverage <= 0:
|
|
||||||
return {
|
|
||||||
'success': False,
|
|
||||||
'message': f'当前杠杆已达 {current_leverage:.1f}x,已超最大限制 {max_total_leverage}x,无法开仓'
|
|
||||||
}
|
|
||||||
|
|
||||||
# 优先使用信号中的 quantity(LLM 决策的保证金金额)
|
|
||||||
quantity_from_signal = signal.get('quantity')
|
|
||||||
if quantity_from_signal is not None and quantity_from_signal > 0:
|
|
||||||
# LLM 决策的 quantity 是保证金金额
|
|
||||||
margin = float(quantity_from_signal)
|
|
||||||
# 计算持仓价值(保证金 × 杠杆)
|
|
||||||
position_value = margin * self.default_leverage
|
|
||||||
logger.info(f"使用 LLM 决策保证金: ${margin:.2f}, 持仓价值: ${position_value:.2f}")
|
|
||||||
|
|
||||||
# 验证:加仓后的总杠杆不超过配置的上限
|
|
||||||
new_total_value = total_position_value + position_value
|
|
||||||
new_leverage = new_total_value / balance if balance > 0 else 0
|
|
||||||
if new_leverage > max_total_leverage:
|
|
||||||
return {
|
|
||||||
'success': False,
|
|
||||||
'message': f'LLM 决策会导致总杠杆 {new_leverage:.1f}x 超过限制 {max_total_leverage}x (保证金 ${margin:.2f}, 持仓价值 ${position_value:.2f})'
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
# 回退到动态仓位计算
|
|
||||||
# 根据可用杠杆空间动态调整仓位比例
|
|
||||||
custom_ratios = {
|
|
||||||
'heavy': 0.12, # heavy: 12% 可用杠杆空间
|
|
||||||
'medium': 0.06, # medium: 6% 可用杠杆空间
|
|
||||||
'light': 0.03 # light: 3% 可用杠杆空间
|
|
||||||
}
|
|
||||||
|
|
||||||
# 计算仓位(使用统一的仓位管理器)
|
|
||||||
margin, position_value = calculate_real_position(
|
|
||||||
balance=balance,
|
|
||||||
used_margin=used_margin,
|
|
||||||
total_position_value=total_position_value,
|
|
||||||
position_size=position_size,
|
|
||||||
symbol=symbol,
|
|
||||||
max_leverage=int(available_leverage), # 使用可用杠杆空间
|
|
||||||
custom_ratios=custom_ratios
|
|
||||||
)
|
|
||||||
|
|
||||||
if margin <= 0 or position_value <= 0:
|
|
||||||
return {
|
|
||||||
'success': False,
|
|
||||||
'message': '无法开仓:仓位计算失败或已达杠杆限制'
|
|
||||||
}
|
|
||||||
|
|
||||||
# 再次验证:加仓后的总杠杆不超过配置的上限
|
|
||||||
new_total_value = total_position_value + position_value
|
|
||||||
new_leverage = new_total_value / balance if balance > 0 else 0
|
|
||||||
if new_leverage > max_total_leverage:
|
|
||||||
# 调整仓位大小到安全范围内
|
|
||||||
safe_position_value = balance * max_total_leverage - total_position_value
|
|
||||||
if safe_position_value > 0:
|
|
||||||
position_value = safe_position_value
|
|
||||||
margin = position_value / self.default_leverage
|
|
||||||
logger.warning(f"仓位已调整,确保总杠杆不超过 {max_total_leverage}x")
|
|
||||||
else:
|
|
||||||
return {
|
|
||||||
'success': False,
|
|
||||||
'message': f'当前杠杆 {current_leverage:.1f}x,无法再加仓'
|
|
||||||
}
|
|
||||||
|
|
||||||
quantity = position_value # 订单数量(以 USDT 计价)
|
|
||||||
|
|
||||||
# 最小仓位限制(100 美金测试,最小 5 USDT)
|
|
||||||
min_quantity = 5
|
|
||||||
if quantity < min_quantity:
|
|
||||||
return {
|
|
||||||
'success': False,
|
|
||||||
'message': f'计算仓位 ${quantity:.2f} 小于最小值 ${min_quantity}'
|
|
||||||
}
|
|
||||||
|
|
||||||
# 创建订单对象
|
|
||||||
order_id = str(uuid.uuid4())
|
|
||||||
order = RealOrder(
|
|
||||||
order_id=order_id,
|
|
||||||
symbol=symbol,
|
|
||||||
side=OrderSide.LONG if side == 'long' else OrderSide.SHORT,
|
|
||||||
entry_price=entry_price,
|
|
||||||
stop_loss=stop_loss,
|
|
||||||
take_profit=take_profit,
|
|
||||||
quantity=quantity,
|
|
||||||
leverage=self.default_leverage,
|
|
||||||
signal_grade=SignalGrade[grade.upper()] if grade.upper() in ['A', 'B', 'C', 'D'] else SignalGrade.D,
|
|
||||||
signal_type=signal.get('signal_type', 'swing'),
|
|
||||||
confidence=confidence,
|
|
||||||
trend=signal.get('trend'),
|
|
||||||
entry_type=EntryType.MARKET if entry_type == 'market' else EntryType.LIMIT,
|
|
||||||
status=OrderStatus.OPEN if entry_type == 'market' else OrderStatus.PENDING
|
|
||||||
)
|
|
||||||
|
|
||||||
db.add(order)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(order)
|
|
||||||
|
|
||||||
# 调用 Bitget API 下单
|
|
||||||
exchange_result = self._place_bitget_order(order, current_price)
|
|
||||||
|
|
||||||
if exchange_result['success']:
|
|
||||||
# 更新订单状态
|
|
||||||
order.exchange_order_id = exchange_result.get('order_id')
|
|
||||||
order.client_order_id = exchange_result.get('client_order_id')
|
|
||||||
order.filled_price = exchange_result.get('filled_price', entry_price)
|
|
||||||
order.filled_at = datetime.now()
|
|
||||||
order.status = OrderStatus.OPEN
|
|
||||||
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
# 添加到内存缓存
|
|
||||||
self.active_orders[order_id] = order
|
|
||||||
|
|
||||||
result['success'] = True
|
|
||||||
result['message'] = f'实盘订单创建成功 (仓位: {position_size})'
|
|
||||||
result['order_id'] = order_id
|
|
||||||
result['exchange_order_id'] = exchange_result.get('order_id')
|
|
||||||
result['position_size'] = position_size
|
|
||||||
result['quantity'] = quantity
|
|
||||||
|
|
||||||
logger.info(f"✅ 实盘订单创建成功: {symbol} {side} ${entry_price} | "
|
|
||||||
f"仓位: {position_size} | 数量: ${quantity:.2f} | "
|
|
||||||
f"-> {exchange_result.get('order_id')}")
|
|
||||||
else:
|
|
||||||
# 下单失败,删除记录
|
|
||||||
db.delete(order)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
result['message'] = f"下单失败: {exchange_result.get('message', '未知错误')}"
|
|
||||||
|
|
||||||
logger.error(f"❌ 实盘订单下单失败: {exchange_result.get('message')}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
db.rollback()
|
|
||||||
result['message'] = f"创建订单失败: {str(e)}"
|
|
||||||
logger.error(f"创建实盘订单失败: {e}")
|
|
||||||
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
def _place_bitget_order(self, order: RealOrder, current_price: float) -> Dict:
|
|
||||||
"""
|
|
||||||
调用 Bitget API 下单 (使用 CCXT SDK)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
{'success': bool, 'order_id': str, 'client_order_id': str, 'filled_price': float, 'message': str}
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# CCXT 使用标准的 buy/sell 方向
|
|
||||||
# 对于合约:buy = 开多/平空,sell = 开空/平多
|
|
||||||
# 这里我们简化为:long = buy, short = sell
|
|
||||||
side_map = {
|
|
||||||
OrderSide.LONG: 'buy',
|
|
||||||
OrderSide.SHORT: 'sell'
|
|
||||||
}
|
|
||||||
|
|
||||||
# 映射订单类型
|
|
||||||
order_type = 'market' if order.entry_type == EntryType.MARKET else 'limit'
|
|
||||||
|
|
||||||
# 计算合约数量(张数)
|
|
||||||
# Bitget U本位合约:1张 = 1 USDT(大多数情况)
|
|
||||||
size = order.quantity # 简化处理,实际应该根据合约规格计算
|
|
||||||
|
|
||||||
# 生成自定义订单ID
|
|
||||||
client_order_id = f"real_{order.order_id[:8]}"
|
|
||||||
|
|
||||||
# 调用 API 下单
|
|
||||||
result = self.trading_api.place_order(
|
|
||||||
symbol=order.symbol,
|
|
||||||
side=side_map[order.side],
|
|
||||||
order_type=order_type,
|
|
||||||
size=size,
|
|
||||||
price=order.entry_price if order_type == 'limit' else None,
|
|
||||||
client_order_id=client_order_id
|
|
||||||
)
|
|
||||||
|
|
||||||
if result:
|
|
||||||
# CCXT 返回的订单对象格式
|
|
||||||
# result['id'] 是交易所订单ID
|
|
||||||
# result['price'] 是委托价格
|
|
||||||
# result['average'] 是成交均价(如果已成交)
|
|
||||||
order_id = result.get('id')
|
|
||||||
filled_price = float(result.get('average', 0)) or current_price
|
|
||||||
|
|
||||||
return {
|
|
||||||
'success': True,
|
|
||||||
'order_id': order_id,
|
|
||||||
'client_order_id': client_order_id,
|
|
||||||
'filled_price': filled_price
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
return {
|
|
||||||
'success': False,
|
|
||||||
'message': 'API 调用失败'
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Bitget 下单失败: {e}")
|
|
||||||
return {
|
|
||||||
'success': False,
|
|
||||||
'message': str(e)
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_active_orders(self) -> List[Dict]:
|
|
||||||
"""获取活跃订单列表"""
|
|
||||||
return [order.to_dict() for order in self.active_orders.values()]
|
|
||||||
|
|
||||||
def get_order(self, order_id: str) -> Optional[Dict]:
|
|
||||||
"""获取指定订单"""
|
|
||||||
order = self.active_orders.get(order_id)
|
|
||||||
if order:
|
|
||||||
return order.to_dict()
|
|
||||||
return None
|
|
||||||
|
|
||||||
def cancel_order(self, order_id: str) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
取消挂单
|
|
||||||
|
|
||||||
Args:
|
|
||||||
order_id: 订单ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
取消结果字典
|
|
||||||
"""
|
|
||||||
if order_id not in self.active_orders:
|
|
||||||
return {
|
|
||||||
'success': False,
|
|
||||||
'message': f'订单不存在: {order_id}'
|
|
||||||
}
|
|
||||||
|
|
||||||
order = self.active_orders[order_id]
|
|
||||||
|
|
||||||
# 只能取消挂单状态的订单
|
|
||||||
if order.status != OrderStatus.PENDING:
|
|
||||||
return {
|
|
||||||
'success': False,
|
|
||||||
'message': f'只能取消挂单状态的订单,当前状态: {order.status.value}'
|
|
||||||
}
|
|
||||||
|
|
||||||
# 调用交易所 API 取消订单
|
|
||||||
if not self.trading_api:
|
|
||||||
return {
|
|
||||||
'success': False,
|
|
||||||
'message': '交易 API 未初始化'
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 获取原始订单ID(如果有)
|
|
||||||
original_order_id = order.original_order_id or order_id
|
|
||||||
|
|
||||||
# 调用交易所取消订单API
|
|
||||||
success = self.trading_api.cancel_order(symbol=order.symbol, order_id=original_order_id)
|
|
||||||
|
|
||||||
if success:
|
|
||||||
# 更新本地订单状态
|
|
||||||
order.status = OrderStatus.CANCELLED
|
|
||||||
order.closed_at = datetime.utcnow()
|
|
||||||
|
|
||||||
# 保存到数据库
|
|
||||||
db = db_service.get_session()
|
|
||||||
try:
|
|
||||||
db_order = db.query(RealOrder).filter(RealOrder.order_id == order_id).first()
|
|
||||||
if db_order:
|
|
||||||
db_order.status = OrderStatus.CANCELLED
|
|
||||||
db_order.closed_at = datetime.utcnow()
|
|
||||||
db.merge(db_order)
|
|
||||||
db.commit()
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"更新数据库订单状态失败: {e}")
|
|
||||||
db.rollback()
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
# 从活跃订单缓存中移除
|
|
||||||
if order_id in self.active_orders:
|
|
||||||
del self.active_orders[order_id]
|
|
||||||
|
|
||||||
logger.info(f"实盘挂单已取消: {order_id} | {order.symbol}")
|
|
||||||
|
|
||||||
return {
|
|
||||||
'success': True,
|
|
||||||
'order_id': order_id,
|
|
||||||
'message': '挂单已取消'
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
return {
|
|
||||||
'success': False,
|
|
||||||
'message': '交易所取消订单失败'
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"取消实盘挂单失败: {e}")
|
|
||||||
return {
|
|
||||||
'success': False,
|
|
||||||
'message': f'取消订单异常: {e}'
|
|
||||||
}
|
|
||||||
|
|
||||||
def sync_positions_from_exchange(self) -> List[Dict]:
|
|
||||||
"""
|
|
||||||
从交易所同步持仓状态
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
同步后的持仓列表
|
|
||||||
"""
|
|
||||||
if not self.trading_api:
|
|
||||||
return []
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 获取交易所实际持仓
|
|
||||||
positions = self.trading_api.get_position()
|
|
||||||
|
|
||||||
logger.info(f"从交易所同步了 {len(positions)} 个持仓")
|
|
||||||
return positions
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"同步持仓失败: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
def get_account_status(self) -> Dict:
|
|
||||||
"""获取账户状态"""
|
|
||||||
if not self.trading_api:
|
|
||||||
return {
|
|
||||||
'current_balance': 0,
|
|
||||||
'used_margin': 0,
|
|
||||||
'total_position_value': 0,
|
|
||||||
'available': 0
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
balance_info = self.trading_api.get_balance()
|
|
||||||
usdt_info = balance_info.get('USDT', {})
|
|
||||||
|
|
||||||
available = float(usdt_info.get('available', 0))
|
|
||||||
frozen = float(usdt_info.get('frozen', 0))
|
|
||||||
locked = float(usdt_info.get('locked', 0))
|
|
||||||
|
|
||||||
# 计算持仓价值
|
|
||||||
total_position_value = 0
|
|
||||||
for order in self.active_orders.values():
|
|
||||||
if order.status == OrderStatus.OPEN:
|
|
||||||
total_position_value += order.quantity
|
|
||||||
|
|
||||||
return {
|
|
||||||
'current_balance': available + frozen + locked,
|
|
||||||
'available': available,
|
|
||||||
'used_margin': frozen + locked,
|
|
||||||
'total_position_value': total_position_value
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"获取账户状态失败: {e}")
|
|
||||||
return {
|
|
||||||
'current_balance': 0,
|
|
||||||
'used_margin': 0,
|
|
||||||
'total_position_value': 0,
|
|
||||||
'available': 0
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_position_info(self) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
获取当前持仓信息(供 LLM 分析使用)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
持仓信息字典
|
|
||||||
"""
|
|
||||||
account = self.get_account_status()
|
|
||||||
active_orders = self.get_active_orders()
|
|
||||||
|
|
||||||
# 计算当前杠杆
|
|
||||||
balance = account['current_balance']
|
|
||||||
total_position_value = account['total_position_value']
|
|
||||||
current_leverage = total_position_value / balance if balance > 0 else 0
|
|
||||||
|
|
||||||
# 格式化持仓列表
|
|
||||||
positions = []
|
|
||||||
for order in active_orders:
|
|
||||||
positions.append({
|
|
||||||
'symbol': order.get('symbol'),
|
|
||||||
'side': order.get('side'),
|
|
||||||
'status': order.get('status'),
|
|
||||||
'entry_price': order.get('filled_price') or order.get('entry_price'),
|
|
||||||
'quantity': order.get('quantity'),
|
|
||||||
'pnl_percent': order.get('pnl_percent', 0)
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
'account_balance': balance,
|
|
||||||
'total_position_value': total_position_value,
|
|
||||||
'current_leverage': current_leverage,
|
|
||||||
'max_leverage': self.default_leverage,
|
|
||||||
'active_order_count': len(active_orders),
|
|
||||||
'max_orders': self.max_orders,
|
|
||||||
'positions': positions
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# 全局实例
|
|
||||||
_real_trading_service: Optional[RealTradingService] = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_real_trading_service() -> Optional[RealTradingService]:
|
|
||||||
"""
|
|
||||||
获取实盘交易服务实例(单例)
|
|
||||||
|
|
||||||
注意:不再检查 REAL_TRADING_ENABLED 配置
|
|
||||||
只要 API 配置了就初始化服务,自动交易可以单独控制
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
RealTradingService 实例或 None(如果未配置 API)
|
|
||||||
"""
|
|
||||||
global _real_trading_service
|
|
||||||
|
|
||||||
if _real_trading_service:
|
|
||||||
return _real_trading_service
|
|
||||||
|
|
||||||
settings = get_settings()
|
|
||||||
|
|
||||||
# 检查是否配置了 API Key(不再检查 REAL_TRADING_ENABLED)
|
|
||||||
if not settings.bitget_api_key or not settings.bitget_api_secret:
|
|
||||||
logger.warning("Bitget API Key 未配置,实盘交易功能不可用")
|
|
||||||
return None
|
|
||||||
|
|
||||||
_real_trading_service = RealTradingService()
|
|
||||||
return _real_trading_service
|
|
||||||
0
backend/backend/tests/__init__.py
Normal file
0
backend/backend/tests/__init__.py
Normal file
34
backend/tests/conftest.py
Normal file
34
backend/tests/conftest.py
Normal 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
|
||||||
844
backend/tests/test_bitget_live_trading_service.py
Normal file
844
backend/tests/test_bitget_live_trading_service.py
Normal file
@ -0,0 +1,844 @@
|
|||||||
|
"""
|
||||||
|
BitgetLiveTradingService 单元测试
|
||||||
|
|
||||||
|
使用 pytest + unittest.mock,mock BitgetTradingAPI 实例(不调用真实 ccxt/网络)。
|
||||||
|
|
||||||
|
测试覆盖:
|
||||||
|
- get_account_state
|
||||||
|
- get_open_positions
|
||||||
|
- get_position_for_symbol
|
||||||
|
- place_market_order
|
||||||
|
- place_limit_order
|
||||||
|
- set_tp_sl
|
||||||
|
- cancel_all_orders
|
||||||
|
- get_open_orders
|
||||||
|
- get_tp_sl_prices
|
||||||
|
- update_leverage
|
||||||
|
- check_risk_limits
|
||||||
|
- get_contract_size / coins_to_contracts
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import MagicMock, patch, PropertyMock
|
||||||
|
|
||||||
|
# 将 backend 目录加入 path,使 app.* 可以导入
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Fixtures ====================
|
||||||
|
|
||||||
|
def make_service(settings_overrides=None):
|
||||||
|
"""
|
||||||
|
创建 BitgetLiveTradingService 实例,直接用 __new__ 绕过 __init__,
|
||||||
|
手动注入 mock trading_api 和 settings,无需真实网络/数据库。
|
||||||
|
"""
|
||||||
|
from app.services.bitget_live_trading_service import BitgetLiveTradingService
|
||||||
|
|
||||||
|
mock_api = MagicMock()
|
||||||
|
mock_exchange = MagicMock()
|
||||||
|
mock_api.exchange = mock_exchange
|
||||||
|
mock_api._standardize_symbol = lambda s: f"{s.replace('USDT', '')}/USDT:USDT"
|
||||||
|
|
||||||
|
mock_settings = MagicMock()
|
||||||
|
mock_settings.bitget_max_total_leverage = 10.0
|
||||||
|
mock_settings.bitget_max_single_position = 1000.0
|
||||||
|
mock_settings.hyperliquid_circuit_breaker_drawdown = 0.10
|
||||||
|
if settings_overrides:
|
||||||
|
for k, v in settings_overrides.items():
|
||||||
|
setattr(mock_settings, k, v)
|
||||||
|
|
||||||
|
# 用 __new__ 跳过 __init__(避免真实 API/数据库调用),手动设置所有属性
|
||||||
|
service = BitgetLiveTradingService.__new__(BitgetLiveTradingService)
|
||||||
|
service.settings = mock_settings
|
||||||
|
service.max_total_leverage = mock_settings.bitget_max_total_leverage
|
||||||
|
service.max_single_position = mock_settings.bitget_max_single_position
|
||||||
|
service.circuit_breaker_drawdown = mock_settings.hyperliquid_circuit_breaker_drawdown
|
||||||
|
service.trading_api = mock_api
|
||||||
|
service.initial_balance = 10000.0
|
||||||
|
|
||||||
|
return service, mock_api
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== TestGetAccountState ====================
|
||||||
|
|
||||||
|
class TestGetAccountState:
|
||||||
|
|
||||||
|
def test_normal(self):
|
||||||
|
service, mock_api = make_service()
|
||||||
|
mock_api.get_balance.return_value = {
|
||||||
|
'USDT': {'available': '8000.0', 'frozen': '2000.0', 'locked': '0'}
|
||||||
|
}
|
||||||
|
state = service.get_account_state()
|
||||||
|
assert state['account_value'] == pytest.approx(10000.0)
|
||||||
|
assert state['available_balance'] == pytest.approx(8000.0)
|
||||||
|
assert state['total_margin_used'] == pytest.approx(2000.0)
|
||||||
|
|
||||||
|
def test_usdt_not_present(self):
|
||||||
|
service, mock_api = make_service()
|
||||||
|
mock_api.get_balance.return_value = {}
|
||||||
|
state = service.get_account_state()
|
||||||
|
assert state['account_value'] == pytest.approx(0.0)
|
||||||
|
assert state['available_balance'] == pytest.approx(0.0)
|
||||||
|
assert state['total_margin_used'] == pytest.approx(0.0)
|
||||||
|
|
||||||
|
def test_none_values_treated_as_zero(self):
|
||||||
|
service, mock_api = make_service()
|
||||||
|
mock_api.get_balance.return_value = {
|
||||||
|
'USDT': {'available': None, 'frozen': None, 'locked': '0'}
|
||||||
|
}
|
||||||
|
state = service.get_account_state()
|
||||||
|
assert state['account_value'] == pytest.approx(0.0)
|
||||||
|
|
||||||
|
def test_api_exception_propagates(self):
|
||||||
|
service, mock_api = make_service()
|
||||||
|
mock_api.get_balance.side_effect = Exception("network error")
|
||||||
|
with pytest.raises(Exception, match="network error"):
|
||||||
|
service.get_account_state()
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== TestGetOpenPositions ====================
|
||||||
|
|
||||||
|
class TestGetOpenPositions:
|
||||||
|
|
||||||
|
def _make_raw_position(self, coin='BTC', contracts=1.0, side='long',
|
||||||
|
entry_price=50000.0, pnl=100.0, leverage=10,
|
||||||
|
liq_price=45000.0):
|
||||||
|
return {
|
||||||
|
'symbol': f'{coin}/USDT:USDT',
|
||||||
|
'contracts': contracts,
|
||||||
|
'side': side,
|
||||||
|
'entryPrice': entry_price,
|
||||||
|
'unrealizedPnl': pnl,
|
||||||
|
'leverage': leverage,
|
||||||
|
'liquidationPrice': liq_price,
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_long_position(self):
|
||||||
|
service, mock_api = make_service()
|
||||||
|
mock_api.get_position.return_value = [
|
||||||
|
self._make_raw_position('BTC', 2.0, 'long', 50000.0)
|
||||||
|
]
|
||||||
|
positions = service.get_open_positions()
|
||||||
|
assert len(positions) == 1
|
||||||
|
pos = positions[0]
|
||||||
|
assert pos['coin'] == 'BTC'
|
||||||
|
# 2 张 × 0.01 BTC/张 = 0.02 BTC,多仓为正
|
||||||
|
assert pos['size'] == pytest.approx(0.02)
|
||||||
|
assert pos['entry_price'] == pytest.approx(50000.0)
|
||||||
|
|
||||||
|
def test_short_position(self):
|
||||||
|
service, mock_api = make_service()
|
||||||
|
mock_api.get_position.return_value = [
|
||||||
|
self._make_raw_position('ETH', 5.0, 'short', 3000.0)
|
||||||
|
]
|
||||||
|
positions = service.get_open_positions()
|
||||||
|
assert len(positions) == 1
|
||||||
|
pos = positions[0]
|
||||||
|
assert pos['coin'] == 'ETH'
|
||||||
|
# 5 张 × 0.1 ETH/张 = 0.5 ETH,空仓为负
|
||||||
|
assert pos['size'] == pytest.approx(-0.5)
|
||||||
|
|
||||||
|
def test_zero_contracts_filtered(self):
|
||||||
|
service, mock_api = make_service()
|
||||||
|
mock_api.get_position.return_value = [
|
||||||
|
self._make_raw_position('BTC', 0.0, 'long', 50000.0)
|
||||||
|
]
|
||||||
|
positions = service.get_open_positions()
|
||||||
|
assert positions == []
|
||||||
|
|
||||||
|
def test_empty_positions(self):
|
||||||
|
service, mock_api = make_service()
|
||||||
|
mock_api.get_position.return_value = []
|
||||||
|
positions = service.get_open_positions()
|
||||||
|
assert positions == []
|
||||||
|
|
||||||
|
def test_multiple_positions(self):
|
||||||
|
service, mock_api = make_service()
|
||||||
|
mock_api.get_position.return_value = [
|
||||||
|
self._make_raw_position('BTC', 1.0, 'long', 50000.0),
|
||||||
|
self._make_raw_position('ETH', 3.0, 'short', 3000.0),
|
||||||
|
]
|
||||||
|
positions = service.get_open_positions()
|
||||||
|
assert len(positions) == 2
|
||||||
|
coins = [p['coin'] for p in positions]
|
||||||
|
assert 'BTC' in coins
|
||||||
|
assert 'ETH' in coins
|
||||||
|
|
||||||
|
def test_api_exception_returns_empty(self):
|
||||||
|
service, mock_api = make_service()
|
||||||
|
mock_api.get_position.side_effect = Exception("timeout")
|
||||||
|
# get_open_positions 内部调用 trading_api.get_position() 会抛出
|
||||||
|
# service 应让异常传播(不静默吞掉),由上层处理
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
service.get_open_positions()
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== TestGetPositionForSymbol ====================
|
||||||
|
|
||||||
|
class TestGetPositionForSymbol:
|
||||||
|
|
||||||
|
def test_found(self):
|
||||||
|
service, mock_api = make_service()
|
||||||
|
mock_api.get_position.return_value = [{
|
||||||
|
'symbol': 'BTC/USDT:USDT',
|
||||||
|
'contracts': 1.0,
|
||||||
|
'side': 'long',
|
||||||
|
'entryPrice': 50000.0,
|
||||||
|
'unrealizedPnl': 0.0,
|
||||||
|
'leverage': 10,
|
||||||
|
'liquidationPrice': 45000.0,
|
||||||
|
}]
|
||||||
|
pos = service.get_position_for_symbol('BTC')
|
||||||
|
assert pos is not None
|
||||||
|
assert pos['coin'] == 'BTC'
|
||||||
|
|
||||||
|
def test_not_found(self):
|
||||||
|
service, mock_api = make_service()
|
||||||
|
mock_api.get_position.return_value = []
|
||||||
|
pos = service.get_position_for_symbol('SOL')
|
||||||
|
assert pos is None
|
||||||
|
|
||||||
|
def test_usdt_suffix_stripped(self):
|
||||||
|
service, mock_api = make_service()
|
||||||
|
mock_api.get_position.return_value = [{
|
||||||
|
'symbol': 'ETH/USDT:USDT',
|
||||||
|
'contracts': 5.0,
|
||||||
|
'side': 'long',
|
||||||
|
'entryPrice': 3000.0,
|
||||||
|
'unrealizedPnl': 0.0,
|
||||||
|
'leverage': 5,
|
||||||
|
'liquidationPrice': 2500.0,
|
||||||
|
}]
|
||||||
|
pos = service.get_position_for_symbol('ETHUSDT')
|
||||||
|
assert pos is not None
|
||||||
|
assert pos['coin'] == 'ETH'
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== TestPlaceMarketOrder ====================
|
||||||
|
|
||||||
|
class TestPlaceMarketOrder:
|
||||||
|
|
||||||
|
def _mock_order(self, order_id='ord001', status='closed'):
|
||||||
|
return {'id': order_id, 'status': status}
|
||||||
|
|
||||||
|
def test_buy_success(self):
|
||||||
|
service, mock_api = make_service()
|
||||||
|
mock_api.exchange.create_order.return_value = self._mock_order('o1')
|
||||||
|
result = service.place_market_order('BTC', is_buy=True, size=1)
|
||||||
|
assert result['success'] is True
|
||||||
|
assert result['side'] == 'buy'
|
||||||
|
assert result['size'] == 1
|
||||||
|
|
||||||
|
call_kwargs = mock_api.exchange.create_order.call_args
|
||||||
|
assert call_kwargs[1]['type'] == 'market' or call_kwargs[0][1] == 'market'
|
||||||
|
|
||||||
|
def test_sell_success(self):
|
||||||
|
service, mock_api = make_service()
|
||||||
|
mock_api.exchange.create_order.return_value = self._mock_order('o2')
|
||||||
|
result = service.place_market_order('ETH', is_buy=False, size=5)
|
||||||
|
assert result['success'] is True
|
||||||
|
assert result['side'] == 'sell'
|
||||||
|
|
||||||
|
def test_reduce_only(self):
|
||||||
|
service, mock_api = make_service()
|
||||||
|
mock_api.exchange.create_order.return_value = self._mock_order('o3')
|
||||||
|
result = service.place_market_order('BTC', is_buy=False, size=1, reduce_only=True)
|
||||||
|
assert result['success'] is True
|
||||||
|
# 验证 reduceOnly 被传入 params
|
||||||
|
call_params = mock_api.exchange.create_order.call_args[1].get('params', {})
|
||||||
|
assert call_params.get('reduceOnly') is True
|
||||||
|
|
||||||
|
def test_api_returns_none(self):
|
||||||
|
service, mock_api = make_service()
|
||||||
|
mock_api.exchange.create_order.return_value = None
|
||||||
|
result = service.place_market_order('BTC', is_buy=True, size=1)
|
||||||
|
assert result['success'] is False
|
||||||
|
assert 'error' in result
|
||||||
|
|
||||||
|
def test_api_exception(self):
|
||||||
|
service, mock_api = make_service()
|
||||||
|
mock_api.exchange.create_order.side_effect = Exception("insufficient margin")
|
||||||
|
result = service.place_market_order('BTC', is_buy=True, size=100)
|
||||||
|
assert result['success'] is False
|
||||||
|
assert 'insufficient margin' in result['error']
|
||||||
|
|
||||||
|
def test_contract_size_applied_btc(self):
|
||||||
|
"""BTC 1张 = 0.01 BTC,传给 create_order 的 amount 应为 0.01"""
|
||||||
|
service, mock_api = make_service()
|
||||||
|
mock_api.exchange.create_order.return_value = self._mock_order()
|
||||||
|
service.place_market_order('BTC', is_buy=True, size=2)
|
||||||
|
call_args = mock_api.exchange.create_order.call_args
|
||||||
|
amount = call_args[1].get('amount') or call_args[0][3]
|
||||||
|
assert amount == pytest.approx(0.02) # 2张 × 0.01
|
||||||
|
|
||||||
|
def test_contract_size_applied_eth(self):
|
||||||
|
"""ETH 3张 = 0.3 ETH"""
|
||||||
|
service, mock_api = make_service()
|
||||||
|
mock_api.exchange.create_order.return_value = self._mock_order()
|
||||||
|
service.place_market_order('ETH', is_buy=True, size=3)
|
||||||
|
call_args = mock_api.exchange.create_order.call_args
|
||||||
|
amount = call_args[1].get('amount') or call_args[0][3]
|
||||||
|
assert amount == pytest.approx(0.3) # 3张 × 0.1
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== TestPlaceLimitOrder ====================
|
||||||
|
|
||||||
|
class TestPlaceLimitOrder:
|
||||||
|
|
||||||
|
def test_resting_order(self):
|
||||||
|
"""限价单未立即成交 → order_status = resting"""
|
||||||
|
service, mock_api = make_service()
|
||||||
|
mock_api.exchange.create_order.return_value = {'id': 'lim001', 'status': 'open'}
|
||||||
|
result = service.place_limit_order('BTC', is_buy=True, size=1, price=49000.0)
|
||||||
|
assert result['success'] is True
|
||||||
|
assert result['order_status'] == 'resting'
|
||||||
|
assert result['order_id'] == 'lim001'
|
||||||
|
assert result['price'] == pytest.approx(49000.0)
|
||||||
|
|
||||||
|
def test_filled_order(self):
|
||||||
|
"""限价单立即成交(status=closed)→ order_status = filled"""
|
||||||
|
service, mock_api = make_service()
|
||||||
|
mock_api.exchange.create_order.return_value = {'id': 'lim002', 'status': 'closed'}
|
||||||
|
result = service.place_limit_order('ETH', is_buy=False, size=2, price=3100.0)
|
||||||
|
assert result['success'] is True
|
||||||
|
assert result['order_status'] == 'filled'
|
||||||
|
|
||||||
|
def test_api_returns_none(self):
|
||||||
|
service, mock_api = make_service()
|
||||||
|
mock_api.exchange.create_order.return_value = None
|
||||||
|
result = service.place_limit_order('BTC', is_buy=True, size=1, price=50000.0)
|
||||||
|
assert result['success'] is False
|
||||||
|
assert 'error' in result
|
||||||
|
|
||||||
|
def test_api_exception(self):
|
||||||
|
service, mock_api = make_service()
|
||||||
|
mock_api.exchange.create_order.side_effect = Exception("price out of range")
|
||||||
|
result = service.place_limit_order('BTC', is_buy=True, size=1, price=1.0)
|
||||||
|
assert result['success'] is False
|
||||||
|
assert 'price out of range' in result['error']
|
||||||
|
|
||||||
|
def test_reduce_only_flag(self):
|
||||||
|
service, mock_api = make_service()
|
||||||
|
mock_api.exchange.create_order.return_value = {'id': 'lim003', 'status': 'open'}
|
||||||
|
result = service.place_limit_order('BTC', is_buy=False, size=1, price=55000.0, reduce_only=True)
|
||||||
|
assert result['success'] is True
|
||||||
|
call_params = mock_api.exchange.create_order.call_args[1].get('params', {})
|
||||||
|
assert call_params.get('reduceOnly') is True
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== TestSetTpSl ====================
|
||||||
|
|
||||||
|
class TestSetTpSl:
|
||||||
|
|
||||||
|
def test_tp_and_sl_success(self):
|
||||||
|
service, mock_api = make_service()
|
||||||
|
mock_api.modify_sl_tp.return_value = True
|
||||||
|
result = service.set_tp_sl('BTC', is_long=True, size=1, tp_price=55000.0, sl_price=47000.0)
|
||||||
|
assert result['success'] is True
|
||||||
|
mock_api.modify_sl_tp.assert_called_once_with(
|
||||||
|
symbol='BTC', stop_loss=47000.0, take_profit=55000.0
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_only_sl(self):
|
||||||
|
service, mock_api = make_service()
|
||||||
|
mock_api.modify_sl_tp.return_value = True
|
||||||
|
result = service.set_tp_sl('ETH', is_long=False, size=2, tp_price=None, sl_price=3200.0)
|
||||||
|
assert result['success'] is True
|
||||||
|
|
||||||
|
def test_modify_sl_tp_returns_false(self):
|
||||||
|
service, mock_api = make_service()
|
||||||
|
mock_api.modify_sl_tp.return_value = False
|
||||||
|
result = service.set_tp_sl('BTC', is_long=True, size=1, tp_price=55000.0, sl_price=47000.0)
|
||||||
|
assert result['success'] is False
|
||||||
|
assert 'error' in result
|
||||||
|
|
||||||
|
def test_api_exception(self):
|
||||||
|
service, mock_api = make_service()
|
||||||
|
mock_api.modify_sl_tp.side_effect = Exception("order rejected")
|
||||||
|
result = service.set_tp_sl('BTC', is_long=True, size=1, tp_price=55000.0, sl_price=47000.0)
|
||||||
|
assert result['success'] is False
|
||||||
|
assert 'order rejected' in result['error']
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== TestCancelAllOrders ====================
|
||||||
|
|
||||||
|
class TestCancelAllOrders:
|
||||||
|
|
||||||
|
def test_cancel_success(self):
|
||||||
|
service, mock_api = make_service()
|
||||||
|
mock_api.cancel_all_orders.return_value = True
|
||||||
|
result = service.cancel_all_orders('BTC')
|
||||||
|
assert result['success'] is True
|
||||||
|
assert result['cancelled'] == 1
|
||||||
|
|
||||||
|
def test_cancel_returns_false(self):
|
||||||
|
service, mock_api = make_service()
|
||||||
|
mock_api.cancel_all_orders.return_value = False
|
||||||
|
result = service.cancel_all_orders('ETH')
|
||||||
|
assert result['success'] is False
|
||||||
|
assert result['cancelled'] == 0
|
||||||
|
|
||||||
|
def test_cancel_no_symbol(self):
|
||||||
|
"""不传 symbol 时撤销全部挂单"""
|
||||||
|
service, mock_api = make_service()
|
||||||
|
mock_api.cancel_all_orders.return_value = True
|
||||||
|
result = service.cancel_all_orders()
|
||||||
|
assert result['success'] is True
|
||||||
|
|
||||||
|
def test_api_exception(self):
|
||||||
|
service, mock_api = make_service()
|
||||||
|
mock_api.cancel_all_orders.side_effect = Exception("connection refused")
|
||||||
|
result = service.cancel_all_orders('BTC')
|
||||||
|
assert result['success'] is False
|
||||||
|
assert 'connection refused' in result['error']
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== TestGetOpenOrders ====================
|
||||||
|
|
||||||
|
class TestGetOpenOrders:
|
||||||
|
|
||||||
|
def _make_ccxt_order(self, order_id, symbol, side, amount, price, reduce_only=False, order_type='limit'):
|
||||||
|
return {
|
||||||
|
'id': order_id,
|
||||||
|
'symbol': symbol,
|
||||||
|
'side': side,
|
||||||
|
'amount': amount,
|
||||||
|
'price': price,
|
||||||
|
'reduceOnly': reduce_only,
|
||||||
|
'type': order_type,
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_returns_formatted_orders(self):
|
||||||
|
service, mock_api = make_service()
|
||||||
|
mock_api.get_open_orders.return_value = [
|
||||||
|
self._make_ccxt_order('o1', 'BTC/USDT:USDT', 'buy', 0.01, 49000.0),
|
||||||
|
self._make_ccxt_order('o2', 'ETH/USDT:USDT', 'sell', 0.1, 3200.0, reduce_only=True),
|
||||||
|
]
|
||||||
|
orders = service.get_open_orders()
|
||||||
|
assert len(orders) == 2
|
||||||
|
assert orders[0]['order_id'] == 'o1'
|
||||||
|
assert orders[0]['symbol'] == 'BTC'
|
||||||
|
assert orders[0]['is_reduce_only'] is False
|
||||||
|
assert orders[1]['is_reduce_only'] is True
|
||||||
|
|
||||||
|
def test_empty_orders(self):
|
||||||
|
service, mock_api = make_service()
|
||||||
|
mock_api.get_open_orders.return_value = []
|
||||||
|
orders = service.get_open_orders()
|
||||||
|
assert orders == []
|
||||||
|
|
||||||
|
def test_with_symbol_filter(self):
|
||||||
|
service, mock_api = make_service()
|
||||||
|
mock_api.get_open_orders.return_value = [
|
||||||
|
self._make_ccxt_order('o1', 'BTC/USDT:USDT', 'buy', 0.01, 49000.0),
|
||||||
|
]
|
||||||
|
orders = service.get_open_orders('BTC')
|
||||||
|
mock_api.get_open_orders.assert_called_with('BTC')
|
||||||
|
assert len(orders) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== TestGetTpSlPrices ====================
|
||||||
|
|
||||||
|
class TestGetTpSlPrices:
|
||||||
|
|
||||||
|
def test_has_tp_and_sl(self):
|
||||||
|
service, mock_api = make_service()
|
||||||
|
mock_api.get_open_orders.return_value = [
|
||||||
|
{'id': 'tp1', 'symbol': 'BTC/USDT:USDT', 'side': 'sell', 'price': 55000.0,
|
||||||
|
'amount': 0.01, 'reduceOnly': True, 'type': 'limit'},
|
||||||
|
{'id': 'sl1', 'symbol': 'BTC/USDT:USDT', 'side': 'sell', 'price': 47000.0,
|
||||||
|
'amount': 0.01, 'reduceOnly': True, 'type': 'stop'},
|
||||||
|
]
|
||||||
|
result = service.get_tp_sl_prices('BTC')
|
||||||
|
assert result['take_profit'] == pytest.approx(55000.0)
|
||||||
|
assert result['stop_loss'] == pytest.approx(47000.0)
|
||||||
|
|
||||||
|
def test_no_positions(self):
|
||||||
|
service, mock_api = make_service()
|
||||||
|
mock_api.get_open_orders.return_value = []
|
||||||
|
result = service.get_tp_sl_prices('BTC')
|
||||||
|
assert result['take_profit'] is None
|
||||||
|
assert result['stop_loss'] is None
|
||||||
|
|
||||||
|
def test_api_exception_returns_none(self):
|
||||||
|
service, mock_api = make_service()
|
||||||
|
mock_api.get_open_orders.side_effect = Exception("timeout")
|
||||||
|
result = service.get_tp_sl_prices('BTC')
|
||||||
|
assert result['take_profit'] is None
|
||||||
|
assert result['stop_loss'] is None
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== TestUpdateLeverage ====================
|
||||||
|
|
||||||
|
class TestUpdateLeverage:
|
||||||
|
|
||||||
|
def test_success(self):
|
||||||
|
service, mock_api = make_service()
|
||||||
|
mock_api.set_leverage.return_value = True
|
||||||
|
service.update_leverage('BTC', 10) # 不应抛出异常
|
||||||
|
mock_api.set_leverage.assert_called_once_with('BTC', 10)
|
||||||
|
|
||||||
|
def test_failure_logged_not_raised(self):
|
||||||
|
"""set_leverage 失败时记录 warning,不抛出异常"""
|
||||||
|
service, mock_api = make_service()
|
||||||
|
mock_api.set_leverage.side_effect = Exception("leverage rejected")
|
||||||
|
service.update_leverage('BTC', 20) # 不应抛出,只 warning
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== TestCheckRiskLimits ====================
|
||||||
|
|
||||||
|
class TestCheckRiskLimits:
|
||||||
|
|
||||||
|
def test_normal_allowed(self):
|
||||||
|
service, mock_api = make_service()
|
||||||
|
mock_api.get_balance.return_value = {
|
||||||
|
'USDT': {'available': '9000.0', 'frozen': '1000.0', 'locked': '0'}
|
||||||
|
}
|
||||||
|
mock_api.get_position.return_value = [] # 无持仓 → 总杠杆 0
|
||||||
|
result = service.check_risk_limits()
|
||||||
|
assert result['allowed'] is True
|
||||||
|
|
||||||
|
def test_circuit_breaker_triggered(self):
|
||||||
|
"""账户余额从 10000 跌至 8900 → 回撤 11% > 10% → 熔断"""
|
||||||
|
service, mock_api = make_service()
|
||||||
|
service.initial_balance = 10000.0
|
||||||
|
mock_api.get_balance.return_value = {
|
||||||
|
'USDT': {'available': '8900.0', 'frozen': '0.0', 'locked': '0'}
|
||||||
|
}
|
||||||
|
mock_api.get_position.return_value = []
|
||||||
|
result = service.check_risk_limits()
|
||||||
|
assert result['allowed'] is False
|
||||||
|
assert '熔断' in result['reason']
|
||||||
|
|
||||||
|
def test_leverage_limit_exceeded(self):
|
||||||
|
"""持仓价值 = 110000, 账户 = 10000 → 杠杆 11x > 10x → 拒绝"""
|
||||||
|
service, mock_api = make_service()
|
||||||
|
service.initial_balance = 10000.0
|
||||||
|
mock_api.get_balance.return_value = {
|
||||||
|
'USDT': {'available': '10000.0', 'frozen': '0.0', 'locked': '0'}
|
||||||
|
}
|
||||||
|
# 构造一个持仓:BTC 多仓,2张=0.02 BTC,入场价 55000 → 价值 1100 USDT
|
||||||
|
# 为使总杠杆超限,entry_price 设很大
|
||||||
|
mock_api.get_position.return_value = [{
|
||||||
|
'symbol': 'BTC/USDT:USDT',
|
||||||
|
'contracts': 200.0, # 200张 × 0.01 = 2 BTC
|
||||||
|
'side': 'long',
|
||||||
|
'entryPrice': 55000.0,
|
||||||
|
'unrealizedPnl': 0.0,
|
||||||
|
'leverage': 10,
|
||||||
|
'liquidationPrice': 50000.0,
|
||||||
|
}]
|
||||||
|
result = service.check_risk_limits()
|
||||||
|
assert result['allowed'] is False
|
||||||
|
assert '杠杆' in result['reason']
|
||||||
|
|
||||||
|
def test_no_initial_balance_skips_circuit_breaker(self):
|
||||||
|
"""initial_balance 为 None 时,跳过熔断检查"""
|
||||||
|
service, mock_api = make_service()
|
||||||
|
service.initial_balance = None
|
||||||
|
mock_api.get_balance.return_value = {
|
||||||
|
'USDT': {'available': '5000.0', 'frozen': '0.0', 'locked': '0'}
|
||||||
|
}
|
||||||
|
mock_api.get_position.return_value = []
|
||||||
|
result = service.check_risk_limits()
|
||||||
|
assert result['allowed'] is True
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== TestContractSize ====================
|
||||||
|
|
||||||
|
class TestContractSize:
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("coin,expected", [
|
||||||
|
('BTC', 0.01),
|
||||||
|
('ETH', 0.1),
|
||||||
|
('SOL', 1.0),
|
||||||
|
('LTC', 0.1),
|
||||||
|
('XRP', 10.0),
|
||||||
|
('DOGE', 100.0),
|
||||||
|
('AVAX', 1.0),
|
||||||
|
])
|
||||||
|
def test_known_coins(self, coin, expected):
|
||||||
|
service, _ = make_service()
|
||||||
|
assert service.get_contract_size(coin) == pytest.approx(expected)
|
||||||
|
|
||||||
|
def test_unknown_coin_from_market(self):
|
||||||
|
"""未知币种从 ccxt market info 获取"""
|
||||||
|
service, mock_api = make_service()
|
||||||
|
mock_api.exchange.market.return_value = {'contractSize': 5.0}
|
||||||
|
size = service.get_contract_size('NEWCOIN')
|
||||||
|
assert size == pytest.approx(5.0)
|
||||||
|
|
||||||
|
def test_unknown_coin_fallback(self):
|
||||||
|
"""ccxt market info 也失败时,默认 1.0"""
|
||||||
|
service, mock_api = make_service()
|
||||||
|
mock_api.exchange.market.side_effect = Exception("market not found")
|
||||||
|
size = service.get_contract_size('UNKNOWNCOIN')
|
||||||
|
assert size == pytest.approx(1.0)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== TestCoinsToContracts ====================
|
||||||
|
|
||||||
|
class TestCoinsToContracts:
|
||||||
|
|
||||||
|
def test_btc(self):
|
||||||
|
service, _ = make_service()
|
||||||
|
# 0.05 BTC / 0.01 BTC/张 = 5 张
|
||||||
|
assert service.coins_to_contracts('BTC', 0.05) == 5
|
||||||
|
|
||||||
|
def test_eth(self):
|
||||||
|
service, _ = make_service()
|
||||||
|
# 0.35 ETH / 0.1 ETH/张 = 3 张(向下取整 3.5 → 3)
|
||||||
|
assert service.coins_to_contracts('ETH', 0.35) == 3
|
||||||
|
|
||||||
|
def test_sol(self):
|
||||||
|
service, _ = make_service()
|
||||||
|
# 7.9 SOL / 1 SOL/张 = 7 张
|
||||||
|
assert service.coins_to_contracts('SOL', 7.9) == 7
|
||||||
|
|
||||||
|
def test_floor_not_round(self):
|
||||||
|
"""必须向下取整,不能四舍五入"""
|
||||||
|
service, _ = make_service()
|
||||||
|
# 0.099 / 0.01 = 9.9 → 应为 9,不是 10
|
||||||
|
assert service.coins_to_contracts('BTC', 0.099) == 9
|
||||||
|
|
||||||
|
def test_zero_amount(self):
|
||||||
|
service, _ = make_service()
|
||||||
|
assert service.coins_to_contracts('BTC', 0.0) == 0
|
||||||
|
|
||||||
|
def test_less_than_one_contract(self):
|
||||||
|
service, _ = make_service()
|
||||||
|
# 0.005 BTC < 0.01 BTC/张 → 0 张
|
||||||
|
assert service.coins_to_contracts('BTC', 0.005) == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== TestGetBitgetLiveService (factory) ====================
|
||||||
|
|
||||||
|
class TestGetBitgetLiveServiceFactory:
|
||||||
|
|
||||||
|
def test_returns_none_when_disabled(self):
|
||||||
|
from app.services.bitget_live_trading_service import get_bitget_live_service, reset_bitget_live_service
|
||||||
|
reset_bitget_live_service()
|
||||||
|
|
||||||
|
mock_settings = MagicMock()
|
||||||
|
mock_settings.bitget_trading_enabled = False
|
||||||
|
|
||||||
|
with patch('app.services.bitget_live_trading_service.get_settings', return_value=mock_settings):
|
||||||
|
result = get_bitget_live_service()
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_returns_service_when_enabled(self):
|
||||||
|
from app.services.bitget_live_trading_service import get_bitget_live_service, reset_bitget_live_service
|
||||||
|
reset_bitget_live_service()
|
||||||
|
|
||||||
|
mock_settings = MagicMock()
|
||||||
|
mock_settings.bitget_trading_enabled = True
|
||||||
|
mock_settings.bitget_max_total_leverage = 10.0
|
||||||
|
mock_settings.bitget_max_single_position = 1000.0
|
||||||
|
mock_settings.hyperliquid_circuit_breaker_drawdown = 0.10
|
||||||
|
|
||||||
|
mock_api = MagicMock()
|
||||||
|
mock_api._standardize_symbol = lambda s: f"{s}/USDT:USDT"
|
||||||
|
mock_api.get_balance.return_value = {
|
||||||
|
'USDT': {'available': '5000', 'frozen': '0', 'locked': '0'}
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch('app.services.bitget_live_trading_service.get_settings', return_value=mock_settings), \
|
||||||
|
patch('app.services.bitget_live_trading_service.get_bitget_trading_api', return_value=mock_api):
|
||||||
|
result = get_bitget_live_service()
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
reset_bitget_live_service()
|
||||||
|
|
||||||
|
def test_init_failure_returns_none(self):
|
||||||
|
from app.services.bitget_live_trading_service import get_bitget_live_service, reset_bitget_live_service
|
||||||
|
reset_bitget_live_service()
|
||||||
|
|
||||||
|
mock_settings = MagicMock()
|
||||||
|
mock_settings.bitget_trading_enabled = True
|
||||||
|
|
||||||
|
with patch('app.services.bitget_live_trading_service.get_settings', return_value=mock_settings), \
|
||||||
|
patch('app.services.bitget_live_trading_service.get_bitget_trading_api', return_value=None):
|
||||||
|
result = get_bitget_live_service()
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
reset_bitget_live_service()
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== TestCancelTpSlOrders ====================
|
||||||
|
|
||||||
|
class TestCancelTpSlOrders:
|
||||||
|
"""cancel_tp_sl_orders 是 cancel_all_orders 的别名,验证调用链正确"""
|
||||||
|
|
||||||
|
def test_delegates_to_cancel_all_orders(self):
|
||||||
|
service, mock_api = make_service()
|
||||||
|
mock_api.cancel_all_orders.return_value = True
|
||||||
|
result = service.cancel_tp_sl_orders('BTC')
|
||||||
|
assert result['success'] is True
|
||||||
|
mock_api.cancel_all_orders.assert_called_once_with('BTC')
|
||||||
|
|
||||||
|
def test_cancel_returns_false(self):
|
||||||
|
service, mock_api = make_service()
|
||||||
|
mock_api.cancel_all_orders.return_value = False
|
||||||
|
result = service.cancel_tp_sl_orders('ETH')
|
||||||
|
assert result['success'] is False
|
||||||
|
|
||||||
|
def test_api_exception(self):
|
||||||
|
service, mock_api = make_service()
|
||||||
|
mock_api.cancel_all_orders.side_effect = Exception("network error")
|
||||||
|
result = service.cancel_tp_sl_orders('SOL')
|
||||||
|
assert result['success'] is False
|
||||||
|
assert 'network error' in result['error']
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== TestMarketCloseAll ====================
|
||||||
|
|
||||||
|
class TestMarketCloseAll:
|
||||||
|
|
||||||
|
def _make_position(self, coin, size, entry_price=50000.0):
|
||||||
|
return {
|
||||||
|
'coin': coin,
|
||||||
|
'size': size,
|
||||||
|
'entry_price': entry_price,
|
||||||
|
'unrealized_pnl': 0.0,
|
||||||
|
'leverage': 10,
|
||||||
|
'liquidation_price': None,
|
||||||
|
'position': {},
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_close_single_long(self):
|
||||||
|
"""单个多仓:发出方向相反(sell)的市价单"""
|
||||||
|
service, mock_api = make_service()
|
||||||
|
mock_api.get_position.return_value = [{
|
||||||
|
'symbol': 'BTC/USDT:USDT',
|
||||||
|
'contracts': 2.0,
|
||||||
|
'side': 'long',
|
||||||
|
'entryPrice': 50000.0,
|
||||||
|
'unrealizedPnl': 0.0,
|
||||||
|
'leverage': 10,
|
||||||
|
'liquidationPrice': 45000.0,
|
||||||
|
}]
|
||||||
|
mock_api.exchange.create_order.return_value = {'id': 'close1', 'status': 'closed'}
|
||||||
|
|
||||||
|
result = service.market_close_all()
|
||||||
|
assert result['success'] is True
|
||||||
|
assert len(result['results']) == 1
|
||||||
|
# 多仓平仓用 sell
|
||||||
|
call_args = mock_api.exchange.create_order.call_args
|
||||||
|
assert call_args[1].get('side') or call_args[0][2] == 'sell'
|
||||||
|
|
||||||
|
def test_close_single_short(self):
|
||||||
|
"""单个空仓:发出 buy 方向的市价单"""
|
||||||
|
service, mock_api = make_service()
|
||||||
|
mock_api.get_position.return_value = [{
|
||||||
|
'symbol': 'ETH/USDT:USDT',
|
||||||
|
'contracts': 5.0,
|
||||||
|
'side': 'short',
|
||||||
|
'entryPrice': 3000.0,
|
||||||
|
'unrealizedPnl': 0.0,
|
||||||
|
'leverage': 5,
|
||||||
|
'liquidationPrice': 3500.0,
|
||||||
|
}]
|
||||||
|
mock_api.exchange.create_order.return_value = {'id': 'close2', 'status': 'closed'}
|
||||||
|
|
||||||
|
result = service.market_close_all()
|
||||||
|
assert result['success'] is True
|
||||||
|
call_args = mock_api.exchange.create_order.call_args
|
||||||
|
assert call_args[1].get('side') or call_args[0][2] == 'buy'
|
||||||
|
|
||||||
|
def test_close_multiple_positions(self):
|
||||||
|
"""多个持仓,全部成功"""
|
||||||
|
service, mock_api = make_service()
|
||||||
|
mock_api.get_position.return_value = [
|
||||||
|
{'symbol': 'BTC/USDT:USDT', 'contracts': 1.0, 'side': 'long',
|
||||||
|
'entryPrice': 50000.0, 'unrealizedPnl': 0.0, 'leverage': 10, 'liquidationPrice': None},
|
||||||
|
{'symbol': 'ETH/USDT:USDT', 'contracts': 3.0, 'side': 'short',
|
||||||
|
'entryPrice': 3000.0, 'unrealizedPnl': 0.0, 'leverage': 5, 'liquidationPrice': None},
|
||||||
|
]
|
||||||
|
mock_api.exchange.create_order.return_value = {'id': 'x', 'status': 'closed'}
|
||||||
|
|
||||||
|
result = service.market_close_all()
|
||||||
|
assert result['success'] is True
|
||||||
|
assert len(result['results']) == 2
|
||||||
|
|
||||||
|
def test_no_positions_returns_success(self):
|
||||||
|
"""无持仓时,成功返回空结果"""
|
||||||
|
service, mock_api = make_service()
|
||||||
|
mock_api.get_position.return_value = []
|
||||||
|
result = service.market_close_all()
|
||||||
|
assert result['success'] is True
|
||||||
|
assert result['results'] == []
|
||||||
|
|
||||||
|
def test_partial_failure(self):
|
||||||
|
"""一个平仓失败,success 应为 False"""
|
||||||
|
service, mock_api = make_service()
|
||||||
|
mock_api.get_position.return_value = [
|
||||||
|
{'symbol': 'BTC/USDT:USDT', 'contracts': 1.0, 'side': 'long',
|
||||||
|
'entryPrice': 50000.0, 'unrealizedPnl': 0.0, 'leverage': 10, 'liquidationPrice': None},
|
||||||
|
{'symbol': 'ETH/USDT:USDT', 'contracts': 3.0, 'side': 'long',
|
||||||
|
'entryPrice': 3000.0, 'unrealizedPnl': 0.0, 'leverage': 5, 'liquidationPrice': None},
|
||||||
|
]
|
||||||
|
# 第一次下单成功,第二次失败
|
||||||
|
mock_api.exchange.create_order.side_effect = [
|
||||||
|
{'id': 'ok1', 'status': 'closed'},
|
||||||
|
Exception("rate limit"),
|
||||||
|
]
|
||||||
|
result = service.market_close_all()
|
||||||
|
assert result['success'] is False
|
||||||
|
|
||||||
|
def test_position_too_small_skipped(self):
|
||||||
|
"""持仓量小于 1 张时跳过,不报错"""
|
||||||
|
service, mock_api = make_service()
|
||||||
|
# BTC 合约面值 0.01,持仓 0.005 BTC → 0 张 → 跳过
|
||||||
|
mock_api.get_position.return_value = [{
|
||||||
|
'symbol': 'BTC/USDT:USDT',
|
||||||
|
'contracts': 0.5, # 0.5张 × 0.01 = 0.005 BTC → floor(0.005/0.01) = 0张
|
||||||
|
'side': 'long',
|
||||||
|
'entryPrice': 50000.0,
|
||||||
|
'unrealizedPnl': 0.0,
|
||||||
|
'leverage': 10,
|
||||||
|
'liquidationPrice': None,
|
||||||
|
}]
|
||||||
|
result = service.market_close_all()
|
||||||
|
assert result['success'] is True
|
||||||
|
assert result['results'] == []
|
||||||
|
mock_api.exchange.create_order.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== TestInitializeAccount ====================
|
||||||
|
|
||||||
|
class TestInitializeAccount:
|
||||||
|
|
||||||
|
def test_sets_initial_balance(self):
|
||||||
|
"""成功时 initial_balance 被赋值"""
|
||||||
|
from app.services.bitget_live_trading_service import BitgetLiveTradingService
|
||||||
|
|
||||||
|
mock_api = MagicMock()
|
||||||
|
mock_api._standardize_symbol = lambda s: f"{s}/USDT:USDT"
|
||||||
|
mock_api.get_balance.return_value = {
|
||||||
|
'USDT': {'available': '8000.0', 'frozen': '2000.0', 'locked': '0'}
|
||||||
|
}
|
||||||
|
|
||||||
|
service = BitgetLiveTradingService.__new__(BitgetLiveTradingService)
|
||||||
|
service.trading_api = mock_api
|
||||||
|
service.initial_balance = None
|
||||||
|
|
||||||
|
service._initialize_account()
|
||||||
|
|
||||||
|
assert service.initial_balance == pytest.approx(10000.0)
|
||||||
|
|
||||||
|
def test_api_failure_leaves_none(self):
|
||||||
|
"""get_balance 抛出异常时,initial_balance 保持 None,不传播异常"""
|
||||||
|
from app.services.bitget_live_trading_service import BitgetLiveTradingService
|
||||||
|
|
||||||
|
mock_api = MagicMock()
|
||||||
|
mock_api.get_balance.side_effect = Exception("timeout")
|
||||||
|
|
||||||
|
service = BitgetLiveTradingService.__new__(BitgetLiveTradingService)
|
||||||
|
service.trading_api = mock_api
|
||||||
|
service.initial_balance = None
|
||||||
|
|
||||||
|
service._initialize_account() # 不应抛出异常
|
||||||
|
|
||||||
|
assert service.initial_balance is None
|
||||||
Loading…
Reference in New Issue
Block a user