diff --git a/docs/规格-期权交易账户盈亏结算.md b/docs/规格-期权交易账户盈亏结算.md new file mode 100644 index 0000000..50a1803 --- /dev/null +++ b/docs/规格-期权交易账户盈亏结算.md @@ -0,0 +1,205 @@ +# 规格:期权盈亏按交易账户结算(一次一仓) + +> 状态:已定稿,按本文实现 +> 适用范围:币本位期权(模拟盘优先落地;实盘同规则) +> 目标:复盘盈亏、数据统计盈亏与交易账户真实 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`。 +- 待结算已平单:不计入已实现,也不重复计入浮盈(浮盈仅当前期权持仓)。 + +### 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 | 初版定稿:交易账户快照差分 + 一次一仓 + 持仓禁划转 + 卖回后冻结 | diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js index c3008cc..027d845 100644 --- a/lib/common/static/options_panel.js +++ b/lib/common/static/options_panel.js @@ -2598,6 +2598,12 @@ paintPositions(list); paintSpotSellBanner(d, list); paintCondOrderLock(d); + if (typeof window.OptionsFundsSetTransferBlocked === "function") { + window.OptionsFundsSetTransferBlocked( + !!(d && d.transfer_blocked), + (d && d.transfer_block_msg) || "持仓或待卖回期间不可划转" + ); + } const fromPos = list.reduce(function (targets, p) { if (!p) return targets; if (p.target_index != null) { diff --git a/lib/common/static/options_settings.js b/lib/common/static/options_settings.js index 25aabf9..1c519d3 100644 --- a/lib/common/static/options_settings.js +++ b/lib/common/static/options_settings.js @@ -353,4 +353,32 @@ intMsg: "opt-page-int-msg", }); } + + /** 持仓/待卖回时划转按钮置灰(规格). */ + window.OptionsFundsSetTransferBlocked = function (blocked, msg) { + const idsList = [ + ["opt-page-int-btn", "opt-page-int-all-btn", "opt-page-int-amount", "opt-page-int-msg"], + ["opt-set-int-btn", "opt-set-int-all-btn", "opt-set-int-amount", "opt-set-int-msg"], + ]; + idsList.forEach(function (ids) { + const btn = document.getElementById(ids[0]); + const allBtn = document.getElementById(ids[1]); + const amount = document.getElementById(ids[2]); + const msgEl = document.getElementById(ids[3]); + [btn, allBtn].forEach(function (el) { + if (!el) return; + el.disabled = !!blocked; + el.style.opacity = blocked ? "0.45" : ""; + el.title = blocked ? String(msg || "持仓中不可划转") : ""; + }); + if (amount) amount.disabled = !!blocked; + if (blocked && msgEl && msg) { + msgEl.textContent = String(msg); + msgEl.classList.add("text-warn"); + } else if (!blocked && msgEl && /持仓|划转|卖回/.test(String(msgEl.textContent || ""))) { + msgEl.textContent = ""; + msgEl.classList.remove("text-warn"); + } + }); + }; })(); diff --git a/lib/options/options_account_pnl_lib.py b/lib/options/options_account_pnl_lib.py new file mode 100644 index 0000000..155cb32 --- /dev/null +++ b/lib/options/options_account_pnl_lib.py @@ -0,0 +1,196 @@ +"""期权已实现盈亏:交易账户 USDT 快照差分(一次一仓). + +规格见 docs/规格-期权交易账户盈亏结算.md +""" +from __future__ import annotations + +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 fetch_trading_usdt(cfg: dict[str, Any] | None, ex: Any) -> float | None: + """读取交易账户 USDT(可用优先).""" + fetch = (cfg or {}).get("fetch_options_balances") if isinstance(cfg, dict) else None + if not callable(fetch): + from lib.exchange.okx_options_lib import fetch_options_balances + + fetch = fetch_options_balances + try: + bal = fetch(ex, force=True) or {} + except TypeError: + try: + bal = fetch(ex) or {} + except Exception: + return None + except Exception: + return None + v = _safe_float(bal.get("trading_usdt_avail")) + if v is None: + v = _safe_float(bal.get("trading_usdt")) + return round(float(v), 8) if v is not None else None + + +def sellable_coin_blocks_open_msg(ex: Any, underlying: str) -> 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}),请先卖回 USDT 后再开仓" + return None + + +def transfer_blocked_by_position_msg(conn, ex: Any = None) -> str | None: + """持仓中或待卖回时禁止划转.""" + from lib.options.options_spot_bridge_lib import has_unfinished_bridge + + try: + if has_unfinished_bridge(conn): + return "期权持仓或待卖回期间禁止划转,请先平仓并卖回 USDT" + except Exception: + pass + try: + row = conn.execute( + "SELECT COUNT(*) AS n FROM options_trades WHERE status='open'" + ).fetchone() + n = int(row["n"] if hasattr(row, "keys") else row[0]) + if n > 0: + return "期权持仓中禁止划转" + except Exception: + pass + if ex is not None: + try: + from lib.exchange.okx_options_lib import fetch_option_positions + + for p in fetch_option_positions(ex) or []: + try: + if abs(float(p.get("pos") or 0)) > 1e-12: + return "存在未平期权持仓,禁止划转" + except (TypeError, ValueError): + continue + except Exception: + pass + return None + + +def try_settle_after_sellback( + get_db: Any, + ex: Any, + *, + bridge_id: int | None = None, + cfg: dict[str, Any] | 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: + return 0 + conn = get_db() if callable(get_db) else get_db + close_conn = callable(get_db) + try: + from lib.options.options_db import init_options_tables + + init_options_tables(conn) + n = 0 + if bridge_id is not None: + n = settle_closed_trades_from_trading_account( + conn, trading_usdt_after=after, bridge_id=int(bridge_id) + ) + if n < 1: + row = conn.execute( + """ + SELECT id FROM options_trades + WHERE status='closed' AND COALESCE(pnl_settled,0)=0 + AND trading_usdt_before IS NOT NULL + ORDER BY id DESC LIMIT 1 + """ + ).fetchone() + if row: + n = settle_closed_trades_from_trading_account( + conn, + trading_usdt_after=after, + trade_id=int(row["id"] if hasattr(row, "keys") else row[0]), + ) + try: + conn.commit() + except Exception: + pass + return n + finally: + if close_conn: + try: + conn.close() + except Exception: + pass + + +def settle_closed_trades_from_trading_account( + conn, + *, + trading_usdt_after: float, + bridge_id: int | None = None, + trade_id: int | None = None, +) -> int: + """卖回后:对本桥/本单未结算已平仓位冻结 after−before. + + 已 pnl_settled=1 的行永不改写. + """ + after = _safe_float(trading_usdt_after) + if after is None: + return 0 + after = round(float(after), 8) + clauses = ["status='closed'", "COALESCE(pnl_settled, 0)=0", "trading_usdt_before IS NOT NULL"] + params: list[Any] = [] + if trade_id is not None: + clauses.append("id=?") + params.append(int(trade_id)) + elif bridge_id is not None: + clauses.append("bridge_id=?") + params.append(int(bridge_id)) + else: + return 0 + rows = conn.execute( + f""" + SELECT id, trading_usdt_before, realized_pnl_usdt + FROM options_trades + WHERE {" AND ".join(clauses)} + """, + tuple(params), + ).fetchall() + updated = 0 + for row in rows: + before = _safe_float(row["trading_usdt_before"] if hasattr(row, "keys") else row[1]) + if before is None: + continue + pnl_u = round(float(after) - float(before), 4) + conn.execute( + """ + UPDATE options_trades + SET trading_usdt_after = ?, + realized_pnl_usdt = ?, + pnl_settled = 1 + WHERE id = ? AND COALESCE(pnl_settled, 0)=0 + """, + (after, pnl_u, int(row["id"] if hasattr(row, "keys") else row[0])), + ) + updated += 1 + return updated diff --git a/lib/options/options_close_exec_lib.py b/lib/options/options_close_exec_lib.py index 84d87d0..b97b1f2 100644 --- a/lib/options/options_close_exec_lib.py +++ b/lib/options/options_close_exec_lib.py @@ -379,7 +379,7 @@ def close_option_by_bid1( """ SELECT id, premium_paid, open_fee, premium_ccy, underlying, exchange_ord_id, budget_usdt, open_index_px, fee_ccy, - eth_amount, open_quote + eth_amount, open_quote, trading_usdt_before FROM options_trades WHERE inst_id = ? AND status = 'open' ORDER BY id ASC @@ -510,7 +510,9 @@ def close_option_by_bid1( fee_ccy=str(row["fee_ccy"] or row["premium_ccy"] or row_ccy or ""), ) pnl = settled.get("realized_pnl") - pnl_usdt = settled.get("realized_pnl_usdt") + # 有交易账户 before 快照:U 盈亏等卖回后结算,平仓不写 realized_pnl_usdt + has_acct_snap = _safe_float(row["trading_usdt_before"]) is not None + pnl_usdt = None if has_acct_snap else settled.get("realized_pnl_usdt") note_sql = "" # 占位: close_quote/recv/pnl/ord/closed_at/open_fee/close_fee/fee_ccy/close_idx/open_idx/pnl_u/[note]/id diff --git a/lib/options/options_coin_open_lib.py b/lib/options/options_coin_open_lib.py index f1f5f98..5da5b4e 100644 --- a/lib/options/options_coin_open_lib.py +++ b/lib/options/options_coin_open_lib.py @@ -216,6 +216,16 @@ def open_coin_option_buy_full( conn.commit() except Exception: pass + # 开仓前有可卖标的币 → 拒开(规格:先卖回) + from lib.options.options_account_pnl_lib import ( + fetch_trading_usdt, + sellable_coin_blocks_open_msg, + ) + + coin_block = sellable_coin_blocks_open_msg(ex, underlying) + if coin_block: + return {"ok": False, "msg": coin_block, "can_open": False} + trading_usdt_before = fetch_trading_usdt(cfg, ex) # 1) 仅买「权利金×现货缓冲」所需 USDT,不全额兑换预算 coin_before = _spot_bridge.fetch_trading_coin_available(ex, underlying) or 0.0 buy = _spot_bridge.spot_market_buy_coin_with_usdt(ex, underlying=underlying, usdt_amount=buy_usdt) @@ -514,6 +524,7 @@ def open_coin_option_buy_full( premium_ccy=premium_ccy, open_fee=open_fee, open_index_px=idx_open, + trading_usdt_before=trading_usdt_before, profit_exit_enabled=profit_exit_enabled, profit_exit_mult=profit_exit_mult, ) @@ -594,15 +605,20 @@ def _insert_coin_trade(conn: Any, **kwargs: Any) -> int: open_index_px = None except (TypeError, ValueError): open_index_px = None + tusdt_before = kwargs.get("trading_usdt_before") + try: + tusdt_before = float(tusdt_before) if tusdt_before is not None else None + except (TypeError, ValueError): + tusdt_before = None cur = conn.execute( """ INSERT INTO options_trades( inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount, 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, + open_fee, fee_ccy, open_index_px, trading_usdt_before, pnl_settled, profit_exit_enabled, profit_exit_mult, profit_exit_state, created_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, 'coin', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, 'coin', ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?) """, ( kwargs["inst_id"], @@ -622,6 +638,7 @@ def _insert_coin_trade(conn: Any, **kwargs: Any) -> int: open_fee, fee_ccy, open_index_px, + tusdt_before, pe, pe_mult, pe_state, @@ -668,18 +685,18 @@ def maybe_sell_spot_after_close( underlying=underlying, inst_id=inst_id, ) - # 模拟盘:卖回后按桥上真实 USDT 净额回写已实现盈亏 + # 卖回成功:交易账户 after − 开仓 before → 冻结本单盈亏 try: - from lib.sim.mode_lib import is_sim_mode - from lib.options.options_stats_lib import sync_sim_cash_realized_pnl_usdt + from lib.options.options_account_pnl_lib import try_settle_after_sellback - if is_sim_mode(cfg["get_db"]): - conn = cfg["get_db"]() - try: - sync_sim_cash_realized_pnl_usdt(conn, ex) - conn.commit() - finally: - conn.close() + if (result or {}).get("ok"): + bid = (result or {}).get("bridge_id") + try_settle_after_sellback( + cfg["get_db"], + ex, + bridge_id=int(bid) if bid is not None else None, + cfg=cfg, + ) except Exception: pass return result @@ -749,6 +766,19 @@ def sweep_spot_after_options_flat( underlying=uly, inst_id=None, ) + if (out or {}).get("ok"): + try: + from lib.options.options_account_pnl_lib import try_settle_after_sellback + + bid = (out or {}).get("bridge_id") + try_settle_after_sellback( + cfg["get_db"], + exchange, + bridge_id=int(bid) if bid is not None else None, + cfg=cfg, + ) + except Exception: + pass sold.append({"underlying": uly, **(out or {})}) except Exception as e: sold.append({"underlying": uly, "ok": False, "msg": str(e)}) diff --git a/lib/options/options_db.py b/lib/options/options_db.py index d5f2460..511186b 100644 --- a/lib/options/options_db.py +++ b/lib/options/options_db.py @@ -129,6 +129,10 @@ def init_options_tables(conn: sqlite3.Connection) -> None: "ALTER TABLE options_trades ADD COLUMN close_index_px REAL", "ALTER TABLE options_trades ADD COLUMN open_index_px REAL", "ALTER TABLE options_trades ADD COLUMN realized_pnl_usdt REAL", + # 交易账户快照盈亏(一次一仓):before→after 差分冻结 + "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", ): try: conn.execute(ddl) diff --git a/lib/options/options_register.py b/lib/options/options_register.py index 310b7ec..c0f870e 100644 --- a/lib/options/options_register.py +++ b/lib/options/options_register.py @@ -1026,6 +1026,21 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: conn_c.close() except Exception: pass + transfer_blocked = False + transfer_block_msg = "" + try: + from lib.options.options_account_pnl_lib import transfer_blocked_by_position_msg + + conn_tb = cfg["get_db"]() + try: + tmsg = transfer_blocked_by_position_msg(conn_tb, ex) + if tmsg: + transfer_blocked = True + transfer_block_msg = tmsg + finally: + conn_tb.close() + except Exception: + pass return jsonify( { "ok": True, @@ -1034,6 +1049,8 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: "cond_order_locked": cond_locked, "cond_order_msg": cond_msg, "cond_order": cond_order, + "transfer_blocked": transfer_blocked, + "transfer_block_msg": transfer_block_msg, } ) @@ -1196,6 +1213,18 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: inst_id=inst_id, ) if out.get("ok"): + try: + from lib.options.options_account_pnl_lib import try_settle_after_sellback + + bid = out.get("bridge_id") + try_settle_after_sellback( + cfg["get_db"], + ex, + bridge_id=int(bid) if bid is not None else None, + cfg=cfg, + ) + except Exception: + pass _mark_balances_stale(cfg) return jsonify(out) @@ -1215,6 +1244,16 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: ex, err = _require_options_ex(cfg) if ex is None: return jsonify({"ok": False, "msg": err}) + # 持仓/待卖回禁止划转(规格) + conn_blk = cfg["get_db"]() + try: + from lib.options.options_account_pnl_lib import transfer_blocked_by_position_msg + + block = transfer_blocked_by_position_msg(conn_blk, ex) + if block: + return jsonify({"ok": False, "msg": block, "transfer_blocked": True}) + finally: + conn_blk.close() data = request.get_json(silent=True) or {} ccy = (data.get("ccy") or "USDC").upper() from_acct = (data.get("from") or "funding").strip() diff --git a/lib/options/options_review_lib.py b/lib/options/options_review_lib.py index 17e02af..af4a3f8 100644 --- a/lib/options/options_review_lib.py +++ b/lib/options/options_review_lib.py @@ -316,7 +316,8 @@ def sync_options_from_local_trades( SELECT id, inst_id, underlying, opt_type, strike, exp_time, sheets, 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 + premium_ccy, created_at, closed_at, signal_note, status, + trading_usdt_before, pnl_settled FROM options_trades WHERE status = 'closed' ORDER BY id DESC @@ -409,6 +410,12 @@ def sync_options_from_local_trades( if not ccy: ccy = premium_ccy_for_mode(margin_mode_from_inst_id(inst), underly) frozen_usdt = _safe_float(r["realized_pnl_usdt"]) + try: + settled = int(r["pnl_settled"] or 0) == 1 + except Exception: + settled = False + # 账户快照路径:未结算不写 U,避免指数假数进复盘 + acct_pending = _safe_float(r["trading_usdt_before"]) is not None and not settled close_idx = _safe_float(r["close_index_px"]) open_idx = _safe_float(r["open_index_px"]) # 折算指数:平仓冻结 > 开仓 > 实时(仅无冻结时兜底) @@ -426,7 +433,9 @@ def sync_options_from_local_trades( if paid0 is not None and recv0 is not None: pnl = recv0 - paid0 prem = _safe_float(r["premium_paid"]) - if frozen_usdt is not None: + if acct_pending: + pnl_u = None + elif frozen_usdt is not None and (settled or _safe_float(r["trading_usdt_before"]) is None): pnl_u = round(float(frozen_usdt), 4) else: pnl_u = _to_usdt(pnl, ccy=ccy, idx=idx) diff --git a/lib/options/options_stats_lib.py b/lib/options/options_stats_lib.py index 134dab2..a6685ec 100644 --- a/lib/options/options_stats_lib.py +++ b/lib/options/options_stats_lib.py @@ -303,7 +303,11 @@ _CLOSED_STATS_SELECT = """ created_at, closed_at, inst_id, underlying, premium_ccy FROM options_trades WHERE status = 'closed' - AND (realized_pnl IS NOT NULL OR realized_pnl_usdt IS NOT NULL) + 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)) + ) """ @@ -322,6 +326,7 @@ def freeze_missing_realized_pnl_usdt(conn, ex: Any = None) -> int: WHERE status = 'closed' AND realized_pnl_usdt IS NULL AND realized_pnl IS NOT NULL + AND trading_usdt_before IS NULL ORDER BY id DESC LIMIT 300 """ @@ -512,96 +517,15 @@ def _sim_net_usdt_converts_for_trade( def sync_sim_cash_realized_pnl_usdt(conn, ex: Any = None) -> int: - """模拟盘:收回×卖回价 − 初始U权利金 − 手续费U. + """兼容入口:已按交易账户结算(pnl_settled=1)的永不改写. - 盈利=(权利金币+盈利币)×卖回价−开仓U权利金−费; - 亏损=剩余权利金U−费−开仓U(同一式).不用买币预算差额. + 有 trading_usdt_before 的待结算单留给卖回结算; + 仅无账户快照的旧单才回退权利金×指数冻结. """ + del ex init_options_tables(conn) - from lib.options.options_pnl_settle_lib import resolve_close_index_px, settle_closed_pnl - - rows = conn.execute( - """ - SELECT id, inst_id, underlying, premium_ccy, premium_paid, premium_received, - open_fee, close_fee, realized_pnl, realized_pnl_usdt, - close_index_px, open_index_px, close_quote, eth_amount - FROM options_trades - WHERE status = 'closed' - ORDER BY id DESC - LIMIT 300 - """ - ).fetchall() - updated = 0 - for row in rows: - ccy = str(_row_get(row, "premium_ccy") or "").strip().upper() - underly = str(_row_get(row, "underlying") or "").strip().upper() - if ccy not in ("ETH", "BTC") and underly not in ("ETH", "BTC"): - continue - underly = underly or ccy - paid = _safe_float(_row_get(row, "premium_paid")) - recv = _safe_float(_row_get(row, "premium_received")) - if recv is None: - cq = _safe_float(_row_get(row, "close_quote")) - eth = _safe_float(_row_get(row, "eth_amount")) - if cq is not None and eth is not None and eth > 0: - try: - from lib.options.options_pricing_lib import total_premium - - recv = float(total_premium(cq, eth)) - except Exception: - recv = None - sell_px = _safe_float(_row_get(row, "close_index_px")) - open_idx = _safe_float(_row_get(row, "open_index_px")) - if sell_px is None or sell_px <= 0: - sell_px = resolve_close_index_px( - ex, - inst_id=str(_row_get(row, "inst_id") or ""), - underlying=underly, - hint=open_idx, - ) - if open_idx is None or open_idx <= 0: - # 无开仓指数时用卖回价兜底(等价旧净币×价) - open_idx = sell_px - if paid is None or recv is None: - net = _safe_float(_row_get(row, "realized_pnl")) - if net is None or sell_px is None or sell_px <= 0: - continue - usdt = round(float(net) * float(sell_px), 4) - close_idx = float(sell_px) - new_pnl = float(net) - open_write = open_idx - else: - settled = settle_closed_pnl( - premium_paid=paid, - premium_received=recv, - open_fee=_row_get(row, "open_fee"), - close_fee=_row_get(row, "close_fee"), - premium_ccy=ccy or underly or "ETH", - index_px=sell_px, - open_index_px=open_idx, - ) - usdt = settled.get("realized_pnl_usdt") - close_idx = settled.get("close_index_px") or sell_px - new_pnl = settled.get("realized_pnl") - open_write = settled.get("open_index_px") or open_idx - if usdt is None: - continue - old = _safe_float(_row_get(row, "realized_pnl_usdt")) - if old is not None and abs(float(old) - float(usdt)) < 1e-6: - continue - conn.execute( - """ - UPDATE options_trades - SET realized_pnl_usdt = ?, - close_index_px = COALESCE(close_index_px, ?), - open_index_px = COALESCE(open_index_px, ?), - realized_pnl = COALESCE(?, realized_pnl) - WHERE id = ? - """, - (float(usdt), close_idx, open_write, new_pnl, int(row["id"])), - ) - updated += 1 - return updated + # 账户快照路径由 sell_residual / maybe_sell 结算,此处不覆盖 + return 0 def load_local_options_stats_history(conn, ex: Any = None) -> list[dict[str, Any]]: diff --git a/tests/test_options_account_pnl.py b/tests/test_options_account_pnl.py new file mode 100644 index 0000000..8b8ec06 --- /dev/null +++ b/tests/test_options_account_pnl.py @@ -0,0 +1,104 @@ +"""交易账户快照盈亏结算.""" +from __future__ import annotations + +import sqlite3 +import unittest + + +class AccountPnlSettleTests(unittest.TestCase): + def test_settle_after_minus_before(self): + from lib.options.options_account_pnl_lib import ( + settle_closed_trades_from_trading_account, + ) + 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-261008-86500-P', 'BTC', 'P', 1, 0.01, + 0.01, 'closed', 7, 1000.0, 0, + '2026-10-05 05:11:30', '2026-10-07 10:03:53' + ) + """ + ) + conn.commit() + n = settle_closed_trades_from_trading_account( + conn, trading_usdt_after=1947.05, bridge_id=7 + ) + self.assertEqual(n, 1) + row = conn.execute( + "SELECT trading_usdt_after, realized_pnl_usdt, pnl_settled FROM options_trades WHERE id=1" + ).fetchone() + self.assertAlmostEqual(float(row["trading_usdt_after"]), 1947.05, places=2) + self.assertAlmostEqual(float(row["realized_pnl_usdt"]), 947.05, places=2) + self.assertEqual(int(row["pnl_settled"]), 1) + + # 已冻结不再改 + n2 = settle_closed_trades_from_trading_account( + conn, trading_usdt_after=9999.0, bridge_id=7 + ) + self.assertEqual(n2, 0) + row2 = conn.execute( + "SELECT realized_pnl_usdt FROM options_trades WHERE id=1" + ).fetchone() + self.assertAlmostEqual(float(row2["realized_pnl_usdt"]), 947.05, places=2) + conn.close() + + def test_stats_only_count_settled_account_trades(self): + 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, + load_local_options_stats_history, + ) + + 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, premium_received, realized_pnl, premium_ccy, + 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' + ) + """ + ) + # 已结算 + 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, + 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' + ) + """ + ) + 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) + conn.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_options_pnl_settle.py b/tests/test_options_pnl_settle.py index f9bc9e7..9d0a82c 100644 --- a/tests/test_options_pnl_settle.py +++ b/tests/test_options_pnl_settle.py @@ -104,83 +104,38 @@ class SettleClosedPnlTests(unittest.TestCase): class SimPremiumPnlStatsTests(unittest.TestCase): - def test_sim_uses_premium_net_not_bridge_cash(self): - """卖回−预算会把权利金本金算进盈利;必须用(收回−已付)×平仓指数.""" + def test_sync_sim_no_longer_overwrites_account_settled(self): + """账户结算后 sync 兼容入口不得改写.""" import sqlite3 from lib.options.options_db import init_options_tables - from lib.options.options_spot_bridge_lib import ensure_bridge_table from lib.options.options_stats_lib import sync_sim_cash_realized_pnl_usdt conn = sqlite3.connect(":memory:") conn.row_factory = sqlite3.Row init_options_tables(conn) - ensure_bridge_table(conn) - # 桥上现金差额 952.96,但权利金净盈亏仅 0.01 ETH × 2500 = 25U - conn.execute( - """ - INSERT INTO options_spot_bridge( - id, underlying, status, budget_usdt, usdt_recovered - ) VALUES (1, 'ETH', 'closed', 1000.0, 1952.96) - """ - ) conn.execute( """ INSERT INTO options_trades( inst_id, underlying, opt_type, sheets, eth_amount, premium_paid, premium_received, realized_pnl, premium_ccy, - status, bridge_id, budget_usdt, realized_pnl_usdt, close_index_px, - created_at, closed_at + 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', 1, 1000.0, 1947.05, 2500.0, - '2026-10-06 10:00:00', '2026-10-08 14:00:00' + 'closed', 1000.0, 1952.96, + 952.96, 1, '2026-10-06 10:00:00', '2026-10-08 14:00:00' ) """ ) conn.commit() n = sync_sim_cash_realized_pnl_usdt(conn, None) - self.assertEqual(n, 1) - row = conn.execute( - "SELECT realized_pnl_usdt, realized_pnl FROM options_trades WHERE id=1" - ).fetchone() - # (0.02-0.01)*2500 = 25, 绝不是 1952.96-1000 - self.assertAlmostEqual(float(row["realized_pnl_usdt"]), 25.0, places=2) - self.assertAlmostEqual(float(row["realized_pnl"]), 0.01, places=8) - conn.close() - - def test_sim_deducts_fees_from_premium_pnl(self): - import sqlite3 - - from lib.options.options_db import init_options_tables - from lib.options.options_stats_lib import sync_sim_cash_realized_pnl_usdt - - 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, premium_received, realized_pnl, premium_ccy, - open_fee, close_fee, status, realized_pnl_usdt, close_index_px, - created_at, closed_at - ) VALUES ( - 'BTC-USD-261008-86500-P', 'BTC', 'P', 1, 0.01, - 0.01, 0.02, 0.01, 'BTC', - 0.0001, 0.0001, 'closed', 1947.05, 80000.0, - '2026-10-05 05:11:30', '2026-10-07 10:03:53' - ) - """ - ) - conn.commit() - sync_sim_cash_realized_pnl_usdt(conn, None) + self.assertEqual(n, 0) row = conn.execute( "SELECT realized_pnl_usdt FROM options_trades WHERE id=1" ).fetchone() - # (0.02-0.01-0.0002)*80000 = 784 - self.assertAlmostEqual(float(row["realized_pnl_usdt"]), 784.0, places=2) + self.assertAlmostEqual(float(row["realized_pnl_usdt"]), 952.96, places=2) conn.close()