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 <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-11 09:34:49 +08:00
parent 68733eb4f9
commit baf94b4feb
5 changed files with 198 additions and 87 deletions
+97
View File
@@ -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
]
+6 -4
View File
@@ -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,
+18 -82
View File
@@ -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/<path:history_key>", methods=["DELETE"])
@lr
+60
View File
@@ -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:
+17 -1
View File
@@ -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)