Align options review PnL with OKX positions-history realizedPnl.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -135,6 +135,98 @@ def _created_at_ms(created_at: Any) -> int | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _group_key_for_closed_trade(row: Any) -> str:
|
||||||
|
inst = str(row["inst_id"] or "").strip()
|
||||||
|
ord_id = str(row["close_ord_id"] or "").strip() if "close_ord_id" in row.keys() else ""
|
||||||
|
if ord_id:
|
||||||
|
return f"{inst}|ord:{ord_id}"
|
||||||
|
closed = str(row["closed_at"] or "").strip()
|
||||||
|
return f"{inst}|close:{(closed[:16] if closed else '')}"
|
||||||
|
|
||||||
|
|
||||||
|
def backfill_closed_options_realized_pnl_from_history(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
hist_rows: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
trade_limit: int = 200,
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
用 OKX positions-history 的 realizedPnl 覆盖本地已平记录.
|
||||||
|
同一次平仓多笔本地 open(加仓)按权利金占比分摊交易所总盈亏.
|
||||||
|
"""
|
||||||
|
by_inst: dict[str, list[dict[str, Any]]] = {}
|
||||||
|
for raw in hist_rows or []:
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
continue
|
||||||
|
inst = str(raw.get("instId") or "").strip()
|
||||||
|
if not inst:
|
||||||
|
continue
|
||||||
|
by_inst.setdefault(inst, []).append(raw)
|
||||||
|
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, inst_id, sheets, premium_paid, realized_pnl, created_at, closed_at, close_ord_id
|
||||||
|
FROM options_trades
|
||||||
|
WHERE status = 'closed'
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(int(trade_limit),),
|
||||||
|
).fetchall()
|
||||||
|
if not rows:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
groups: dict[str, list[Any]] = {}
|
||||||
|
for row in rows:
|
||||||
|
inst = str(row["inst_id"] or "").strip()
|
||||||
|
if not inst or inst not in by_inst:
|
||||||
|
continue
|
||||||
|
groups.setdefault(_group_key_for_closed_trade(row), []).append(row)
|
||||||
|
|
||||||
|
updated = 0
|
||||||
|
for group in groups.values():
|
||||||
|
inst = str(group[0]["inst_id"] or "").strip()
|
||||||
|
open_candidates = [_created_at_ms(r["created_at"]) for r in group]
|
||||||
|
open_ms = min((x for x in open_candidates if x is not None), default=None)
|
||||||
|
close_info = resolve_option_close_from_history(by_inst.get(inst) or [], open_ms=open_ms)
|
||||||
|
if not close_info:
|
||||||
|
continue
|
||||||
|
ex_pnl = _safe_float(close_info.get("realized_pnl"))
|
||||||
|
if ex_pnl is None:
|
||||||
|
continue
|
||||||
|
close_quote = _safe_float(close_info.get("close_quote"))
|
||||||
|
total_paid = 0.0
|
||||||
|
for r in group:
|
||||||
|
total_paid += float(_safe_float(r["premium_paid"]) or 0.0)
|
||||||
|
allocated = 0.0
|
||||||
|
for i, r in enumerate(group):
|
||||||
|
paid = float(_safe_float(r["premium_paid"]) or 0.0)
|
||||||
|
if i == len(group) - 1:
|
||||||
|
share = round(float(ex_pnl) - allocated, 4)
|
||||||
|
elif total_paid > 0:
|
||||||
|
share = round(float(ex_pnl) * (paid / total_paid), 4)
|
||||||
|
allocated += share
|
||||||
|
else:
|
||||||
|
share = round(float(ex_pnl) / len(group), 4)
|
||||||
|
allocated += share
|
||||||
|
local = _safe_float(r["realized_pnl"])
|
||||||
|
if local is not None and abs(local - share) < 1e-6:
|
||||||
|
continue
|
||||||
|
prem_recv = round(paid + share, 4)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE options_trades
|
||||||
|
SET realized_pnl = ?,
|
||||||
|
premium_received = ?,
|
||||||
|
close_quote = COALESCE(?, close_quote)
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(share, prem_recv, close_quote, int(r["id"])),
|
||||||
|
)
|
||||||
|
updated += 1
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
def sync_open_options_trades(
|
def sync_open_options_trades(
|
||||||
conn: sqlite3.Connection,
|
conn: sqlite3.Connection,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -263,8 +263,12 @@ def _sync_options_trades(
|
|||||||
if not force and now - _OPTIONS_SYNC_LAST_AT < _OPTIONS_SYNC_INTERVAL_SEC:
|
if not force and now - _OPTIONS_SYNC_LAST_AT < _OPTIONS_SYNC_INTERVAL_SEC:
|
||||||
return
|
return
|
||||||
_OPTIONS_SYNC_LAST_AT = now
|
_OPTIONS_SYNC_LAST_AT = now
|
||||||
from lib.exchange.okx_options_lib import fetch_option_position_history
|
from lib.exchange.okx_options_lib import fetch_all_option_positions_history, fetch_option_position_history
|
||||||
from lib.options.options_monitor_lib import reconcile_live_open_trades, sync_open_options_trades
|
from lib.options.options_monitor_lib import (
|
||||||
|
backfill_closed_options_realized_pnl_from_history,
|
||||||
|
reconcile_live_open_trades,
|
||||||
|
sync_open_options_trades,
|
||||||
|
)
|
||||||
|
|
||||||
if raw_positions is None:
|
if raw_positions is None:
|
||||||
raw = cfg["fetch_option_positions"](ex)
|
raw = cfg["fetch_option_positions"](ex)
|
||||||
@@ -282,6 +286,11 @@ def _sync_options_trades(
|
|||||||
init_options_tables(conn)
|
init_options_tables(conn)
|
||||||
reconcile_live_open_trades(conn, live_inst_ids=live_ids)
|
reconcile_live_open_trades(conn, live_inst_ids=live_ids)
|
||||||
sync_open_options_trades(conn, live_inst_ids=live_ids, fetch_history_fn=_hist)
|
sync_open_options_trades(conn, live_inst_ids=live_ids, fetch_history_fn=_hist)
|
||||||
|
try:
|
||||||
|
hist_all = fetch_all_option_positions_history(ex, limit=200)
|
||||||
|
backfill_closed_options_realized_pnl_from_history(conn, hist_all)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
conn.commit()
|
conn.commit()
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|||||||
@@ -556,8 +556,24 @@ def sync_all_review_sources(
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def ensure_local_review_synced(conn: sqlite3.Connection) -> dict[str, Any]:
|
def ensure_local_review_synced(
|
||||||
"""列表/统计前轻量刷新本地源."""
|
conn: sqlite3.Connection,
|
||||||
|
*,
|
||||||
|
ex: Any | None = None,
|
||||||
|
backfill_exchange_pnl: bool = True,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""列表/统计前轻量刷新本地源;有交易所时先用历史仓位盈亏覆盖本地再导入复盘."""
|
||||||
|
if backfill_exchange_pnl and ex is not None:
|
||||||
|
try:
|
||||||
|
from lib.exchange.okx_options_lib import fetch_all_option_positions_history
|
||||||
|
from lib.options.options_monitor_lib import (
|
||||||
|
backfill_closed_options_realized_pnl_from_history,
|
||||||
|
)
|
||||||
|
|
||||||
|
hist = fetch_all_option_positions_history(ex, limit=200)
|
||||||
|
backfill_closed_options_realized_pnl_from_history(conn, hist)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return sync_all_review_sources(conn, from_exchange=False)
|
return sync_all_review_sources(conn, from_exchange=False)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ from lib.options.options_review_lib import (
|
|||||||
hide_review_trade,
|
hide_review_trade,
|
||||||
list_review_trades,
|
list_review_trades,
|
||||||
save_review_entry,
|
save_review_entry,
|
||||||
sync_all_review_sources,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -112,11 +111,12 @@ def register_options_review_routes(app: Flask, cfg: dict[str, Any], repo_root: s
|
|||||||
@app.route("/api/options/review/sync", methods=["POST"])
|
@app.route("/api/options/review/sync", methods=["POST"])
|
||||||
@lr
|
@lr
|
||||||
def api_options_review_sync():
|
def api_options_review_sync():
|
||||||
"""刷新本地 options_trades + 已结束对冲计划(不访问交易所)."""
|
"""刷新本地 options_trades + 已结束对冲计划;尽量用交易所历史盈亏覆盖本地估算."""
|
||||||
conn = cfg["get_db"]()
|
conn = cfg["get_db"]()
|
||||||
try:
|
try:
|
||||||
init_options_review_tables(conn)
|
init_options_review_tables(conn)
|
||||||
result = sync_all_review_sources(conn, from_exchange=False)
|
ex, _err = _require_ex(cfg)
|
||||||
|
result = ensure_local_review_synced(conn, ex=ex if ex is not None else None)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
finally:
|
finally:
|
||||||
@@ -134,7 +134,8 @@ def register_options_review_routes(app: Flask, cfg: dict[str, Any], repo_root: s
|
|||||||
"no",
|
"no",
|
||||||
)
|
)
|
||||||
if do_sync:
|
if do_sync:
|
||||||
ensure_local_review_synced(conn)
|
ex, _err = _require_ex(cfg)
|
||||||
|
ensure_local_review_synced(conn, ex=ex if ex is not None else None)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
filt = dict(
|
filt = dict(
|
||||||
source_type=(request.args.get("source_type") or "").strip() or None,
|
source_type=(request.args.get("source_type") or "").strip() or None,
|
||||||
@@ -266,7 +267,8 @@ def register_options_review_routes(app: Flask, cfg: dict[str, Any], repo_root: s
|
|||||||
def api_options_review_stats():
|
def api_options_review_stats():
|
||||||
conn = cfg["get_db"]()
|
conn = cfg["get_db"]()
|
||||||
try:
|
try:
|
||||||
ensure_local_review_synced(conn)
|
ex, _err = _require_ex(cfg)
|
||||||
|
ensure_local_review_synced(conn, ex=ex if ex is not None else None)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
stats = compute_review_stats(
|
stats = compute_review_stats(
|
||||||
conn,
|
conn,
|
||||||
|
|||||||
@@ -173,3 +173,43 @@ def test_reconcile_live_open_trades_reopens_sync_artifact():
|
|||||||
assert row["realized_pnl"] == -0.5
|
assert row["realized_pnl"] == -0.5
|
||||||
assert row["premium_received"] == 0.74
|
assert row["premium_received"] == 0.74
|
||||||
assert row["close_ord_id"] == "pos-1"
|
assert row["close_ord_id"] == "pos-1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_backfill_closed_options_realized_pnl_from_history():
|
||||||
|
from lib.options.options_monitor_lib import backfill_closed_options_realized_pnl_from_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, strike, exp_time, sheets, eth_amount,
|
||||||
|
open_quote, close_quote, premium_paid, premium_received, realized_pnl,
|
||||||
|
status, created_at, closed_at, close_ord_id)
|
||||||
|
VALUES (?, 'ETH', 'C', 1860, '', 43, 0.43, 22.4, 50.6, 9.632, 21.758, 12.126,
|
||||||
|
'closed', '2026-07-20 08:02:14', '2026-07-21 01:38:43', 'ord-1')
|
||||||
|
""",
|
||||||
|
("ETH-USD_UM-260721-1860-C",),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
hist = [
|
||||||
|
{
|
||||||
|
"instId": "ETH-USD_UM-260721-1860-C",
|
||||||
|
"uTime": "1784564323000",
|
||||||
|
"realizedPnl": "11.64",
|
||||||
|
"closeAvgPx": "48.5",
|
||||||
|
"closeTotalPos": "43",
|
||||||
|
"posId": "pos-x",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
n = backfill_closed_options_realized_pnl_from_history(conn, hist)
|
||||||
|
assert n == 1
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT realized_pnl, premium_received, close_quote FROM options_trades WHERE id=1"
|
||||||
|
).fetchone()
|
||||||
|
assert row["realized_pnl"] == 11.64
|
||||||
|
assert abs(float(row["premium_received"]) - (9.632 + 11.64)) < 1e-6
|
||||||
|
assert float(row["close_quote"]) == 48.5
|
||||||
|
# idempotent
|
||||||
|
assert backfill_closed_options_realized_pnl_from_history(conn, hist) == 0
|
||||||
|
|||||||
Reference in New Issue
Block a user