diff --git a/docs/订单与统计一致性说明.md b/docs/订单与统计一致性说明.md index 7d031f3..b1e1383 100644 --- a/docs/订单与统计一致性说明.md +++ b/docs/订单与统计一致性说明.md @@ -130,3 +130,43 @@ Authorization: Bearer - **希望 DB 与币安尽量一致**:可先执行 `POST /api/trades/sync-binance`(按「最近 N 天」从币安拉订单并更新/补全平仓与 `exit_order_id`);再跑一次 `GET /api/trades/verify-binance` 看是否仍存在缺失或不一致,再针对单条记录排查或人工修正。 总结:**日常用「仅可对账」统计 + 定期或按需调 `verify-binance` 校验**,即可把订单记录的准确性把握住,策略执行分析所依赖的数据与币安一致。 + +--- + +## 七、持仓里「未知来源」订单是哪里来的? + +持仓单里出现「未知」或「未知来源」,通常来自下面几类情况。 + +### 1. 仪表板「入场类型:未知」 + +- **数据来源**:`GET /api/account/positions` 用**币安实时持仓**列表,再按 symbol 匹配本账号 DB 里 `status=open` 的记录,取 `entry_order_id`;若有订单号再调币安 `futures_get_order` 取订单类型 → 得到「限价/市价/未知」。 +- **出现「未知」的两种情况**: + - **币安有仓、DB 没有对应 open 记录**(尚未补建或未匹配上):没有 `entry_order_id`,自然没有订单类型 → 显示「未知」。 + - **DB 有记录但 `entry_order_id` 为空**(例如补建时没查到开仓订单、或历史单无订单号):同样无法查订单类型 → 显示「未知」。 + +即:**凡是「币安有仓但系统没有可关联的开仓订单号」的持仓,在仪表板上都会显示为入场类型「未知」。** + +### 2. 交易记录里「入场原因」= sync_recovered_unknown_origin(来历不明) + +- **产生位置**:**trading_system 的持仓状态同步**(`position_manager.sync_positions_with_binance`),在「币安有仓、DB 无 open 记录」时会对该仓**补建**一条交易记录。 +- **何时标成「未知来源」**(`entry_reason = sync_recovered_unknown_origin`): + - 配置了 **SYSTEM_ORDER_ID_PREFIX** 且能查到开仓订单时:若该订单的 **clientOrderId 不以系统前缀开头**(例如手动在币安下单、或其它系统下的单),则视为「来历不明」,仍会补建、挂止损止盈并纳入监控,但 `entry_reason` 记为 `sync_recovered_unknown_origin`。 + - **未配置** SYSTEM_ORDER_ID_PREFIX 且开启了 **SYNC_RECOVER_ONLY_WHEN_HAS_SLTP** 时:若该仓**没有**止损/止盈单,也记为「来历不明」并补建。 +- **后端 POST /api/account/positions/sync**:逻辑不同——会**跳过**「明确非系统前缀」的仓(不补建),所以不会在「同步接口」里产生 unknown_origin;只有**交易进程内**的定时同步会按上面规则补建并标记 `sync_recovered_unknown_origin`。 + +因此:**持仓里「未知来源」的订单 = 币安上存在、但开仓不是本系统下的单(或无法用系统前缀/止损止盈判断为本系统单),由交易系统状态同步补建并标记为「来历不明」的那部分。** 系统仍会为它们挂止损止盈并监控,只是统计/分析时可通过「仅可对账」过滤掉无 `entry_order_id` 的这类记录。 + +### 3. 本系统开的仓为什么会出现「没正确落库」或「没记订单号」? + +正常流程是:**开仓订单在币安成交 → 等待 FILLED → 用实际成交价/数量调用 `Trade.create(..., entry_order_id=..., client_order_id=...)`**。下面情况会导致「开了不落库」或补建时「没订单号」。 + +| 原因 | 说明 | +|------|------| +| **数据库不可用** | `position_manager` 启动时尝试导入 `backend.database.models`,若 `backend` 目录不存在、或导入失败(缺依赖、Python 路径错误、DB 连不上等),会设 `DB_AVAILABLE = False`,成交后直接跳过保存并打日志「数据库不可用,跳过保存」。 | +| **Trade.create 抛异常** | 落库时发生异常(如连接超时、唯一约束冲突、磁盘满、字段错误等),代码会打错并 `return None`,不会重试,币安已有仓但 DB 无记录。 | +| **进程在落库前退出** | 订单已在币安成交,但在执行到 `Trade.create` 之前进程被 kill、崩溃或重启,也会出现币安有仓、DB 无记录。 | +| **补建时拿不到订单号** | 同步补建时用 `get_recent_trades(symbol, limit=30)` 取最近同向成交并取第一条的 `orderId` 作为 `entry_order_id`。若接口失败、返回空、或该笔开仓已不在最近 30 条成交里,则 `entry_order_id` 为空,补建记录就会「没记订单号」。 | + +**建议**:保证交易进程与 backend 同机或能访问同一 DB、且 `backend` 路径正确,避免 `DB_AVAILABLE = False`;若曾出现「开了不落库」,可查交易进程日志中的「保存交易记录到数据库失败」或「数据库不可用」;补建后仍无订单号的记录可用「仅可对账」过滤,不影响基于可对账记录的统计。 + +**落库失败独立日志**:当币安订单已成交但未写入 DB 时(如 `Trade.create` 抛异常、或 `DB_AVAILABLE=False`、或 `Trade` 未导入),会额外写入项目目录下 **`logs/trade_db_failures.log`**,每行一条 JSON(含 `ts`、`symbol`、`entry_order_id`、`side`、`quantity`、`entry_price`、`account_id`、`reason`、以及失败时的 `error_type`/`error_message`)。不依赖 DB/Redis,便于单独 grep 或脚本统计「成交未落库」笔数,便于与币安对账。 diff --git a/trading_system/position_manager.py b/trading_system/position_manager.py index 542cbb7..44dae9e 100644 --- a/trading_system/position_manager.py +++ b/trading_system/position_manager.py @@ -6,6 +6,7 @@ import logging import json import aiohttp import time +from pathlib import Path from typing import Dict, List, Optional from datetime import datetime try: @@ -53,6 +54,47 @@ if get_beijing_time is None: """获取当前北京时间(UTC+8)""" return datetime.now(BEIJING_TZ).replace(tzinfo=None) +# 落库失败独立日志:币安已成交但未写入 DB 时写入此文件,便于排查与对账(不依赖 DB/Redis) +_FAILURE_LOG_DIR = None +_FAILURE_LOG_PATH = None +def _get_db_failure_log_path(): + global _FAILURE_LOG_DIR, _FAILURE_LOG_PATH + if _FAILURE_LOG_PATH is not None: + return _FAILURE_LOG_PATH + try: + root = Path(__file__).parent.parent + _FAILURE_LOG_DIR = root / "logs" + _FAILURE_LOG_DIR.mkdir(parents=True, exist_ok=True) + _FAILURE_LOG_PATH = _FAILURE_LOG_DIR / "trade_db_failures.log" + return _FAILURE_LOG_PATH + except Exception: + return None + +def _log_trade_db_failure(symbol, entry_order_id, side, quantity, entry_price, account_id, reason, error_type=None, error_message=None): + """将「成交但落库失败」记录到独立日志文件(一行 JSON),便于 grep/脚本统计,不依赖 DB/Redis。""" + path = _get_db_failure_log_path() + if not path: + return + try: + rec = { + "ts": datetime.utcnow().isoformat() + "Z", + "symbol": symbol, + "entry_order_id": entry_order_id, + "side": side, + "quantity": quantity, + "entry_price": entry_price, + "account_id": account_id, + "reason": reason, + } + if error_type: + rec["error_type"] = error_type + if error_message: + rec["error_message"] = (str(error_message) or "")[:500] + with open(path, "a", encoding="utf-8") as f: + f.write(json.dumps(rec, ensure_ascii=False) + "\n") + except Exception as e: + logger.warning(f"写入落库失败日志失败: {e}") + class PositionManager: """仓位管理类""" @@ -717,11 +759,26 @@ class PositionManager: logger.error(f" 错误类型: {type(e).__name__}") import traceback logger.error(f" 错误详情:\n{traceback.format_exc()}") + _log_trade_db_failure( + symbol=symbol, entry_order_id=entry_order_id, side=side, + quantity=quantity, entry_price=entry_price, account_id=self.account_id, + reason="create_exception", error_type=type(e).__name__, error_message=str(e) + ) return None elif not DB_AVAILABLE: logger.debug(f"数据库不可用,跳过保存 {symbol} 交易记录") + _log_trade_db_failure( + symbol=symbol, entry_order_id=entry_order_id, side=side, + quantity=quantity, entry_price=entry_price, account_id=self.account_id, + reason="DB_AVAILABLE=False" + ) elif not Trade: logger.warning(f"Trade模型未导入,无法保存 {symbol} 交易记录") + _log_trade_db_failure( + symbol=symbol, entry_order_id=entry_order_id, side=side, + quantity=quantity, entry_price=entry_price, account_id=self.account_id, + reason="Trade=None" + ) # 记录持仓信息(包含动态止损止盈和分步止盈) from datetime import datetime