feat(trades): 添加对账校验接口以验证交易记录准确性

新增 `GET /api/trades/verify-binance` 接口,允许用户校验与币安的交易记录一致性。该接口支持指定时间范围和校验条数,返回校验结果的汇总和详细信息,确保策略执行分析所依赖的数据与交易所一致。
This commit is contained in:
薇薇安 2026-02-16 14:02:55 +08:00
parent 225cb436d1
commit b9392e096c
2 changed files with 204 additions and 1 deletions

View File

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

View File

@ -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 <token>
```
`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` 校验**,即可把订单记录的准确性把握住,策略执行分析所依赖的数据与币安一致。