From e261df00099a81cc39e09f80695af48373e90f43 Mon Sep 17 00:00:00 2001 From: dekun Date: Thu, 20 Aug 2026 21:51:03 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=B8=81=E6=9C=AC=E4=BD=8D?= =?UTF-8?q?=E6=9C=9F=E6=9D=83=E5=8E=86=E5=8F=B2=E6=9D=83=E5=88=A9=E9=87=91?= =?UTF-8?q?/=E7=9B=88=E4=BA=8F=E6=98=BE=E7=A4=BA0.00=EF=BC=9B=E5=A4=8D?= =?UTF-8?q?=E7=9B=98=E7=9B=88=E4=BA=8F=E6=8C=89=E6=8C=87=E6=95=B0=E6=8D=A2?= =?UTF-8?q?=E7=AE=97=E4=B8=BAU?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- lib/common/static/options_panel.js | 20 +++- lib/common/static/options_review.js | 2 +- lib/exchange/okx_options_lib.py | 21 +++- lib/options/options_history_lib.py | 72 ++++++++++-- lib/options/options_review_db.py | 3 + lib/options/options_review_lib.py | 172 ++++++++++++++++++++++++++-- tests/test_options_history_coin.py | 50 ++++++++ 7 files changed, 319 insertions(+), 21 deletions(-) create mode 100644 tests/test_options_history_coin.py diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js index aebcc4f..34bfc1f 100644 --- a/lib/common/static/options_panel.js +++ b/lib/common/static/options_panel.js @@ -2560,11 +2560,25 @@ } list.forEach(function (h) { const tr = document.createElement("tr"); - const premTxt = fmtDisplay(h.premium_paid_fmt, h.premium_paid != null ? fmtUsdc(h.premium_paid) : null); + const ccy = posPremiumCcy(h); + const premTxt = + h.premium_paid != null && !Number.isNaN(Number(h.premium_paid)) + ? fmtPremiumAmt(h.premium_paid, ccy) + (ccy !== "USDC" ? " " + ccy : "") + : "—"; const isOpen = h.status === "open"; const pnl = isOpen ? null : h.realized_pnl; - const pnlTxt = pnl != null ? fmt(pnl, 2) : "—"; - const pnlCls = pnl > 0 ? "pos-pnl-profit" : pnl < 0 ? "pos-pnl-loss" : ""; + let pnlTxt = "—"; + let pnlCls = ""; + if (pnl != null && !Number.isNaN(Number(pnl))) { + const n = Number(pnl); + pnlCls = n > 0 ? "pos-pnl-profit" : n < 0 ? "pos-pnl-loss" : ""; + if (ccy === "ETH" || ccy === "BTC") { + const sign = n > 0 ? "+" : n < 0 ? "-" : ""; + pnlTxt = sign + fmtPremiumAmt(Math.abs(n), ccy) + " " + ccy; + } else { + pnlTxt = (n > 0 ? "+" : "") + fmt(n, 2); + } + } const timeTxt = (h.closed_at || h.created_at || "—").replace("T", " ").slice(0, 19); const histKey = h.history_key || ""; tr.innerHTML = diff --git a/lib/common/static/options_review.js b/lib/common/static/options_review.js index 88d2540..bc80c7b 100644 --- a/lib/common/static/options_review.js +++ b/lib/common/static/options_review.js @@ -49,7 +49,7 @@ if (v == null || v === "") return "—"; var n = Number(v); if (Number.isNaN(n)) return "—"; - return (n >= 0 ? "+" : "") + n.toFixed(2); + return (n >= 0 ? "+" : "") + n.toFixed(2) + "U"; } function fmtHold(sec) { diff --git a/lib/exchange/okx_options_lib.py b/lib/exchange/okx_options_lib.py index 4c313b7..0055a06 100644 --- a/lib/exchange/okx_options_lib.py +++ b/lib/exchange/okx_options_lib.py @@ -1456,6 +1456,15 @@ def format_option_history_row( ctime = _safe_float(raw.get("cTime")) opt_type, strike = option_fields_from_inst_id(inst_id) uly = str(raw.get("uly") or inst_id.split("-")[0] or "").replace("-USD_UM", "").replace("-USD", "") + try: + from lib.options.options_margin_mode_lib import margin_mode_from_inst_id, premium_ccy_for_mode + + row_mode = margin_mode_from_inst_id(inst_id) if inst_id else "usdc" + premium_ccy = premium_ccy_for_mode(row_mode, uly or "ETH") + except Exception: + row_mode = "usdc" + premium_ccy = "USDC" + idx_px = _safe_float(raw.get("idxPx") or raw.get("idx_px")) if close_type in ("3", "4"): status_label = "强平" else: @@ -1477,12 +1486,17 @@ def format_option_history_row( "strike": strike, "sheets": sheets_i, "eth_amount": eth_amount, + "ct_mult": ct_mult, "open_avg_px": open_avg, "open_avg_px_fmt": format_option_px(open_avg, tick_sz) if open_avg is not None else None, "close_avg_px": close_avg, "close_avg_px_fmt": format_option_px(close_avg, tick_sz) if close_avg is not None else None, "premium_paid": premium_paid, - "premium_paid_fmt": format_usdc_amount(premium_paid), + "premium_paid_fmt": format_premium_amount(premium_paid, ccy=premium_ccy), + "premium_ccy": premium_ccy, + "margin_mode": row_mode, + "margin_mode_label": "币本位" if row_mode == "coin" else "USDC", + "idx_px": idx_px, "realized_pnl": realized, "pnl_ratio_pct": round(pnl_ratio * 100, 2) if pnl_ratio is not None else None, "status": "closed", @@ -1505,6 +1519,7 @@ def format_live_option_history_row( inst_id = str(row.get("inst_id") or "").strip() pos_id = str((row.get("raw") or {}).get("posId") or "").strip() or None close_ms = open_ms + premium_ccy = str(row.get("premium_ccy") or "USDC").strip().upper() or "USDC" return { "source": "live", "history_key": option_history_row_key( @@ -1526,6 +1541,10 @@ def format_live_option_history_row( "close_avg_px_fmt": None, "premium_paid": row.get("premium_paid"), "premium_paid_fmt": row.get("premium_paid_fmt"), + "premium_ccy": premium_ccy, + "margin_mode": row.get("margin_mode"), + "margin_mode_label": row.get("margin_mode_label"), + "idx_px": row.get("idx_px"), "realized_pnl": row.get("upl"), "pnl_ratio_pct": row.get("upl_ratio_pct"), "status": "open", diff --git a/lib/options/options_history_lib.py b/lib/options/options_history_lib.py index 0dec61a..ca9283c 100644 --- a/lib/options/options_history_lib.py +++ b/lib/options/options_history_lib.py @@ -3,7 +3,9 @@ from __future__ import annotations from typing import Any +from lib.exchange.okx_options_lib import format_premium_amount from lib.options.options_db import init_options_tables, sum_open_premium_paid +from lib.options.options_margin_mode_lib import margin_mode_from_inst_id, premium_ccy_for_mode def enrich_position_row_display( @@ -14,8 +16,7 @@ def enrich_position_row_display( meta_cache: dict[str, dict[str, Any] | None] | None = None, premium_override: float | None = None, ) -> dict[str, Any]: - from lib.exchange.okx_options_lib import format_position_row, format_premium_amount, tick_sz_and_ct_mult - from lib.options.options_margin_mode_lib import margin_mode_from_inst_id, premium_ccy_for_mode + from lib.exchange.okx_options_lib import format_position_row, tick_sz_and_ct_mult inst_id = str(raw_pos.get("instId") or "").strip() tick_sz, ct_mult = tick_sz_and_ct_mult(ex, inst_id, meta_cache) @@ -32,6 +33,59 @@ def enrich_position_row_display( return row +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 _load_local_closed_by_inst(conn: Any) -> dict[str, dict[str, Any]]: + """同合约取最新已平本地单,用于补交易所历史权利金/盈亏.""" + out: dict[str, dict[str, Any]] = {} + try: + rows = conn.execute( + """ + SELECT inst_id, premium_paid, realized_pnl, premium_ccy, margin_mode, + open_quote, close_quote, sheets, closed_at + FROM options_trades + WHERE status = 'closed' + ORDER BY id DESC + """ + ).fetchall() + except Exception: + return out + for r in rows: + inst = str(r["inst_id"] or "").strip() + if not inst or inst in out: + continue + out[inst] = dict(r) + return out + + +def _overlay_local_closed(row: dict[str, Any], local: dict[str, Any] | None) -> dict[str, Any]: + if not local: + return row + prem = _safe_float(row.get("premium_paid")) + pnl = _safe_float(row.get("realized_pnl")) + local_prem = _safe_float(local.get("premium_paid")) + local_pnl = _safe_float(local.get("realized_pnl")) + # 交易所缺数或被两位小数抹成 0 时,用本地币本位落库值 + if (prem is None or abs(prem) < 1e-10) and local_prem is not None and abs(local_prem) > 0: + row["premium_paid"] = local_prem + if (pnl is None or abs(pnl) < 1e-10) and local_pnl is not None and abs(local_pnl) > 0: + row["realized_pnl"] = local_pnl + if not row.get("premium_ccy") and local.get("premium_ccy"): + row["premium_ccy"] = local.get("premium_ccy") + if not row.get("margin_mode") and local.get("margin_mode"): + row["margin_mode"] = local.get("margin_mode") + ccy = str(row.get("premium_ccy") or "USDC").strip().upper() or "USDC" + row["premium_paid_fmt"] = format_premium_amount(row.get("premium_paid"), ccy=ccy) + return row + + def load_options_history(ex: Any, cfg: dict[str, Any]) -> list[dict[str, Any]]: """与期权历史页相同的数据源:交易所全平记录 + 当前持仓,排除本地隐藏项.""" from lib.exchange.okx_options_lib import ( @@ -55,6 +109,7 @@ def load_options_history(ex: Any, cfg: dict[str, Any]) -> list[dict[str, Any]]: str(r["history_key"]) for r in conn.execute("SELECT history_key FROM options_history_hidden").fetchall() } + local_closed = _load_local_closed_by_inst(conn) for p in raw_live: inst = str(p.get("instId") or "").strip() premium_override = sum_open_premium_paid(conn, inst) if inst else None @@ -73,15 +128,16 @@ def load_options_history(ex: Any, cfg: dict[str, Any]) -> list[dict[str, Any]]: except (TypeError, ValueError): open_ms = None items.append(format_live_option_history_row(row, open_ms=open_ms)) + + hist_raw = fetch_all_option_positions_history(ex, limit=200) + for raw in hist_raw: + inst_id = str(raw.get("instId") or "").strip() + tick_sz, ct_mult = tick_sz_and_ct_mult(ex, inst_id, meta_cache) + row = format_option_history_row(raw, tick_sz=tick_sz, ct_mult=ct_mult) + items.append(_overlay_local_closed(row, local_closed.get(inst_id))) finally: conn.close() - hist_raw = fetch_all_option_positions_history(ex, limit=200) - for raw in hist_raw: - inst_id = str(raw.get("instId") or "").strip() - tick_sz, ct_mult = tick_sz_and_ct_mult(ex, inst_id, meta_cache) - items.append(format_option_history_row(raw, tick_sz=tick_sz, ct_mult=ct_mult)) - open_rows = [x for x in items if x.get("status") == "open"] closed = [x for x in items if x.get("status") != "open"] closed.sort(key=lambda x: int(x.get("close_ms") or 0), reverse=True) diff --git a/lib/options/options_review_db.py b/lib/options/options_review_db.py index f0db635..6fa6309 100644 --- a/lib/options/options_review_db.py +++ b/lib/options/options_review_db.py @@ -130,6 +130,9 @@ 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", "premium_ccy", "TEXT") + _ensure_column(conn, "options_review_trades", "pnl_quote_ccy", "TEXT") + _ensure_column(conn, "options_review_trades", "idx_px", "REAL") def _ensure_column(conn: sqlite3.Connection, table: str, col: str, typedef: str) -> None: diff --git a/lib/options/options_review_lib.py b/lib/options/options_review_lib.py index a57d88a..5fff340 100644 --- a/lib/options/options_review_lib.py +++ b/lib/options/options_review_lib.py @@ -99,8 +99,140 @@ def _purge_review_trade_by_key(conn: sqlite3.Connection, history_key: str) -> bo return True -def upsert_option_history_row(conn: sqlite3.Connection, row: dict[str, Any]) -> str: - """幂等写入纯期权快照;不触碰 options_review_entries;已隐藏的不再导入.""" +def _is_coin_option_row(row: dict[str, Any]) -> bool: + ccy = str(row.get("premium_ccy") or "").strip().upper() + if ccy in ("ETH", "BTC"): + return True + if str(row.get("margin_mode") or "").strip().lower() == "coin": + return True + inst = str(row.get("inst_id") or "").strip().upper() + return bool(inst) and "-USD-" in inst and "_UM" not in inst + + +def _resolve_review_index_px( + row: dict[str, Any], + *, + ex: Any = None, + cache: dict[str, float | None] | None = None, +) -> Optional[float]: + px = _safe_float(row.get("idx_px") or row.get("index_px") or row.get("options_index_px")) + if px is not None and px > 0: + return px + underly = str(row.get("underlying") or "").strip().upper() + if not underly: + inst = str(row.get("inst_id") or "") + underly = (inst.split("-")[0] if inst else "ETH").upper() or "ETH" + if cache is not None and underly in cache: + return cache[underly] + if ex is None: + return None + try: + from lib.exchange.okx_options_lib import fetch_index_price + + got = fetch_index_price(ex, underly) + px = _safe_float(got) + if cache is not None: + cache[underly] = px if px is not None and px > 0 else None + return px if px is not None and px > 0 else None + except Exception: + if cache is not None: + cache[underly] = None + return None + + +def convert_option_amounts_to_usdt( + row: dict[str, Any], + *, + index_px: float | None = None, + ex: Any = None, + cache: dict[str, float | None] | None = None, +) -> dict[str, Any]: + """币本位权利金/盈亏换算为 USDT;已标记 pnl_quote_ccy=USDT 则跳过.""" + out = dict(row) + quote = str(out.get("pnl_quote_ccy") or "").strip().upper() + if quote in ("USDT", "USDC", "U"): + return out + if not _is_coin_option_row(out): + out["pnl_quote_ccy"] = "USDT" + return out + px = index_px if index_px is not None and index_px > 0 else _resolve_review_index_px( + out, ex=ex, cache=cache + ) + if px is None or px <= 0: + return out + for key in ("realized_pnl", "premium_paid", "realized_pnl_total"): + v = _safe_float(out.get(key)) + if v is not None: + out[key] = round(float(v) * float(px), 4) + out["idx_px"] = float(px) + out["pnl_quote_ccy"] = "USDT" + out["premium_ccy"] = "USDC" + return out + + +def repair_coin_review_rows_to_usdt( + conn: sqlite3.Connection, + *, + ex: Any = None, +) -> int: + """把仍按币计价落库的复盘纯期权行换算成 U(幂等).""" + init_options_review_tables(conn) + rows = conn.execute( + """ + SELECT * FROM options_review_trades + WHERE source_type = ? + AND (pnl_quote_ccy IS NULL OR TRIM(pnl_quote_ccy) = '' OR UPPER(pnl_quote_ccy) NOT IN ('USDT','USDC','U')) + ORDER BY id DESC + LIMIT 500 + """, + (SOURCE_OPTION,), + ).fetchall() + cache: dict[str, float | None] = {} + fixed = 0 + for raw in rows: + row = dict(raw) + if not _is_coin_option_row(row): + conn.execute( + "UPDATE options_review_trades SET pnl_quote_ccy='USDT' WHERE id=?", + (int(row["id"]),), + ) + continue + converted = convert_option_amounts_to_usdt(row, ex=ex, cache=cache) + if str(converted.get("pnl_quote_ccy") or "").upper() not in ("USDT", "USDC", "U"): + continue + conn.execute( + """ + UPDATE options_review_trades + SET premium_paid=?, realized_pnl=?, realized_pnl_total=?, + premium_ccy=?, pnl_quote_ccy=?, idx_px=? + WHERE id=? + """, + ( + converted.get("premium_paid"), + converted.get("realized_pnl"), + converted.get("realized_pnl") + if converted.get("realized_pnl") is not None + else converted.get("realized_pnl_total"), + converted.get("premium_ccy") or "USDC", + "USDT", + converted.get("idx_px"), + int(row["id"]), + ), + ) + fixed += 1 + return fixed + + +def upsert_option_history_row( + conn: sqlite3.Connection, + row: dict[str, Any], + *, + ex: Any = None, + index_cache: dict[str, float | None] | None = None, +) -> str: + """幂等写入纯期权快照;不触碰 options_review_entries;已隐藏的不再导入. + 币本位金额在写入前换算为 USDT. + """ history_key = str(row.get("history_key") or "").strip() if not history_key: return "skip" @@ -112,6 +244,7 @@ def upsert_option_history_row(conn: sqlite3.Connection, row: dict[str, Any]) -> ): # 若此前已导入,清掉,避免列表残留 return "purged" if _purge_review_trade_by_key(conn, history_key) else "hidden" + row = convert_option_amounts_to_usdt(row, ex=ex, cache=index_cache) opened_at = row.get("created_at") or row.get("opened_at") closed_at = row.get("closed_at") pnl = _safe_float(row.get("realized_pnl")) @@ -139,6 +272,9 @@ 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, + "premium_ccy": str(row.get("premium_ccy") or "USDC").strip().upper() or "USDC", + "pnl_quote_ccy": str(row.get("pnl_quote_ccy") or "USDT").strip().upper() or "USDT", + "idx_px": _safe_float(row.get("idx_px")), } cols = list(fields.keys()) if existing: @@ -271,8 +407,14 @@ def hide_review_trade(conn: sqlite3.Connection, trade_id: int) -> dict[str, Any] -def sync_options_from_local_trades(conn: sqlite3.Connection) -> dict[str, Any]: - """从本地 options_trades 已平仓记录导入复盘快照(不访问交易所).""" +def sync_options_from_local_trades( + conn: sqlite3.Connection, + *, + ex: Any = None, +) -> dict[str, Any]: + """从本地 options_trades 已平仓记录导入复盘快照(不访问交易所). + 币本位金额按指数换算为 USDT 后入库. + """ init_options_review_tables(conn) from lib.options.options_db import init_options_tables @@ -281,7 +423,8 @@ def sync_options_from_local_trades(conn: sqlite3.Connection) -> dict[str, Any]: """ SELECT id, inst_id, underlying, opt_type, strike, exp_time, sheets, open_quote, close_quote, premium_paid, realized_pnl, - created_at, closed_at, signal_note, status + created_at, closed_at, signal_note, status, + margin_mode, premium_ccy FROM options_trades WHERE status = 'closed' ORDER BY id DESC @@ -289,6 +432,7 @@ def sync_options_from_local_trades(conn: sqlite3.Connection) -> dict[str, Any]: """ ).fetchall() inserted = updated = skipped = 0 + index_cache: dict[str, float | None] = {} for r in rows: trade_id = int(r["id"]) history_key = f"local_opt:{trade_id}" @@ -313,7 +457,11 @@ def sync_options_from_local_trades(conn: sqlite3.Connection) -> dict[str, Any]: "created_at": opened_at, "closed_at": closed_at, "status_label": "已平", + "margin_mode": r["margin_mode"] if "margin_mode" in r.keys() else None, + "premium_ccy": r["premium_ccy"] if "premium_ccy" in r.keys() else None, }, + ex=ex, + index_cache=index_cache, ) if action == "inserted": inserted += 1 @@ -354,6 +502,7 @@ def sync_options_from_exchange( fmt = format_fn or format_option_history_row raw_rows = fetch(ex, limit=limit) meta_cache: dict[str, dict[str, Any] | None] = {} + index_cache: dict[str, float | None] = {} inserted = updated = skipped = 0 for raw in raw_rows: inst_id = str(raw.get("instId") or "").strip() @@ -363,7 +512,9 @@ def sync_options_from_exchange( except Exception: pass formatted = fmt(raw, tick_sz=tick_sz, ct_mult=ct_mult) - action = upsert_option_history_row(conn, formatted) + action = upsert_option_history_row( + conn, formatted, ex=ex, index_cache=index_cache + ) if action == "inserted": inserted += 1 elif action == "updated": @@ -552,7 +703,7 @@ def sync_all_review_sources( conn, ex, limit=options_limit, fetch_fn=fetch_fn, format_fn=format_fn ) else: - out["options"] = sync_options_from_local_trades(conn) + out["options"] = sync_options_from_local_trades(conn, ex=ex) out["hedge"] = sync_hedge_plans_closed(conn) return out @@ -579,7 +730,12 @@ def ensure_local_review_synced( backfill_hedge_option_legs_realized_pnl(conn, hist) except Exception: pass - return sync_all_review_sources(conn, from_exchange=False) + out = sync_all_review_sources(conn, ex=ex, from_exchange=False) + try: + out["repaired_usdt"] = repair_coin_review_rows_to_usdt(conn, ex=ex) + except Exception: + out["repaired_usdt"] = 0 + return out def _row_to_dict(row: Any) -> dict[str, Any]: diff --git a/tests/test_options_history_coin.py b/tests/test_options_history_coin.py new file mode 100644 index 0000000..43470d5 --- /dev/null +++ b/tests/test_options_history_coin.py @@ -0,0 +1,50 @@ +"""期权历史/复盘币本位金额.""" + +from __future__ import annotations + +import unittest + +from lib.exchange.okx_options_lib import format_option_history_row +from lib.options.options_review_lib import convert_option_amounts_to_usdt + + +class TestOptionsHistoryCoin(unittest.TestCase): + def test_format_option_history_row_coin_premium_fmt(self) -> None: + raw = { + "instId": "ETH-USD-260822-2250-C", + "openAvgPx": "0.02", + "closeAvgPx": "0.03", + "closeTotalPos": "2", + "realizedPnl": "0.002", + "pnlRatio": "0.5", + "type": "2", + "uTime": "1724146497000", + "cTime": "1724126855000", + "posId": "123", + "uly": "ETH-USD", + } + row = format_option_history_row(raw, tick_sz="0.0001", ct_mult=0.1) + self.assertEqual(row["margin_mode"], "coin") + self.assertEqual(row["premium_ccy"], "ETH") + self.assertAlmostEqual(float(row["premium_paid"]), 0.004, places=6) + self.assertNotEqual(row["premium_paid_fmt"], "0.00") + self.assertIn("0.004", str(row["premium_paid_fmt"])) + + def test_convert_option_amounts_to_usdt(self) -> None: + out = convert_option_amounts_to_usdt( + { + "inst_id": "ETH-USD-260822-2250-C", + "premium_ccy": "ETH", + "margin_mode": "coin", + "premium_paid": 0.004, + "realized_pnl": 0.002, + }, + index_px=2000.0, + ) + self.assertEqual(out["pnl_quote_ccy"], "USDT") + self.assertEqual(out["premium_paid"], 8.0) + self.assertEqual(out["realized_pnl"], 4.0) + + +if __name__ == "__main__": + unittest.main()