diff --git a/docs/系统分析-期权主链条.md b/docs/系统分析-期权主链条.md index d6ed2be..0f6b1a4 100644 --- a/docs/系统分析-期权主链条.md +++ b/docs/系统分析-期权主链条.md @@ -1,401 +1,400 @@ -# 系统分析:期权主链条 - -> 范围:币本位期权(`OKX_OPTIONS_MARGIN_MODE=coin`) -> 配套规格:[规格-期权交易账户盈亏结算.md](./规格-期权交易账户盈亏结算.md)、[实盘说明.md](./实盘说明.md) -> 目的:从架构到开仓 / 平仓 / 展示 / 复盘 / 统计 / 对账 / 条件单,说明数据流、关键模块与边界 - ---- - -## 0. 总览 - -### 0.1 三条控制面 - -| 控制面 | 入口 | 职责 | -|--------|------|------| -| 面板 API | `/options` → `/api/options/*` | 人工开平仓、链/报价、持仓、划转、统计 | -| 监控线程 | `options_monitor_lib`(安装时拉起) | 目标位平仓、止盈平仓、条件单、持仓同步、卖回扫尾 | -| 中控 Hub | `/api/hub/options/*` | 快照、统计、远程平仓、条件单 CRUD | - -### 0.2 资金主链(商业真源) - -```text -USDT(交易户) - │ 开仓前快照 trading_usdt_before - ▼ -买 ETH/BTC(现货桥) - ▼ -买期权(IOC @ 卖一) - ▼ -持仓中(禁划转;浮盈跟盘) - ▼ -卖期权(买一 / 深度) - ▼ -卖回残留币 → 快照 trading_usdt_after - ▼ -realized_pnl_usdt = after − before,pnl_settled=1(冻结,永不改) - ▼ -复盘 / 统计只读冻结值 -``` - -### 0.3 启动装配 - -```text -app.py - → install_options_trading # options_register - → install_options_review # options_review_register - → register_hub_bridge_routes # hub_bridge_register - → 监控守护线程 # options_monitor_lib -``` - -核心配置包:`_build_cfg`(交易所、DB、预算、报价/下单回调)。 -UI:`lib/options/templates/options_panel.html` + `lib/common/static/options_panel.js`。 - -### 0.4 关键表(SQLite) - -| 表 | 用途 | -|----|------| -| `options_trades` | 本地开平流水;含 `trading_usdt_before/after`、`realized_pnl_usdt`、`pnl_settled` | -| `options_spot_bridge` | 买币/卖回桥状态 | -| `options_target_monitors` | 目标位监控 | -| `options_cond_orders` | 条件单 | -| `options_exchange_pnl_freeze` | 实盘历史盈亏冻结辅助 | -| `options_review_*` | 复盘成交 / 笔记 / 隐藏 / 图 | -| `options_transfer_log` 等 | 划转等资金侧日志 | - ---- - -## 1. 主链条(架构) - -### 1.1 模块分层 - -| 层 | 代表文件 | 说明 | -|----|----------|------| -| 路由装配 | `lib/options/options_register.py` | HTTP、尺寸归一、同步持仓 | -| 开仓编排 | `options_coin_open_lib.py` | 预算→买币→开期权→写库 | -| 平仓执行 | `options_close_exec_lib.py` | 买一/深度卖出 | -| 账户结算 | `options_account_pnl_lib.py` | USDT 快照、卖回后冻结 | -| 公式结算 | `options_pnl_settle_lib.py` | 权利金侧辅助结算(有账户快照时 U 以账户为准) | -| 交易所 | `lib/exchange/okx_options_lib.py` | 链、报价、下单、持仓 | -| 模式/预算 | `options_margin_mode_lib.py` | 币本位、预算、张数规划 | -| 展示 enrichment | `options_positions_lib.py` / `options_history_lib.py` | 浮盈、平仓预览 | -| Env UI | `lib/env/env_ui_manifest.py` | 期权相关默认与文案 | - -### 1.2 运行模式 - -- `OKX_TRADE_MODE=options`:主站偏期权。 -- `OKX_OPTIONS_ENABLED`:模块开关。 -- 模拟 / 实盘由顶栏 `trading.mode` + `LIVE_TRADING_ENABLED` 等控制;实盘要求现货+期权权限(见实盘说明)。 - -### 1.3 与盈亏规格的关系 - -规格文档定义**产品规则**;本分析描述**代码落点**。 -凡「已实现盈亏」对外展示(复盘 / 统计 / 顶栏已实现),在新规则下均以 `pnl_settled=1` 的账户差分为准;旧单无快照字段时走遗留口径,互不回写。 - ---- - -## 2. 开仓 - -### 2.1 入口 - -| 来源 | 路径 / 调用 | -|------|-------------| -| 面板 | `POST /api/options/open` | -| 条件单触发 | `options_cond_order_lib` → 同一套 `open_coin_option_buy_full` | - -前端默认尺寸模式:**按可用余额打满**(`budget_full`);复利开时 UI 切 `compound_full`。 -后端 `_normalize_size_mode`:币本位保留 `budget_full` / `compound_full`;USDC 遗留路径才落到 `sheets`。 - -### 2.2 尺寸模式 - -| mode | 含义 | -|------|------| -| `budget_full` | min(可用 USDT, 单笔预算) × 预算缓冲 → 张数(默认) | -| `compound_full` | 交易户可用 × 缓冲(受全仓上限 env 约束) | -| `sheets` | 指定张数 | -| `eth` / `eth_amount` | 指定币数量反推张数 | - -预算计算:`compute_coin_budget_usdt` / `plan_coin_open_by_budget`(`options_margin_mode_lib.py`)。 - -### 2.3 流程 - -```text -请求 {inst_id, mode, sheets?, eth?, target_index?, profit_exit_*} - → 交易模式门禁 / 条件单占用锁 / 持仓笔数上限 - → 有可卖标的币 → 拒开(须先卖回) - → coin_budget_preview → 规划张数与买币量 - → fetch_trading_usdt → 写入 trading_usdt_before - → 现货市价买币(× SPOT_BUY_BUFFER)→ options_spot_bridge=bought - → 期权限价 IOC @ 卖一 → 等待成交 - 失败 → 回滚卖币 - 成功 → bridge=holding,_insert_coin_trade(open, pnl_settled=0) - → 可选:目标位 / 止盈倍数 / 微信通知 -``` - -### 2.4 关键 env - -| 变量 | 默认(代码/清单) | 作用 | -|------|-------------------|------| -| `OKX_OPTIONS_COIN_COMPOUND` | true | 是否按交易户复利 | -| `OKX_OPTIONS_COIN_BUDGET_USDT` | (单笔预算) | 打满时的封顶 | -| `OKX_OPTIONS_BUDGET_BUFFER` | 1 | 预算缓冲 | -| `OKX_OPTIONS_COIN_SPOT_BUY_BUFFER` | 1.05 | 现货多买缓冲 | -| `OKX_OPTIONS_MAX_ACTIVE_POSITIONS` | 1 | 同时持仓笔数;0=不限 | -| `OKX_OPTIONS_OPEN_FILL_TIMEOUT_SEC` | — | 开仓等待成交超时 | - -### 2.5 边界 - -- 开仓**只快照 before**,不结算盈亏。 -- 持仓或桥未完成期间:`transfer_blocked_by_position_msg` 禁止划转。 -- 一次一仓是对账规格前提;上限默认 1。 - ---- - -## 3. 平仓 - -### 3.1 三条平仓通道 - -| 通道 | 触发 | 门控 | 执行 | -|------|------|------|------| -| 手动 / Hub | 用户或中控 | 一般只校验流动性 | `close_option_by_bid1(require_recycle_gate=False)` | -| 目标位 | 指数触达目标 | 回收门控(权利金倍数 / 净盈亏 + 持续秒数) | `close_option_by_bid_depth` | -| 止盈倍数 | 买一回收 ≥ 权利金×(1+mult) | 自有回收条件,不走 close_gate | `close_option_by_bid_profit_exit` | - -监控循环:`run_options_target_closes` / `run_options_profit_exits`。 - -### 3.2 流程 - -```text -卖期权(买一或深度,reduce-only) - → 部分成交:累计平仓张数 - → 全平: - settle_closed_pnl(币侧/费用;有账户快照时 U 不以公式定稿) - → maybe_sell_spot_after_close / sell_residual - → try_settle_after_sellback - after − before → realized_pnl_usdt,pnl_settled=1 -``` - -卖回失败:本单「待结算」,已实现不进统计;可 `POST /api/options/spot-bridge/retry-sell` 或监控扫尾。 - -### 3.3 回收门控 env - -| 变量 | 默认 | 作用 | -|------|------|------| -| `OKX_OPTIONS_CLOSE_GATE_MODE` | premium | `premium` / `net_pnl` | -| `OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN` | 1.05 | 币本位回收倍数 | -| `OKX_OPTIONS_CLOSE_HOLD_SECONDS` | 20 | 条件持续秒数 | -| `OKX_OPTIONS_CLOSE_NET_PNL_MIN_USDT` | 0 | net_pnl 模式下限 | - -### 3.4 边界 - -- 手动平仓可绕过回收门控;自动目标位不可。 -- 冻结后任何划转、指数、复盘同步**不得改写** `realized_pnl_usdt`。 - ---- - -## 4. 期权显示 - -### 4.1 页面与 API - -| UI | API | 数据源 | -|----|-----|--------| -| 期权链 / 到期 / 虚实度过滤 | `GET /api/options/chain` | `build_option_chain` | -| 下单弹窗报价与估张 | `GET /api/options/quote` | `quote_option_contract` + 币本位规划 | -| 持仓卡片 | `GET /api/options/positions` | 交易所持仓 + 本地 trade enrichment | -| 余额 / 顶栏 | `GET /api/options/balances` + header pack | 交易户 / 资金户 | -| 挂单 / 目标位 | `.../orders/pending`、`.../targets` | 本地 + 交易所 | -| 中控快照 | `GET /api/hub/options/snapshot` | `build_options_hub_snapshot` | - -前端主文件:`options_panel.js`、`options_position_cards.js`、`options_expiry_countdown.js`。 - -### 4.2 展示口径(勿与冻结混淆) - -| 指标 | 口径 | -|------|------| -| 持仓浮盈 | 买一回收估算(`display_pnl_*`),跟盘变 | -| 合计盈亏 | Σ已冻结 + 当前浮盈 | -| 顶栏钱包 | 交易所余额,不是盈亏账本 | -| 下单估权利金 / 杠杆 / 打满提示 | 报价瞬间估算,非成交结果 | - -### 4.3 链过滤 env - -`OKX_OPTIONS_CHAIN_MAX_DTE_DAYS`、`OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED`、`OKX_OPTIONS_ITM_MAX_DIST_USD`、`OKX_OPTIONS_DEFAULT_UNDERLY`。 - ---- - -## 5. 复盘 - -### 5.1 入口 - -- 页:`/options/review` -- 注册:`options_review_register.py` -- 逻辑:`options_review_lib.py` + `options_review_db.py` -- 前端:`options_review_panel.html`、`options_review.js` - -### 5.2 数据流 - -```text -options_trades(已平) - → sync_options_from_local_trades / ensure_local_review_synced - → options_review_trades(history_key ≈ local_opt:{id}) - → 笔记 options_review_entries + 上传图 / 异步图表 - → 隐藏 options_review_hidden -``` - -主要 API:`POST /api/options/review/sync`,`GET/DELETE .../trades`,`POST .../entry`,`POST .../upload_slot`,`GET .../stats`。 - -### 5.3 盈亏读取规则 - -| 本地单状态 | 复盘展示 | -|------------|----------| -| 有 `trading_usdt_before` 且未 `pnl_settled` | 已实现 U 待结算(不展示假冻结) | -| `pnl_settled=1` | 只读冻结 `realized_pnl_usdt` | -| 无账户快照的历史单 | 遗留口径,不强制改写成账户差分 | - -复盘是**消费者**,不负责改写结算真源。 - ---- - -## 6. 统计 - -### 6.1 入口 - -| 场景 | API / 函数 | -|------|------------| -| 期权面板 KPI | `GET /api/options/stats` → `compute_options_stats_auto` | -| 复盘页统计 | `GET /api/options/review/stats` | -| 顶栏窗口统计 | `header_options_stats_for_window` | -| 中控 | `GET /api/hub/options/stats` | - -核心库:`options_stats_lib.py`。 - -### 6.2 窗口与构成 - -- 窗口:`today` / `7d` / `30d` / `all`(`normalize_stats_window`)。 -- 模拟:本地已平且已结算样本。 -- 实盘:交易所历史 + `options_exchange_pnl_freeze` 对齐后同一套 KPI。 -- 面板合计:`净已实现(仅 settled) + 持仓浮盈`。 - -### 6.3 边界 - -- 待卖回、未冻结单**不计入**已实现。 -- 统计不重新推导账户差分;最多做冻结补齐 / 修复类运维函数(如 `freeze_missing_realized_pnl_usdt`),与规格「冻结后不改」一致的运维路径需谨慎使用。 - ---- - -## 7. 对账 - -此处「对账」= **交易账户快照结算 + 本地/交易所持仓对齐**,不是已删除的永续 `reconcile_flat`。 - -### 7.1 结算真源(账户账) - -| 步骤 | 函数 | 结果 | -|------|------|------| -| 开仓前 | `fetch_trading_usdt` | `trading_usdt_before` | -| 卖回后 | `try_settle_after_sellback` / `settle_closed_trades_from_trading_account` | `after`、`realized_pnl_usdt`、`pnl_settled=1` | -| 持仓禁划转 | `transfer_blocked_by_position_msg` | 前端灰 + API 拒 | - -公式身份(规格 §6 思路): - -```text -Σ 冻结已实现 + 当前浮盈 ≈ 面板合计盈亏 -Σ 冻结已实现 + 区间净划转 ≈ 钱包 USDT 变动(在一次一仓、无并行污染前提下) -``` - -### 7.2 持仓对齐(仓位账) - -监控 / `_sync_options_trades`: - -| 动作 | 含义 | -|------|------| -| `reconcile_live_open_trades` / `sync_open_options_trades` | 交易所仍有仓而本地已关 → 纠偏重开等 | -| `backfill_closed_options_realized_pnl_from_history` | 历史回填;**跳过已冻结 U** | -| `sell_spot_after_options_sync` / `sweep_spot_after_options_flat` | 平仓后残留币扫尾,推动账户结算 | - -### 7.3 双账清晰 - -| 账本 | 看见什么 | -|------|----------| -| 钱包(顶栏) | 交易所余额,随划转变 | -| 盈亏账 | 冻结历史 + 浮盈;划转不改已冻结单 | - ---- - -## 8. 条件单 - -### 8.1 定位 - -中控下发「价格带触发 → 自动开仓 → 挂目标/止盈 → 等平仓结束」的自动化;与面板互斥:有活跃条件单时**锁定手动开仓**(`cond_order_blocks_manual_open_msg`)。 - -### 8.2 入口 - -| API | 作用 | -|-----|------| -| `GET/POST /api/hub/options/cond_orders` | 列表 / 创建 | -| `POST ...//cancel` | 取消 | -| `POST .../exit` | 更新出场参数 | - -库:`options_cond_order_lib.py`。 -Tick:监控循环 `run_cond_orders_tick` → `_tick_one`。 - -### 8.3 生命周期 - -```text -Hub 创建(trigger_px, deviation_pct, 认购/认沽, 出场) - → options_cond_orders = armed - → 指数落入 band_bounds - → pick_nearest_itm_contract - → 走 open_coin_option_buy_full(同人工开仓:before 快照、买币、IOC) - → 挂目标位 / 止盈 - → 等待仓位平坦 → _end_order - → 周四 23:00 等策略可 expire 未触发单 -``` - -### 8.4 与主链关系 - -- **不另建盈亏账本**;触发开仓后的卖回冻结与人工单相同。 -- 占用期间面板开仓按钮应不可用,避免双开破坏「一次一仓」。 - ---- - -## 9. 端到端对照表 - -| 阶段 | 写库 / 状态 | 用户可见 | 消费方 | -|------|-------------|----------|--------| -| 开仓成功 | trade open + before + bridge holding | 持仓卡、浮盈 | 监控、禁划转 | -| 期权已平、币未卖回 | 待结算 | 提示待卖回 | 统计不含已实现 | -| 卖回成功 | after、realized、settled=1 | 复盘/统计定格 | 对账完成 | -| 条件单 armed | cond_orders | 横幅锁定手动开仓 | 监控 tick | -| 目标位/止盈触发 | 平仓执行 | 持仓消失→卖回→冻结 | 同平仓链 | - ---- - -## 10. 关键文件索引 - -| 主题 | 路径 | -|------|------| -| 路由 / 开平仓 API | `lib/options/options_register.py` | -| 币本位开仓 | `lib/options/options_coin_open_lib.py` | -| 现货桥 | `lib/options/options_spot_bridge_lib.py` | -| 平仓执行 | `lib/options/options_close_exec_lib.py` | -| 回收门控 | `lib/options/options_close_gate_lib.py` | -| 目标位 | `lib/options/options_target_lib.py` | -| 止盈 | `lib/options/options_profit_exit_lib.py` | -| 账户盈亏 | `lib/options/options_account_pnl_lib.py` | -| 统计 | `lib/options/options_stats_lib.py` | -| 复盘 | `lib/options/options_review_*.py` | -| 条件单 | `lib/options/options_cond_order_lib.py` | -| 监控 | `lib/options/options_monitor_lib.py` | -| Hub 快照 | `lib/options/options_hub_lib.py` | -| Hub 路由 | `lib/hub/hub_bridge_register.py` | -| Schema | `lib/options/options_db.py` | -| 面板 | `lib/options/templates/options_panel.html`、`lib/common/static/options_panel.js` | - ---- - -## 11. 维护时注意 - -1. **改盈亏口径**:先改规格文档,再改 `options_account_pnl_lib` 与复盘/统计读取,避免三处漂移。 -2. **改开仓默认尺寸**:同时看前端 `currentSizeMode`、HTML `checked`、后端 `_normalize_size_mode`(币本位勿再压成 `sheets`)。 -3. **改门控秒数/缓冲**:代码默认、`env_ui_manifest` 文案与 `_RUNTIME_ENV_DEFAULTS` 一并对齐;已有 `.env` 旧值不会自动覆盖。 -4. **条件单与手动开仓**:任何新开仓入口都要接 `cond_order_blocks_manual_open_msg`(或等价锁)。 -5. **对账前提**:一次一仓 + 持仓禁划转 + 卖回后冻结;破坏任一前提,钱包差分将无法与单笔盈亏对齐。 +# 系统分析:期权主链条 + +> 范围:币本位期权(`OKX_OPTIONS_MARGIN_MODE=coin`) +> 配套规格:[规格-期权交易账户盈亏结算.md](./规格-期权交易账户盈亏结算.md)、[实盘说明.md](./实盘说明.md) +> 目的:从架构到开仓 / 平仓 / 展示 / 复盘 / 统计 / 对账 / 条件单,说明数据流、关键模块与边界 + +--- + +## 0. 总览 + +### 0.1 三条控制面 + +| 控制面 | 入口 | 职责 | +|--------|------|------| +| 面板 API | `/options` → `/api/options/*` | 人工开平仓、链/报价、持仓、划转、统计 | +| 监控线程 | `options_monitor_lib`(安装时拉起) | 目标位平仓、止盈平仓、条件单、持仓同步、卖回扫尾 | +| 中控 Hub | `/api/hub/options/*` | 快照、统计、远程平仓、条件单 CRUD | + +### 0.2 资金主链(双口径) + +```text +USDT(交易户) + │ 开仓前快照 trading_usdt_before;initial_premium = 开仓指数×权利金币量 + ▼ +买 ETH/BTC(现货桥) + ▼ +买期权(IOC @ 卖一) + ▼ +持仓中(禁划转;浮盈跟盘) + ▼ +卖期权(可多次部分平;以全部平完为准) + ▼ +卖光可卖标的币(禁止按开仓权利金封顶)→ 快照 trading_usdt_after + ▼ +口径 A(复盘): realized_pnl_usdt = after − before,pnl_settled=1 +口径 B(数据统计): 历史币盈亏 × 平仓卖回价(与 A 独立) +``` + +### 0.3 启动装配 + +```text +app.py + → install_options_trading # options_register + → install_options_review # options_review_register + → register_hub_bridge_routes # hub_bridge_register + → 监控守护线程 # options_monitor_lib +``` + +核心配置包:`_build_cfg`(交易所、DB、预算、报价/下单回调)。 +UI:`lib/options/templates/options_panel.html` + `lib/common/static/options_panel.js`。 + +### 0.4 关键表(SQLite) + +| 表 | 用途 | +|----|------| +| `options_trades` | 本地开平流水;含 `trading_usdt_before/after`、`realized_pnl_usdt`、`pnl_settled` | +| `options_spot_bridge` | 买币/卖回桥状态 | +| `options_target_monitors` | 目标位监控 | +| `options_cond_orders` | 条件单 | +| `options_exchange_pnl_freeze` | 实盘历史盈亏冻结辅助 | +| `options_review_*` | 复盘成交 / 笔记 / 隐藏 / 图 | +| `options_transfer_log` 等 | 划转等资金侧日志 | + +--- + +## 1. 主链条(架构) + +### 1.1 模块分层 + +| 层 | 代表文件 | 说明 | +|----|----------|------| +| 路由装配 | `lib/options/options_register.py` | HTTP、尺寸归一、同步持仓 | +| 开仓编排 | `options_coin_open_lib.py` | 预算→买币→开期权→写库 | +| 平仓执行 | `options_close_exec_lib.py` | 买一/深度卖出 | +| 账户结算 | `options_account_pnl_lib.py` | USDT 快照、卖回后冻结 | +| 公式结算 | `options_pnl_settle_lib.py` | 权利金侧辅助结算(有账户快照时 U 以账户为准) | +| 交易所 | `lib/exchange/okx_options_lib.py` | 链、报价、下单、持仓 | +| 模式/预算 | `options_margin_mode_lib.py` | 币本位、预算、张数规划 | +| 展示 enrichment | `options_positions_lib.py` / `options_history_lib.py` | 浮盈、平仓预览 | +| Env UI | `lib/env/env_ui_manifest.py` | 期权相关默认与文案 | + +### 1.2 运行模式 + +- `OKX_TRADE_MODE=options`:主站偏期权。 +- `OKX_OPTIONS_ENABLED`:模块开关。 +- 模拟 / 实盘由顶栏 `trading.mode` + `LIVE_TRADING_ENABLED` 等控制;实盘要求现货+期权权限(见实盘说明)。 + +### 1.3 与盈亏规格的关系 + +规格文档定义**产品规则**;本分析描述**代码落点**。 +凡「已实现盈亏」对外展示(复盘 / 统计 / 顶栏已实现),在新规则下均以 `pnl_settled=1` 的账户差分为准;旧单无快照字段时走遗留口径,互不回写。 + +--- + +## 2. 开仓 + +### 2.1 入口 + +| 来源 | 路径 / 调用 | +|------|-------------| +| 面板 | `POST /api/options/open` | +| 条件单触发 | `options_cond_order_lib` → 同一套 `open_coin_option_buy_full` | + +前端默认尺寸模式:**按可用余额打满**(`budget_full`);复利开时 UI 切 `compound_full`。 +后端 `_normalize_size_mode`:币本位保留 `budget_full` / `compound_full`;USDC 遗留路径才落到 `sheets`。 + +### 2.2 尺寸模式 + +| mode | 含义 | +|------|------| +| `budget_full` | min(可用 USDT, 单笔预算) × 预算缓冲 → 张数(默认) | +| `compound_full` | 交易户可用 × 缓冲(受全仓上限 env 约束) | +| `sheets` | 指定张数 | +| `eth` / `eth_amount` | 指定币数量反推张数 | + +预算计算:`compute_coin_budget_usdt` / `plan_coin_open_by_budget`(`options_margin_mode_lib.py`)。 + +### 2.3 流程 + +```text +请求 {inst_id, mode, sheets?, eth?, target_index?, profit_exit_*} + → 交易模式门禁 / 条件单占用锁 / 持仓笔数上限 + → 有可卖标的币 → 拒开(须先卖回) + → coin_budget_preview → 规划张数与买币量 + → fetch_trading_usdt → 写入 trading_usdt_before + → 现货市价买币(× SPOT_BUY_BUFFER)→ options_spot_bridge=bought + → 期权限价 IOC @ 卖一 → 等待成交 + 失败 → 回滚卖币 + 成功 → bridge=holding,_insert_coin_trade(open, pnl_settled=0) + → 可选:目标位 / 止盈倍数 / 微信通知 +``` + +### 2.4 关键 env + +| 变量 | 默认(代码/清单) | 作用 | +|------|-------------------|------| +| `OKX_OPTIONS_COIN_COMPOUND` | true | 是否按交易户复利 | +| `OKX_OPTIONS_COIN_BUDGET_USDT` | (单笔预算) | 打满时的封顶 | +| `OKX_OPTIONS_BUDGET_BUFFER` | 1 | 预算缓冲 | +| `OKX_OPTIONS_COIN_SPOT_BUY_BUFFER` | 1.05 | 现货多买缓冲 | +| `OKX_OPTIONS_MAX_ACTIVE_POSITIONS` | 1 | 同时持仓笔数;0=不限 | +| `OKX_OPTIONS_OPEN_FILL_TIMEOUT_SEC` | — | 开仓等待成交超时 | + +### 2.5 边界 + +- 开仓**只快照 before**,不结算盈亏。 +- 持仓或桥未完成期间:`transfer_blocked_by_position_msg` 禁止划转。 +- 一次一仓是对账规格前提;上限默认 1。 + +--- + +## 3. 平仓 + +### 3.1 三条平仓通道 + +| 通道 | 触发 | 门控 | 执行 | +|------|------|------|------| +| 手动 / Hub | 用户或中控 | 一般只校验流动性 | `close_option_by_bid1(require_recycle_gate=False)` | +| 目标位 | 指数触达目标 | 回收门控(权利金倍数 / 净盈亏 + 持续秒数) | `close_option_by_bid_depth` | +| 止盈倍数 | 买一回收 ≥ 权利金×(1+mult) | 自有回收条件,不走 close_gate | `close_option_by_bid_profit_exit` | + +监控循环:`run_options_target_closes` / `run_options_profit_exits`。 + +### 3.2 流程 + +```text +卖期权(买一或深度,reduce-only) + → 部分成交:累计平仓张数 + → 全平: + settle_closed_pnl(币侧/费用;有账户快照时 U 不以公式定稿) + → maybe_sell_spot_after_close / sell_residual + → try_settle_after_sellback + after − before → realized_pnl_usdt,pnl_settled=1 +``` + +卖回失败:本单「待结算」,已实现不进统计;可 `POST /api/options/spot-bridge/retry-sell` 或监控扫尾。 + +### 3.3 回收门控 env + +| 变量 | 默认 | 作用 | +|------|------|------| +| `OKX_OPTIONS_CLOSE_GATE_MODE` | premium | `premium` / `net_pnl` | +| `OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN` | 1.05 | 币本位回收倍数 | +| `OKX_OPTIONS_CLOSE_HOLD_SECONDS` | 20 | 条件持续秒数 | +| `OKX_OPTIONS_CLOSE_NET_PNL_MIN_USDT` | 0 | net_pnl 模式下限 | + +### 3.4 边界 + +- 手动平仓可绕过回收门控;自动目标位不可。 +- 冻结后任何划转、指数、复盘同步**不得改写** `realized_pnl_usdt`。 + +--- + +## 4. 期权显示 + +### 4.1 页面与 API + +| UI | API | 数据源 | +|----|-----|--------| +| 期权链 / 到期 / 虚实度过滤 | `GET /api/options/chain` | `build_option_chain` | +| 下单弹窗报价与估张 | `GET /api/options/quote` | `quote_option_contract` + 币本位规划 | +| 持仓卡片 | `GET /api/options/positions` | 交易所持仓 + 本地 trade enrichment | +| 余额 / 顶栏 | `GET /api/options/balances` + header pack | 交易户 / 资金户 | +| 挂单 / 目标位 | `.../orders/pending`、`.../targets` | 本地 + 交易所 | +| 中控快照 | `GET /api/hub/options/snapshot` | `build_options_hub_snapshot` | + +前端主文件:`options_panel.js`、`options_position_cards.js`、`options_expiry_countdown.js`。 + +### 4.2 展示口径(勿与冻结混淆) + +| 指标 | 口径 | +|------|------| +| 持仓浮盈 | 买一回收估算(`display_pnl_*`),跟盘变 | +| 合计盈亏 | Σ已冻结 + 当前浮盈 | +| 顶栏钱包 | 交易所余额,不是盈亏账本 | +| 下单估权利金 / 杠杆 / 打满提示 | 报价瞬间估算,非成交结果 | + +### 4.3 链过滤 env + +`OKX_OPTIONS_CHAIN_MAX_DTE_DAYS`、`OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED`、`OKX_OPTIONS_ITM_MAX_DIST_USD`、`OKX_OPTIONS_DEFAULT_UNDERLY`。 + +--- + +## 5. 复盘 + +### 5.1 入口 + +- 页:`/options/review` +- 注册:`options_review_register.py` +- 逻辑:`options_review_lib.py` + `options_review_db.py` +- 前端:`options_review_panel.html`、`options_review.js` + +### 5.2 数据流 + +```text +options_trades(已平) + → sync_options_from_local_trades / ensure_local_review_synced + → options_review_trades(history_key ≈ local_opt:{id}) + → 笔记 options_review_entries + 上传图 / 异步图表 + → 隐藏 options_review_hidden +``` + +主要 API:`POST /api/options/review/sync`,`GET/DELETE .../trades`,`POST .../entry`,`POST .../upload_slot`,`GET .../stats`。 + +### 5.3 盈亏读取规则 + +| 本地单状态 | 复盘展示 | +|------------|----------| +| 有 `trading_usdt_before` 且未 `pnl_settled` | 已实现 U 待结算(不展示假冻结) | +| `pnl_settled=1` | 只读冻结 `realized_pnl_usdt` | +| 无账户快照的历史单 | 遗留口径,不强制改写成账户差分 | + +复盘是**消费者**,不负责改写结算真源。 + +--- + +## 6. 统计 + +### 6.1 入口 + +| 场景 | API / 函数 | +|------|------------| +| 期权面板 KPI | `GET /api/options/stats` → `compute_options_stats_auto` | +| 复盘页统计 | `GET /api/options/review/stats` | +| 顶栏窗口统计 | `header_options_stats_for_window` | +| 中控 | `GET /api/hub/options/stats` | + +核心库:`options_stats_lib.py`。 + +### 6.2 窗口与构成 + +- 窗口:`today` / `7d` / `30d` / `all`(`normalize_stats_window`)。 +- 模拟:本地已平且已结算样本。 +- 实盘:交易所历史 + `options_exchange_pnl_freeze` 对齐后同一套 KPI。 +- 面板合计:`净已实现(仅 settled) + 持仓浮盈`。 + +### 6.3 边界 + +- 待卖回、未冻结单**不计入**已实现。 +- 统计不重新推导账户差分;最多做冻结补齐 / 修复类运维函数(如 `freeze_missing_realized_pnl_usdt`),与规格「冻结后不改」一致的运维路径需谨慎使用。 + +--- + +## 7. 对账 + +此处「对账」= **交易账户快照结算 + 本地/交易所持仓对齐**,不是已删除的永续 `reconcile_flat`。 + +### 7.1 结算真源(账户账) + +| 步骤 | 函数 | 结果 | +|------|------|------| +| 开仓前 | `fetch_trading_usdt` | `trading_usdt_before` | +| 卖回后 | `try_settle_after_sellback` / `settle_closed_trades_from_trading_account` | `after`、`realized_pnl_usdt`、`pnl_settled=1` | +| 持仓禁划转 | `transfer_blocked_by_position_msg` | 前端灰 + API 拒 | + +公式身份(规格 §6 思路): + +```text +Σ 冻结已实现 + 当前浮盈 ≈ 面板合计盈亏 +Σ 冻结已实现 + 区间净划转 ≈ 钱包 USDT 变动(在一次一仓、无并行污染前提下) +``` + +### 7.2 持仓对齐(仓位账) + +监控 / `_sync_options_trades`: + +| 动作 | 含义 | +|------|------| +| `reconcile_live_open_trades` / `sync_open_options_trades` | 交易所仍有仓而本地已关 → 纠偏重开等 | +| `backfill_closed_options_realized_pnl_from_history` | 历史回填;**跳过已冻结 U** | +| `sell_spot_after_options_sync` / `sweep_spot_after_options_flat` | 平仓后残留币扫尾,推动账户结算 | + +### 7.3 双账清晰 + +| 账本 | 看见什么 | +|------|----------| +| 钱包(顶栏) | 交易所余额,随划转变 | +| 盈亏账 | 冻结历史 + 浮盈;划转不改已冻结单 | + +--- + +## 8. 条件单 + +### 8.1 定位 + +中控下发「价格带触发 → 自动开仓 → 挂目标/止盈 → 等平仓结束」的自动化;与面板互斥:有活跃条件单时**锁定手动开仓**(`cond_order_blocks_manual_open_msg`)。 + +### 8.2 入口 + +| API | 作用 | +|-----|------| +| `GET/POST /api/hub/options/cond_orders` | 列表 / 创建 | +| `POST ...//cancel` | 取消 | +| `POST .../exit` | 更新出场参数 | + +库:`options_cond_order_lib.py`。 +Tick:监控循环 `run_cond_orders_tick` → `_tick_one`。 + +### 8.3 生命周期 + +```text +Hub 创建(trigger_px, deviation_pct, 认购/认沽, 出场) + → options_cond_orders = armed + → 指数落入 band_bounds + → pick_nearest_itm_contract + → 走 open_coin_option_buy_full(同人工开仓:before 快照、买币、IOC) + → 挂目标位 / 止盈 + → 等待仓位平坦 → _end_order + → 周四 23:00 等策略可 expire 未触发单 +``` + +### 8.4 与主链关系 + +- **不另建盈亏账本**;触发开仓后的卖回冻结与人工单相同。 +- 占用期间面板开仓按钮应不可用,避免双开破坏「一次一仓」。 + +--- + +## 9. 端到端对照表 + +| 阶段 | 写库 / 状态 | 用户可见 | 消费方 | +|------|-------------|----------|--------| +| 开仓成功 | trade open + before + bridge holding | 持仓卡、浮盈 | 监控、禁划转 | +| 期权已平、币未卖回 | 待结算 | 提示待卖回 | 统计不含已实现 | +| 卖回成功 | after、realized、settled=1 | 复盘/统计定格 | 对账完成 | +| 条件单 armed | cond_orders | 横幅锁定手动开仓 | 监控 tick | +| 目标位/止盈触发 | 平仓执行 | 持仓消失→卖回→冻结 | 同平仓链 | + +--- + +## 10. 关键文件索引 + +| 主题 | 路径 | +|------|------| +| 路由 / 开平仓 API | `lib/options/options_register.py` | +| 币本位开仓 | `lib/options/options_coin_open_lib.py` | +| 现货桥 | `lib/options/options_spot_bridge_lib.py` | +| 平仓执行 | `lib/options/options_close_exec_lib.py` | +| 回收门控 | `lib/options/options_close_gate_lib.py` | +| 目标位 | `lib/options/options_target_lib.py` | +| 止盈 | `lib/options/options_profit_exit_lib.py` | +| 账户盈亏 | `lib/options/options_account_pnl_lib.py` | +| 统计 | `lib/options/options_stats_lib.py` | +| 复盘 | `lib/options/options_review_*.py` | +| 条件单 | `lib/options/options_cond_order_lib.py` | +| 监控 | `lib/options/options_monitor_lib.py` | +| Hub 快照 | `lib/options/options_hub_lib.py` | +| Hub 路由 | `lib/hub/hub_bridge_register.py` | +| Schema | `lib/options/options_db.py` | +| 面板 | `lib/options/templates/options_panel.html`、`lib/common/static/options_panel.js` | + +--- + +## 11. 维护时注意 + +1. **改盈亏口径**:先改规格文档,再改 `options_account_pnl_lib` 与复盘/统计读取,避免三处漂移。 +2. **改开仓默认尺寸**:同时看前端 `currentSizeMode`、HTML `checked`、后端 `_normalize_size_mode`(币本位勿再压成 `sheets`)。 +3. **改门控秒数/缓冲**:代码默认、`env_ui_manifest` 文案与 `_RUNTIME_ENV_DEFAULTS` 一并对齐;已有 `.env` 旧值不会自动覆盖。 +4. **条件单与手动开仓**:任何新开仓入口都要接 `cond_order_blocks_manual_open_msg`(或等价锁)。 +5. **对账前提**:一次一仓 + 持仓禁划转 + 卖回后冻结;破坏任一前提,钱包差分将无法与单笔盈亏对齐。 diff --git a/docs/规格-期权交易账户盈亏结算.md b/docs/规格-期权交易账户盈亏结算.md index 3262252..43e7661 100644 --- a/docs/规格-期权交易账户盈亏结算.md +++ b/docs/规格-期权交易账户盈亏结算.md @@ -1,206 +1,263 @@ -# 规格:期权盈亏按交易账户结算(一次一仓) - -> 状态:已定稿,按本文实现 -> 适用范围:币本位期权(模拟盘优先落地;实盘同规则) -> 目标:复盘盈亏、数据统计盈亏与交易账户真实 USDT 变动一致,可对账、可冻结、可商用 - ---- - -## 1. 目标与原则 - -### 1.1 要解决什么 - -此前单笔盈亏混用多套口径(权利金×指数、卖回−买币预算、实时指数重算),导致: - -- 持仓「合计盈亏」与复盘单笔不一致 -- 数字随行情跳动 -- 后期账本无法审计 - -### 1.2 原则 - -| 原则 | 说明 | -|------|------| -| 真源 | **交易账户 USDT 快照差分** 为本单已实现盈亏真源 | -| 一次一仓 | 同一账户同一时间只允许一笔期权持仓 | -| 卖回后结算 | 未卖回标的币前,本单不进入已实现 | -| 冻结 | 结算完成后永不修改 | -| 禁划转 | 持仓期间禁止资金划转(前端灰 + 后端拒) | -| 双账清晰 | 顶栏余额 = 钱包;合计/复盘盈亏 = 冻结历史 +(持仓时)浮盈 | - ---- - -## 2. 产品规则(用户可见) - -### 2.1 开仓前 - -1. **有可卖标的币(ETH/BTC)→ 拒开仓** - - 提示:先卖回 USDT(可走自动扫尾/卖回)。 - - 粉尘:低于现货最小可卖量可忽略;达到可卖量一律视为有币并拒开。 -2. **交易账户 USDT 快照** `trading_usdt_before` - - 取开仓前(买币前)交易账户 USDT。 - - 开仓前若有过多次划转:以**开仓前最后一次划转完成后**的余额为准(即开仓那一刻的余额)。 -3. 通过检查后才允许买币并开期权。 - -### 2.2 持仓中 - -1. **划转按钮灰色不可点**;API 同步拒绝。 -2. **合计盈亏(动态)** = - `Σ已冻结历史盈亏` + `当前持仓浮盈` -3. 本单未卖回结算前:不计入「已冻结历史」;浮盈跟盘更新。 - -### 2.3 平仓与卖回 - -1. 期权平仓后触发**自动卖回**残留标的币。 -2. **卖回未完成**:本单状态为「待结算」,已实现盈亏不写死。 -3. **卖回成功后**:再拍交易账户 USDT 快照 `trading_usdt_after`。 - -### 2.4 结算与冻结 - -``` -本单已实现盈亏(U) = trading_usdt_after − trading_usdt_before -``` - -1. 写入本单并标记已冻结。 -2. **此后不得因划转、指数变动、复盘同步而修改。** -3. 期权复盘、数据统计的已实现盈亏均读此冻结值。 - -### 2.5 划转与资金 - -| 时机 | 行为 | -|------|------| -| 无持仓、无待卖回 | 允许划转;顶栏资金立即刷新 | -| 持仓中或待卖回 | 禁止划转 | -| 划转后 | 只影响下一笔开仓前的 `usdt_before`;**不重算**已冻结历史单 | - ---- - -## 3. 生命周期 - -```text -[空仓, 无残留币] - │ - ├─ 有可卖币? ──是──> 拒开仓「请先卖回」 - │ - ├─ 快照 trading_usdt_before - ├─ 买币 → 开期权 - │ -[持仓中] 划转禁用;合计 = 冻结历史 + 浮盈 - │ - ├─ 平仓 - │ -[待卖回] 已实现未结算;划转仍禁用 - │ - ├─ 自动/重试卖回成功 - ├─ 快照 trading_usdt_after - ├─ realized = after − before → 冻结 - │ -[已结算] 复盘/统计只读冻结值 -``` - ---- - -## 4. 数据字段(实现约定) - -在 `options_trades` 增加(或等价持久化): - -| 字段 | 含义 | -|------|------| -| `trading_usdt_before` | 开仓前交易账户 USDT | -| `trading_usdt_after` | 卖回完成后交易账户 USDT | -| `realized_pnl_usdt` | 冻结已实现 = after − before(结算后写入) | -| `pnl_settled` | 0/1,是否已按账户差分结算冻结 | - -说明: - -- `budget_usdt` 仍表示本笔买币花费,**不得**当作开仓前账户快照。 -- 币数量 `realized_pnl` 可保留供期权历史展示(币),与 U 冻结分离。 -- 统计/复盘的 **U 盈亏** 只认 `pnl_settled=1` 时的 `realized_pnl_usdt`。 - ---- - -## 5. 模块行为 - -### 5.1 开仓 `open_coin_option_buy_full` - -1. 检查一次一仓 / 未完成桥。 -2. `fetch_trading_coin_available`:可卖则拒开。 -3. 读取交易账户 USDT → `trading_usdt_before`。 -4. 买币、下单、写库(带 before,`pnl_settled=0`)。 - -### 5.2 划转 `POST /api/options/transfer` - -1. 若存在 `options_trades.status='open'` 或桥状态 ∈ {bought, holding, pending_sell} → **403/业务错误**。 -2. 前端:持仓或待卖回时划转按钮 `disabled` + 提示「持仓中不可划转」。 - -### 5.3 卖回结算 - -在 `sell_residual_after_option_flat`(及 retry-sell)桥关闭成功后: - -1. 读取交易账户 USDT → `trading_usdt_after`。 -2. 对本桥关联且未结算的已平仓位: - `realized_pnl_usdt = after - before`,`pnl_settled=1`。 -3. **停止**用「权利金×指数」或「卖回−预算」覆盖已结算单。 - -粉尘关闭桥(无法卖出): -- 仍拍 `usdt_after` 并结算(差额含粉尘损失);或 -- 记 `pnl_settled` 并附 message「粉尘忽略」——实现取:拍售后余额并冻结,保证可对账。 - -### 5.4 数据统计 - -- `net_realized_pnl` / 已平相关:仅 `pnl_settled=1` 的冻结值之和。 -- `total_pnl`(持仓页合计)= `net_realized_pnl` + `open_float_pnl`。 -- 待结算已平单:不计入已实现,也不重复计入浮盈(浮盈仅当前期权持仓)。 -- **顶栏三项**(总交易 / 胜率 / 盈亏比):与上同一过滤口径(`header_options_stats_for_window`)。 - -### 5.5 期权复盘 - -- 同步本地已平:优先读 `realized_pnl_usdt`(且已结算)。 -- 未结算:复盘显示「待结算」或暂不导入已实现 U。 -- 禁止再用实时指数改写已冻结 U。 - -### 5.6 期权历史列表 - -- **仍按币**展示权利金/币盈亏(不强制改成 U)。 -- U 口径只用于复盘与数据统计。 - ---- - -## 6. 对账关系(商用) - -``` -Σ 已冻结单笔盈亏 + 当前浮盈 ≈ 合计盈亏展示 -Σ 已冻结单笔盈亏 + 期间净划转 ≈ 交易账户相对「首笔 before」的累计变化 -``` - -差异来源应可归因:粉尘、手续费计入方式、卖回滑点(已含在 after−before 内)。 -禁止用展示公式回写钱包余额。 - ---- - -## 7. 非目标 / 不做 - -- 不用买币预算 `budget_usdt` 当单笔盈利。 -- 不用「净币 × 单一指数」覆盖已结算账户差分。 -- 持仓中不因行情重算已冻结历史。 -- 不把期权历史列表强制改为 U 展示。 - ---- - -## 8. 验收清单 - -- [ ] 开仓前有可卖 BTC/ETH → 拒开并提示卖回 -- [ ] 开仓写入 `trading_usdt_before` -- [ ] 持仓中划转按钮灰 + API 拒绝 -- [ ] 平仓未卖回前,统计已实现不含本单 -- [ ] 卖回后 `realized = after − before` 并 `pnl_settled=1` -- [ ] 再次同步复盘/统计,冻结值不变 -- [ ] 合计盈亏 = 冻结历史 + 浮盈(有持仓时动态) -- [ ] 空仓划转后顶栏资金更新,历史冻结单不变 - ---- - -## 9. 修订记录 - -| 日期 | 说明 | -|------|------| -| 2026-10-09 | 初版定稿:交易账户快照差分 + 一次一仓 + 持仓禁划转 + 卖回后冻结 | +# 规格:期权盈亏双口径结算(一次一仓) + +> 状态:已定稿(2026-10-13 修订) +> 适用范围:币本位期权(模拟盘优先;实盘同规则) +> 目标:复盘可对账、数据统计跟期权历史一致;初始权利金 / 盈利率可展示;错账可脚本修复 + +--- + +## 1. 目标与原则 + +### 1.1 要解决什么 + +此前单笔盈亏混用多套口径,且卖回按开仓权利金封顶,导致: + +- 盈利单(如约 1.5 倍)在复盘被冻成亏损(账户差分在盈利币未卖回时结算) +- 复盘与数据统计共用账户冻结,与「历史币盈亏 × 卖回价」不一致 +- 缺少初始权利金(U)、盈利率展示 + +### 1.2 原则 + +| 原则 | 说明 | +|------|------| +| 双口径 | **A 复盘** = 交易账户 USDT 快照差分;**B 数据统计** = 期权历史币盈亏 × 平仓卖回价 | +| 一次一仓 | 同一账户同一时间只允许一笔期权持仓 | +| 卖光再结算 | A 口径:未卖光可卖标的币前,本单不冻结 | +| 冻结 | A 结算完成后永不修改;B 按平仓卖回价冻结后不跟盘 | +| 禁划转 | 持仓 / 待卖回期间禁止资金划转 | +| 权利金不含多买 | 初始权利金 = 开仓指数 × 权利金币量(不含现货缓冲多买) | + +--- + +## 2. 双口径定义 + +### 2.1 口径 A — 期权复盘(交易账户) + +``` +本单已实现盈亏(U) = trading_usdt_after − trading_usdt_before +``` + +- **消费者**:期权复盘「交易记录」「复盘记录」、复盘 KPI +- **刻意含多买缓冲 / 滑点 / 手续费**:便于交易账户对账 +- **门禁**:全部期权仓位已平 **且** 标的币无可卖量(粉尘可忽略)后才拍 `after` 并冻结 + +### 2.2 口径 B — 期权数据统计(历史 × 卖回价) + +``` +统计已实现盈亏(U) = 期权历史.realized_pnl(币) × 平仓卖回价 +``` + +- **平仓卖回价**:优先卖回成交均价 / 冻结的 `close_index_px`;缺失时再用当时指数一次性冻结 +- **消费者**:期权面板「数据统计」合计/胜率/盈亏比、数据统计页 KPI、顶栏期权窗口统计 +- **不含**账户多买缓冲差分;与复盘 A **独立**,禁止混用 + +### 2.3 初始权利金与盈利率 + +| 字段 | 定义 | +|------|------| +| 初始权利金(U) | `open_index_px × premium_paid`(`premium_paid` 为开仓权利金币量;**不含** `budget_usdt` 多买) | +| 盈利率(复盘) | `realized_pnl_usdt(A) / initial_premium_usdt` | +| 盈利率(统计) | `统计盈亏(B) / initial_premium_usdt`(展示层可算,不强制落库) | + +复盘列表与复盘详情须展示:初始权利金(U)、盈利率。 + +--- + +## 3. 产品规则 + +### 3.1 开仓前 + +1. 有可卖标的币(ETH/BTC)→ 拒开仓(粉尘低于现货最小可卖可忽略)。 +2. 拍交易账户 USDT → `trading_usdt_before`。 +3. 开仓前若有多次划转:以**开仓前最后一次划转完成后**的余额为准(即开仓瞬间余额)。 +4. 开仓失败:回滚卖币,并拍**最新**资金快照(不留下脏 before / 半成品桥阻挡后续)。 +5. 写入 `initial_premium_usdt = open_index_px × premium_paid`(开仓成功后)。 + +### 3.2 持仓中 + +1. 划转按钮灰 + API 拒绝。 +2. 面板合计盈亏(动态)= `Σ口径B已冻结` + `当前持仓浮盈`。 +3. 本单未完成 A 结算前:复盘显示「待结算」,不写假 U。 + +### 3.3 平仓与卖回 + +1. 系统可多次部分平仓;**以最后一次全部平仓完成**为准进入卖回。 +2. 期权空仓后:**卖光**交易账户该标的可卖币(**禁止**用开仓权利金 `coin_bought` 封顶)。 +3. `coin_bought` 仅用于开仓阶段清理过量买币,不作平仓卖回上限。 +4. 卖回未完成(仍有可卖币):桥保持待卖回,**不**进入 A 结算。 +5. 真粉尘(低于现货最小):可关桥并进入 A 结算(差额含粉尘损失)。 + +### 3.4 结算与冻结(A) + +1. `realized_pnl_usdt = after − before`,`pnl_settled=1`。 +2. 此后不得因划转、指数、复盘同步、监控公式而修改。 +3. 仅复盘读此冻结值。 + +### 3.5 划转 + +| 时机 | 行为 | +|------|------| +| 无持仓、无待卖回 | 允许划转 | +| 持仓中或待卖回 | 禁止划转 | +| 划转后 | 只影响下一笔开仓前的 `usdt_before`;不重算已冻结 A/B | + +--- + +## 4. 状态机 + +```text +IDLE + │ 有可卖币? ──是──> 拒开仓 + │ 快照 trading_usdt_before(划转取最后一次后余额) + ▼ +OPENING ──失败──> 回滚卖币 → 最新资金快照 → IDLE + │ 成功(一次一仓);写 initial_premium_usdt + ▼ +OPEN(禁划转) + │ 可多次部分平仓 + ▼ +CLOSING(交易所仓位未清零则停留) + │ 仓位 = 0 + ▼ +SELLING_SPOT(卖光可卖标的币;禁止权利金封顶) + │ 可卖币 < minSz(真粉尘)或已空 + ▼ +SETTLE_A(after − before → 冻结;仅此态可写 pnl_settled=1) + ▼ +IDLE +``` + +**硬门禁** + +| 条件 | 行为 | +|------|------| +| OPEN / CLOSING / SELLING_SPOT | 禁止划转 | +| 未进 SETTLE_A | 复盘 U 为待结算 | +| pnl_settled=1 | 禁止覆盖 A | +| SETTLE_A | 要求 open_positions=0 且无可卖标的币 | + +实现上可为显式状态字段,或由 `options_trades.status` + 桥状态 + `pnl_settled` 等价表达,但**结算入口必须统一校验门禁**。 + +--- + +## 5. 数据字段 + +### 5.1 `options_trades` + +| 字段 | 含义 | +|------|------| +| `trading_usdt_before` | 开仓前交易账户 USDT | +| `trading_usdt_after` | 卖光后交易账户 USDT | +| `realized_pnl_usdt` | **口径 A** 冻结值 | +| `pnl_settled` | 0/1,A 是否已冻结 | +| `realized_pnl` | 币数量净盈亏(期权历史展示) | +| `close_index_px` | 平仓/卖回价(口径 B 用) | +| `open_index_px` | 开仓指数 | +| `premium_paid` | 开仓权利金币量 | +| `initial_premium_usdt` | 初始权利金 U = open_index × premium_paid | +| `budget_usdt` | 买币花费(含缓冲),**不得**当权利金或 before | + +### 5.2 `options_review_trades` + +| 字段 | 含义 | +|------|------| +| `realized_pnl_total` | **仅口径 A**(或待结算空) | +| `initial_premium_usdt` | 初始权利金 U | +| `profit_rate` | A / 初始权利金(小数,如 1.5 = 1.5 倍) | + +### 5.3 `options_exchange_pnl_freeze` + +实盘统计辅助:历史 key → 口径 B 的 U 冻结。 + +--- + +## 6. 模块行为 + +### 6.1 开仓 `open_coin_option_buy_full` + +1. 一次一仓 / 未完成桥检查;可卖币拒开。 +2. `trading_usdt_before`;买币、下单、写库(`pnl_settled=0`,`initial_premium_usdt`)。 +3. 桥 `coin_bought` 可记为权利金币量(供开仓清过量),**平仓卖回不得用其封顶**。 + +### 6.2 划转 `POST /api/options/transfer` + +持仓或桥 ∈ {bought, holding, pending_sell} → 拒绝。 + +### 6.3 卖回与 A 结算 + +`sell_residual_after_option_flat`(及 retry / sweep): + +1. 空仓后卖光可用标的币(`coin_amount=None`)。 +2. 仍有可卖 → 桥 `pending_sell`,返回未完成,**不结算**。 +3. 仅粉尘/已空 → 关桥 → `try_settle_after_sellback`(内再校验无可卖币)→ A 冻结。 + +### 6.4 数据统计(口径 B) + +- `net_realized_pnl` / 胜率 / 盈亏比:历史币盈亏 × 卖回价。 +- **不读** `pnl_settled` 账户差分。 +- 数据统计页不得再打复盘 stats API 作为盈亏真源。 + +### 6.5 期权复盘(口径 A) + +- 同步本地已平:优先 `pnl_settled=1` 的 `realized_pnl_usdt`。 +- 未结算:显示待结算,不导入假 U。 +- 同步 `initial_premium_usdt`、`profit_rate`。 + +### 6.6 期权历史列表 + +仍按**币**展示权利金/币盈亏;U 只用于复盘(A)与数据统计(B)。 + +--- + +## 7. 对账关系 + +``` +口径 A: Σ 已冻结单笔 A + 期间净划转 ≈ 交易账户相对首笔 before 的累计变化 +口径 B: Σ (历史币盈亏 × 卖回价) ≈ 面板数据统计已实现 +``` + +差异应可归因:粉尘、滑点、手续费(A 内已含);B 不含多买缓冲。 + +--- + +## 8. 非目标 / 不做 + +- 不用 `budget_usdt` 当单笔盈利或初始权利金。 +- 不用口径 A 覆盖数据统计,也不用口径 B 覆盖复盘。 +- 持仓中不因行情重算已冻结 A/B。 +- 不把期权历史列表强制改为 U。 + +--- + +## 9. 历史数据修复 + +对已错误冻结的账户差分(典型:币盈亏>0 且 A 为负,且 `coin_sold≈premium_paid≪premium_received`): + +1. 回填 `initial_premium_usdt = open_index_px × premium_paid`。 +2. 口径 B:按币盈亏 × 卖回价重算统计侧(含 `options_exchange_pnl_freeze`)。 +3. 口径 A:卖回完整可重建则重算;否则 `pnl_settled=0` 标 `needs_resettle` / 异常,勿盲信错账。 +4. 重刷 `options_review_trades.realized_pnl_total` / `profit_rate`。 + +脚本:`scripts/repair_options_dual_pnl.py`(dry-run → CSV → apply)。 + +--- + +## 10. 验收清单 + +- [ ] 盈利约 1.5 倍单:卖回含盈利币;复盘 A 为正,约等于账户差分 +- [ ] 卖回不得按开仓权利金封顶 +- [ ] 仍有可卖币时不 `pnl_settled=1` +- [ ] 复盘展示初始权利金、盈利率 +- [ ] 数据统计 = 历史币盈亏 × 卖回价,与复盘 A 可不同 +- [ ] 开仓写入 before + initial_premium;失败有最新快照/回滚 +- [ ] 持仓禁划转;空仓划转不影响已冻结 +- [ ] 历史错账可用脚本修复 + +--- + +## 11. 修订记录 + +| 日期 | 说明 | +|------|------| +| 2026-10-09 | 初版:交易账户快照差分 + 一次一仓 + 持仓禁划转 + 卖回后冻结 | +| 2026-10-13 | 修订:双口径 A/B;卖光再结算;初始权利金/盈利率;状态机;历史修复脚本 | diff --git a/lib/common/static/options_data_stats.js b/lib/common/static/options_data_stats.js index f08b825..c67fd5a 100644 --- a/lib/common/static/options_data_stats.js +++ b/lib/common/static/options_data_stats.js @@ -102,12 +102,8 @@ var kpi = $("ods-kpi"); var groups = $("ods-stats-groups"); if (!kpi || !groups) return Promise.resolve(); - var qs = new URLSearchParams(); - qs.set("source_type", sourceType()); - qs.set("sync", "0"); - return fetch("/api/options/review/stats?" + qs.toString(), { - credentials: "same-origin", - }) + // 口径 B:期权历史币盈亏×卖回价(/api/options/stats),与复盘账户差分独立 + return fetch("/api/options/stats", { credentials: "same-origin" }) .then(function (r) { return r.json(); }) @@ -117,19 +113,26 @@ groups.innerHTML = ""; return; } - var k = data.kpi || {}; + var unit = data.pnl_unit || "U"; + var net = data.net_realized_pnl; + var avg = + data.total_closed > 0 && net != null + ? Number(net) / Number(data.total_closed) + : null; kpi.innerHTML = [ - ["笔数", k.total], - ["已复盘率", (k.review_rate || 0) + "%"], - ["胜率", (k.win_rate || 0) + "%"], - ["累计盈亏", fmtPnl(k.pnl_sum)], - ["平均盈亏", fmtPnl(k.avg_pnl)], - ["平均持有", fmtHold(k.avg_hold_sec)], + ["已平笔数", data.total_closed || 0], + ["胜率", (data.win_rate || 0) + "%"], + ["盈亏比", data.profit_loss_ratio != null ? data.profit_loss_ratio : "—"], + ["累计盈亏", fmtPnl(net)], + ["平均盈利", data.avg_win != null ? fmtPnl(data.avg_win) : "—"], + ["平均亏损", data.avg_loss != null ? fmtPnl(-Math.abs(Number(data.avg_loss))) : "—"], + ["平均持有", fmtHold(data.avg_hold_sec)], ] .map(function (pair) { var cls = ""; - if (pair[0] === "累计盈亏") cls = pnlClass(k.pnl_sum); - if (pair[0] === "平均盈亏") cls = pnlClass(k.avg_pnl); + if (pair[0] === "累计盈亏") cls = pnlClass(net); + if (pair[0] === "平均盈利") cls = "pnl-pos"; + if (pair[0] === "平均亏损") cls = "pnl-neg"; return ( '
' + pair[0] + @@ -141,18 +144,11 @@ ); }) .join(""); - var html = [ - renderGroup("按类型", data.by_source_type), - renderGroup("按标的", data.by_underlying), - renderGroup("按策略", data.by_strategy), - renderGroup("对冲结束原因", data.by_close_reason), - renderGroup("持有周期", data.by_hold_bucket), - renderGroup("Call/Put", data.by_opt_type), - ] - .filter(Boolean) - .join(""); groups.innerHTML = - html || '
暂无分组数据
'; + '
数据统计口径:期权历史盈亏×平仓卖回价(与复盘账户差分独立)。单位 ' + + unit + + (avg != null ? " · 笔均 " + fmtPnl(avg) : "") + + "
"; }) .catch(function () { kpi.innerHTML = '
统计加载失败
'; diff --git a/lib/common/static/options_review.js b/lib/common/static/options_review.js index 420a8e9..67df14b 100644 --- a/lib/common/static/options_review.js +++ b/lib/common/static/options_review.js @@ -1,1516 +1,1561 @@ -/** - * OKX 期权复盘:待复盘交易(5行) → 点复盘出表单 → 复盘记录详情 → 统计. - */ -(function (global) { - "use strict"; - - var PAGE_SIZE = 5; - var TAB_LABELS = { - option_spot: "期权交易记录", - options_options: "期期对冲记录", - perp_options: "永期对冲记录", - }; - var FORM_PRESETS = { - option_spot: { - strategy: ["顺势", "反转"], - direction: ["多", "空"], - entry: ["假突破", "结构突破", "左侧条件单"], - }, - hedge: { - strategy: ["横盘", "趋势"], - direction: ["多", "空"], - entry: ["横盘博弈方向", "趋势对冲止损"], - }, - }; - var RESULT_OPTIONS = ["盈利", "亏损", "持平"]; - var activeSource = "option_spot"; - var currentTradeId = null; - var draftId = ""; - var tradesCache = {}; - var reviewedCache = {}; - var tradesPage = 0; - var tradesPages = 1; - var reviewedPage = 0; - var reviewedPages = 1; - - function $(id) { - return document.getElementById(id); - } - - function escapeHtml(s) { - return String(s == null ? "" : s) - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """); - } - - function fmtPnl(v) { - if (v == null || v === "") return "—"; - var n = Number(v); - if (Number.isNaN(n)) return "—"; - return (n >= 0 ? "+" : "") + n.toFixed(2) + "U"; - } - - function fmtHold(sec) { - if (sec == null) return "—"; - var s = Math.max(0, Number(sec) || 0); - if (s < 3600) return Math.round(s / 60) + "m"; - if (s < 86400) return (s / 3600).toFixed(1) + "h"; - return (s / 86400).toFixed(1) + "d"; - } - - function toLocalInput(ts) { - if (!ts) return ""; - var s = String(ts).trim().replace(" ", "T"); - if (s.length >= 16) return s.slice(0, 16); - return s; - } - - function closeReasonLabel(r) { - var map = { - perp_tp: "永续止盈", - perp_sl: "永续止损", - oo_expiry_loss: "期期到期亏损", - oo_expiry_win: "期期到期盈利", - target_win_leg: "期期平盈利腿", - target_up_win_leg: "期期上破·平盈利腿", - target_down_win_leg: "期期下破·平盈利腿", - profit_rr_win_leg: "期期盈亏比达标·平盈利腿", - oo_rest_closing: "期期残值平·清亏损腿中", - oo_rest_closed: "期期残值平·两腿已平", - orphaned_after_tp: "止盈后持有至到期", - orphaned_option_expiry: "残腿到期", - hold_to_expiry: "持有至到期", - expiry: "到期", - manual: "人工结束", - partial_fail: "半腿失败", - cancelled: "已取消", - tp: "止盈", - sl: "止损", - }; - var key = String(r || "").trim(); - if (!key) return "—"; - return map[key] || key; - } - - function legRoleLabel(role) { - var map = { - perp: "永续腿", - option_hedge: "保险期权", - option_a: "期期腿A", - option_b: "期期腿B", - }; - var key = String(role || "").trim(); - if (!key) return "—"; - return map[key] || key; - } - - function tradeTitle(t) { - if (!t) return "—"; - if (t.source_type === "option_spot") return t.inst_id || "—"; - return ( - (t.underlying || "") + - (t.direction ? " " + t.direction : "") + - (t.plan_close_reason ? " · " + closeReasonLabel(t.plan_close_reason) : "") - ); - } - - function pnlClass(v) { - var n = Number(v); - if (n > 0) return "pos-pnl-profit"; - if (n < 0) return "pos-pnl-loss"; - return ""; - } - - function resultClass(tag) { - var t = String(tag || "").trim(); - if (t === "盈利") return "pos-pnl-profit"; - if (t === "亏损") return "pos-pnl-loss"; - return ""; - } - - function tradeContractLabel(t) { - if (!t) return "—"; - if (t.source_type === "option_spot") return t.inst_id || t.underlying || "—"; - return t.underlying || "—"; - } - - function newDraftId() { - if (global.crypto && typeof global.crypto.randomUUID === "function") { - return global.crypto.randomUUID().replace(/-/g, ""); - } - var s = ""; - for (var i = 0; i < 32; i++) s += Math.floor(Math.random() * 16).toString(16); - return s; - } - - function baseQs() { - var p = new URLSearchParams(); - p.set("source_type", activeSource); - var uly = ($("or-filter-uly") || {}).value || ""; - var opt = ($("or-filter-opt") || {}).value || ""; - var q = (($("or-filter-q") || $("or-filter-strategy") || {}).value || "").trim(); - var from = ($("or-filter-from") || {}).value || ""; - var to = ($("or-filter-to") || {}).value || ""; - if (uly) p.set("underlying", uly); - if (opt) p.set("opt_type", opt); - if (q) p.set("q", q); - if (from) p.set("closed_from", from.replace("T", " ") + ":00"); - if (to) p.set("closed_to", to.replace("T", " ") + ":00"); - if (($("or-include-hedge-legs") || {}).checked) p.set("include_hedge_legs", "1"); - return p; - } - - function setSyncStatus(text) { - var el = $("or-sync-status"); - if (el) el.textContent = text || ""; - } - - function reloadAll() { - // 先无同步出列表(统计同理),再后台同步;避免模拟盘握库+拉行情把列表卡死在「加载中」 - setSyncStatus("读取本地记录…"); - loadTrades({ sync: false }); - loadReviewed({ sync: false }); - loadStats(); - backgroundSync(); - } - - function backgroundSync() { - setSyncStatus("同步本地记录…"); - fetch("/api/options/review/sync", { credentials: "same-origin", method: "POST" }) - .then(function (r) { - return r.json(); - }) - .then(function (data) { - if (data && data.ok === false) { - setSyncStatus("同步跳过,已显示本地缓存"); - return; - } - setSyncStatus("本地记录已加载"); - loadTrades({ sync: false, soft: true }); - loadReviewed({ sync: false, soft: true }); - loadStats(); - }) - .catch(function () { - setSyncStatus("同步跳过,已显示本地缓存"); - }); - } - - function isHedgeSource(sourceType) { - return sourceType === "options_options" || sourceType === "perp_options"; - } - - function fillSelect(el, options, placeholder) { - if (!el) return; - var keep = el.value; - el.innerHTML = ""; - var first = document.createElement("option"); - first.value = ""; - first.textContent = placeholder || ""; - el.appendChild(first); - (options || []).forEach(function (v) { - var opt = document.createElement("option"); - opt.value = v; - opt.textContent = v; - el.appendChild(opt); - }); - if (keep) setSelectValue(el, keep); - } - - function setSelectValue(el, value) { - if (!el) return; - var v = value == null ? "" : String(value); - if (!v) { - el.value = ""; - return; - } - var found = false; - for (var i = 0; i < el.options.length; i++) { - if (el.options[i].value === v) { - found = true; - break; - } - } - if (!found) { - var opt = document.createElement("option"); - opt.value = v; - opt.textContent = v; - el.appendChild(opt); - } - el.value = v; - } - - function applyFormPresets(sourceType) { - var preset = isHedgeSource(sourceType) ? FORM_PRESETS.hedge : FORM_PRESETS.option_spot; - fillSelect($("or-f-strategy"), preset.strategy, "策略标签"); - fillSelect($("or-f-direction"), preset.direction, "方向判断"); - fillSelect($("or-f-entry"), preset.entry, "入场逻辑"); - fillSelect($("or-f-result"), RESULT_OPTIONS, "结果标签"); - } - - function autoDirection(t) { - if (!t) return ""; - if (isHedgeSource(t.source_type)) { - var d = String(t.direction || "").trim().toLowerCase(); - if (d === "long" || d === "buy" || d === "多") return "多"; - if (d === "short" || d === "sell" || d === "空") return "空"; - return ""; - } - var ot = String(t.opt_type || "").trim().toUpperCase(); - if (ot === "C" || ot === "CALL") return "多"; - if (ot === "P" || ot === "PUT") return "空"; - return ""; - } - - function autoResultTag(pnl) { - if (pnl == null || pnl === "") return ""; - var n = Number(pnl); - if (Number.isNaN(n)) return ""; - if (n > 0) return "盈利"; - if (n < 0) return "亏损"; - return "持平"; - } - - function tradeModeFromDom() { - var tabs = document.querySelector(".or-tabs"); - return (tabs && tabs.getAttribute("data-okx-trade-mode")) || "options"; - } - - function defaultSourceForMode(mode) { - if (mode === "options_options") return "options_options"; - if (mode === "perp_options") return "perp_options"; - return "option_spot"; - } - - function setActiveTab(source) { - var mode = tradeModeFromDom(); - var allowed = defaultSourceForMode(mode); - activeSource = source || allowed; - if (activeSource !== allowed) activeSource = allowed; - tradesPage = 0; - reviewedPage = 0; - document.querySelectorAll(".or-tab").forEach(function (btn) { - btn.classList.toggle("active", btn.getAttribute("data-source") === activeSource); - }); - var title = $("or-list-title"); - if (title) title.textContent = TAB_LABELS[activeSource] || "记录"; - applyFormPresets(activeSource); - hideJournalForm(); - hideDetail(); - reloadAll(); - } - - function updateTradesPager() { - var label = $("or-trades-page-label"); - var prev = $("or-trades-prev"); - var next = $("or-trades-next"); - if (label) { - label.textContent = "第 " + (tradesPage + 1) + " / " + tradesPages + " 页"; - } - if (prev) prev.disabled = tradesPage <= 0; - if (next) next.disabled = tradesPage + 1 >= tradesPages; - } - - function updateReviewedPager() { - var label = $("or-reviewed-page-label"); - var prev = $("or-reviewed-prev"); - var next = $("or-reviewed-next"); - if (label) { - label.textContent = "第 " + (reviewedPage + 1) + " / " + reviewedPages + " 页"; - } - if (prev) prev.disabled = reviewedPage <= 0; - if (next) next.disabled = reviewedPage + 1 >= reviewedPages; - } - - function applyPagerMeta(data, kind) { - var pages = Number(data.pages || 1); - if (!pages || pages < 1) pages = 1; - var clamped = false; - if (kind === "trades") { - tradesPages = pages; - if (tradesPage >= tradesPages) { - tradesPage = Math.max(0, tradesPages - 1); - clamped = true; - } - updateTradesPager(); - } else { - reviewedPages = pages; - if (reviewedPage >= reviewedPages) { - reviewedPage = Math.max(0, reviewedPages - 1); - clamped = true; - } - updateReviewedPager(); - } - return clamped; - } - - function beginListLoad(wrapId, soft) { - var wrap = $(wrapId); - if (!wrap) return null; - if (soft) { - if (!wrap.style.minHeight) { - wrap.style.minHeight = Math.max(wrap.offsetHeight, 1) + "px"; - } - wrap.classList.add("or-list-loading"); - } else { - wrap.classList.remove("or-list-loading"); - wrap.style.minHeight = ""; - } - return wrap; - } - - function endListLoad(wrap) { - if (!wrap) return; - wrap.classList.remove("or-list-loading"); - wrap.style.minHeight = ""; - } - - function loadTrades(opts) { - opts = opts || {}; - var doSync = opts.sync !== false; - var soft = !!opts.soft; - var tbody = $("or-trades-tbody"); - if (!tbody) return; - var wrap = beginListLoad("or-trades-wrap", soft); - if (!soft) { - tbody.innerHTML = '加载中…'; - } - var p = baseQs(); - // 交易记录保留已复盘条目,不再只显示待复盘 - p.set("limit", String(PAGE_SIZE)); - p.set("offset", String(tradesPage * PAGE_SIZE)); - if (!doSync) p.set("sync", "0"); - fetch("/api/options/review/trades?" + p.toString(), { credentials: "same-origin" }) - .then(function (r) { - return r.json(); - }) - .then(function (data) { - if (doSync) setSyncStatus("本地记录已加载"); - if (!data.ok) { - tbody.innerHTML = '加载失败'; - endListLoad(wrap); - return; - } - if (applyPagerMeta(data, "trades") && Number(data.total || 0) > 0) { - loadTrades(opts); - return; - } - var rows = data.trades || []; - tradesCache = {}; - if (!rows.length) { - tbody.innerHTML = - '暂无交易记录'; - endListLoad(wrap); - return; - } - tbody.innerHTML = rows - .map(function (t) { - tradesCache[t.id] = t; - var active = currentTradeId === t.id ? " or-row-active" : ""; - var reviewed = !!t.reviewed; - var actionBtn = reviewed - ? '' - : ''; - var badgeExtra = reviewed - ? ' 已复盘' - : ""; - return ( - '' + - "" + - escapeHtml(t.source_label || t.source_type) + - "" + - badgeExtra + - "" + - "" + - escapeHtml(tradeTitle(t)) + - "" + - '' + - fmtPnl(t.realized_pnl_total) + - "" + - '' + - escapeHtml(t.opened_at || "—") + - "" + - '' + - escapeHtml(t.closed_at || "—") + - "" + - "" + - fmtHold(t.hold_seconds) + - "" + - "" + - actionBtn + - " " + - '' + - "" - ); - }) - .join(""); - tbody.querySelectorAll(".or-review-btn").forEach(function (btn) { - btn.addEventListener("click", function (ev) { - ev.preventDefault(); - ev.stopPropagation(); - openJournalForm(Number(btn.getAttribute("data-id"))); - }); - }); - tbody.querySelectorAll(".or-hide-btn").forEach(function (btn) { - btn.addEventListener("click", function (ev) { - ev.preventDefault(); - ev.stopPropagation(); - hideTrade(Number(btn.getAttribute("data-id"))); - }); - }); - endListLoad(wrap); - }) - .catch(function () { - if (doSync) setSyncStatus("加载失败"); - tbody.innerHTML = '加载失败'; - endListLoad(wrap); - }); - } - - function loadReviewed(opts) { - opts = opts || {}; - var doSync = opts.sync === true; - var soft = !!opts.soft; - var tbody = $("or-reviewed-tbody"); - if (!tbody) return; - var wrap = beginListLoad("or-reviewed-wrap", soft); - if (!soft) { - tbody.innerHTML = '加载中…'; - } - var p = baseQs(); - p.set("reviewed", "1"); - p.set("limit", String(PAGE_SIZE)); - p.set("offset", String(reviewedPage * PAGE_SIZE)); - if (!doSync) p.set("sync", "0"); - fetch("/api/options/review/trades?" + p.toString(), { credentials: "same-origin" }) - .then(function (r) { - return r.json(); - }) - .then(function (data) { - if (!data.ok) { - tbody.innerHTML = '加载失败'; - endListLoad(wrap); - return; - } - if (applyPagerMeta(data, "reviewed") && Number(data.total || 0) > 0) { - loadReviewed(opts); - return; - } - var rows = data.trades || []; - reviewedCache = {}; - if (!rows.length) { - tbody.innerHTML = '暂无复盘记录'; - endListLoad(wrap); - return; - } - tbody.innerHTML = rows - .map(function (t) { - reviewedCache[t.id] = t; - var entry = t.entry || {}; - var direction = t.direction_view || entry.direction_view || ""; - var entryLogic = t.entry_logic || entry.entry_logic || ""; - return ( - '' + - "" + - escapeHtml(t.source_label || t.source_type) + - "" + - "" + - escapeHtml(tradeContractLabel(t)) + - "" + - "" + - escapeHtml(direction || "—") + - "" + - '' + - fmtPnl(t.realized_pnl_total) + - "" + - '' + - escapeHtml(t.opened_at || "—") + - "" + - '' + - escapeHtml(t.closed_at || "—") + - "" + - "" + - escapeHtml(fmtHold(t.hold_seconds)) + - "" + - "" + - escapeHtml(t.strategy_tag || "—") + - "" + - "" + - escapeHtml(entryLogic || "—") + - "" + - '' + - escapeHtml(t.result_tag || "—") + - "" + - '' + - escapeHtml(t.reviewed_at || "—") + - "" + - "" - ); - }) - .join(""); - tbody.querySelectorAll(".or-reviewed-row").forEach(function (tr) { - tr.addEventListener("click", function () { - openDetail(Number(tr.getAttribute("data-id"))); - }); - }); - endListLoad(wrap); - }) - .catch(function () { - tbody.innerHTML = '加载失败'; - endListLoad(wrap); - }); - } - - function hideLightbox() { - var box = $("or-img-lightbox"); - if (box) box.hidden = true; - var img = $("or-img-lightbox-img"); - if (img) img.src = ""; - } - - function showLightbox(src) { - var url = String(src || "").trim(); - if (!url) return; - var box = $("or-img-lightbox"); - var img = $("or-img-lightbox-img"); - if (box && img) { - img.src = url; - box.hidden = false; - return; - } - if (typeof global.showImage === "function") { - global.showImage(url); - } else if (typeof window.showImage === "function") { - window.showImage(url); - } else { - global.open(url, "_blank"); - } - } - - var detailChartPollTimer = null; - var detailChartPollTradeId = 0; - - function hideDetail() { - hideLightbox(); - stopDetailChartPoll(); - var backdrop = $("or-detail-backdrop"); - if (backdrop) backdrop.hidden = true; - } - - function stopDetailChartPoll() { - if (detailChartPollTimer) { - clearTimeout(detailChartPollTimer); - detailChartPollTimer = null; - } - detailChartPollTradeId = 0; - } - - function tradeHasAutoChart(t) { - var images = ((t && t.entry) || {}).images || []; - for (var i = 0; i < images.length; i++) { - var tf = String((images[i] && images[i].tf) || "").trim(); - var file = String((images[i] && images[i].file) || "").trim(); - if (tf === "auto" && file) return true; - } - return false; - } - - function openDetail(tradeId, opts) { - opts = opts || {}; - var waitAuto = !!opts.waitAutoChart; - var backdrop = $("or-detail-backdrop"); - if (!backdrop) return; - stopDetailChartPoll(); - backdrop.hidden = false; - ($("or-detail-title") || {}).textContent = "加载中…"; - ($("or-detail-meta") || {}).innerHTML = ""; - ($("or-detail-text") || {}).innerHTML = ""; - ($("or-detail-images") || {}).innerHTML = waitAuto - ? '
自动K线
生成中…
' - : ""; - - function applyTrade(trade, pendingAuto) { - renderDetail(trade, { pendingAuto: !!pendingAuto }); - } - - function fetchOnce() { - return fetch("/api/options/review/trades/" + tradeId, { - credentials: "same-origin", - }).then(function (r) { - return r.json(); - }); - } - - fetchOnce() - .then(function (data) { - if (!data.ok || !data.trade) { - ($("or-detail-title") || {}).textContent = "加载失败"; - return; - } - if (!waitAuto || tradeHasAutoChart(data.trade)) { - applyTrade(data.trade, false); - return; - } - applyTrade(data.trade, true); - detailChartPollTradeId = tradeId; - var tries = 0; - var maxTries = 20; - function poll() { - if (detailChartPollTradeId !== tradeId) return; - tries += 1; - fetchOnce() - .then(function (again) { - if (detailChartPollTradeId !== tradeId) return; - if (again.ok && again.trade && tradeHasAutoChart(again.trade)) { - stopDetailChartPoll(); - applyTrade(again.trade, false); - loadReviewed({ sync: false, soft: true }); - return; - } - if (tries >= maxTries) { - stopDetailChartPoll(); - applyTrade((again && again.trade) || data.trade, false); - return; - } - detailChartPollTimer = setTimeout(poll, 1500); - }) - .catch(function () { - if (detailChartPollTradeId !== tradeId) return; - if (tries >= maxTries) { - stopDetailChartPoll(); - return; - } - detailChartPollTimer = setTimeout(poll, 1500); - }); - } - detailChartPollTimer = setTimeout(poll, 1200); - }) - .catch(function () { - ($("or-detail-title") || {}).textContent = "加载失败"; - }); - } - - function optionsJournalImgSrc(file) { - var name = String(file || "").trim().replace(/\\/g, "/"); - var slash = name.lastIndexOf("/"); - if (slash >= 0) name = name.slice(slash + 1); - if (!name) return ""; - // options_journal_* 在子目录;误走合约上传的 journal_* 在 static/images 根目录 - var base = - name.toLowerCase().indexOf("options_journal_") === 0 - ? "/static/images/options_journal/" - : "/static/images/"; - return base + encodeURIComponent(name); - } - - function chartTfLabel(tf) { - if (tf === "auto") return "自动K线"; - if (tf === "_") return "截图"; - return tf; - } - - function setChartOptionsVisible(visible) { - var box = $("or-chart-options"); - var hint = $("or-chart-hint"); - if (box) box.classList.toggle("hidden", !visible); - if (hint) hint.classList.toggle("hidden", !visible); - } - - function resetChartOptions() { - var cb = $("or-exchange-chart"); - // 期权复盘:默认勾选后台生成 K 线 - if (cb) cb.checked = true; - } - - function renderDetailImages(images, opts) { - var imagesHost = $("or-detail-images"); - if (!imagesHost) return; - opts = opts || {}; - var pendingAuto = !!opts.pendingAuto; - var byTf = {}; - (images || []).forEach(function (img) { - var tf = String((img && img.tf) || "").trim(); - var file = String((img && img.file) || "").trim(); - if (!file) return; - var key = tf || "_"; - byTf[key] = file; - }); - // 互斥:有自动K线只显示自动;否则只显示手动四周期 - var hasAuto = !!byTf.auto; - if (pendingAuto && !hasAuto) { - imagesHost.innerHTML = - '
' + - '自动K线' + - '
生成中…
' + - "
"; - return; - } - var order = hasAuto ? ["auto"] : ["5m", "15m", "1h", "4h"]; - var keys = order.slice(); - if (!hasAuto) { - Object.keys(byTf).forEach(function (k) { - if (k === "auto") return; - if (keys.indexOf(k) < 0) keys.push(k); - }); - } - var cells = keys - .map(function (tf) { - var file = byTf[tf]; - if (!file) { - if (hasAuto || order.indexOf(tf) < 0) return ""; - return ( - '
' + - '' + - escapeHtml(tf) + - "" + - '
未上传
' + - "
" - ); - } - var src = optionsJournalImgSrc(file); - var label = escapeHtml(chartTfLabel(tf)); - return ( - '
' + - '' + - label + - "" + - '' +
-          label +
-          '' + - "
" - ); - }) - .filter(Boolean); - if (!cells.length) { - imagesHost.innerHTML = '
无截图
'; - return; - } - imagesHost.innerHTML = cells.join(""); - imagesHost.querySelectorAll("img").forEach(function (img) { - img.addEventListener("error", function () { - var cell = img.closest(".or-detail-img-cell"); - if (!cell) return; - var label = cell.querySelector(".or-detail-img-label"); - var tf = label ? label.textContent : "截图"; - cell.innerHTML = - '' + - escapeHtml(tf) + - "" + - '
文件缺失或无法加载
'; - }); - img.addEventListener("click", function () { - var src = img.getAttribute("data-src") || img.src; - showLightbox(src); - }); - }); - } - - function renderDetail(t, opts) { - var e = t.entry || {}; - reviewedCache[t.id] = t; - ($("or-detail-title") || {}).textContent = - "复盘详情 · " + (t.source_label || "") + " · " + tradeTitle(t); - var editBtn = $("or-detail-edit-btn"); - if (editBtn) editBtn.setAttribute("data-id", String(t.id)); - - var meta = $("or-detail-meta"); - if (meta) { - var cells = [ - ["标的", t.underlying || "—"], - ["合约/计划", tradeTitle(t)], - ["盈亏", fmtPnl(t.realized_pnl_total)], - ["持有", fmtHold(t.hold_seconds)], - ["开仓时间", t.opened_at || "—"], - ["平仓时间", t.closed_at || "—"], - ["策略", e.strategy_tag || "—"], - ["方向", e.direction_view || "—"], - ["结果", e.result_tag || "—"], - ["离场", e.exit_reason || "—"], - ["按计划", e.followed_plan || "—"], - ["入场逻辑", e.entry_logic || "—"], - ["入场周期", e.prev_entry_tf || "—"], - ]; - if (t.is_hedge) { - cells.push(["永续盈亏", fmtPnl(t.realized_pnl_perp)]); - cells.push(["期权盈亏", fmtPnl(t.realized_pnl_options)]); - } - meta.innerHTML = cells - .map(function (pair) { - var cls = ""; - if (pair[0] === "盈亏" || pair[0] === "永续盈亏" || pair[0] === "期权盈亏") { - cls = pnlClass(t.realized_pnl_total); - if (pair[0] === "永续盈亏") cls = pnlClass(t.realized_pnl_perp); - if (pair[0] === "期权盈亏") cls = pnlClass(t.realized_pnl_options); - } else if (pair[0] === "结果") { - cls = resultClass(e.result_tag); - } - return ( - "
" + - escapeHtml(pair[0]) + - '
' + - escapeHtml(pair[1]) + - "
" - ); - }) - .join(""); - } - - var text = $("or-detail-text"); - if (text) { - var lines = []; - if (e.mistake_tags) lines.push("
心理标签:" + escapeHtml(e.mistake_tags) + "
"); - if (e.note) lines.push("
备注:" + escapeHtml(e.note).replace(/\n/g, "
") + "
"); - if (t.legs && t.legs.length) { - lines.push( - "
计划腿
" + - t.legs - .map(function (leg) { - return ( - "" - ); - }) - .join("") + - "
腿合约盈亏原因
" + - escapeHtml(legRoleLabel(leg.leg_role)) + - "" + - escapeHtml(leg.inst_id || leg.symbol || "") + - "" + - fmtPnl(leg.realized_pnl) + - "" + - escapeHtml(closeReasonLabel(leg.close_reason)) + - "
" - ); - } - text.innerHTML = lines.join("") || '
无额外备注
'; - } - - var imagesHost = $("or-detail-images"); - if (imagesHost) { - renderDetailImages(e.images || [], opts || {}); - } - } - - function renderGroup(title, items) { - if (!items || !items.length) return ""; - var lines = items - .slice(0, 8) - .map(function (g) { - var keyLabel = - title === "对冲结束原因" ? closeReasonLabel(g.key) : String(g.key || ""); - return ( - '
' + - '' + - escapeHtml(keyLabel) + - " · " + - g.count + - "笔" + - '' + - fmtPnl(g.pnl_sum) + - " / 胜" + - (g.win_rate || 0) + - "%" + - "
" - ); - }) - .join(""); - return ( - '
' + - title + - "
" + - lines + - "
" - ); - } - - function loadStats() { - var kpi = $("or-kpi"); - var groups = $("or-stats-groups"); - if (!kpi || !groups) return; - // 与 trades?sync=1 并行时不再二次同步,避免 SQLite database is locked - var qs = baseQs(); - qs.set("sync", "0"); - fetch("/api/options/review/stats?" + qs.toString(), { credentials: "same-origin" }) - .then(function (r) { - return r.json(); - }) - .then(function (data) { - if (!data.ok) return; - var k = data.kpi || {}; - kpi.innerHTML = [ - ["笔数", k.total], - ["已复盘率", (k.review_rate || 0) + "%"], - ["胜率", (k.win_rate || 0) + "%"], - ["累计盈亏", fmtPnl(k.pnl_sum)], - ["平均盈亏", fmtPnl(k.avg_pnl)], - ["平均持有", fmtHold(k.avg_hold_sec)], - ] - .map(function (pair) { - var cls = ""; - if (pair[0] === "累计盈亏") cls = pnlClass(k.pnl_sum); - if (pair[0] === "平均盈亏") cls = pnlClass(k.avg_pnl); - return ( - '
' + - pair[0] + - '
' + - pair[1] + - "
" - ); - }) - .join(""); - var html = [ - renderGroup("按类型", data.by_source_type), - renderGroup("按标的", data.by_underlying), - renderGroup("按策略", data.by_strategy), - renderGroup("对冲结束原因", data.by_close_reason), - renderGroup("持有周期", data.by_hold_bucket), - renderGroup("Call/Put", data.by_opt_type), - ] - .filter(Boolean) - .join(""); - groups.innerHTML = html || '
暂无分组数据
'; - }) - .catch(function () {}); - } - - function resetUploadSlots() { - draftId = newDraftId(); - var draftEl = $("or-draft-id"); - if (draftEl) draftEl.value = draftId; - document.querySelectorAll("#or-upload-slots .or-upload-hidden").forEach(function (el) { - el.value = ""; - }); - document.querySelectorAll("#or-upload-slots .or-upload-input").forEach(function (el) { - el.value = ""; - }); - document.querySelectorAll("#or-upload-slots .or-upload-status").forEach(function (el) { - el.textContent = ""; - }); - } - - function bindUploadSlots() { - document.querySelectorAll("#or-upload-slots .or-upload-input").forEach(function (input) { - if (input.dataset.orBound === "1") return; - input.dataset.orBound = "1"; - input.addEventListener("change", function () { - var file = input.files && input.files[0]; - var row = input.closest(".journal-upload-row"); - var status = row && row.querySelector(".or-upload-status"); - var hidden = row && row.querySelector(".or-upload-hidden"); - if (!file) { - if (hidden) hidden.value = ""; - if (status) { - status.textContent = ""; - status.className = "journal-upload-status or-upload-status"; - } - return; - } - if (!draftId) draftId = newDraftId(); - if (status) { - status.textContent = "上传中…"; - status.className = "journal-upload-status or-upload-status journal-upload-status--pending"; - } - var fd = new FormData(); - fd.append("draft_id", draftId); - fd.append("tf", input.getAttribute("data-tf") || ""); - fd.append("file", file); - fetch("/api/options/review/upload_slot", { - method: "POST", - body: fd, - credentials: "same-origin", - }) - .then(function (r) { - return r.json(); - }) - .then(function (data) { - if (!data.ok) throw new Error(data.error || "fail"); - if (hidden) hidden.value = data.file; - if (status) { - status.textContent = "上传成功 " + data.file; - status.className = "journal-upload-status or-upload-status journal-upload-status--ok"; - } - input.value = ""; - }) - .catch(function () { - if (hidden) hidden.value = ""; - if (status) { - status.textContent = "上传失败"; - status.className = "journal-upload-status or-upload-status journal-upload-status--err"; - } - }); - }); - }); - } - - function setMoodTags(raw) { - var set = {}; - String(raw || "") - .split(/[,,]/) - .map(function (x) { - return x.trim(); - }) - .filter(Boolean) - .forEach(function (x) { - set[x] = true; - }); - document.querySelectorAll(".or-mood").forEach(function (cb) { - cb.checked = !!set[cb.value]; - }); - } - - function collectMoodTags() { - var out = []; - document.querySelectorAll(".or-mood:checked").forEach(function (cb) { - out.push(cb.value); - }); - return out.join(","); - } - - function collectImages() { - var out = []; - document.querySelectorAll("#or-upload-slots .or-upload-hidden").forEach(function (el) { - var file = (el.value || "").trim(); - if (file) out.push({ tf: el.getAttribute("data-tf") || "", file: file }); - }); - return out; - } - - function hideJournalForm() { - currentTradeId = null; - var card = $("or-journal-card"); - if (card) card.classList.add("hidden"); - document.querySelectorAll(".or-trade-row").forEach(function (tr) { - tr.classList.remove("or-row-active"); - }); - ($("or-trade-id") || {}).value = ""; - ($("or-f-open") || {}).value = ""; - ($("or-f-close") || {}).value = ""; - ($("or-f-coin") || {}).value = ""; - ($("or-f-inst") || {}).value = ""; - ($("or-f-pnl") || {}).value = ""; - ($("or-f-hold") || {}).value = ""; - ($("or-f-strategy") || {}).value = ""; - ($("or-f-direction") || {}).value = ""; - ($("or-f-exit") || {}).value = ""; - ($("or-f-followed") || {}).value = ""; - ($("or-f-result") || {}).value = ""; - ($("or-f-entry") || {}).value = ""; - ($("or-f-prev-tf") || {}).value = ""; - ($("or-f-note") || {}).value = ""; - setMoodTags(""); - resetUploadSlots(); - resetChartOptions(); - setChartOptionsVisible(false); - var summary = $("or-journal-summary"); - if (summary) { - summary.textContent = "截图槽位与合约复盘相同(5m / 15m / 1h / 4h)."; - } - var legsHost = $("or-legs-host"); - if (legsHost) legsHost.innerHTML = ""; - ($("or-save-status") || {}).textContent = ""; - } - - function openJournalForm(tradeId) { - currentTradeId = tradeId; - var card = $("or-journal-card"); - if (!card) return; - card.classList.remove("hidden"); - card.scrollIntoView({ behavior: "smooth", block: "start" }); - document.querySelectorAll(".or-trade-row").forEach(function (tr) { - tr.classList.toggle("or-row-active", Number(tr.getAttribute("data-id")) === tradeId); - }); - resetUploadSlots(); - bindUploadSlots(); - ($("or-save-status") || {}).textContent = "加载中…"; - - fetch("/api/options/review/trades/" + tradeId, { credentials: "same-origin" }) - .then(function (r) { - return r.json(); - }) - .then(function (data) { - if (!data.ok || !data.trade) { - ($("or-save-status") || {}).textContent = "加载失败"; - return; - } - fillForm(data.trade); - ($("or-save-status") || {}).textContent = "已选中 #" + tradeId; - }) - .catch(function () { - ($("or-save-status") || {}).textContent = "加载失败"; - }); - } - - function fillForm(t) { - var e = t.entry || {}; - applyFormPresets(t.source_type || activeSource); - ($("or-trade-id") || {}).value = String(t.id || ""); - ($("or-f-open") || {}).value = toLocalInput(t.opened_at); - ($("or-f-close") || {}).value = toLocalInput(t.closed_at); - ($("or-f-coin") || {}).value = t.underlying || ""; - ($("or-f-inst") || {}).value = - t.source_type === "option_spot" - ? t.inst_id || "" - : (t.source_label || "") + - (t.plan_close_reason ? " · " + closeReasonLabel(t.plan_close_reason) : ""); - ($("or-f-pnl") || {}).value = fmtPnl(t.realized_pnl_total); - ($("or-f-hold") || {}).value = fmtHold(t.hold_seconds); - setSelectValue($("or-f-strategy"), e.strategy_tag || ""); - setSelectValue($("or-f-direction"), e.direction_view || autoDirection(t)); - ($("or-f-exit") || {}).value = e.exit_reason || closeReasonLabel(t.plan_close_reason) || ""; - ($("or-f-followed") || {}).value = e.followed_plan || ""; - setSelectValue($("or-f-result"), e.result_tag || autoResultTag(t.realized_pnl_total)); - setSelectValue($("or-f-entry"), e.entry_logic || ""); - setSelectValue($("or-f-prev-tf"), e.prev_entry_tf || ""); - ($("or-f-note") || {}).value = e.note || ""; - setMoodTags(e.mistake_tags); - resetChartOptions(); - setChartOptionsVisible(t.source_type === "option_spot"); - - var summary = $("or-journal-summary"); - if (summary) { - summary.textContent = - (t.source_label || "") + - " · " + - (t.inst_id || t.underlying || "#" + t.id) + - " · 盈亏 " + - fmtPnl(t.realized_pnl_total) + - (t.is_hedge - ? " (永续 " + fmtPnl(t.realized_pnl_perp) + " / 期权 " + fmtPnl(t.realized_pnl_options) + ")" - : ""); - } - - (e.images || []).forEach(function (img) { - var tf = String((img && img.tf) || "").trim(); - if (tf === "auto") return; - var hidden = document.querySelector( - '#or-upload-slots .or-upload-hidden[data-tf="' + img.tf + '"]' - ); - var status = document.querySelector( - '#or-upload-slots .or-upload-status[data-tf="' + img.tf + '"]' - ); - if (hidden && img.file) { - hidden.value = img.file; - if (status) { - var src = optionsJournalImgSrc(img.file); - status.innerHTML = - '已有 ' + - escapeHtml(img.file) + - '
' +
-            escapeHtml(img.tf || '; - status.className = - "journal-upload-status or-upload-status journal-upload-status--ok"; - } - } - }); - - var legsHost = $("or-legs-host"); - if (legsHost) { - if (t.legs && t.legs.length) { - legsHost.innerHTML = - "

计划腿

" + - t.legs - .map(function (leg) { - return ( - "" - ); - }) - .join("") + - "
腿合约盈亏原因
" + - escapeHtml(legRoleLabel(leg.leg_role)) + - "" + - escapeHtml(leg.inst_id || leg.symbol || "") + - "" + - fmtPnl(leg.realized_pnl) + - "" + - escapeHtml(closeReasonLabel(leg.close_reason)) + - "
"; - } else { - legsHost.innerHTML = ""; - } - } - } - - function hideTrade(tradeId) { - if (!tradeId) return; - if (!confirm("从待复盘列表删除并隐藏?刷新后也不会再出现.")) return; - fetch("/api/options/review/trades/" + tradeId, { - method: "DELETE", - credentials: "same-origin", - }) - .then(function (r) { - return r.json(); - }) - .then(function (data) { - if (!data.ok) { - alert(data.msg || "删除失败"); - return; - } - if (currentTradeId === tradeId) hideJournalForm(); - reloadAll(); - }) - .catch(function () { - alert("删除失败"); - }); - } - - function saveEntry() { - var tradeId = Number(($("or-trade-id") || {}).value || 0); - if (!tradeId) { - alert("请先点击交易记录中的「复盘」"); - return; - } - var strategy = (($("or-f-strategy") || {}).value || "").trim(); - if (!strategy) { - alert("请选择策略标签"); - return; - } - var payload = { - trade_id: tradeId, - strategy_tag: strategy, - direction_view: ($("or-f-direction") || {}).value || "", - exit_reason: ($("or-f-exit") || {}).value || "", - followed_plan: ($("or-f-followed") || {}).value || "", - result_tag: ($("or-f-result") || {}).value || "", - mistake_tags: collectMoodTags(), - entry_logic: ($("or-f-entry") || {}).value || "", - prev_entry_tf: ($("or-f-prev-tf") || {}).value || "", - note: ($("or-f-note") || {}).value || "", - images: collectImages(), - }; - var chartBox = $("or-chart-options"); - var chartVisible = chartBox && !chartBox.classList.contains("hidden"); - if (chartVisible && ($("or-exchange-chart") || {}).checked) { - payload.journal_exchange_chart = true; - payload.journal_chart_tf1 = ($("or-chart-tf1") || {}).value || ""; - payload.journal_chart_tf2 = ($("or-chart-tf2") || {}).value || ""; - payload.journal_chart_limit = ($("or-chart-limit") || {}).value || ""; - payload.journal_chart_anchor = ($("or-chart-anchor") || {}).value || "close"; - } - ($("or-save-status") || {}).textContent = "保存中…"; - fetch("/api/options/review/entry", { - method: "POST", - credentials: "same-origin", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }) - .then(function (r) { - return r.json(); - }) - .then(function (data) { - if (!data.ok) { - ($("or-save-status") || {}).textContent = data.msg || "保存失败"; - return; - } - ($("or-save-status") || {}).textContent = data.chart_queued - ? "已保存,K线图后台生成中" - : "已保存"; - hideJournalForm(); - reloadAll(); - openDetail(tradeId, { waitAutoChart: !!data.chart_queued }); - }) - .catch(function () { - ($("or-save-status") || {}).textContent = "保存失败"; - }); - } - - function deleteEntry() { - var tradeId = Number(($("or-trade-id") || {}).value || 0); - if (!tradeId) return; - if (!confirm("删除该条复盘内容与图片?交易记录会回到待复盘列表.")) return; - fetch("/api/options/review/entry/" + tradeId, { - method: "DELETE", - credentials: "same-origin", - }) - .then(function (r) { - return r.json(); - }) - .then(function () { - hideJournalForm(); - hideDetail(); - reloadAll(); - }); - } - - function init() { - if (!$("options-review-root")) return; - document.querySelectorAll(".or-tab").forEach(function (btn) { - btn.addEventListener("click", function () { - setActiveTab(btn.getAttribute("data-source")); - }); - }); - var reloadBtn = $("or-reload-btn"); - var saveBtn = $("or-save-btn"); - var clearBtn = $("or-clear-btn"); - var delBtn = $("or-del-btn"); - var prevBtn = $("or-trades-prev"); - var nextBtn = $("or-trades-next"); - var reviewedPrev = $("or-reviewed-prev"); - var reviewedNext = $("or-reviewed-next"); - var detailClose = $("or-detail-close-btn"); - var detailEdit = $("or-detail-edit-btn"); - if (reloadBtn) reloadBtn.addEventListener("click", reloadAll); - if (saveBtn) saveBtn.addEventListener("click", saveEntry); - if (clearBtn) clearBtn.addEventListener("click", hideJournalForm); - if (delBtn) delBtn.addEventListener("click", deleteEntry); - if (prevBtn) { - prevBtn.addEventListener("click", function (ev) { - ev.preventDefault(); - ev.stopPropagation(); - if (tradesPage <= 0) return; - tradesPage -= 1; - updateTradesPager(); - loadTrades({ sync: false, soft: true }); - }); - } - if (nextBtn) { - nextBtn.addEventListener("click", function (ev) { - ev.preventDefault(); - ev.stopPropagation(); - if (tradesPage + 1 >= tradesPages) return; - tradesPage += 1; - updateTradesPager(); - loadTrades({ sync: false, soft: true }); - }); - } - if (reviewedPrev) { - reviewedPrev.addEventListener("click", function (ev) { - ev.preventDefault(); - ev.stopPropagation(); - if (reviewedPage <= 0) return; - reviewedPage -= 1; - updateReviewedPager(); - loadReviewed({ sync: false, soft: true }); - }); - } - if (reviewedNext) { - reviewedNext.addEventListener("click", function (ev) { - ev.preventDefault(); - ev.stopPropagation(); - if (reviewedPage + 1 >= reviewedPages) return; - reviewedPage += 1; - updateReviewedPager(); - loadReviewed({ sync: false, soft: true }); - }); - } - if (detailClose) detailClose.addEventListener("click", hideDetail); - if (detailEdit) { - detailEdit.addEventListener("click", function () { - var id = Number(detailEdit.getAttribute("data-id") || 0); - if (id) { - hideDetail(); - openJournalForm(id); - } - }); - } - var detailBackdrop = $("or-detail-backdrop"); - if (detailBackdrop) { - detailBackdrop.addEventListener("click", function (ev) { - if (ev.target === detailBackdrop) hideDetail(); - }); - } - var lightbox = $("or-img-lightbox"); - if (lightbox) { - lightbox.addEventListener("click", function () { - hideLightbox(); - }); - } - document.addEventListener("keydown", function (ev) { - if (ev.key !== "Escape") return; - var lb = $("or-img-lightbox"); - if (lb && !lb.hidden) { - hideLightbox(); - return; - } - var bd = $("or-detail-backdrop"); - if (bd && !bd.hidden) hideDetail(); - }); - ["or-filter-uly", "or-filter-opt", "or-include-hedge-legs"].forEach(function (id) { - var el = $(id); - if (el) { - el.addEventListener("change", function () { - tradesPage = 0; - reviewedPage = 0; - reloadAll(); - }); - } - }); - ["or-filter-q", "or-filter-strategy", "or-filter-from", "or-filter-to"].forEach(function (id) { - var el = $(id); - if (el) { - el.addEventListener("change", function () { - tradesPage = 0; - reviewedPage = 0; - reloadAll(); - }); - } - }); - bindUploadSlots(); - hideJournalForm(); - hideDetail(); - hardenSearchAutofill(); - setActiveTab(defaultSourceForMode(tradeModeFromDom())); - } - - function hardenSearchAutofill() { - var qEl = $("or-filter-q"); - if (!qEl) return; - function wipe() { - qEl.value = ""; - } - wipe(); - qEl.addEventListener("focus", function () { - qEl.removeAttribute("readonly"); - }); - qEl.addEventListener("blur", function () { - if (!qEl.value) qEl.setAttribute("readonly", "readonly"); - }); - // 密码管理器常延后写入用户名,加载后再清两次 - setTimeout(wipe, 200); - setTimeout(wipe, 800); - } - - global.OptionsReview = { - init: init, - openJournalForm: openJournalForm, - hideJournalForm: hideJournalForm, - }; - - if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", init); - } else { - init(); - } -})(typeof window !== "undefined" ? window : globalThis); +/** + * OKX 期权复盘:待复盘交易(5行) → 点复盘出表单 → 复盘记录详情 → 统计. + */ +(function (global) { + "use strict"; + + var PAGE_SIZE = 5; + var TAB_LABELS = { + option_spot: "期权交易记录", + options_options: "期期对冲记录", + perp_options: "永期对冲记录", + }; + var FORM_PRESETS = { + option_spot: { + strategy: ["顺势", "反转"], + direction: ["多", "空"], + entry: ["假突破", "结构突破", "左侧条件单"], + }, + hedge: { + strategy: ["横盘", "趋势"], + direction: ["多", "空"], + entry: ["横盘博弈方向", "趋势对冲止损"], + }, + }; + var RESULT_OPTIONS = ["盈利", "亏损", "持平"]; + var activeSource = "option_spot"; + var currentTradeId = null; + var draftId = ""; + var tradesCache = {}; + var reviewedCache = {}; + var tradesPage = 0; + var tradesPages = 1; + var reviewedPage = 0; + var reviewedPages = 1; + + function $(id) { + return document.getElementById(id); + } + + function escapeHtml(s) { + return String(s == null ? "" : s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function fmtPnl(v) { + if (v == null || v === "") return "—"; + var n = Number(v); + if (Number.isNaN(n)) return "—"; + return (n >= 0 ? "+" : "") + n.toFixed(2) + "U"; + } + + function fmtPremiumU(v) { + if (v == null || v === "") return "—"; + var n = Number(v); + if (Number.isNaN(n)) return "—"; + return n.toFixed(2) + "U"; + } + + function fmtProfitRate(v) { + if (v == null || v === "") return "—"; + var n = Number(v); + if (Number.isNaN(n)) return "—"; + // 存小数倍率(1.5=1.5倍);展示为百分比 + return (n >= 0 ? "+" : "") + (n * 100).toFixed(1) + "%"; + } + + function resolveProfitRate(t) { + if (!t) return null; + if (t.profit_rate != null && t.profit_rate !== "") return Number(t.profit_rate); + var pnl = Number(t.realized_pnl_total); + var prem = Number(t.initial_premium_usdt); + if (!Number.isFinite(pnl) || !Number.isFinite(prem) || Math.abs(prem) < 1e-12) return null; + return pnl / prem; + } + + function fmtHold(sec) { + if (sec == null) return "—"; + var s = Math.max(0, Number(sec) || 0); + if (s < 3600) return Math.round(s / 60) + "m"; + if (s < 86400) return (s / 3600).toFixed(1) + "h"; + return (s / 86400).toFixed(1) + "d"; + } + + function toLocalInput(ts) { + if (!ts) return ""; + var s = String(ts).trim().replace(" ", "T"); + if (s.length >= 16) return s.slice(0, 16); + return s; + } + + function closeReasonLabel(r) { + var map = { + perp_tp: "永续止盈", + perp_sl: "永续止损", + oo_expiry_loss: "期期到期亏损", + oo_expiry_win: "期期到期盈利", + target_win_leg: "期期平盈利腿", + target_up_win_leg: "期期上破·平盈利腿", + target_down_win_leg: "期期下破·平盈利腿", + profit_rr_win_leg: "期期盈亏比达标·平盈利腿", + oo_rest_closing: "期期残值平·清亏损腿中", + oo_rest_closed: "期期残值平·两腿已平", + orphaned_after_tp: "止盈后持有至到期", + orphaned_option_expiry: "残腿到期", + hold_to_expiry: "持有至到期", + expiry: "到期", + manual: "人工结束", + partial_fail: "半腿失败", + cancelled: "已取消", + tp: "止盈", + sl: "止损", + }; + var key = String(r || "").trim(); + if (!key) return "—"; + return map[key] || key; + } + + function legRoleLabel(role) { + var map = { + perp: "永续腿", + option_hedge: "保险期权", + option_a: "期期腿A", + option_b: "期期腿B", + }; + var key = String(role || "").trim(); + if (!key) return "—"; + return map[key] || key; + } + + function tradeTitle(t) { + if (!t) return "—"; + if (t.source_type === "option_spot") return t.inst_id || "—"; + return ( + (t.underlying || "") + + (t.direction ? " " + t.direction : "") + + (t.plan_close_reason ? " · " + closeReasonLabel(t.plan_close_reason) : "") + ); + } + + function pnlClass(v) { + var n = Number(v); + if (n > 0) return "pos-pnl-profit"; + if (n < 0) return "pos-pnl-loss"; + return ""; + } + + function resultClass(tag) { + var t = String(tag || "").trim(); + if (t === "盈利") return "pos-pnl-profit"; + if (t === "亏损") return "pos-pnl-loss"; + return ""; + } + + function tradeContractLabel(t) { + if (!t) return "—"; + if (t.source_type === "option_spot") return t.inst_id || t.underlying || "—"; + return t.underlying || "—"; + } + + function newDraftId() { + if (global.crypto && typeof global.crypto.randomUUID === "function") { + return global.crypto.randomUUID().replace(/-/g, ""); + } + var s = ""; + for (var i = 0; i < 32; i++) s += Math.floor(Math.random() * 16).toString(16); + return s; + } + + function baseQs() { + var p = new URLSearchParams(); + p.set("source_type", activeSource); + var uly = ($("or-filter-uly") || {}).value || ""; + var opt = ($("or-filter-opt") || {}).value || ""; + var q = (($("or-filter-q") || $("or-filter-strategy") || {}).value || "").trim(); + var from = ($("or-filter-from") || {}).value || ""; + var to = ($("or-filter-to") || {}).value || ""; + if (uly) p.set("underlying", uly); + if (opt) p.set("opt_type", opt); + if (q) p.set("q", q); + if (from) p.set("closed_from", from.replace("T", " ") + ":00"); + if (to) p.set("closed_to", to.replace("T", " ") + ":00"); + if (($("or-include-hedge-legs") || {}).checked) p.set("include_hedge_legs", "1"); + return p; + } + + function setSyncStatus(text) { + var el = $("or-sync-status"); + if (el) el.textContent = text || ""; + } + + function reloadAll() { + // 先无同步出列表(统计同理),再后台同步;避免模拟盘握库+拉行情把列表卡死在「加载中」 + setSyncStatus("读取本地记录…"); + loadTrades({ sync: false }); + loadReviewed({ sync: false }); + loadStats(); + backgroundSync(); + } + + function backgroundSync() { + setSyncStatus("同步本地记录…"); + fetch("/api/options/review/sync", { credentials: "same-origin", method: "POST" }) + .then(function (r) { + return r.json(); + }) + .then(function (data) { + if (data && data.ok === false) { + setSyncStatus("同步跳过,已显示本地缓存"); + return; + } + setSyncStatus("本地记录已加载"); + loadTrades({ sync: false, soft: true }); + loadReviewed({ sync: false, soft: true }); + loadStats(); + }) + .catch(function () { + setSyncStatus("同步跳过,已显示本地缓存"); + }); + } + + function isHedgeSource(sourceType) { + return sourceType === "options_options" || sourceType === "perp_options"; + } + + function fillSelect(el, options, placeholder) { + if (!el) return; + var keep = el.value; + el.innerHTML = ""; + var first = document.createElement("option"); + first.value = ""; + first.textContent = placeholder || ""; + el.appendChild(first); + (options || []).forEach(function (v) { + var opt = document.createElement("option"); + opt.value = v; + opt.textContent = v; + el.appendChild(opt); + }); + if (keep) setSelectValue(el, keep); + } + + function setSelectValue(el, value) { + if (!el) return; + var v = value == null ? "" : String(value); + if (!v) { + el.value = ""; + return; + } + var found = false; + for (var i = 0; i < el.options.length; i++) { + if (el.options[i].value === v) { + found = true; + break; + } + } + if (!found) { + var opt = document.createElement("option"); + opt.value = v; + opt.textContent = v; + el.appendChild(opt); + } + el.value = v; + } + + function applyFormPresets(sourceType) { + var preset = isHedgeSource(sourceType) ? FORM_PRESETS.hedge : FORM_PRESETS.option_spot; + fillSelect($("or-f-strategy"), preset.strategy, "策略标签"); + fillSelect($("or-f-direction"), preset.direction, "方向判断"); + fillSelect($("or-f-entry"), preset.entry, "入场逻辑"); + fillSelect($("or-f-result"), RESULT_OPTIONS, "结果标签"); + } + + function autoDirection(t) { + if (!t) return ""; + if (isHedgeSource(t.source_type)) { + var d = String(t.direction || "").trim().toLowerCase(); + if (d === "long" || d === "buy" || d === "多") return "多"; + if (d === "short" || d === "sell" || d === "空") return "空"; + return ""; + } + var ot = String(t.opt_type || "").trim().toUpperCase(); + if (ot === "C" || ot === "CALL") return "多"; + if (ot === "P" || ot === "PUT") return "空"; + return ""; + } + + function autoResultTag(pnl) { + if (pnl == null || pnl === "") return ""; + var n = Number(pnl); + if (Number.isNaN(n)) return ""; + if (n > 0) return "盈利"; + if (n < 0) return "亏损"; + return "持平"; + } + + function tradeModeFromDom() { + var tabs = document.querySelector(".or-tabs"); + return (tabs && tabs.getAttribute("data-okx-trade-mode")) || "options"; + } + + function defaultSourceForMode(mode) { + if (mode === "options_options") return "options_options"; + if (mode === "perp_options") return "perp_options"; + return "option_spot"; + } + + function setActiveTab(source) { + var mode = tradeModeFromDom(); + var allowed = defaultSourceForMode(mode); + activeSource = source || allowed; + if (activeSource !== allowed) activeSource = allowed; + tradesPage = 0; + reviewedPage = 0; + document.querySelectorAll(".or-tab").forEach(function (btn) { + btn.classList.toggle("active", btn.getAttribute("data-source") === activeSource); + }); + var title = $("or-list-title"); + if (title) title.textContent = TAB_LABELS[activeSource] || "记录"; + applyFormPresets(activeSource); + hideJournalForm(); + hideDetail(); + reloadAll(); + } + + function updateTradesPager() { + var label = $("or-trades-page-label"); + var prev = $("or-trades-prev"); + var next = $("or-trades-next"); + if (label) { + label.textContent = "第 " + (tradesPage + 1) + " / " + tradesPages + " 页"; + } + if (prev) prev.disabled = tradesPage <= 0; + if (next) next.disabled = tradesPage + 1 >= tradesPages; + } + + function updateReviewedPager() { + var label = $("or-reviewed-page-label"); + var prev = $("or-reviewed-prev"); + var next = $("or-reviewed-next"); + if (label) { + label.textContent = "第 " + (reviewedPage + 1) + " / " + reviewedPages + " 页"; + } + if (prev) prev.disabled = reviewedPage <= 0; + if (next) next.disabled = reviewedPage + 1 >= reviewedPages; + } + + function applyPagerMeta(data, kind) { + var pages = Number(data.pages || 1); + if (!pages || pages < 1) pages = 1; + var clamped = false; + if (kind === "trades") { + tradesPages = pages; + if (tradesPage >= tradesPages) { + tradesPage = Math.max(0, tradesPages - 1); + clamped = true; + } + updateTradesPager(); + } else { + reviewedPages = pages; + if (reviewedPage >= reviewedPages) { + reviewedPage = Math.max(0, reviewedPages - 1); + clamped = true; + } + updateReviewedPager(); + } + return clamped; + } + + function beginListLoad(wrapId, soft) { + var wrap = $(wrapId); + if (!wrap) return null; + if (soft) { + if (!wrap.style.minHeight) { + wrap.style.minHeight = Math.max(wrap.offsetHeight, 1) + "px"; + } + wrap.classList.add("or-list-loading"); + } else { + wrap.classList.remove("or-list-loading"); + wrap.style.minHeight = ""; + } + return wrap; + } + + function endListLoad(wrap) { + if (!wrap) return; + wrap.classList.remove("or-list-loading"); + wrap.style.minHeight = ""; + } + + function loadTrades(opts) { + opts = opts || {}; + var doSync = opts.sync !== false; + var soft = !!opts.soft; + var tbody = $("or-trades-tbody"); + if (!tbody) return; + var wrap = beginListLoad("or-trades-wrap", soft); + if (!soft) { + tbody.innerHTML = '加载中…'; + } + var p = baseQs(); + // 交易记录保留已复盘条目,不再只显示待复盘 + p.set("limit", String(PAGE_SIZE)); + p.set("offset", String(tradesPage * PAGE_SIZE)); + if (!doSync) p.set("sync", "0"); + fetch("/api/options/review/trades?" + p.toString(), { credentials: "same-origin" }) + .then(function (r) { + return r.json(); + }) + .then(function (data) { + if (doSync) setSyncStatus("本地记录已加载"); + if (!data.ok) { + tbody.innerHTML = '加载失败'; + endListLoad(wrap); + return; + } + if (applyPagerMeta(data, "trades") && Number(data.total || 0) > 0) { + loadTrades(opts); + return; + } + var rows = data.trades || []; + tradesCache = {}; + if (!rows.length) { + tbody.innerHTML = + '暂无交易记录'; + endListLoad(wrap); + return; + } + tbody.innerHTML = rows + .map(function (t) { + tradesCache[t.id] = t; + var active = currentTradeId === t.id ? " or-row-active" : ""; + var reviewed = !!t.reviewed; + var pr = resolveProfitRate(t); + var actionBtn = reviewed + ? '' + : ''; + var badgeExtra = reviewed + ? ' 已复盘' + : ""; + return ( + '' + + "" + + escapeHtml(t.source_label || t.source_type) + + "" + + badgeExtra + + "" + + "" + + escapeHtml(tradeTitle(t)) + + "" + + "" + + fmtPremiumU(t.initial_premium_usdt) + + "" + + '' + + fmtPnl(t.realized_pnl_total) + + "" + + '' + + fmtProfitRate(pr) + + "" + + '' + + escapeHtml(t.opened_at || "—") + + "" + + '' + + escapeHtml(t.closed_at || "—") + + "" + + "" + + fmtHold(t.hold_seconds) + + "" + + "" + + actionBtn + + " " + + '' + + "" + ); + }) + .join(""); + tbody.querySelectorAll(".or-review-btn").forEach(function (btn) { + btn.addEventListener("click", function (ev) { + ev.preventDefault(); + ev.stopPropagation(); + openJournalForm(Number(btn.getAttribute("data-id"))); + }); + }); + tbody.querySelectorAll(".or-hide-btn").forEach(function (btn) { + btn.addEventListener("click", function (ev) { + ev.preventDefault(); + ev.stopPropagation(); + hideTrade(Number(btn.getAttribute("data-id"))); + }); + }); + endListLoad(wrap); + }) + .catch(function () { + if (doSync) setSyncStatus("加载失败"); + tbody.innerHTML = '加载失败'; + endListLoad(wrap); + }); + } + + function loadReviewed(opts) { + opts = opts || {}; + var doSync = opts.sync === true; + var soft = !!opts.soft; + var tbody = $("or-reviewed-tbody"); + if (!tbody) return; + var wrap = beginListLoad("or-reviewed-wrap", soft); + if (!soft) { + tbody.innerHTML = '加载中…'; + } + var p = baseQs(); + p.set("reviewed", "1"); + p.set("limit", String(PAGE_SIZE)); + p.set("offset", String(reviewedPage * PAGE_SIZE)); + if (!doSync) p.set("sync", "0"); + fetch("/api/options/review/trades?" + p.toString(), { credentials: "same-origin" }) + .then(function (r) { + return r.json(); + }) + .then(function (data) { + if (!data.ok) { + tbody.innerHTML = '加载失败'; + endListLoad(wrap); + return; + } + if (applyPagerMeta(data, "reviewed") && Number(data.total || 0) > 0) { + loadReviewed(opts); + return; + } + var rows = data.trades || []; + reviewedCache = {}; + if (!rows.length) { + tbody.innerHTML = '暂无复盘记录'; + endListLoad(wrap); + return; + } + tbody.innerHTML = rows + .map(function (t) { + reviewedCache[t.id] = t; + var entry = t.entry || {}; + var direction = t.direction_view || entry.direction_view || ""; + var entryLogic = t.entry_logic || entry.entry_logic || ""; + return ( + '' + + "" + + escapeHtml(t.source_label || t.source_type) + + "" + + "" + + escapeHtml(tradeContractLabel(t)) + + "" + + "" + + escapeHtml(direction || "—") + + "" + + "" + + fmtPremiumU(t.initial_premium_usdt) + + "" + + '' + + fmtPnl(t.realized_pnl_total) + + "" + + '' + + fmtProfitRate(resolveProfitRate(t)) + + "" + + '' + + escapeHtml(t.opened_at || "—") + + "" + + '' + + escapeHtml(t.closed_at || "—") + + "" + + "" + + escapeHtml(fmtHold(t.hold_seconds)) + + "" + + "" + + escapeHtml(t.strategy_tag || "—") + + "" + + "" + + escapeHtml(entryLogic || "—") + + "" + + '' + + escapeHtml(t.result_tag || "—") + + "" + + '' + + escapeHtml(t.reviewed_at || "—") + + "" + + "" + ); + }) + .join(""); + tbody.querySelectorAll(".or-reviewed-row").forEach(function (tr) { + tr.addEventListener("click", function () { + openDetail(Number(tr.getAttribute("data-id"))); + }); + }); + endListLoad(wrap); + }) + .catch(function () { + tbody.innerHTML = '加载失败'; + endListLoad(wrap); + }); + } + + function hideLightbox() { + var box = $("or-img-lightbox"); + if (box) box.hidden = true; + var img = $("or-img-lightbox-img"); + if (img) img.src = ""; + } + + function showLightbox(src) { + var url = String(src || "").trim(); + if (!url) return; + var box = $("or-img-lightbox"); + var img = $("or-img-lightbox-img"); + if (box && img) { + img.src = url; + box.hidden = false; + return; + } + if (typeof global.showImage === "function") { + global.showImage(url); + } else if (typeof window.showImage === "function") { + window.showImage(url); + } else { + global.open(url, "_blank"); + } + } + + var detailChartPollTimer = null; + var detailChartPollTradeId = 0; + + function hideDetail() { + hideLightbox(); + stopDetailChartPoll(); + var backdrop = $("or-detail-backdrop"); + if (backdrop) backdrop.hidden = true; + } + + function stopDetailChartPoll() { + if (detailChartPollTimer) { + clearTimeout(detailChartPollTimer); + detailChartPollTimer = null; + } + detailChartPollTradeId = 0; + } + + function tradeHasAutoChart(t) { + var images = ((t && t.entry) || {}).images || []; + for (var i = 0; i < images.length; i++) { + var tf = String((images[i] && images[i].tf) || "").trim(); + var file = String((images[i] && images[i].file) || "").trim(); + if (tf === "auto" && file) return true; + } + return false; + } + + function openDetail(tradeId, opts) { + opts = opts || {}; + var waitAuto = !!opts.waitAutoChart; + var backdrop = $("or-detail-backdrop"); + if (!backdrop) return; + stopDetailChartPoll(); + backdrop.hidden = false; + ($("or-detail-title") || {}).textContent = "加载中…"; + ($("or-detail-meta") || {}).innerHTML = ""; + ($("or-detail-text") || {}).innerHTML = ""; + ($("or-detail-images") || {}).innerHTML = waitAuto + ? '
自动K线
生成中…
' + : ""; + + function applyTrade(trade, pendingAuto) { + renderDetail(trade, { pendingAuto: !!pendingAuto }); + } + + function fetchOnce() { + return fetch("/api/options/review/trades/" + tradeId, { + credentials: "same-origin", + }).then(function (r) { + return r.json(); + }); + } + + fetchOnce() + .then(function (data) { + if (!data.ok || !data.trade) { + ($("or-detail-title") || {}).textContent = "加载失败"; + return; + } + if (!waitAuto || tradeHasAutoChart(data.trade)) { + applyTrade(data.trade, false); + return; + } + applyTrade(data.trade, true); + detailChartPollTradeId = tradeId; + var tries = 0; + var maxTries = 20; + function poll() { + if (detailChartPollTradeId !== tradeId) return; + tries += 1; + fetchOnce() + .then(function (again) { + if (detailChartPollTradeId !== tradeId) return; + if (again.ok && again.trade && tradeHasAutoChart(again.trade)) { + stopDetailChartPoll(); + applyTrade(again.trade, false); + loadReviewed({ sync: false, soft: true }); + return; + } + if (tries >= maxTries) { + stopDetailChartPoll(); + applyTrade((again && again.trade) || data.trade, false); + return; + } + detailChartPollTimer = setTimeout(poll, 1500); + }) + .catch(function () { + if (detailChartPollTradeId !== tradeId) return; + if (tries >= maxTries) { + stopDetailChartPoll(); + return; + } + detailChartPollTimer = setTimeout(poll, 1500); + }); + } + detailChartPollTimer = setTimeout(poll, 1200); + }) + .catch(function () { + ($("or-detail-title") || {}).textContent = "加载失败"; + }); + } + + function optionsJournalImgSrc(file) { + var name = String(file || "").trim().replace(/\\/g, "/"); + var slash = name.lastIndexOf("/"); + if (slash >= 0) name = name.slice(slash + 1); + if (!name) return ""; + // options_journal_* 在子目录;误走合约上传的 journal_* 在 static/images 根目录 + var base = + name.toLowerCase().indexOf("options_journal_") === 0 + ? "/static/images/options_journal/" + : "/static/images/"; + return base + encodeURIComponent(name); + } + + function chartTfLabel(tf) { + if (tf === "auto") return "自动K线"; + if (tf === "_") return "截图"; + return tf; + } + + function setChartOptionsVisible(visible) { + var box = $("or-chart-options"); + var hint = $("or-chart-hint"); + if (box) box.classList.toggle("hidden", !visible); + if (hint) hint.classList.toggle("hidden", !visible); + } + + function resetChartOptions() { + var cb = $("or-exchange-chart"); + // 期权复盘:默认勾选后台生成 K 线 + if (cb) cb.checked = true; + } + + function renderDetailImages(images, opts) { + var imagesHost = $("or-detail-images"); + if (!imagesHost) return; + opts = opts || {}; + var pendingAuto = !!opts.pendingAuto; + var byTf = {}; + (images || []).forEach(function (img) { + var tf = String((img && img.tf) || "").trim(); + var file = String((img && img.file) || "").trim(); + if (!file) return; + var key = tf || "_"; + byTf[key] = file; + }); + // 互斥:有自动K线只显示自动;否则只显示手动四周期 + var hasAuto = !!byTf.auto; + if (pendingAuto && !hasAuto) { + imagesHost.innerHTML = + '
' + + '自动K线' + + '
生成中…
' + + "
"; + return; + } + var order = hasAuto ? ["auto"] : ["5m", "15m", "1h", "4h"]; + var keys = order.slice(); + if (!hasAuto) { + Object.keys(byTf).forEach(function (k) { + if (k === "auto") return; + if (keys.indexOf(k) < 0) keys.push(k); + }); + } + var cells = keys + .map(function (tf) { + var file = byTf[tf]; + if (!file) { + if (hasAuto || order.indexOf(tf) < 0) return ""; + return ( + '
' + + '' + + escapeHtml(tf) + + "" + + '
未上传
' + + "
" + ); + } + var src = optionsJournalImgSrc(file); + var label = escapeHtml(chartTfLabel(tf)); + return ( + '
' + + '' + + label + + "" + + '' +
+          label +
+          '' + + "
" + ); + }) + .filter(Boolean); + if (!cells.length) { + imagesHost.innerHTML = '
无截图
'; + return; + } + imagesHost.innerHTML = cells.join(""); + imagesHost.querySelectorAll("img").forEach(function (img) { + img.addEventListener("error", function () { + var cell = img.closest(".or-detail-img-cell"); + if (!cell) return; + var label = cell.querySelector(".or-detail-img-label"); + var tf = label ? label.textContent : "截图"; + cell.innerHTML = + '' + + escapeHtml(tf) + + "" + + '
文件缺失或无法加载
'; + }); + img.addEventListener("click", function () { + var src = img.getAttribute("data-src") || img.src; + showLightbox(src); + }); + }); + } + + function renderDetail(t, opts) { + var e = t.entry || {}; + reviewedCache[t.id] = t; + ($("or-detail-title") || {}).textContent = + "复盘详情 · " + (t.source_label || "") + " · " + tradeTitle(t); + var editBtn = $("or-detail-edit-btn"); + if (editBtn) editBtn.setAttribute("data-id", String(t.id)); + + var meta = $("or-detail-meta"); + if (meta) { + var cells = [ + ["标的", t.underlying || "—"], + ["合约/计划", tradeTitle(t)], + ["初始权利金", fmtPremiumU(t.initial_premium_usdt)], + ["盈亏", fmtPnl(t.realized_pnl_total)], + ["盈利率", fmtProfitRate(resolveProfitRate(t))], + ["持有", fmtHold(t.hold_seconds)], + ["开仓时间", t.opened_at || "—"], + ["平仓时间", t.closed_at || "—"], + ["策略", e.strategy_tag || "—"], + ["方向", e.direction_view || "—"], + ["结果", e.result_tag || "—"], + ["离场", e.exit_reason || "—"], + ["按计划", e.followed_plan || "—"], + ["入场逻辑", e.entry_logic || "—"], + ["入场周期", e.prev_entry_tf || "—"], + ]; + if (t.is_hedge) { + cells.push(["永续盈亏", fmtPnl(t.realized_pnl_perp)]); + cells.push(["期权盈亏", fmtPnl(t.realized_pnl_options)]); + } + meta.innerHTML = cells + .map(function (pair) { + var cls = ""; + if (pair[0] === "盈亏" || pair[0] === "永续盈亏" || pair[0] === "期权盈亏") { + cls = pnlClass(t.realized_pnl_total); + if (pair[0] === "永续盈亏") cls = pnlClass(t.realized_pnl_perp); + if (pair[0] === "期权盈亏") cls = pnlClass(t.realized_pnl_options); + } else if (pair[0] === "结果") { + cls = resultClass(e.result_tag); + } + return ( + "
" + + escapeHtml(pair[0]) + + '
' + + escapeHtml(pair[1]) + + "
" + ); + }) + .join(""); + } + + var text = $("or-detail-text"); + if (text) { + var lines = []; + if (e.mistake_tags) lines.push("
心理标签:" + escapeHtml(e.mistake_tags) + "
"); + if (e.note) lines.push("
备注:" + escapeHtml(e.note).replace(/\n/g, "
") + "
"); + if (t.legs && t.legs.length) { + lines.push( + "
计划腿
" + + t.legs + .map(function (leg) { + return ( + "" + ); + }) + .join("") + + "
腿合约盈亏原因
" + + escapeHtml(legRoleLabel(leg.leg_role)) + + "" + + escapeHtml(leg.inst_id || leg.symbol || "") + + "" + + fmtPnl(leg.realized_pnl) + + "" + + escapeHtml(closeReasonLabel(leg.close_reason)) + + "
" + ); + } + text.innerHTML = lines.join("") || '
无额外备注
'; + } + + var imagesHost = $("or-detail-images"); + if (imagesHost) { + renderDetailImages(e.images || [], opts || {}); + } + } + + function renderGroup(title, items) { + if (!items || !items.length) return ""; + var lines = items + .slice(0, 8) + .map(function (g) { + var keyLabel = + title === "对冲结束原因" ? closeReasonLabel(g.key) : String(g.key || ""); + return ( + '
' + + '' + + escapeHtml(keyLabel) + + " · " + + g.count + + "笔" + + '' + + fmtPnl(g.pnl_sum) + + " / 胜" + + (g.win_rate || 0) + + "%" + + "
" + ); + }) + .join(""); + return ( + '
' + + title + + "
" + + lines + + "
" + ); + } + + function loadStats() { + var kpi = $("or-kpi"); + var groups = $("or-stats-groups"); + if (!kpi || !groups) return; + // 与 trades?sync=1 并行时不再二次同步,避免 SQLite database is locked + var qs = baseQs(); + qs.set("sync", "0"); + fetch("/api/options/review/stats?" + qs.toString(), { credentials: "same-origin" }) + .then(function (r) { + return r.json(); + }) + .then(function (data) { + if (!data.ok) return; + var k = data.kpi || {}; + kpi.innerHTML = [ + ["笔数", k.total], + ["已复盘率", (k.review_rate || 0) + "%"], + ["胜率", (k.win_rate || 0) + "%"], + ["累计盈亏", fmtPnl(k.pnl_sum)], + ["平均盈亏", fmtPnl(k.avg_pnl)], + ["平均持有", fmtHold(k.avg_hold_sec)], + ] + .map(function (pair) { + var cls = ""; + if (pair[0] === "累计盈亏") cls = pnlClass(k.pnl_sum); + if (pair[0] === "平均盈亏") cls = pnlClass(k.avg_pnl); + return ( + '
' + + pair[0] + + '
' + + pair[1] + + "
" + ); + }) + .join(""); + var html = [ + renderGroup("按类型", data.by_source_type), + renderGroup("按标的", data.by_underlying), + renderGroup("按策略", data.by_strategy), + renderGroup("对冲结束原因", data.by_close_reason), + renderGroup("持有周期", data.by_hold_bucket), + renderGroup("Call/Put", data.by_opt_type), + ] + .filter(Boolean) + .join(""); + groups.innerHTML = html || '
暂无分组数据
'; + }) + .catch(function () {}); + } + + function resetUploadSlots() { + draftId = newDraftId(); + var draftEl = $("or-draft-id"); + if (draftEl) draftEl.value = draftId; + document.querySelectorAll("#or-upload-slots .or-upload-hidden").forEach(function (el) { + el.value = ""; + }); + document.querySelectorAll("#or-upload-slots .or-upload-input").forEach(function (el) { + el.value = ""; + }); + document.querySelectorAll("#or-upload-slots .or-upload-status").forEach(function (el) { + el.textContent = ""; + }); + } + + function bindUploadSlots() { + document.querySelectorAll("#or-upload-slots .or-upload-input").forEach(function (input) { + if (input.dataset.orBound === "1") return; + input.dataset.orBound = "1"; + input.addEventListener("change", function () { + var file = input.files && input.files[0]; + var row = input.closest(".journal-upload-row"); + var status = row && row.querySelector(".or-upload-status"); + var hidden = row && row.querySelector(".or-upload-hidden"); + if (!file) { + if (hidden) hidden.value = ""; + if (status) { + status.textContent = ""; + status.className = "journal-upload-status or-upload-status"; + } + return; + } + if (!draftId) draftId = newDraftId(); + if (status) { + status.textContent = "上传中…"; + status.className = "journal-upload-status or-upload-status journal-upload-status--pending"; + } + var fd = new FormData(); + fd.append("draft_id", draftId); + fd.append("tf", input.getAttribute("data-tf") || ""); + fd.append("file", file); + fetch("/api/options/review/upload_slot", { + method: "POST", + body: fd, + credentials: "same-origin", + }) + .then(function (r) { + return r.json(); + }) + .then(function (data) { + if (!data.ok) throw new Error(data.error || "fail"); + if (hidden) hidden.value = data.file; + if (status) { + status.textContent = "上传成功 " + data.file; + status.className = "journal-upload-status or-upload-status journal-upload-status--ok"; + } + input.value = ""; + }) + .catch(function () { + if (hidden) hidden.value = ""; + if (status) { + status.textContent = "上传失败"; + status.className = "journal-upload-status or-upload-status journal-upload-status--err"; + } + }); + }); + }); + } + + function setMoodTags(raw) { + var set = {}; + String(raw || "") + .split(/[,,]/) + .map(function (x) { + return x.trim(); + }) + .filter(Boolean) + .forEach(function (x) { + set[x] = true; + }); + document.querySelectorAll(".or-mood").forEach(function (cb) { + cb.checked = !!set[cb.value]; + }); + } + + function collectMoodTags() { + var out = []; + document.querySelectorAll(".or-mood:checked").forEach(function (cb) { + out.push(cb.value); + }); + return out.join(","); + } + + function collectImages() { + var out = []; + document.querySelectorAll("#or-upload-slots .or-upload-hidden").forEach(function (el) { + var file = (el.value || "").trim(); + if (file) out.push({ tf: el.getAttribute("data-tf") || "", file: file }); + }); + return out; + } + + function hideJournalForm() { + currentTradeId = null; + var card = $("or-journal-card"); + if (card) card.classList.add("hidden"); + document.querySelectorAll(".or-trade-row").forEach(function (tr) { + tr.classList.remove("or-row-active"); + }); + ($("or-trade-id") || {}).value = ""; + ($("or-f-open") || {}).value = ""; + ($("or-f-close") || {}).value = ""; + ($("or-f-coin") || {}).value = ""; + ($("or-f-inst") || {}).value = ""; + ($("or-f-pnl") || {}).value = ""; + ($("or-f-hold") || {}).value = ""; + ($("or-f-strategy") || {}).value = ""; + ($("or-f-direction") || {}).value = ""; + ($("or-f-exit") || {}).value = ""; + ($("or-f-followed") || {}).value = ""; + ($("or-f-result") || {}).value = ""; + ($("or-f-entry") || {}).value = ""; + ($("or-f-prev-tf") || {}).value = ""; + ($("or-f-note") || {}).value = ""; + setMoodTags(""); + resetUploadSlots(); + resetChartOptions(); + setChartOptionsVisible(false); + var summary = $("or-journal-summary"); + if (summary) { + summary.textContent = "截图槽位与合约复盘相同(5m / 15m / 1h / 4h)."; + } + var legsHost = $("or-legs-host"); + if (legsHost) legsHost.innerHTML = ""; + ($("or-save-status") || {}).textContent = ""; + } + + function openJournalForm(tradeId) { + currentTradeId = tradeId; + var card = $("or-journal-card"); + if (!card) return; + card.classList.remove("hidden"); + card.scrollIntoView({ behavior: "smooth", block: "start" }); + document.querySelectorAll(".or-trade-row").forEach(function (tr) { + tr.classList.toggle("or-row-active", Number(tr.getAttribute("data-id")) === tradeId); + }); + resetUploadSlots(); + bindUploadSlots(); + ($("or-save-status") || {}).textContent = "加载中…"; + + fetch("/api/options/review/trades/" + tradeId, { credentials: "same-origin" }) + .then(function (r) { + return r.json(); + }) + .then(function (data) { + if (!data.ok || !data.trade) { + ($("or-save-status") || {}).textContent = "加载失败"; + return; + } + fillForm(data.trade); + ($("or-save-status") || {}).textContent = "已选中 #" + tradeId; + }) + .catch(function () { + ($("or-save-status") || {}).textContent = "加载失败"; + }); + } + + function fillForm(t) { + var e = t.entry || {}; + applyFormPresets(t.source_type || activeSource); + ($("or-trade-id") || {}).value = String(t.id || ""); + ($("or-f-open") || {}).value = toLocalInput(t.opened_at); + ($("or-f-close") || {}).value = toLocalInput(t.closed_at); + ($("or-f-coin") || {}).value = t.underlying || ""; + ($("or-f-inst") || {}).value = + t.source_type === "option_spot" + ? t.inst_id || "" + : (t.source_label || "") + + (t.plan_close_reason ? " · " + closeReasonLabel(t.plan_close_reason) : ""); + ($("or-f-pnl") || {}).value = fmtPnl(t.realized_pnl_total); + ($("or-f-initial-prem") || {}).value = fmtPremiumU(t.initial_premium_usdt); + ($("or-f-profit-rate") || {}).value = fmtProfitRate(resolveProfitRate(t)); + ($("or-f-hold") || {}).value = fmtHold(t.hold_seconds); + setSelectValue($("or-f-strategy"), e.strategy_tag || ""); + setSelectValue($("or-f-direction"), e.direction_view || autoDirection(t)); + ($("or-f-exit") || {}).value = e.exit_reason || closeReasonLabel(t.plan_close_reason) || ""; + ($("or-f-followed") || {}).value = e.followed_plan || ""; + setSelectValue($("or-f-result"), e.result_tag || autoResultTag(t.realized_pnl_total)); + setSelectValue($("or-f-entry"), e.entry_logic || ""); + setSelectValue($("or-f-prev-tf"), e.prev_entry_tf || ""); + ($("or-f-note") || {}).value = e.note || ""; + setMoodTags(e.mistake_tags); + resetChartOptions(); + setChartOptionsVisible(t.source_type === "option_spot"); + + var summary = $("or-journal-summary"); + if (summary) { + summary.textContent = + (t.source_label || "") + + " · " + + (t.inst_id || t.underlying || "#" + t.id) + + " · 盈亏 " + + fmtPnl(t.realized_pnl_total) + + (t.is_hedge + ? " (永续 " + fmtPnl(t.realized_pnl_perp) + " / 期权 " + fmtPnl(t.realized_pnl_options) + ")" + : ""); + } + + (e.images || []).forEach(function (img) { + var tf = String((img && img.tf) || "").trim(); + if (tf === "auto") return; + var hidden = document.querySelector( + '#or-upload-slots .or-upload-hidden[data-tf="' + img.tf + '"]' + ); + var status = document.querySelector( + '#or-upload-slots .or-upload-status[data-tf="' + img.tf + '"]' + ); + if (hidden && img.file) { + hidden.value = img.file; + if (status) { + var src = optionsJournalImgSrc(img.file); + status.innerHTML = + '已有 ' + + escapeHtml(img.file) + + '
' +
+            escapeHtml(img.tf || '; + status.className = + "journal-upload-status or-upload-status journal-upload-status--ok"; + } + } + }); + + var legsHost = $("or-legs-host"); + if (legsHost) { + if (t.legs && t.legs.length) { + legsHost.innerHTML = + "

计划腿

" + + t.legs + .map(function (leg) { + return ( + "" + ); + }) + .join("") + + "
腿合约盈亏原因
" + + escapeHtml(legRoleLabel(leg.leg_role)) + + "" + + escapeHtml(leg.inst_id || leg.symbol || "") + + "" + + fmtPnl(leg.realized_pnl) + + "" + + escapeHtml(closeReasonLabel(leg.close_reason)) + + "
"; + } else { + legsHost.innerHTML = ""; + } + } + } + + function hideTrade(tradeId) { + if (!tradeId) return; + if (!confirm("从待复盘列表删除并隐藏?刷新后也不会再出现.")) return; + fetch("/api/options/review/trades/" + tradeId, { + method: "DELETE", + credentials: "same-origin", + }) + .then(function (r) { + return r.json(); + }) + .then(function (data) { + if (!data.ok) { + alert(data.msg || "删除失败"); + return; + } + if (currentTradeId === tradeId) hideJournalForm(); + reloadAll(); + }) + .catch(function () { + alert("删除失败"); + }); + } + + function saveEntry() { + var tradeId = Number(($("or-trade-id") || {}).value || 0); + if (!tradeId) { + alert("请先点击交易记录中的「复盘」"); + return; + } + var strategy = (($("or-f-strategy") || {}).value || "").trim(); + if (!strategy) { + alert("请选择策略标签"); + return; + } + var payload = { + trade_id: tradeId, + strategy_tag: strategy, + direction_view: ($("or-f-direction") || {}).value || "", + exit_reason: ($("or-f-exit") || {}).value || "", + followed_plan: ($("or-f-followed") || {}).value || "", + result_tag: ($("or-f-result") || {}).value || "", + mistake_tags: collectMoodTags(), + entry_logic: ($("or-f-entry") || {}).value || "", + prev_entry_tf: ($("or-f-prev-tf") || {}).value || "", + note: ($("or-f-note") || {}).value || "", + images: collectImages(), + }; + var chartBox = $("or-chart-options"); + var chartVisible = chartBox && !chartBox.classList.contains("hidden"); + if (chartVisible && ($("or-exchange-chart") || {}).checked) { + payload.journal_exchange_chart = true; + payload.journal_chart_tf1 = ($("or-chart-tf1") || {}).value || ""; + payload.journal_chart_tf2 = ($("or-chart-tf2") || {}).value || ""; + payload.journal_chart_limit = ($("or-chart-limit") || {}).value || ""; + payload.journal_chart_anchor = ($("or-chart-anchor") || {}).value || "close"; + } + ($("or-save-status") || {}).textContent = "保存中…"; + fetch("/api/options/review/entry", { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }) + .then(function (r) { + return r.json(); + }) + .then(function (data) { + if (!data.ok) { + ($("or-save-status") || {}).textContent = data.msg || "保存失败"; + return; + } + ($("or-save-status") || {}).textContent = data.chart_queued + ? "已保存,K线图后台生成中" + : "已保存"; + hideJournalForm(); + reloadAll(); + openDetail(tradeId, { waitAutoChart: !!data.chart_queued }); + }) + .catch(function () { + ($("or-save-status") || {}).textContent = "保存失败"; + }); + } + + function deleteEntry() { + var tradeId = Number(($("or-trade-id") || {}).value || 0); + if (!tradeId) return; + if (!confirm("删除该条复盘内容与图片?交易记录会回到待复盘列表.")) return; + fetch("/api/options/review/entry/" + tradeId, { + method: "DELETE", + credentials: "same-origin", + }) + .then(function (r) { + return r.json(); + }) + .then(function () { + hideJournalForm(); + hideDetail(); + reloadAll(); + }); + } + + function init() { + if (!$("options-review-root")) return; + document.querySelectorAll(".or-tab").forEach(function (btn) { + btn.addEventListener("click", function () { + setActiveTab(btn.getAttribute("data-source")); + }); + }); + var reloadBtn = $("or-reload-btn"); + var saveBtn = $("or-save-btn"); + var clearBtn = $("or-clear-btn"); + var delBtn = $("or-del-btn"); + var prevBtn = $("or-trades-prev"); + var nextBtn = $("or-trades-next"); + var reviewedPrev = $("or-reviewed-prev"); + var reviewedNext = $("or-reviewed-next"); + var detailClose = $("or-detail-close-btn"); + var detailEdit = $("or-detail-edit-btn"); + if (reloadBtn) reloadBtn.addEventListener("click", reloadAll); + if (saveBtn) saveBtn.addEventListener("click", saveEntry); + if (clearBtn) clearBtn.addEventListener("click", hideJournalForm); + if (delBtn) delBtn.addEventListener("click", deleteEntry); + if (prevBtn) { + prevBtn.addEventListener("click", function (ev) { + ev.preventDefault(); + ev.stopPropagation(); + if (tradesPage <= 0) return; + tradesPage -= 1; + updateTradesPager(); + loadTrades({ sync: false, soft: true }); + }); + } + if (nextBtn) { + nextBtn.addEventListener("click", function (ev) { + ev.preventDefault(); + ev.stopPropagation(); + if (tradesPage + 1 >= tradesPages) return; + tradesPage += 1; + updateTradesPager(); + loadTrades({ sync: false, soft: true }); + }); + } + if (reviewedPrev) { + reviewedPrev.addEventListener("click", function (ev) { + ev.preventDefault(); + ev.stopPropagation(); + if (reviewedPage <= 0) return; + reviewedPage -= 1; + updateReviewedPager(); + loadReviewed({ sync: false, soft: true }); + }); + } + if (reviewedNext) { + reviewedNext.addEventListener("click", function (ev) { + ev.preventDefault(); + ev.stopPropagation(); + if (reviewedPage + 1 >= reviewedPages) return; + reviewedPage += 1; + updateReviewedPager(); + loadReviewed({ sync: false, soft: true }); + }); + } + if (detailClose) detailClose.addEventListener("click", hideDetail); + if (detailEdit) { + detailEdit.addEventListener("click", function () { + var id = Number(detailEdit.getAttribute("data-id") || 0); + if (id) { + hideDetail(); + openJournalForm(id); + } + }); + } + var detailBackdrop = $("or-detail-backdrop"); + if (detailBackdrop) { + detailBackdrop.addEventListener("click", function (ev) { + if (ev.target === detailBackdrop) hideDetail(); + }); + } + var lightbox = $("or-img-lightbox"); + if (lightbox) { + lightbox.addEventListener("click", function () { + hideLightbox(); + }); + } + document.addEventListener("keydown", function (ev) { + if (ev.key !== "Escape") return; + var lb = $("or-img-lightbox"); + if (lb && !lb.hidden) { + hideLightbox(); + return; + } + var bd = $("or-detail-backdrop"); + if (bd && !bd.hidden) hideDetail(); + }); + ["or-filter-uly", "or-filter-opt", "or-include-hedge-legs"].forEach(function (id) { + var el = $(id); + if (el) { + el.addEventListener("change", function () { + tradesPage = 0; + reviewedPage = 0; + reloadAll(); + }); + } + }); + ["or-filter-q", "or-filter-strategy", "or-filter-from", "or-filter-to"].forEach(function (id) { + var el = $(id); + if (el) { + el.addEventListener("change", function () { + tradesPage = 0; + reviewedPage = 0; + reloadAll(); + }); + } + }); + bindUploadSlots(); + hideJournalForm(); + hideDetail(); + hardenSearchAutofill(); + setActiveTab(defaultSourceForMode(tradeModeFromDom())); + } + + function hardenSearchAutofill() { + var qEl = $("or-filter-q"); + if (!qEl) return; + function wipe() { + qEl.value = ""; + } + wipe(); + qEl.addEventListener("focus", function () { + qEl.removeAttribute("readonly"); + }); + qEl.addEventListener("blur", function () { + if (!qEl.value) qEl.setAttribute("readonly", "readonly"); + }); + // 密码管理器常延后写入用户名,加载后再清两次 + setTimeout(wipe, 200); + setTimeout(wipe, 800); + } + + global.OptionsReview = { + init: init, + openJournalForm: openJournalForm, + hideJournalForm: hideJournalForm, + }; + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})(typeof window !== "undefined" ? window : globalThis); diff --git a/lib/options/options_account_pnl_lib.py b/lib/options/options_account_pnl_lib.py index 155cb32..2514e34 100644 --- a/lib/options/options_account_pnl_lib.py +++ b/lib/options/options_account_pnl_lib.py @@ -92,24 +92,107 @@ def transfer_blocked_by_position_msg(conn, ex: Any = None) -> str | None: return None +def sellable_coin_blocks_settle( + ex: Any, + underlying: str | None, +) -> str | None: + """仍有可卖标的币则禁止账户差分结算(规格:卖光再冻结).""" + from lib.options.options_spot_bridge_lib import ( + fetch_trading_coin_available, + is_sellable_spot_coin, + ) + + uly = (underlying or "").strip().upper() + if uly not in ("ETH", "BTC"): + return None + try: + avail = fetch_trading_coin_available(ex, uly) + except Exception: + return None + if avail is None: + return None + if is_sellable_spot_coin(ex, uly, avail): + return f"仍有可卖 {uly}({float(avail):g}),暂不结算账户盈亏" + return None + + +def _underlying_for_settle( + conn, + *, + bridge_id: int | None = None, + trade_id: int | None = None, +) -> str | None: + try: + if trade_id is not None: + row = conn.execute( + "SELECT underlying FROM options_trades WHERE id=?", + (int(trade_id),), + ).fetchone() + if row: + return str(row["underlying"] if hasattr(row, "keys") else row[0] or "").strip().upper() or None + if bridge_id is not None: + row = conn.execute( + """ + SELECT underlying FROM options_trades + WHERE bridge_id=? AND status='closed' + ORDER BY id DESC LIMIT 1 + """, + (int(bridge_id),), + ).fetchone() + if row: + return str(row["underlying"] if hasattr(row, "keys") else row[0] or "").strip().upper() or None + brow = conn.execute( + "SELECT underlying FROM options_spot_bridge WHERE id=?", + (int(bridge_id),), + ).fetchone() + if brow: + return str(brow["underlying"] if hasattr(brow, "keys") else brow[0] or "").strip().upper() or None + except Exception: + return None + return None + + def try_settle_after_sellback( get_db: Any, ex: Any, *, bridge_id: int | None = None, cfg: dict[str, Any] | None = None, + underlying: str | None = None, + sell_complete: bool | None = None, ) -> int: - """卖回路径通用结算:读交易账户 after 并冻结未结算单.""" - pack = cfg if isinstance(cfg, dict) else {"fetch_options_balances": None} - after = fetch_trading_usdt(pack, ex) - if after is None: + """卖回路径通用结算:读交易账户 after 并冻结未结算单. + + 门禁:sell_complete 显式 False 时不结算;仍有可卖标的币时不结算. + """ + if sell_complete is False: return 0 - conn = get_db() if callable(get_db) else get_db - close_conn = callable(get_db) + pack = cfg if isinstance(cfg, dict) else {"fetch_options_balances": None} + import sqlite3 + + # Windows 上 sqlite3.Connection 的 callable() 可能为 True,不能据此判断 get_db + if isinstance(get_db, sqlite3.Connection): + conn = get_db + close_conn = False + elif callable(get_db): + conn = get_db() + close_conn = True + else: + conn = get_db + close_conn = False try: from lib.options.options_db import init_options_tables init_options_tables(conn) + uly = (underlying or "").strip().upper() or None + if not uly: + uly = _underlying_for_settle(conn, bridge_id=bridge_id) + block = sellable_coin_blocks_settle(ex, uly) + if block: + return 0 + after = fetch_trading_usdt(pack, ex) + if after is None: + return 0 n = 0 if bridge_id is not None: n = settle_closed_trades_from_trading_account( @@ -125,10 +208,16 @@ def try_settle_after_sellback( """ ).fetchone() if row: + tid = int(row["id"] if hasattr(row, "keys") else row[0]) + if not uly: + uly = _underlying_for_settle(conn, trade_id=tid) + block2 = sellable_coin_blocks_settle(ex, uly) + if block2: + return 0 n = settle_closed_trades_from_trading_account( conn, trading_usdt_after=after, - trade_id=int(row["id"] if hasattr(row, "keys") else row[0]), + trade_id=tid, ) try: conn.commit() diff --git a/lib/options/options_coin_open_lib.py b/lib/options/options_coin_open_lib.py index df3a1e9..d2b350c 100644 --- a/lib/options/options_coin_open_lib.py +++ b/lib/options/options_coin_open_lib.py @@ -610,6 +610,14 @@ def _insert_coin_trade(conn: Any, **kwargs: Any) -> int: tusdt_before = float(tusdt_before) if tusdt_before is not None else None except (TypeError, ValueError): tusdt_before = None + paid = kwargs.get("premium_paid") + try: + paid_f = float(paid) if paid is not None else None + except (TypeError, ValueError): + paid_f = None + initial_premium_usdt = None + if open_index_px is not None and paid_f is not None and paid_f > 0: + initial_premium_usdt = round(float(open_index_px) * float(paid_f), 4) cur = conn.execute( """ INSERT INTO options_trades( @@ -617,8 +625,9 @@ def _insert_coin_trade(conn: Any, **kwargs: Any) -> int: open_quote, premium_paid, status, signal_note, exchange_ord_id, margin_mode, premium_ccy, bridge_id, budget_usdt, open_fee, fee_ccy, open_index_px, trading_usdt_before, pnl_settled, + initial_premium_usdt, profit_exit_enabled, profit_exit_mult, profit_exit_state, created_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, 'coin', ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, 'coin', ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?) """, ( kwargs["inst_id"], @@ -639,6 +648,7 @@ def _insert_coin_trade(conn: Any, **kwargs: Any) -> int: fee_ccy, open_index_px, tusdt_before, + initial_premium_usdt, pe, pe_mult, pe_state, @@ -685,17 +695,20 @@ def maybe_sell_spot_after_close( underlying=underlying, inst_id=inst_id, ) - # 卖回成功:交易账户 after − 开仓 before → 冻结本单盈亏 + # 卖回完成(sell_complete):交易账户 after − 开仓 before → 冻结本单盈亏 try: from lib.options.options_account_pnl_lib import try_settle_after_sellback - if (result or {}).get("ok"): - bid = (result or {}).get("bridge_id") + res = result or {} + if res.get("ok") and res.get("sell_complete", True) and not res.get("incomplete"): + bid = res.get("bridge_id") try_settle_after_sellback( cfg["get_db"], ex, bridge_id=int(bid) if bid is not None else None, cfg=cfg, + underlying=underlying, + sell_complete=True, ) except Exception: pass @@ -766,20 +779,23 @@ def sweep_spot_after_options_flat( underlying=uly, inst_id=None, ) - if (out or {}).get("ok"): + out = out or {} + if out.get("ok") and out.get("sell_complete", True) and not out.get("incomplete"): try: from lib.options.options_account_pnl_lib import try_settle_after_sellback - bid = (out or {}).get("bridge_id") + bid = out.get("bridge_id") try_settle_after_sellback( cfg["get_db"], exchange, bridge_id=int(bid) if bid is not None else None, cfg=cfg, + underlying=uly, + sell_complete=True, ) except Exception: pass - sold.append({"underlying": uly, **(out or {})}) + sold.append({"underlying": uly, **out}) except Exception as e: sold.append({"underlying": uly, "ok": False, "msg": str(e)}) return {"ok": True, "results": sold, "skipped": not sold} diff --git a/lib/options/options_db.py b/lib/options/options_db.py index 511186b..f4f5860 100644 --- a/lib/options/options_db.py +++ b/lib/options/options_db.py @@ -133,6 +133,8 @@ def init_options_tables(conn: sqlite3.Connection) -> None: "ALTER TABLE options_trades ADD COLUMN trading_usdt_before REAL", "ALTER TABLE options_trades ADD COLUMN trading_usdt_after REAL", "ALTER TABLE options_trades ADD COLUMN pnl_settled INTEGER DEFAULT 0", + # 初始权利金(U)=开仓指数×权利金币量,不含多买缓冲 + "ALTER TABLE options_trades ADD COLUMN initial_premium_usdt REAL", ): try: conn.execute(ddl) diff --git a/lib/options/options_register.py b/lib/options/options_register.py index 5419b6a..f675016 100644 --- a/lib/options/options_register.py +++ b/lib/options/options_register.py @@ -1225,7 +1225,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: underlying=underlying, inst_id=inst_id, ) - if out.get("ok"): + if out.get("ok") and out.get("sell_complete", True) and not out.get("incomplete"): try: from lib.options.options_account_pnl_lib import try_settle_after_sellback @@ -1235,10 +1235,14 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: ex, bridge_id=int(bid) if bid is not None else None, cfg=cfg, + underlying=underlying, + sell_complete=True, ) except Exception: pass _mark_balances_stale(cfg) + elif out.get("ok"): + _mark_balances_stale(cfg) return jsonify(out) @app.route("/api/options/convert/quote", methods=["POST"]) diff --git a/lib/options/options_review_db.py b/lib/options/options_review_db.py index 8c79ae4..bf19a0d 100644 --- a/lib/options/options_review_db.py +++ b/lib/options/options_review_db.py @@ -131,6 +131,8 @@ def init_options_review_tables(conn: sqlite3.Connection) -> None: _ensure_column(conn, "options_review_trades", "target_price_up", "REAL") _ensure_column(conn, "options_review_trades", "target_price_down", "REAL") _ensure_column(conn, "options_review_trades", "profit_rr", "REAL") + _ensure_column(conn, "options_review_trades", "initial_premium_usdt", "REAL") + _ensure_column(conn, "options_review_trades", "profit_rate", "REAL") _ensure_column(conn, "options_review_entries", "prev_entry_tf", "TEXT") diff --git a/lib/options/options_review_lib.py b/lib/options/options_review_lib.py index af4a3f8..e94d2e0 100644 --- a/lib/options/options_review_lib.py +++ b/lib/options/options_review_lib.py @@ -146,6 +146,8 @@ def upsert_option_history_row(conn: sqlite3.Connection, row: dict[str, Any]) -> "close_avg": _safe_float(row.get("close_avg_px") if row.get("close_avg_px") is not None else row.get("close_avg")), "premium_paid": _safe_float(row.get("premium_paid")), "realized_pnl": pnl, + "initial_premium_usdt": _safe_float(row.get("initial_premium_usdt")), + "profit_rate": _safe_float(row.get("profit_rate")), } cols = list(fields.keys()) if existing: @@ -317,7 +319,7 @@ def sync_options_from_local_trades( open_quote, close_quote, premium_paid, premium_received, realized_pnl, realized_pnl_usdt, close_index_px, open_index_px, budget_usdt, premium_ccy, created_at, closed_at, signal_note, status, - trading_usdt_before, pnl_settled + trading_usdt_before, pnl_settled, initial_premium_usdt FROM options_trades WHERE status = 'closed' ORDER BY id DESC @@ -439,12 +441,19 @@ def sync_options_from_local_trades( pnl_u = round(float(frozen_usdt), 4) else: pnl_u = _to_usdt(pnl, ccy=ccy, idx=idx) - # 权利金用平仓/开仓冻结指数折算,不用实时价 - prem_u = _to_usdt(prem, ccy=ccy, idx=idx) - if ccy in ("ETH", "BTC") and pnl_u is None: + # 初始权利金(U)=开仓指数×权利金币量;不含多买 + stored_initial = _safe_float(r["initial_premium_usdt"]) + open_idx_for_prem = open_idx if (open_idx is not None and open_idx > 0) else idx + prem_u = stored_initial + if prem_u is None: + prem_u = _to_usdt(prem, ccy=ccy, idx=open_idx_for_prem) + if ccy in ("ETH", "BTC") and pnl_u is None and not acct_pending: pnl_u = pnl if ccy in ("ETH", "BTC") and prem_u is None: prem_u = prem + profit_rate = None + if pnl_u is not None and prem_u is not None and abs(float(prem_u)) > 1e-12: + profit_rate = round(float(pnl_u) / float(prem_u), 6) opened_at = r["created_at"] closed_at = r["closed_at"] action = upsert_option_history_row( @@ -461,10 +470,12 @@ def sync_options_from_local_trades( "open_avg_px": r["open_quote"], "close_avg_px": r["close_quote"], "premium_paid": prem_u if prem_u is not None else prem, - "realized_pnl": pnl_u if pnl_u is not None else pnl, + "realized_pnl": pnl_u if pnl_u is not None else (None if acct_pending else pnl), + "initial_premium_usdt": prem_u, + "profit_rate": profit_rate, "created_at": opened_at, "closed_at": closed_at, - "status_label": "已平", + "status_label": "待结算" if acct_pending else "已平", }, ) if action == "inserted": @@ -759,6 +770,12 @@ def _row_to_dict(row: Any) -> dict[str, Any]: def enrich_trade_row(row: dict[str, Any], entry: dict[str, Any] | None = None) -> dict[str, Any]: out = dict(row) + # 盈利率缺失时按 复盘盈亏/初始权利金 补算 + if _safe_float(out.get("profit_rate")) is None: + pnl = _safe_float(out.get("realized_pnl_total")) + prem = _safe_float(out.get("initial_premium_usdt")) + if pnl is not None and prem is not None and abs(prem) > 1e-12: + out["profit_rate"] = round(pnl / prem, 6) out["source_label"] = SOURCE_LABELS.get(str(out.get("source_type") or ""), out.get("source_type")) out["is_hedge"] = str(out.get("source_type") or "") in (SOURCE_PERP_OPTIONS, SOURCE_OPTIONS_OPTIONS) legs = [] diff --git a/lib/options/options_spot_bridge_lib.py b/lib/options/options_spot_bridge_lib.py index 130ef05..10162f2 100644 --- a/lib/options/options_spot_bridge_lib.py +++ b/lib/options/options_spot_bridge_lib.py @@ -451,14 +451,14 @@ def sell_residual_after_option_flat( underlying: str, inst_id: str | None = None, ) -> dict[str, Any]: - """期权已平:卖掉本桥残留标的币;优先关闭 matching holding/pending 桥. + """期权已平:卖光交易户该标的可卖币(一次一仓,含盈利币). + 规格:平仓卖回禁止按开仓权利金 coin_bought 封顶;仍有可卖则不关桥、不结算. 可传 get_db 可调用对象(推荐)或已打开的 Connection。 卖币(尤其模拟盘写钱包)期间绝不握着业务连接,否则会 database is locked。 """ get_db = conn_or_get_db if callable(conn_or_get_db) else None bid: int | None = None - sell_cap: float | None = None want_inst = str(inst_id or "").strip() def _pick_bridge(bridges: list[dict[str, Any]]) -> dict[str, Any] | None: @@ -485,7 +485,6 @@ def sell_residual_after_option_flat( target = _pick_bridge(list_open_bridges(conn)) if target is not None: bid = int(target["id"]) - sell_cap = _safe_float(target.get("coin_bought")) try: conn.commit() except Exception: @@ -501,7 +500,6 @@ def sell_residual_after_option_flat( target = _pick_bridge(list_open_bridges(conn)) if target is not None: bid = int(target["id"]) - sell_cap = _safe_float(target.get("coin_bought")) try: conn.commit() except Exception: @@ -512,13 +510,10 @@ def sell_residual_after_option_flat( avail_chk = fetch_trading_coin_available(ex, underlying) except Exception: avail_chk = None - dust_amt = avail_chk - if dust_amt is None and sell_cap is not None: - dust_amt = sell_cap - if dust_amt is not None and not is_sellable_spot_coin(ex, underlying, dust_amt): + if avail_chk is not None and not is_sellable_spot_coin(ex, underlying, avail_chk): dust_msg = "粉尘低于现货最小可卖,已忽略" if bid is None: - return {"ok": True, "msg": dust_msg, "skipped": True, "dust": True} + return {"ok": True, "msg": dust_msg, "skipped": True, "dust": True, "sell_complete": True} def _close_dust(c: sqlite3.Connection) -> dict[str, Any]: update_bridge( c, @@ -533,6 +528,7 @@ def sell_residual_after_option_flat( "msg": dust_msg, "skipped": True, "dust": True, + "sell_complete": True, "bridge_id": bid, "bridge_status": BRIDGE_CLOSED, } @@ -547,20 +543,33 @@ def sell_residual_after_option_flat( pass return _close_dust(conn_or_get_db) - # 有桥时按 coin_bought 封顶,避免误卖无关余额;无桥空仓扫尾仍卖光可用 + # 一次一仓空仓后卖光可用(含盈利币);禁止用 coin_bought/权利金封顶 # 此处无业务连接:模拟盘 convert 可安全另开写钱包 sell = spot_market_sell_coin_to_usdt( ex, underlying=underlying, - coin_amount=sell_cap if (bid is not None and sell_cap is not None and sell_cap > 0) else None, + coin_amount=None, ) if bid is None: if not sell.get("ok"): msg = str(sell.get("msg") or "") if "无可用" in msg or "过小" in msg: - return {"ok": True, "msg": "无残留币需卖回", "skipped": True} - return {"ok": False, "msg": msg, "bridge_status": BRIDGE_PENDING_SELL} - return {"ok": True, "sell": sell, "bridge_status": None} + return {"ok": True, "msg": "无残留币需卖回", "skipped": True, "sell_complete": True} + return {"ok": False, "msg": msg, "bridge_status": BRIDGE_PENDING_SELL, "sell_complete": False} + # 再确认无可卖残留 + try: + left = fetch_trading_coin_available(ex, underlying) + except Exception: + left = None + if left is not None and is_sellable_spot_coin(ex, underlying, left): + return { + "ok": False, + "msg": f"卖回未完成,仍有可卖 {str(underlying or '').upper()}", + "incomplete": True, + "sell_complete": False, + "sell": sell, + } + return {"ok": True, "sell": sell, "bridge_status": None, "sell_complete": True} def _apply_bridge(c: sqlite3.Connection) -> dict[str, Any]: if not sell.get("ok"): @@ -580,6 +589,7 @@ def sell_residual_after_option_flat( "msg": "粉尘低于现货最小可卖,已忽略", "skipped": True, "dust": True, + "sell_complete": True, "bridge_id": bid, "bridge_status": BRIDGE_CLOSED, } @@ -595,10 +605,36 @@ def sell_residual_after_option_flat( "msg": msg, "bridge_id": bid, "bridge_status": BRIDGE_PENDING_SELL, + "sell_complete": False, } recovered = _safe_float(sell.get("usdt_recovered")) if recovered is not None and recovered > 0: add_bridge_usdt_recovered(c, bid, recovered) + # 卖后仍有可卖 → 不关桥、不结算(盈利币未卖回时曾误冻亏损) + try: + left = fetch_trading_coin_available(ex, underlying) + except Exception: + left = None + if left is not None and is_sellable_spot_coin(ex, underlying, left): + left_msg = f"卖回未完成,仍有可卖 {str(underlying or '').upper()}({float(left):g})" + update_bridge( + c, + bid, + status=BRIDGE_PENDING_SELL, + sell_ord_id=str(sell.get("ord_id") or ""), + coin_sold=_safe_float(sell.get("coin_sold")), + inst_id=inst_id, + message=left_msg, + ) + return { + "ok": False, + "msg": left_msg, + "incomplete": True, + "sell_complete": False, + "sell": sell, + "bridge_id": bid, + "bridge_status": BRIDGE_PENDING_SELL, + } update_bridge( c, bid, @@ -609,7 +645,13 @@ def sell_residual_after_option_flat( message="期权已平,币已卖回 USDT", close=True, ) - return {"ok": True, "sell": sell, "bridge_id": bid, "bridge_status": BRIDGE_CLOSED} + return { + "ok": True, + "sell": sell, + "bridge_id": bid, + "bridge_status": BRIDGE_CLOSED, + "sell_complete": True, + } if get_db is not None: conn2 = get_db() diff --git a/lib/options/options_stats_lib.py b/lib/options/options_stats_lib.py index 00f8eee..4268edf 100644 --- a/lib/options/options_stats_lib.py +++ b/lib/options/options_stats_lib.py @@ -59,11 +59,16 @@ def _underlying_index_usdt(ex: Any, underly: str) -> float | None: return None -def history_pnl_to_usdt(history: list[dict[str, Any]], ex: Any = None) -> list[dict[str, Any]]: - """ - 统计用:优先已冻结的 realized_pnl_usdt(平仓当时口径,含手续费后净额); - 否则币本位用 close_index_px(再否则实时指数)折 U;USDC 原样. - 折算失败的币仓剔除盈亏字段,避免把「币数量」当成 U. +def history_pnl_to_usdt( + history: list[dict[str, Any]], + ex: Any = None, + *, + prefer_frozen_usdt: bool = True, +) -> list[dict[str, Any]]: + """折 U. + + prefer_frozen_usdt=True:优先行内 realized_pnl_usdt(实盘 exchange freeze / 旧兼容). + prefer_frozen_usdt=False:口径 B — 币盈亏 × 平仓卖回价(close_index_px),忽略账户差分 A. """ from lib.options.options_margin_mode_lib import margin_mode_from_inst_id, premium_ccy_for_mode @@ -75,7 +80,7 @@ def history_pnl_to_usdt(history: list[dict[str, Any]], ex: Any = None) -> list[d out.append(r) continue frozen = _safe_float(r.get("realized_pnl_usdt")) - if frozen is not None: + if prefer_frozen_usdt and frozen is not None: r["realized_pnl"] = round(float(frozen), 4) out.append(r) continue @@ -104,6 +109,11 @@ def history_pnl_to_usdt(history: list[dict[str, Any]], ex: Any = None) -> list[d return out +def history_pnl_to_usdt_stats(history: list[dict[str, Any]], ex: Any = None) -> list[dict[str, Any]]: + """数据统计口径 B:期权历史币盈亏 × 平仓卖回价.""" + return history_pnl_to_usdt(history, ex, prefer_frozen_usdt=False) + + def _parse_ts(raw: Any) -> datetime | None: if raw is None or raw == "": return None @@ -286,7 +296,8 @@ def _closed_trade_dicts(closed_rows) -> list[dict[str, Any]]: { "status": "closed", "realized_pnl": _row_get(row, "realized_pnl"), - "realized_pnl_usdt": _row_get(row, "realized_pnl_usdt"), + # 口径 B 不读账户 realized_pnl_usdt;保留键位兼容旧调用 + "realized_pnl_usdt": None, "close_index_px": _row_get(row, "close_index_px"), "created_at": _row_get(row, "created_at"), "closed_at": _row_get(row, "closed_at"), @@ -298,16 +309,13 @@ def _closed_trade_dicts(closed_rows) -> list[dict[str, Any]]: return out +# 口径 B:凡有币盈亏的已平均计入;U 由 history_pnl_to_usdt_stats 按卖回价折算 _CLOSED_STATS_SELECT = """ - SELECT realized_pnl, realized_pnl_usdt, close_index_px, + SELECT realized_pnl, close_index_px, created_at, closed_at, inst_id, underlying, premium_ccy FROM options_trades WHERE status = 'closed' - AND ( - (COALESCE(pnl_settled, 0) = 1 AND realized_pnl_usdt IS NOT NULL) - OR (trading_usdt_before IS NULL - AND (realized_pnl IS NOT NULL OR realized_pnl_usdt IS NOT NULL)) - ) + AND realized_pnl IS NOT NULL """ @@ -634,7 +642,7 @@ def apply_exchange_pnl_freeze( def compute_options_stats(get_db, ex: Any = None) -> dict[str, Any]: - """模拟盘:本地交易结算.实盘请用 compute_options_stats_live_exchange.""" + """模拟盘数据统计:口径 B(历史币盈亏×卖回价).""" conn = get_db() try: history = load_local_options_stats_history(conn, ex) @@ -642,7 +650,7 @@ def compute_options_stats(get_db, ex: Any = None) -> dict[str, Any]: conn.commit() except Exception: pass - return compute_options_stats_from_history(history_pnl_to_usdt(history, ex)) + return compute_options_stats_from_history(history_pnl_to_usdt_stats(history, ex)) finally: conn.close() @@ -717,9 +725,11 @@ def load_live_exchange_stats_history(cfg: dict[str, Any], ex: Any) -> list[dict[ def compute_options_stats_live_exchange(cfg: dict[str, Any], ex: Any) -> dict[str, Any]: - """实盘:与「期权历史」同一数据源(交易所 positions-history),盈亏以交易所为准.""" + """实盘:与「期权历史」同一数据源;口径 B=币盈亏×卖回价(freeze).""" history = load_live_exchange_stats_history(cfg, ex) - return compute_options_stats_from_history(history_pnl_to_usdt(history, ex)) + return compute_options_stats_from_history( + history_pnl_to_usdt(history, ex, prefer_frozen_usdt=True) + ) def _apply_exchange_pnl_freeze_with_hints( @@ -812,7 +822,7 @@ def _stats_from_option_trade_rows(closed_rows, open_rows, *, ex: Any = None) -> history = _closed_trade_dicts(closed_rows) for row in open_rows or []: history.append({"status": "open", "created_at": _row_get(row, "created_at")}) - return compute_options_stats_from_history(history_pnl_to_usdt(history, ex)) + return compute_options_stats_from_history(history_pnl_to_usdt_stats(history, ex)) def header_options_stats_for_window( @@ -821,32 +831,20 @@ def header_options_stats_for_window( app_tz, ex: Any = None, ) -> dict[str, Any]: - """顶栏总交易/胜率/盈亏比:与期权统计同一结算口径. - - 新单(有 trading_usdt_before):仅 pnl_settled=1 的账户差分计入; - 旧单(无 before):仍按原 realized 兼容.币数量须折 U 后再算盈亏比. - """ + """顶栏总交易/胜率/盈亏比:口径 B(历史币盈亏×卖回价).""" from lib.common.history_window_lib import utc_window_to_bj_sql_strings init_options_tables(conn) start_bj, end_bj = utc_window_to_bj_sql_strings( list_window["start_utc"], list_window["end_utc"], app_tz ) - try: - freeze_missing_realized_pnl_usdt(conn, ex) - except Exception: - pass closed_rows = conn.execute( """ - SELECT realized_pnl, realized_pnl_usdt, close_index_px, + SELECT realized_pnl, close_index_px, created_at, closed_at, inst_id, underlying, premium_ccy FROM options_trades WHERE status = 'closed' - AND ( - (COALESCE(pnl_settled, 0) = 1 AND realized_pnl_usdt IS NOT NULL) - OR (trading_usdt_before IS NULL - AND (realized_pnl IS NOT NULL OR realized_pnl_usdt IS NOT NULL)) - ) + AND realized_pnl IS NOT NULL AND COALESCE(closed_at, created_at) >= ? AND COALESCE(closed_at, created_at) <= ? """, @@ -858,7 +856,7 @@ def header_options_stats_for_window( history = _closed_trade_dicts(closed_rows) for row in open_rows: history.append({"status": "open", "created_at": _row_get(row, "created_at")}) - stats = compute_options_stats_from_history(history_pnl_to_usdt(history, ex)) + stats = compute_options_stats_from_history(history_pnl_to_usdt_stats(history, ex)) return { "total": int(stats.get("total_closed") or 0), "rate": float(stats.get("win_rate") or 0), diff --git a/lib/options/templates/options_review_panel.html b/lib/options/templates/options_review_panel.html index ad92e24..55a7b49 100644 --- a/lib/options/templates/options_review_panel.html +++ b/lib/options/templates/options_review_panel.html @@ -76,7 +76,8 @@ } .or-list-loading{opacity:.55;pointer-events:none;transition:opacity .12s ease} .or-trades-table-wrap,.or-reviewed-table-wrap{min-height:9.5rem;overflow-x:auto} - .or-reviewed-table{min-width:980px} + .or-reviewed-table{min-width:1180px} + .or-trades-table{min-width:960px} .or-kpi-row{ display:grid;grid-template-columns:repeat(6,minmax(0,1fr)); gap:8px;margin-bottom:12px; @@ -237,7 +238,9 @@ 类型 标的/合约 + 初始权利金 盈亏 + 盈利率 开仓时间 平仓时间 持有 @@ -245,7 +248,7 @@ - 加载中… + 加载中…
@@ -280,6 +283,8 @@ data-lpignore="true" data-1p-ignore="true" data-form-type="other"> + +
@@ -403,7 +408,9 @@ 类型 标的/合约 方向 + 初始权利金 盈亏 + 盈利率 开仓时间 平仓时间 持仓时长 @@ -414,7 +421,7 @@ - 加载中… + 加载中…
diff --git a/scripts/repair_options_dual_pnl.py b/scripts/repair_options_dual_pnl.py new file mode 100644 index 0000000..355fed0 --- /dev/null +++ b/scripts/repair_options_dual_pnl.py @@ -0,0 +1,285 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""修复期权双口径历史数据(规格 docs/规格-期权交易账户盈亏结算.md §9). + +默认 dry-run,打印将要修改的行;加 --apply 才写库. + +用法: + python scripts/repair_options_dual_pnl.py --db path/to/instance.db + python scripts/repair_options_dual_pnl.py --db path/to/instance.db --apply + python scripts/repair_options_dual_pnl.py --db path/to/instance.db --csv out.csv --apply +""" +from __future__ import annotations + +import argparse +import csv +import sqlite3 +import sys +from pathlib import Path +from typing import Any + + +def _safe_float(v: Any) -> float | None: + if v is None or v == "": + return None + try: + return float(v) + except (TypeError, ValueError): + return None + + +def _connect(db_path: Path) -> sqlite3.Connection: + conn = sqlite3.connect(str(db_path)) + conn.row_factory = sqlite3.Row + return conn + + +def _ensure_columns(conn: sqlite3.Connection) -> None: + # 与业务库迁移一致,可重复执行 + for ddl in ( + "ALTER TABLE options_trades ADD COLUMN initial_premium_usdt REAL", + "ALTER TABLE options_trades ADD COLUMN trading_usdt_before REAL", + "ALTER TABLE options_trades ADD COLUMN trading_usdt_after REAL", + "ALTER TABLE options_trades ADD COLUMN pnl_settled INTEGER DEFAULT 0", + "ALTER TABLE options_review_trades ADD COLUMN initial_premium_usdt REAL", + "ALTER TABLE options_review_trades ADD COLUMN profit_rate REAL", + ): + try: + conn.execute(ddl) + except sqlite3.OperationalError: + pass + + +def repair(conn: sqlite3.Connection, *, apply: bool) -> list[dict[str, Any]]: + _ensure_columns(conn) + rows = conn.execute( + """ + SELECT id, inst_id, premium_paid, premium_received, realized_pnl, + open_index_px, close_index_px, budget_usdt, + trading_usdt_before, trading_usdt_after, + realized_pnl_usdt, pnl_settled, bridge_id, initial_premium_usdt + FROM options_trades + WHERE status='closed' + ORDER BY id + """ + ).fetchall() + + report: list[dict[str, Any]] = [] + for r in rows: + tid = int(r["id"]) + paid = _safe_float(r["premium_paid"]) + recv = _safe_float(r["premium_received"]) + coin_pnl = _safe_float(r["realized_pnl"]) + open_idx = _safe_float(r["open_index_px"]) + close_idx = _safe_float(r["close_index_px"]) + before = _safe_float(r["trading_usdt_before"]) + after = _safe_float(r["trading_usdt_after"]) + settled_u = _safe_float(r["realized_pnl_usdt"]) + settled = int(r["pnl_settled"] or 0) == 1 + initial = _safe_float(r["initial_premium_usdt"]) + + new_initial = initial + if new_initial is None and open_idx and paid and open_idx > 0 and paid > 0: + new_initial = round(open_idx * paid, 4) + + stats_u = None + if coin_pnl is not None and close_idx is not None and close_idx > 0: + stats_u = round(coin_pnl * close_idx, 4) + elif coin_pnl is not None and open_idx is not None and open_idx > 0: + stats_u = round(coin_pnl * open_idx, 4) + + # 错账:币盈亏为正、账户冻结为负(典型卖回封顶) + suspicious = bool( + settled + and coin_pnl is not None + and coin_pnl > 0 + and settled_u is not None + and settled_u < 0 + ) + + # 桥侧:coin_sold ≈ premium 且远小于 recv → 未卖盈利币 + bridge_hint = "" + bid = r["bridge_id"] + coin_bought = coin_sold = None + if bid is not None: + try: + b = conn.execute( + "SELECT coin_bought, coin_sold FROM options_spot_bridge WHERE id=?", + (int(bid),), + ).fetchone() + if b: + coin_bought = _safe_float(b["coin_bought"]) + coin_sold = _safe_float(b["coin_sold"]) + if ( + paid + and coin_sold is not None + and recv + and coin_sold <= paid * 1.05 + and recv > paid * 1.2 + ): + bridge_hint = "sell_capped_at_premium" + suspicious = True + except Exception: + pass + + action = "ok" + new_settled_u = settled_u + new_pnl_settled = 1 if settled else 0 + if suspicious: + # 无法可靠重建 after 时解除错误冻结,待卖回后重结算 + action = "unsettle_bad_account_freeze" + new_pnl_settled = 0 + new_settled_u = None + elif before is not None and after is not None and not settled: + action = "settle_from_snapshots" + new_settled_u = round(after - before, 4) + new_pnl_settled = 1 + + profit_rate = None + a_for_rate = new_settled_u if new_pnl_settled == 1 else None + if a_for_rate is not None and new_initial and abs(new_initial) > 1e-12: + profit_rate = round(a_for_rate / new_initial, 6) + + rec = { + "trade_id": tid, + "inst_id": r["inst_id"], + "action": action, + "suspicious": suspicious, + "bridge_hint": bridge_hint, + "coin_pnl": coin_pnl, + "stats_pnl_usdt_B": stats_u, + "old_realized_pnl_usdt_A": settled_u, + "new_realized_pnl_usdt_A": new_settled_u, + "old_pnl_settled": int(r["pnl_settled"] or 0), + "new_pnl_settled": new_pnl_settled, + "initial_premium_usdt": new_initial, + "profit_rate": profit_rate, + "coin_bought": coin_bought, + "coin_sold": coin_sold, + "premium_paid": paid, + "premium_received": recv, + } + report.append(rec) + + if not apply: + continue + + if new_initial is not None and initial != new_initial: + conn.execute( + "UPDATE options_trades SET initial_premium_usdt=? WHERE id=?", + (new_initial, tid), + ) + if action == "unsettle_bad_account_freeze": + conn.execute( + """ + UPDATE options_trades + SET pnl_settled=0, realized_pnl_usdt=NULL, trading_usdt_after=NULL + WHERE id=? AND COALESCE(pnl_settled,0)=1 + """, + (tid,), + ) + elif action == "settle_from_snapshots" and new_settled_u is not None: + conn.execute( + """ + UPDATE options_trades + SET realized_pnl_usdt=?, pnl_settled=1 + WHERE id=? AND COALESCE(pnl_settled,0)=0 + """, + (new_settled_u, tid), + ) + + # 复盘表 + hk = f"local_opt:{tid}" + rev = conn.execute( + "SELECT id FROM options_review_trades WHERE history_key=?", (hk,) + ).fetchone() + if rev: + pnl_total = new_settled_u if new_pnl_settled == 1 else None + conn.execute( + """ + UPDATE options_review_trades + SET initial_premium_usdt=COALESCE(?, initial_premium_usdt), + realized_pnl_total=?, + realized_pnl=?, + profit_rate=?, + status_raw=? + WHERE history_key=? + """, + ( + new_initial, + pnl_total, + pnl_total, + profit_rate, + "待结算" if new_pnl_settled == 0 and before is not None else "已平", + hk, + ), + ) + + # 实盘/统计冻结表:口径 B + if stats_u is not None: + try: + conn.execute( + """ + INSERT INTO options_exchange_pnl_freeze( + history_key, realized_pnl, realized_pnl_usdt, close_index_px, + premium_ccy, updated_at + ) VALUES (?, ?, ?, ?, ?, datetime('now')) + ON CONFLICT(history_key) DO UPDATE SET + realized_pnl=excluded.realized_pnl, + realized_pnl_usdt=excluded.realized_pnl_usdt, + close_index_px=excluded.close_index_px, + updated_at=excluded.updated_at + """, + ( + hk, + coin_pnl, + stats_u, + close_idx or open_idx, + "BTC" if "BTC" in str(r["inst_id"] or "") else "ETH", + ), + ) + except Exception: + pass + + if apply: + conn.commit() + return report + + +def main() -> int: + ap = argparse.ArgumentParser(description="修复期权双口径历史盈亏") + ap.add_argument("--db", required=True, help="实例 SQLite 路径") + ap.add_argument("--apply", action="store_true", help="实际写库(默认 dry-run)") + ap.add_argument("--csv", default="", help="导出报告 CSV 路径") + args = ap.parse_args() + db_path = Path(args.db) + if not db_path.is_file(): + print(f"db not found: {db_path}", file=sys.stderr) + return 2 + conn = _connect(db_path) + try: + report = repair(conn, apply=bool(args.apply)) + finally: + conn.close() + + bad = [x for x in report if x.get("suspicious") or x.get("action") != "ok"] + print(f"scanned={len(report)} need_attention={len(bad)} apply={bool(args.apply)}") + for x in bad[:50]: + print( + f" #{x['trade_id']} {x['inst_id']} action={x['action']} " + f"A:{x['old_realized_pnl_usdt_A']}->{x['new_realized_pnl_usdt_A']} " + f"B={x['stats_pnl_usdt_B']} hint={x['bridge_hint']}" + ) + if args.csv: + out = Path(args.csv) + if report: + with out.open("w", newline="", encoding="utf-8-sig") as f: + w = csv.DictWriter(f, fieldnames=list(report[0].keys())) + w.writeheader() + w.writerows(report) + print(f"wrote {out}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_options_account_pnl.py b/tests/test_options_account_pnl.py index 25829e6..9871837 100644 --- a/tests/test_options_account_pnl.py +++ b/tests/test_options_account_pnl.py @@ -51,55 +51,59 @@ class AccountPnlSettleTests(unittest.TestCase): self.assertAlmostEqual(float(row2["realized_pnl_usdt"]), 947.05, places=2) conn.close() - def test_stats_only_count_settled_account_trades(self): + def test_stats_use_coin_times_sell_px_not_account(self): + """口径 B:有币盈亏即计入,U=币×卖回价;不读账户差分.""" from lib.options.options_db import init_options_tables from lib.options.options_stats_lib import ( compute_options_stats_from_history, - history_pnl_to_usdt, + history_pnl_to_usdt_stats, load_local_options_stats_history, ) conn = sqlite3.connect(":memory:") conn.row_factory = sqlite3.Row init_options_tables(conn) - # 待结算:不进统计 + # 账户未结算但有币盈亏+卖回价 → 进统计 B conn.execute( """ INSERT INTO options_trades( inst_id, underlying, opt_type, sheets, eth_amount, premium_paid, premium_received, realized_pnl, premium_ccy, - status, trading_usdt_before, pnl_settled, created_at, closed_at + close_index_px, status, trading_usdt_before, pnl_settled, + created_at, closed_at ) VALUES ( 'ETH-USD-261009-2500-P', 'ETH', 'P', 1, 0.01, 0.01, 0.02, 0.01, 'ETH', - 'closed', 500.0, 0, '2026-10-06 10:00:00', '2026-10-08 14:00:00' + 3000.0, 'closed', 500.0, 0, + '2026-10-06 10:00:00', '2026-10-08 14:00:00' ) """ ) - # 已结算 + # 账户冻结为负、币盈亏为正 → 统计仍用币×价 conn.execute( """ INSERT INTO options_trades( inst_id, underlying, opt_type, sheets, eth_amount, premium_paid, premium_received, realized_pnl, premium_ccy, - status, trading_usdt_before, trading_usdt_after, + close_index_px, status, trading_usdt_before, trading_usdt_after, realized_pnl_usdt, pnl_settled, created_at, closed_at ) VALUES ( - 'ETH-USD-261009-2500-P', 'ETH', 'P', 1, 0.01, - 0.01, 0.02, 0.01, 'ETH', - 'closed', 1000.0, 1952.96, - 952.96, 1, '2026-10-01 10:00:00', '2026-10-02 14:00:00' + 'BTC-USD-261015-84000-P', 'BTC', 'P', 1, 0.01, + 0.01, 0.025, 0.015, 'BTC', + 100000.0, 'closed', 1000.0, 948.82, + -51.18, 1, '2026-10-01 10:00:00', '2026-10-02 14:00:00' ) """ ) conn.commit() hist = load_local_options_stats_history(conn, None) - stats = compute_options_stats_from_history(history_pnl_to_usdt(hist, None)) - self.assertEqual(int(stats["total_closed"]), 1) - self.assertAlmostEqual(float(stats["net_realized_pnl"]), 952.96, places=2) + stats = compute_options_stats_from_history(history_pnl_to_usdt_stats(hist, None)) + self.assertEqual(int(stats["total_closed"]), 2) + # 0.01*3000 + 0.015*100000 = 30 + 1500 = 1530 + self.assertAlmostEqual(float(stats["net_realized_pnl"]), 1530.0, places=2) conn.close() - def test_header_stats_skip_unsettled_account_trades(self): + def test_header_stats_count_coin_pnl_rows(self): from datetime import datetime, timezone from lib.options.options_db import init_options_tables @@ -112,11 +116,11 @@ class AccountPnlSettleTests(unittest.TestCase): """ INSERT INTO options_trades( inst_id, underlying, opt_type, sheets, eth_amount, - premium_paid, realized_pnl, premium_ccy, status, + premium_paid, realized_pnl, premium_ccy, close_index_px, status, trading_usdt_before, pnl_settled, created_at, closed_at ) VALUES ( 'ETH-USD-261009-2500-P', 'ETH', 'P', 1, 0.01, - 0.01, 0.01, 'ETH', 'closed', + 0.01, 0.01, 'ETH', 3000.0, 'closed', 500.0, 0, '2026-10-08 10:00:00', '2026-10-08 12:00:00' ) """ @@ -125,12 +129,12 @@ class AccountPnlSettleTests(unittest.TestCase): """ INSERT INTO options_trades( inst_id, underlying, opt_type, sheets, eth_amount, - premium_paid, realized_pnl, premium_ccy, status, + premium_paid, realized_pnl, premium_ccy, close_index_px, status, trading_usdt_before, trading_usdt_after, realized_pnl_usdt, pnl_settled, created_at, closed_at ) VALUES ( 'ETH-USD-261009-2500-P', 'ETH', 'P', 1, 0.01, - 0.01, 0.01, 'ETH', 'closed', + 0.01, 0.01, 'ETH', 3000.0, 'closed', 1000.0, 1100.0, 100.0, 1, '2026-10-08 08:00:00', '2026-10-08 09:00:00' ) @@ -142,7 +146,50 @@ class AccountPnlSettleTests(unittest.TestCase): "end_utc": datetime(2026, 10, 31, 23, 59, 59, tzinfo=timezone.utc), } out = header_options_stats_for_window(conn, win, timezone.utc, None) - self.assertEqual(int(out["total"]), 1) + self.assertEqual(int(out["total"]), 2) + conn.close() + + def test_settle_blocked_when_sellable_coin(self): + from lib.options.options_account_pnl_lib import try_settle_after_sellback + from lib.options.options_db import init_options_tables + + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + init_options_tables(conn) + conn.execute( + """ + INSERT INTO options_trades( + inst_id, underlying, opt_type, sheets, eth_amount, + premium_paid, status, bridge_id, trading_usdt_before, pnl_settled, + created_at, closed_at + ) VALUES ( + 'BTC-USD-261015-84000-P', 'BTC', 'P', 1, 0.01, + 0.01, 'closed', 7, 1000.0, 0, + '2026-10-12 01:46:50', '2026-10-13 08:07:35' + ) + """ + ) + conn.commit() + + class _Ex: + pass + + # monkey: 仍有可卖 BTC + import lib.options.options_account_pnl_lib as mod + + old_block = mod.sellable_coin_blocks_settle + mod.sellable_coin_blocks_settle = lambda ex, uly: "仍有可卖 BTC" + try: + n = try_settle_after_sellback( + conn, _Ex(), bridge_id=7, sell_complete=True + ) + self.assertEqual(n, 0) + row = conn.execute( + "SELECT pnl_settled FROM options_trades WHERE id=1" + ).fetchone() + self.assertEqual(int(row["pnl_settled"]), 0) + finally: + mod.sellable_coin_blocks_settle = old_block conn.close() diff --git a/tests/test_spot_dust_and_sell_cap.py b/tests/test_spot_dust_and_sell_cap.py index 0f1488d..889c396 100644 --- a/tests/test_spot_dust_and_sell_cap.py +++ b/tests/test_spot_dust_and_sell_cap.py @@ -28,12 +28,22 @@ class TestSpotDust(unittest.TestCase): class TestSellCapLogic(unittest.TestCase): def test_excess_cap_math(self): + """开仓清过量仍按 coin_bought 封顶;平仓卖回不再用权利金封顶.""" bought, keep = 0.01, 0.004 excess = max(0.0, bought - keep) self.assertAlmostEqual(excess, 0.006) sell_amt = min(0.02, excess) self.assertAlmostEqual(sell_amt, 0.006) + def test_flat_sell_does_not_use_premium_cap(self): + import inspect + + from lib.options import options_spot_bridge_lib as m + + src = inspect.getsource(m.sell_residual_after_option_flat) + self.assertIn("coin_amount=None", src) + self.assertNotIn("sell_cap if", src) + class TestMaybeSellAfterCloseGuard(unittest.TestCase): """平仓后卖回:持仓查询失败/空响应绝不卖币."""