diff --git a/backend/api/routes/trades.py b/backend/api/routes/trades.py index e8fe17f..ac460bf 100644 --- a/backend/api/routes/trades.py +++ b/backend/api/routes/trades.py @@ -17,7 +17,7 @@ project_root = Path(__file__).parent.parent.parent.parent sys.path.insert(0, str(project_root)) sys.path.insert(0, str(project_root / 'backend')) -from database.models import Trade +from database.models import Trade, Account from api.auth_deps import get_account_id router = APIRouter() @@ -650,3 +650,168 @@ async def sync_trades_from_binance( except Exception as e: logger.error(f"同步币安订单失败: {e}", exc_info=True) raise HTTPException(status_code=500, detail=f"同步币安订单失败: {str(e)}") + + +@router.get("/verify-binance") +async def verify_trades_against_binance( + account_id: int = Depends(get_account_id), + days: int = Query(30, ge=1, le=90, description="校验最近 N 天的记录,默认 30"), + limit: int = Query(100, ge=1, le=500, description="最多校验条数,默认 100"), +): + """ + 用币安订单接口逐条校验本账号「可对账」交易记录的准确性,便于把握策略执行分析所依赖的订单数据是否与交易所一致。 + - 只校验有 entry_order_id 的记录(已平仓的还会校验 exit_order_id)。 + - 每条记录会请求币安 futures_get_order 核对订单是否存在、symbol/side/数量是否一致。 + - 返回汇总(一致/缺失/不一致数量)与明细,便于排查对不上的记录。 + """ + beijing_tz = timezone(timedelta(hours=8)) + now = datetime.now(beijing_tz) + end_ts = int(now.timestamp()) + start_ts = end_ts - days * 24 * 3600 + + trades = Trade.get_all( + account_id=account_id, + start_timestamp=start_ts, + end_timestamp=end_ts, + ) + # 只校验「可对账」记录:有 entry_order_id;若已平仓则还须有 exit_order_id + def _has_entry(eid): + return eid is not None and str(eid).strip() not in ("", "0") + def _has_exit(xid): + return xid is not None and str(xid).strip() not in ("", "0") + to_verify = [ + t for t in trades + if _has_entry(t.get("entry_order_id")) + and (t.get("status") != "closed" or _has_exit(t.get("exit_order_id"))) + ][:limit] + + if not to_verify: + return { + "success": True, + "account_id": account_id, + "summary": {"total_verified": 0, "entry_ok": 0, "entry_missing": 0, "entry_mismatch": 0, "exit_ok": 0, "exit_missing": 0, "exit_mismatch": 0}, + "details": [], + "message": "该时间范围内没有可对账记录(需有 entry_order_id,已平仓需有 exit_order_id)", + } + + api_key, api_secret, use_testnet, status = Account.get_credentials(account_id) + if not api_key or not api_secret: + raise HTTPException(status_code=400, detail=f"账号 {account_id} 未配置 API 密钥,无法请求币安") + + trading_system_path = project_root / "trading_system" + if not trading_system_path.exists(): + trading_system_path = project_root / "backend" / "trading_system" + sys.path.insert(0, str(project_root)) + sys.path.insert(0, str(trading_system_path)) + try: + from binance_client import BinanceClient + except ImportError: + raise HTTPException(status_code=500, detail="无法导入 BinanceClient") + + client = BinanceClient(api_key=api_key, api_secret=api_secret, testnet=use_testnet) + await client.connect() + try: + summary = {"total_verified": 0, "entry_ok": 0, "entry_missing": 0, "entry_mismatch": 0, "exit_ok": 0, "exit_missing": 0, "exit_mismatch": 0} + details = [] + + for t in to_verify: + tid = t.get("id") + symbol = t.get("symbol") or "" + eid = t.get("entry_order_id") + xid = t.get("exit_order_id") + status_t = t.get("status") or "open" + side_t = t.get("side") or "" + qty_t = float(t.get("quantity") or 0) + entry_price_t = float(t.get("entry_price") or 0) + + row = { + "trade_id": tid, + "symbol": symbol, + "side": side_t, + "status": status_t, + "entry_order_id": eid, + "exit_order_id": xid, + "entry_verified": None, + "entry_message": None, + "exit_verified": None, + "exit_message": None, + } + + # 校验开仓订单 + if _has_entry(eid): + summary["total_verified"] += 1 + try: + order = await client.client.futures_get_order(symbol=symbol, orderId=int(eid)) + if not order: + summary["entry_missing"] += 1 + row["entry_verified"] = False + row["entry_message"] = "币安未返回订单" + else: + ob_side = (order.get("side") or "").upper() + ob_qty = float(order.get("origQty") or order.get("executedQty") or 0) + ob_price = float(order.get("avgPrice") or 0) + if ob_side != side_t or abs(ob_qty - qty_t) > 1e-8: + summary["entry_mismatch"] += 1 + row["entry_verified"] = False + row["entry_message"] = f"币安 side={ob_side} qty={ob_qty},DB side={side_t} qty={qty_t}" + else: + summary["entry_ok"] += 1 + row["entry_verified"] = True + row["entry_message"] = "一致" + except Exception as ex: + err = str(ex) + if "Unknown order" in err or "-2011" in err or "404" in err.lower(): + summary["entry_missing"] += 1 + row["entry_verified"] = False + row["entry_message"] = "币安无此订单" + else: + summary["entry_mismatch"] += 1 + row["entry_verified"] = False + row["entry_message"] = err[:200] + + # 校验平仓订单(仅已平仓且存在 exit_order_id) + if status_t == "closed" and _has_exit(xid): + try: + order = await client.client.futures_get_order(symbol=symbol, orderId=int(xid)) + if not order: + summary["exit_missing"] += 1 + row["exit_verified"] = False + row["exit_message"] = "币安未返回订单" + else: + ob_side = (order.get("side") or "").upper() + ob_qty = float(order.get("executedQty") or order.get("origQty") or 0) + if ob_side != side_t or abs(ob_qty - qty_t) > 1e-8: + summary["exit_mismatch"] += 1 + row["exit_verified"] = False + row["exit_message"] = f"币安 side={ob_side} qty={ob_qty},DB side={side_t} qty={qty_t}" + elif not order.get("reduceOnly"): + summary["exit_mismatch"] += 1 + row["exit_verified"] = False + row["exit_message"] = "币安订单非 reduceOnly,非平仓单" + else: + summary["exit_ok"] += 1 + row["exit_verified"] = True + row["exit_message"] = "一致" + except Exception as ex: + err = str(ex) + if "Unknown order" in err or "-2011" in err or "404" in err.lower(): + summary["exit_missing"] += 1 + row["exit_verified"] = False + row["exit_message"] = "币安无此订单" + else: + summary["exit_mismatch"] += 1 + row["exit_verified"] = False + row["exit_message"] = err[:200] + + details.append(row) + await asyncio.sleep(0.05) + + return { + "success": True, + "account_id": account_id, + "summary": summary, + "details": details, + "message": f"已校验 {summary['total_verified']} 条开仓订单,开仓一致 {summary['entry_ok']}、缺失 {summary['entry_missing']}、不一致 {summary['entry_mismatch']};平仓一致 {summary['exit_ok']}、缺失 {summary['exit_missing']}、不一致 {summary['exit_mismatch']}", + } + finally: + await client.disconnect() diff --git a/docs/订单与统计一致性说明.md b/docs/订单与统计一致性说明.md index 5bf8679..7d031f3 100644 --- a/docs/订单与统计一致性说明.md +++ b/docs/订单与统计一致性说明.md @@ -92,3 +92,41 @@ 表示:仅对 **当前已在 `active_positions` 中且具备 SL/TP 价格的持仓** 在交易所侧挂或更新保护单;每次挂前会先取消同类型旧单再挂新单,不会重复堆积同一 symbol 的多组 SL/TP。 若希望减少「想下单」类日志的干扰,可将「跳过开仓」类日志级别调低(如改为 debug),或仅在有实际下单时再打 info。 + +--- + +## 六、如何确认订单记录的准确性 + +策略执行情况分析依赖订单记录,必须保证「能对上的」记录与币安一致。可按下面步骤把握。 + +### 1. 日常使用:只用「可对账」口径 + +- 前端「交易记录」**保持勾选「仅可对账(与币安一致)」**(默认即勾选)。 +- 看日盈亏、胜率、策略统计时,接口已默认 `reconciled_only=true`,只统计有 `entry_order_id` 且已平仓有 `exit_order_id` 的记录,与币安可一一对应。 +- 分析策略执行、复盘盈亏时,以这批记录为准,避免被无订单号或补建脏数据干扰。 + +### 2. 主动校验:调用「对账校验」接口 + +- **接口**:`GET /api/trades/verify-binance`(需登录并带 `X-Account-Id` 指定账号)。 +- **参数**:`days`(校验最近 N 天,默认 30)、`limit`(最多校验条数,默认 100)。 +- **作用**:对当前账号在时间范围内的「可对账」记录,逐条用币安 `futures_get_order` 校验: + - 开仓订单:订单是否存在、symbol/side/数量是否与 DB 一致; + - 平仓订单(已平仓且存在 `exit_order_id`):同上,并确认为 reduceOnly。 +- **返回**:`summary`(一致/缺失/不一致数量)+ `details`(每条记录的 `entry_verified`/`exit_verified` 及说明)。便于快速发现「对不上」的记录。 + +**示例**(校验最近 30 天、最多 100 条): + +```http +GET /api/trades/verify-binance?days=30&limit=100 +X-Account-Id: 1 +Authorization: Bearer +``` + +若 `entry_missing` 或 `exit_missing` 不为 0,说明 DB 里有的订单号在币安查不到(可能错写、错账号或历史清理);若 `entry_mismatch`/`exit_mismatch` 不为 0,说明订单存在但 symbol/side/数量等与 DB 不一致,需排查写入或同步逻辑。 + +### 3. 发现对不上时怎么处理 + +- **仅做策略分析**:继续以「仅可对账」口径为准;有问题的记录因缺订单号或校验不通过,不会进入默认统计。 +- **希望 DB 与币安尽量一致**:可先执行 `POST /api/trades/sync-binance`(按「最近 N 天」从币安拉订单并更新/补全平仓与 `exit_order_id`);再跑一次 `GET /api/trades/verify-binance` 看是否仍存在缺失或不一致,再针对单条记录排查或人工修正。 + +总结:**日常用「仅可对账」统计 + 定期或按需调 `verify-binance` 校验**,即可把订单记录的准确性把握住,策略执行分析所依赖的数据与币安一致。