From baf94b4feb75bd1f66b4710066b031f8ec8e2470 Mon Sep 17 00:00:00 2001 From: dekun Date: Sat, 11 Jul 2026 09:34:49 +0800 Subject: [PATCH] Compute option stats from exchange history instead of local DB. Share history loading between history and stats APIs so average profit/loss matches the option history tab. Co-authored-by: Cursor --- lib/options/options_history_lib.py | 97 ++++++++++++++++++++++++++++ lib/options/options_hub_lib.py | 10 +-- lib/options/options_register.py | 100 ++++++----------------------- lib/options/options_stats_lib.py | 60 +++++++++++++++++ tests/test_options_stats_lib.py | 18 +++++- 5 files changed, 198 insertions(+), 87 deletions(-) create mode 100644 lib/options/options_history_lib.py diff --git a/lib/options/options_history_lib.py b/lib/options/options_history_lib.py new file mode 100644 index 0000000..6099cd4 --- /dev/null +++ b/lib/options/options_history_lib.py @@ -0,0 +1,97 @@ +"""期权历史列表(交易所 positions-history + 当前持仓).""" +from __future__ import annotations + +from typing import Any + +from lib.options.options_db import init_options_tables + + +def enrich_position_row_display( + cfg: dict[str, Any], + ex: Any, + raw_pos: dict[str, Any], + *, + 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_usdc_amount, 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) + row = format_position_row(raw_pos, ct_mult=ct_mult, tick_sz=tick_sz) + if premium_override is not None: + row["premium_paid"] = premium_override + row["premium_paid_fmt"] = format_usdc_amount(premium_override) + return row + + +def load_options_history(ex: Any, cfg: dict[str, Any]) -> list[dict[str, Any]]: + """与期权历史页相同的数据源:交易所全平记录 + 当前持仓,排除本地隐藏项.""" + from lib.exchange.okx_options_lib import ( + fetch_all_option_positions_history, + format_live_option_history_row, + format_option_history_row, + tick_sz_and_ct_mult, + ) + + meta_cache: dict[str, dict[str, Any] | None] = {} + items: list[dict[str, Any]] = [] + + raw_live = cfg["fetch_option_positions"](ex) + if raw_live is None: + return [] + + conn = cfg["get_db"]() + try: + init_options_tables(conn) + hidden_keys = { + str(r["history_key"]) + for r in conn.execute("SELECT history_key FROM options_history_hidden").fetchall() + } + for p in raw_live: + inst = str(p.get("instId") or "").strip() + premium_override = None + if inst: + rec = conn.execute( + """ + SELECT premium_paid FROM options_trades + WHERE inst_id = ? AND status = 'open' + ORDER BY id DESC LIMIT 1 + """, + (inst,), + ).fetchone() + if rec and rec["premium_paid"] is not None: + premium_override = float(rec["premium_paid"]) + row = enrich_position_row_display( + cfg, + ex, + p, + meta_cache=meta_cache, + premium_override=premium_override, + ) + open_ms = None + ctime = p.get("cTime") or (row.get("raw") or {}).get("cTime") + try: + if ctime is not None and str(ctime).strip(): + open_ms = int(float(ctime)) + except (TypeError, ValueError): + open_ms = None + items.append(format_live_option_history_row(row, open_ms=open_ms)) + 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) + open_rows.sort(key=lambda x: int(x.get("close_ms") or 0), reverse=True) + return [ + x + for x in (open_rows + closed) + if str(x.get("history_key") or "") not in hidden_keys + ] diff --git a/lib/options/options_hub_lib.py b/lib/options/options_hub_lib.py index 6e6675f..8f49b77 100644 --- a/lib/options/options_hub_lib.py +++ b/lib/options/options_hub_lib.py @@ -4,11 +4,13 @@ from __future__ import annotations from typing import Any -from lib.options.options_stats_lib import compute_options_stats +from lib.options.options_history_lib import load_options_history +from lib.options.options_stats_lib import compute_options_stats_from_history -def _compute_options_stats(get_db) -> dict[str, Any]: - return compute_options_stats(get_db) +def _compute_options_stats(ex, cfg) -> dict[str, Any]: + history = load_options_history(ex, cfg) + return compute_options_stats_from_history(history) def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]: @@ -35,7 +37,7 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]: has_upl = True upl_total += float(upl) bal = cfg["fetch_options_balances"](ex) - stats = _compute_options_stats(cfg["get_db"]) + stats = _compute_options_stats(ex, cfg) return { "ok": True, "enabled": True, diff --git a/lib/options/options_register.py b/lib/options/options_register.py index 7528c7a..0689e2f 100644 --- a/lib/options/options_register.py +++ b/lib/options/options_register.py @@ -197,17 +197,15 @@ 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, tick_sz_and_ct_mult + from lib.options.options_history_lib import enrich_position_row_display - inst_id = str(raw_pos.get("instId") or "").strip() - tick_sz, ct_mult = tick_sz_and_ct_mult(ex, inst_id, meta_cache) - row = format_position_row(raw_pos, ct_mult=ct_mult, tick_sz=tick_sz) - if premium_override is not None: - row["premium_paid"] = premium_override - from lib.exchange.okx_options_lib import format_usdc_amount - - row["premium_paid_fmt"] = format_usdc_amount(premium_override) - return row + return enrich_position_row_display( + cfg, + ex, + raw_pos, + meta_cache=meta_cache, + premium_override=premium_override, + ) def _attach_close_preview( @@ -868,80 +866,13 @@ 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}) - from lib.exchange.okx_options_lib import ( - fetch_all_option_positions_history, - format_live_option_history_row, - format_option_history_row, - tick_sz_and_ct_mult, - ) - - meta_cache: dict[str, dict[str, Any] | None] = {} - items: list[dict[str, Any]] = [] + from lib.options.options_history_lib import load_options_history raw_live = cfg["fetch_option_positions"](ex) if raw_live is None: return jsonify({"ok": False, "msg": "获取期权持仓失败"}) - conn = cfg["get_db"]() - try: - for p in raw_live: - inst = str(p.get("instId") or "").strip() - premium_override = None - if inst: - rec = conn.execute( - """ - SELECT premium_paid FROM options_trades - WHERE inst_id = ? AND status = 'open' - ORDER BY id DESC LIMIT 1 - """, - (inst,), - ).fetchone() - if rec and rec["premium_paid"] is not None: - premium_override = float(rec["premium_paid"]) - row = _enrich_position_row_display( - cfg, - ex, - p, - meta_cache=meta_cache, - premium_override=premium_override, - ) - open_ms = None - ctime = p.get("cTime") or (row.get("raw") or {}).get("cTime") - try: - if ctime is not None and str(ctime).strip(): - open_ms = int(float(ctime)) - except (TypeError, ValueError): - open_ms = None - items.append(format_live_option_history_row(row, open_ms=open_ms)) - finally: - conn.close() - - hidden_keys: set[str] = set() - conn = cfg["get_db"]() - try: - init_options_tables(conn) - hidden_keys = { - str(r["history_key"]) - for r in conn.execute("SELECT history_key FROM options_history_hidden").fetchall() - } - 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) - open_rows.sort(key=lambda x: int(x.get("close_ms") or 0), reverse=True) - history = [ - x - for x in (open_rows + closed) - if str(x.get("history_key") or "") not in hidden_keys - ] - live_ids = {str(x.get("inst_id") or "") for x in open_rows} + history = load_options_history(ex, cfg) + live_ids = {str(x.get("inst_id") or "") for x in history if x.get("status") == "open"} return jsonify({"ok": True, "history": history, "live_inst_ids": sorted(live_ids)}) @app.route("/api/options/stats") @@ -950,9 +881,14 @@ 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}) - from lib.options.options_stats_lib import compute_options_stats + from lib.options.options_history_lib import load_options_history + from lib.options.options_stats_lib import compute_options_stats_from_history - return jsonify({"ok": True, **compute_options_stats(cfg["get_db"])}) + raw_live = cfg["fetch_option_positions"](ex) + if raw_live is None: + return jsonify({"ok": False, "msg": "获取期权持仓失败"}) + history = load_options_history(ex, cfg) + return jsonify({"ok": True, **compute_options_stats_from_history(history)}) @app.route("/api/options/history/", methods=["DELETE"]) @lr diff --git a/lib/options/options_stats_lib.py b/lib/options/options_stats_lib.py index 41e4bb3..cefef5a 100644 --- a/lib/options/options_stats_lib.py +++ b/lib/options/options_stats_lib.py @@ -33,6 +33,66 @@ def _avg_seconds(values: list[float]) -> float | None: return round(sum(values) / len(values), 1) +def compute_options_stats_from_history(history: list[dict[str, Any]]) -> dict[str, Any]: + """基于期权历史列表(交易所)计算统计.""" + wins: list[float] = [] + losses: list[float] = [] + win_holds: list[float] = [] + loss_holds: list[float] = [] + all_holds: list[float] = [] + open_holds: list[float] = [] + now = datetime.now() + + for row in history: + if row.get("status") == "open": + start = _parse_ts(row.get("created_at")) + if start is not None: + sec = (now - start).total_seconds() + if sec >= 0: + open_holds.append(sec) + continue + pnl_raw = row.get("realized_pnl") + if pnl_raw is None: + continue + try: + pnl = float(pnl_raw) + except (TypeError, ValueError): + continue + hold = _hold_seconds(row.get("created_at"), row.get("closed_at")) + if hold is not None: + all_holds.append(hold) + if pnl > 0: + wins.append(pnl) + if hold is not None: + win_holds.append(hold) + elif pnl < 0: + losses.append(pnl) + if hold is not None: + loss_holds.append(hold) + + total_closed = len(wins) + len(losses) + win_rate = round(len(wins) / total_closed * 100, 2) if total_closed else 0 + avg_win = sum(wins) / len(wins) if wins else None + avg_loss = sum(losses) / len(losses) if losses else None + + return { + "total_closed": total_closed, + "win_count": len(wins), + "loss_count": len(losses), + "win_rate": win_rate, + "profit_loss_ratio": profit_loss_ratio_from_averages(avg_win, avg_loss), + "avg_win": round(avg_win, 4) if avg_win is not None else None, + "avg_loss": round(abs(avg_loss), 4) if avg_loss is not None else None, + "total_profit": round(sum(wins), 4) if wins else 0.0, + "total_loss": round(abs(sum(losses)), 4) if losses else 0.0, + "avg_hold_sec": _avg_seconds(all_holds), + "avg_win_hold_sec": _avg_seconds(win_holds), + "avg_loss_hold_sec": _avg_seconds(loss_holds), + "open_count": len(open_holds), + "avg_open_hold_sec": _avg_seconds(open_holds), + } + + def compute_options_stats(get_db) -> dict[str, Any]: conn = get_db() try: diff --git a/tests/test_options_stats_lib.py b/tests/test_options_stats_lib.py index af254cf..9c9201c 100644 --- a/tests/test_options_stats_lib.py +++ b/tests/test_options_stats_lib.py @@ -4,7 +4,7 @@ from datetime import datetime, timedelta from unittest import TestCase from lib.options.options_db import init_options_tables -from lib.options.options_stats_lib import compute_options_stats +from lib.options.options_stats_lib import compute_options_stats, compute_options_stats_from_history class OptionsStatsLibTests(TestCase): @@ -67,3 +67,19 @@ class OptionsStatsLibTests(TestCase): self.assertAlmostEqual(out["avg_loss_hold_sec"], 3 * 3600.0, delta=5.0) self.assertEqual(out["open_count"], 1) self.assertGreater(out["avg_open_hold_sec"], 1700.0) + + def test_compute_options_stats_from_history_exchange_rows(self): + history = [ + {"status": "open", "created_at": "2026-07-11 08:08:38"}, + {"status": "closed", "realized_pnl": -3.99, "created_at": "2026-07-09 14:11:46", "closed_at": "2026-07-10 16:00:35"}, + {"status": "closed", "realized_pnl": 0.87, "created_at": "2026-07-09 14:11:46", "closed_at": "2026-07-10 09:55:34"}, + {"status": "closed", "realized_pnl": -1.33, "created_at": "2026-07-08 02:32:44", "closed_at": "2026-07-09 16:00:26"}, + ] + out = compute_options_stats_from_history(history) + self.assertEqual(out["total_closed"], 3) + self.assertEqual(out["win_count"], 1) + self.assertEqual(out["loss_count"], 2) + self.assertAlmostEqual(out["avg_win"], 0.87, places=4) + self.assertAlmostEqual(out["avg_loss"], 2.66, places=2) + self.assertAlmostEqual(out["profit_loss_ratio"], 0.33, places=2) + self.assertEqual(out["open_count"], 1)