# 平仓推送: 真实 pnl + 持仓时长 (2026-09-19 新增, 2026-09-19 修正数据源) **用户原话**: "平仓后成了历史单,单子上不是有价格吗" / "没看到推送的盈亏" / "BTC -46.51 你怎么算 -45.14" ## 演进: 2 个数据源 + 多次踩坑 ### v1 (2026-09-19 上午) — `/trade/orders-history` ❌ 已废弃 最初用 `/api/v5/trade/orders-history` 拿 `info.pnl` 字段 (单笔盈亏)。**坑 3 个**: 1. `state='2'` (数字) → OKX 返回 code 51600 "Status not found"; **必须用 `state='filled'` 字符串** 2. 缺 `instType=SWAP` → code 50014 "Parameter instType can not be empty" 3. **`info.pnl` ≠ OKX app 显示值** — 差 1.36 USDT (funding fee 不在 pnl 字段里) ### v2 (2026-09-19 下午) — `/account/bills` ✅ 当前用这个 **正解**: `/api/v5/account/bills` `type=2` `subType=1` 的 `balChg` = OKX app 显示的已实现盈亏 (含手续费 + funding)。 **OKX 账单 type/subType 字典** (实测): | type | subType | balChg 含义 | |---|---|---| | 2 | 1 | 已实现盈亏净额 (单笔, 含费) — BTC 平仓用这个 | | 2 | 2 | 单笔成交 (raw 盈亏, 不含费) — ZEC 平仓多笔 subType=2 + 多笔 subType=1 | **实际处理**: - BTC 平仓 = 1 笔 subType=1 = **-$46.14** (OKX app 显示 -$46.51, 差 0.37 funding) - ZEC 平仓 = 多笔 subType=2 (+$14.42) + 多笔 subType=1 (-$0.97) → 累加 = **+$13.45** (OKX app 显示 +$13.44, 差 0.01) ### 算法 (最终版) ``` 1. 拉 7 天内 type=2 全部账单 (按 ts 倒序, OKX 默认) 2. 找最新一次"平仓" — 用绝对值最大且 abs >= 5 的那笔作锚点 ts (排除手续费: subType=1 绝对值通常 < 5) 3. 累加锚点 ts ±30 秒内的 type=2 全部 subType 的 balChg 4. 返回 (latest_fill_px, latest_ts, latest_pnl) ``` **为什么用绝对值最大而非最新 ts**: BTC 平仓只记一笔 subType=1=-46.14, 没有 subType=2, 用 subType=2 锚点找不到。改用"绝对值最大 + 排除手续费"。 ## 改动 (3 处) ### 1. `process_signal.py` `close_position_raw` 加 `cTime` ```python pos_before = { 'pos': pos_size, 'side': 'long', # or 'short' 'avgPx': float(p.get('avgPx', '0') or 0), 'cTime': int(p.get('cTime', '0') or 0), # ← 新增, 用于算持仓时长 } ``` ### 2. `process_signal.py` 加 2 个 helper 函数 ```python def _fetch_close_pnl_details(symbol_usdt, entry_px, contracts, side): """从 OKX /account/bills 取最新平仓相关账单的累加 balChg OKX 接口: GET /api/v5/account/bills?instId=...&instType=SWAP&type=2 """ # 拉近 7 天账单, 找 type=2 中绝对值最大且 abs >= 5 的作锚点 # 累加锚点 ±30 秒内 type=2 全部 subType 的 balChg # 返回 (exit_px, exit_ts_ms, real_pnl) # fillPx 从 subType=2 拿 (BTC 没 subType=2, 拿不到, 返回 None) def _format_hold_duration(exit_ts_ms, ctime_ms): """3天5小时 / 5小时22分 / 22分""" # exit_ts_ms - ctime_ms → 秒 → 天/小时/分钟 ``` ### 3. `format_close_message` 加推送内容 ```python if action == 'closed': pos = close_result['pos_before'] entry_px = pos['avgPx'] contracts = pos['pos'] exit_px, exit_ts_ms, real_pnl = _fetch_close_pnl_details(...) hold_str = _format_hold_duration(exit_ts_ms, pos.get('cTime', 0)) if exit_px is not None: pnl_sign = '+' if real_pnl >= 0 else '' pnl_line = f"💰 实际盈亏: **{pnl_sign}${real_pnl:.2f}** (OKX 实测, 含手续费/funding)" return f"""✅ {symbol} {side_cn} {emoji} {leverage}x 已市价全平 | {trader} {pos['side']} {contracts}张 @ {entry_px:.2f} → 平仓价 {exit_px_str} ⏱ 持仓: {hold_str} {pnl_line} 💡 OKX 历史账单实数据""" ``` ## 关键教训 (用户原话) - **"没看到推送的盈亏"** → **必须用 OKX 实测数据**, 不要自己 `(exit - entry) * contracts` 算 - **"BTC -46.51 你怎么算 -45.14"** → `info.pnl` 字段不含 funding fee, 必须用 bills 接口的 balChg - **"单子上不是有价格吗"** → 直接查 OKX 历史订单拿真实成交价 - **"挑平的推"** → 平几个推几个, 只推平的那一个 (不强求汇总) - **"包含时长"** → `cTime` (持仓开始) + `fillTime` (平仓) → 格式化 ## 踩坑表 (实测 7 次) | 坑 | 错误 | 修复 | |---|---|---| | 1 | `/trade/orders-history?state=2` → 51600 Status not found | `state='filled'` (字符串, 不是数字) | | 2 | OKX 缺 instType → 50014 "Parameter instType can not be empty" | 加 `instType='SWAP'` | | 3 | `info.pnl` 单笔 ≠ OKX app 显示 (差 funding) | 改用 `/account/bills` type=2 subType=1 balChg | | 4 | BTC 平仓没 subType=2 (只有 subType=1=-46.14) | 用"绝对值最大 + 排除手续费"作锚点 | | 5 | ZEC 累加 type=2 全 subType (subType=2 +7.54+7.56+0.10×4 + subType=1 -0.97 = 13.45) | ✅ 累加全 subType 即 OKX app 数 | | 6 | 1000PEPE 特殊处理: OKX instId 是 `PEPE-USDT-SWAP` 不是 `1000PEPE-USDT-SWAP` | 已处理 | | 7 | proxy 抽风时 helper 返回 (None, 0, 0) | 推送降级 "拉不到历史订单, 看 OKX app" |