diff --git a/backend/api/routes/account.py b/backend/api/routes/account.py index cf28239..a9bdc87 100644 --- a/backend/api/routes/account.py +++ b/backend/api/routes/account.py @@ -1724,6 +1724,22 @@ async def sync_positions( margin = entry_value / leverage if leverage > 0 else entry_value pnl_percent_margin = (pnl / margin * 100) if margin > 0 else 0 + # 从币安成交获取手续费与实际盈亏,保证统计与币安一致 + sync_commission = None + sync_commission_asset = None + sync_realized_pnl = None + if exit_order_id: + try: + recent_trades = await client.get_recent_trades(symbol, limit=30) + related = [t for t in recent_trades if str(t.get('orderId')) == str(exit_order_id)] + if related: + sync_commission = sum(float(t.get('commission', 0)) for t in related) + assets = {t.get('commissionAsset') for t in related if t.get('commissionAsset')} + sync_commission_asset = "/".join(assets) if assets else None + sync_realized_pnl = sum(float(t.get('realizedPnl', 0)) for t in related) + except Exception as fee_err: + logger.debug(f"同步 {symbol} 平仓手续费失败: {fee_err}") + # 更新数据库记录 duration_minutes = None try: @@ -1744,6 +1760,9 @@ async def sync_positions( exit_order_id=exit_order_id, duration_minutes=duration_minutes, exit_time_ts=exit_time_ts, + commission=sync_commission, + commission_asset=sync_commission_asset or None, + realized_pnl=sync_realized_pnl, ) updated_count += 1 logger.info( diff --git a/backend/api/routes/trades.py b/backend/api/routes/trades.py index 046de86..1a6b1e8 100644 --- a/backend/api/routes/trades.py +++ b/backend/api/routes/trades.py @@ -527,6 +527,21 @@ async def sync_trades_from_binance( except Exception: pass + # 从币安成交获取手续费与实际盈亏,保证统计与币安一致 + sync_commission = None + sync_commission_asset = None + sync_realized_pnl = None + try: + recent_trades = await client.get_recent_trades(symbol, limit=30) + related = [t for t in recent_trades if str(t.get('orderId')) == str(order_id)] + if related: + sync_commission = sum(float(t.get('commission', 0)) for t in related) + assets = {t.get('commissionAsset') for t in related if t.get('commissionAsset')} + sync_commission_asset = "/".join(assets) if assets else None + sync_realized_pnl = sum(float(t.get('realizedPnl', 0)) for t in related) + except Exception as fee_err: + logger.debug(f"同步订单 {order_id} 手续费失败: {fee_err}") + # 持仓持续时间(分钟) duration_minutes = None try: @@ -538,7 +553,7 @@ async def sync_trades_from_binance( except Exception: duration_minutes = None - # 更新数据库(包含订单号) + # 更新数据库(包含订单号、手续费与实际盈亏) Trade.update_exit( trade_id=trade_id, exit_price=avg_price, @@ -548,6 +563,9 @@ async def sync_trades_from_binance( exit_order_id=order_id, # 保存订单号,确保唯一性 duration_minutes=duration_minutes, exit_time_ts=exit_time_ts, + commission=sync_commission, + commission_asset=sync_commission_asset, + realized_pnl=sync_realized_pnl, ) updated_count += 1 logger.debug( diff --git a/docs/订单与统计一致性说明.md b/docs/订单与统计一致性说明.md new file mode 100644 index 0000000..1ffdac8 --- /dev/null +++ b/docs/订单与统计一致性说明.md @@ -0,0 +1,59 @@ +# 持仓、订单记录、统计与币安一致性说明 + +在引入「订单号前后一致」处理(`entry_order_id` / `exit_order_id` / `SYSTEM_ORDER_ID_PREFIX`)后,以下内容的状态如下。 + +--- + +## 一、已能保证的部分 + +### 1. 持仓与币安一致 + +- **仪表板「当前持仓」**:数据来自 **币安实时持仓**(`get_open_positions()`),与币安页面一致(仅受 `POSITION_MIN_NOTIONAL_USDT` 过滤影响)。 +- **补建逻辑**:只有「开仓订单 `clientOrderId` 以配置前缀开头」的持仓会补建 DB 记录,避免把手动单算进系统。 + +### 2. 订单记录与币安可对账 + +- **开仓**:系统下单时写入 `newClientOrderId = 前缀_时间戳_随机`,并保存 `entry_order_id` 到 DB。 +- **平仓**:平仓时保存 `exit_order_id`,`Trade.update_exit` 会做 `get_by_exit_order_id` 防重复。 +- **同步**: + - `POST /api/account/positions/sync`:只对「开仓订单 clientOrderId 前缀匹配」的持仓补建,且从成交里取 `orderId` 作为 `entry_order_id`。 + - `POST /api/trades/sync-binance`:用 `get_by_exit_order_id(order_id)` 判断是否已同步,避免重复;开仓侧用 `get_by_entry_order_id(order_id)` 判断是否已存在。 +- 因此:**每条 DB 记录都可与币安订单一一对应**(通过 `entry_order_id` / `exit_order_id`),订单记录与币安在「谁开的、谁平的」上一致。 + +### 3. 统计口径与去重 + +- **统计**:来自 `Trade.get_all(..., account_id)`,只统计该账号的 DB 记录。 +- **净盈亏**:`get_net_pnl(t)` 逻辑为: + - 若有 `realized_pnl`:用 `realized_pnl`,再若 `commission_asset == 'USDT'` 则减去 `commission`; + - 否则用 `pnl`(按价格差算)。 +- 胜率、总盈亏、盈亏比等均基于上述净盈亏汇总,**不会因为重复同步同一条平仓而重复计入**(由 `exit_order_id` 唯一性保证)。 + +--- + +## 二、仍依赖「谁在写」的部分 + +### 1. 手续费(commission) + +| 场景 | 是否写入 commission | 说明 | +|--------------------|----------------------|------| +| 交易系统内平仓 | ✅ 是 | 从 `get_recent_trades` 按 `exit_order_id` 汇总 commission,写入 `update_exit`。 | +| 仪表板「平仓」按钮 | ✅ 是 | 同上,取成交里的 commission/realizedPnl 写入。 | +| 持仓同步(positions/sync) | ✅ 是(已补全) | 更新 closed 前按 `exit_order_id` 拉取 `get_recent_trades`,汇总 commission/realizedPnl 并写入。 | +| 订单同步(trades/sync-binance) | ✅ 是(已补全) | 更新平仓记录前按订单号拉取成交,写入 commission/realized_pnl。 | + +因此:**所有标记为已平仓的记录都会尽量带上手续费与实际盈亏**,统计与币安一致。 + +### 2. 实际盈亏(realized_pnl) + +- **交易系统平仓、仪表板平仓**:从币安成交取 `realizedPnl` 并写入。 +- **持仓同步、订单同步**:已补全逻辑,同样按 `exit_order_id` 拉取成交并写入 `realized_pnl`。 +- 统计中若存在 `realized_pnl` 会优先使用并再扣 USDT 手续费,否则用价格差 `pnl`。 + +--- + +## 三、小结:能否「直接保证」? + +- **持仓与币安一致**:可以,当前实现已保证(实时持仓 + 按前缀补建)。 +- **订单记录与币安可对账**:可以,`entry_order_id` / `exit_order_id` 与防重复逻辑已保证一一对应、不重复。 +- **统计准确性**:在「同一笔平仓只被记录一次」和「按净盈亏汇总」上已保证;**手续费与实际盈亏**已在所有关仓路径补全: + - 系统/仪表板平仓、持仓同步、订单同步 均会按 `exit_order_id` 拉取成交并写入 commission/realized_pnl,统计与币安对齐。