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:
@@ -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
|
||||||
|
]
|
||||||
@@ -4,11 +4,13 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any
|
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]:
|
def _compute_options_stats(ex, cfg) -> dict[str, Any]:
|
||||||
return compute_options_stats(get_db)
|
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]:
|
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
|
has_upl = True
|
||||||
upl_total += float(upl)
|
upl_total += float(upl)
|
||||||
bal = cfg["fetch_options_balances"](ex)
|
bal = cfg["fetch_options_balances"](ex)
|
||||||
stats = _compute_options_stats(cfg["get_db"])
|
stats = _compute_options_stats(ex, cfg)
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
|
|||||||
@@ -197,17 +197,15 @@ def _enrich_position_row_display(
|
|||||||
meta_cache: dict[str, dict[str, Any] | None] | None = None,
|
meta_cache: dict[str, dict[str, Any] | None] | None = None,
|
||||||
premium_override: float | None = None,
|
premium_override: float | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> 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()
|
return enrich_position_row_display(
|
||||||
tick_sz, ct_mult = tick_sz_and_ct_mult(ex, inst_id, meta_cache)
|
cfg,
|
||||||
row = format_position_row(raw_pos, ct_mult=ct_mult, tick_sz=tick_sz)
|
ex,
|
||||||
if premium_override is not None:
|
raw_pos,
|
||||||
row["premium_paid"] = premium_override
|
meta_cache=meta_cache,
|
||||||
from lib.exchange.okx_options_lib import format_usdc_amount
|
premium_override=premium_override,
|
||||||
|
)
|
||||||
row["premium_paid_fmt"] = format_usdc_amount(premium_override)
|
|
||||||
return row
|
|
||||||
|
|
||||||
|
|
||||||
def _attach_close_preview(
|
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)
|
ex, err = _require_options_ex(cfg)
|
||||||
if ex is None:
|
if ex is None:
|
||||||
return jsonify({"ok": False, "msg": err})
|
return jsonify({"ok": False, "msg": err})
|
||||||
from lib.exchange.okx_options_lib import (
|
from lib.options.options_history_lib import load_options_history
|
||||||
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)
|
raw_live = cfg["fetch_option_positions"](ex)
|
||||||
if raw_live is None:
|
if raw_live is None:
|
||||||
return jsonify({"ok": False, "msg": "获取期权持仓失败"})
|
return jsonify({"ok": False, "msg": "获取期权持仓失败"})
|
||||||
conn = cfg["get_db"]()
|
history = load_options_history(ex, cfg)
|
||||||
try:
|
live_ids = {str(x.get("inst_id") or "") for x in history if x.get("status") == "open"}
|
||||||
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}
|
|
||||||
return jsonify({"ok": True, "history": history, "live_inst_ids": sorted(live_ids)})
|
return jsonify({"ok": True, "history": history, "live_inst_ids": sorted(live_ids)})
|
||||||
|
|
||||||
@app.route("/api/options/stats")
|
@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)
|
ex, err = _require_options_ex(cfg)
|
||||||
if ex is None:
|
if ex is None:
|
||||||
return jsonify({"ok": False, "msg": err})
|
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"])
|
@app.route("/api/options/history/<path:history_key>", methods=["DELETE"])
|
||||||
@lr
|
@lr
|
||||||
|
|||||||
@@ -33,6 +33,66 @@ def _avg_seconds(values: list[float]) -> float | None:
|
|||||||
return round(sum(values) / len(values), 1)
|
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]:
|
def compute_options_stats(get_db) -> dict[str, Any]:
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from datetime import datetime, timedelta
|
|||||||
from unittest import TestCase
|
from unittest import TestCase
|
||||||
|
|
||||||
from lib.options.options_db import init_options_tables
|
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):
|
class OptionsStatsLibTests(TestCase):
|
||||||
@@ -67,3 +67,19 @@ class OptionsStatsLibTests(TestCase):
|
|||||||
self.assertAlmostEqual(out["avg_loss_hold_sec"], 3 * 3600.0, delta=5.0)
|
self.assertAlmostEqual(out["avg_loss_hold_sec"], 3 * 3600.0, delta=5.0)
|
||||||
self.assertEqual(out["open_count"], 1)
|
self.assertEqual(out["open_count"], 1)
|
||||||
self.assertGreater(out["avg_open_hold_sec"], 1700.0)
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user