Fix intermittent loss of options positions and realtime PnL on refresh.

Use stale-while-revalidate for positions API and UI, throttle sync calls, and avoid overwriting displayed PnL with null on transient failures.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-09 18:09:58 +08:00
parent 5a97e14f3e
commit f99900ac40
7 changed files with 146 additions and 17 deletions
+43 -8
View File
@@ -17,6 +17,12 @@
expandedPosInst: null, expandedPosInst: null,
}; };
let lastGoodPositions = null;
let lastGoodPositionsAt = 0;
let positionsRefreshSeq = 0;
let refreshAllTimer = null;
const POSITIONS_STALE_MS = 45000;
function fmt(v, d) { function fmt(v, d) {
if (v === null || v === undefined || Number.isNaN(Number(v))) return "—"; if (v === null || v === undefined || Number.isNaN(Number(v))) return "—";
return Number(v).toFixed(d == null ? 2 : d); return Number(v).toFixed(d == null ? 2 : d);
@@ -570,20 +576,38 @@
} }
} }
async function refreshPositions() { function resolvePositionsList(d) {
const d = await apiJson("/api/options/positions"); const now = Date.now();
const list = (d && d.ok && d.positions) ? d.positions : [];
if (d && d.ok) {
if (list.length) {
lastGoodPositions = list;
lastGoodPositionsAt = now;
return list;
}
lastGoodPositions = null;
lastGoodPositionsAt = 0;
return list;
}
if (lastGoodPositions && lastGoodPositions.length && now - lastGoodPositionsAt < POSITIONS_STALE_MS) {
return lastGoodPositions;
}
return [];
}
function paintPositions(list) {
const wrap = document.getElementById("opt-pos-cards"); const wrap = document.getElementById("opt-pos-cards");
const empty = document.getElementById("opt-pos-empty"); const empty = document.getElementById("opt-pos-empty");
const livePane = document.getElementById("opt-pos-live"); const livePane = document.getElementById("opt-pos-live");
const list = (d.ok && d.positions) || []; if (!wrap) return;
wrap.innerHTML = ""; wrap.innerHTML = "";
if (!list.length) { if (!list.length) {
empty.style.display = ""; if (empty) empty.style.display = "";
state.expandedPosInst = null; state.expandedPosInst = null;
if (livePane) livePane.classList.remove("options-pos-live-pane--accordion"); if (livePane) livePane.classList.remove("options-pos-live-pane--accordion");
return; return;
} }
empty.style.display = "none"; if (empty) empty.style.display = "none";
const multi = list.length >= 2; const multi = list.length >= 2;
wrap.classList.toggle("opt-pos-cards--accordion", multi); wrap.classList.toggle("opt-pos-cards--accordion", multi);
if (livePane) livePane.classList.toggle("options-pos-live-pane--accordion", multi); if (livePane) livePane.classList.toggle("options-pos-live-pane--accordion", multi);
@@ -611,6 +635,13 @@
} }
} }
async function refreshPositions() {
const seq = ++positionsRefreshSeq;
const d = await apiJson("/api/options/positions");
if (seq !== positionsRefreshSeq) return;
paintPositions(resolvePositionsList(d));
}
async function refreshStats() { async function refreshStats() {
const d = await apiJson("/api/options/stats"); const d = await apiJson("/api/options/stats");
const winEl = document.getElementById("opt-stats-winrate"); const winEl = document.getElementById("opt-stats-winrate");
@@ -704,9 +735,13 @@
} }
function refreshAllPositions() { function refreshAllPositions() {
refreshPositions(); if (refreshAllTimer) clearTimeout(refreshAllTimer);
refreshStats(); refreshAllTimer = setTimeout(function () {
refreshHistory(); refreshAllTimer = null;
refreshPositions();
refreshStats();
refreshHistory();
}, 120);
} }
function bootOptionsPanel() { function bootOptionsPanel() {
+38 -3
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import json import json
import math import math
import re import re
import threading
import time import time
from typing import Any, Callable from typing import Any, Callable
@@ -628,7 +629,26 @@ def place_option_market_order(
return {"ok": False, "msg": _okx_trade_error_message(e)} return {"ok": False, "msg": _okx_trade_error_message(e)}
def fetch_option_positions(ex: ccxt.okx) -> list[dict[str, Any]]: _OPTION_POSITIONS_CACHE: dict[str, Any] = {"updated_at": 0.0, "rows": None, "failed": False}
_OPTION_POSITIONS_CACHE_LOCK = threading.Lock()
_OPTION_POSITIONS_CACHE_TTL = 4.0
_OPTION_POSITIONS_STALE_OK_SEC = 30.0
def invalidate_option_positions_cache() -> None:
with _OPTION_POSITIONS_CACHE_LOCK:
_OPTION_POSITIONS_CACHE["updated_at"] = 0.0
_OPTION_POSITIONS_CACHE["failed"] = False
def fetch_option_positions(ex: ccxt.okx) -> list[dict[str, Any]] | None:
"""期权持仓:有仓返回列表,无仓返回 [],API 失败返回 None(短时回退缓存)."""
now = time.time()
with _OPTION_POSITIONS_CACHE_LOCK:
age = now - float(_OPTION_POSITIONS_CACHE["updated_at"] or 0.0)
cached = _OPTION_POSITIONS_CACHE["rows"]
if age < _OPTION_POSITIONS_CACHE_TTL and cached is not None and not _OPTION_POSITIONS_CACHE["failed"]:
return list(cached)
try: try:
rows = ex.private_get_account_positions({"instType": "OPTION"}).get("data") or [] rows = ex.private_get_account_positions({"instType": "OPTION"}).get("data") or []
out = [] out = []
@@ -639,9 +659,21 @@ def fetch_option_positions(ex: ccxt.okx) -> list[dict[str, Any]]:
if pos is None or abs(pos) < 1e-12: if pos is None or abs(pos) < 1e-12:
continue continue
out.append(r) out.append(r)
with _OPTION_POSITIONS_CACHE_LOCK:
_OPTION_POSITIONS_CACHE["updated_at"] = now
_OPTION_POSITIONS_CACHE["rows"] = out
_OPTION_POSITIONS_CACHE["failed"] = False
return out return out
except Exception: except Exception:
return [] with _OPTION_POSITIONS_CACHE_LOCK:
cached = _OPTION_POSITIONS_CACHE["rows"]
age = now - float(_OPTION_POSITIONS_CACHE["updated_at"] or 0.0)
if cached is not None and age < _OPTION_POSITIONS_STALE_OK_SEC:
return list(cached)
_OPTION_POSITIONS_CACHE["updated_at"] = now
_OPTION_POSITIONS_CACHE["rows"] = None
_OPTION_POSITIONS_CACHE["failed"] = True
return None
def fetch_option_position_history( def fetch_option_position_history(
@@ -700,9 +732,12 @@ def resolve_option_close_from_history(
def fetch_options_unrealized_pnl_usdc(ex: ccxt.okx) -> float | None: def fetch_options_unrealized_pnl_usdc(ex: ccxt.okx) -> float | None:
"""期权持仓未实现盈亏合计(USDC,统计口径与 USDT 1:1).""" """期权持仓未实现盈亏合计(USDC,统计口径与 USDT 1:1)."""
positions = fetch_option_positions(ex)
if positions is None:
return None
total = 0.0 total = 0.0
found = False found = False
for pos in fetch_option_positions(ex): for pos in positions:
upl = _safe_float(pos.get("upl")) upl = _safe_float(pos.get("upl"))
if upl is None: if upl is None:
continue continue
+11 -1
View File
@@ -1081,6 +1081,16 @@ function paintRealtimePnl(v){
pnlEl.classList.toggle("pnl-neg", n < 0); pnlEl.classList.toggle("pnl-neg", n < 0);
}); });
} }
let lastRealtimePnl = null;
function updateRealtimePnl(v){
if(v != null && !Number.isNaN(Number(v))){
lastRealtimePnl = Number(v);
paintRealtimePnl(v);
return;
}
if(lastRealtimePnl != null) return;
paintRealtimePnl(v);
}
function sumOrdersFloatPnl(orders){ function sumOrdersFloatPnl(orders){
if(!orders || !orders.length) return null; if(!orders || !orders.length) return null;
let total = 0, found = false; let total = 0, found = false;
@@ -1153,7 +1163,7 @@ function applyAccountSnapshot(data){
setFundsFieldText("options-trading-usdc", `${Number(data.options_trading_usdc).toFixed(2)} USDC`); setFundsFieldText("options-trading-usdc", `${Number(data.options_trading_usdc).toFixed(2)} USDC`);
} }
if(typeof data.unrealized_pnl !== "undefined"){ if(typeof data.unrealized_pnl !== "undefined"){
paintRealtimePnl(data.unrealized_pnl); updateRealtimePnl(data.unrealized_pnl);
} }
if(typeof data.available_trading_usdt !== "undefined" && data.available_trading_usdt !== null){ if(typeof data.available_trading_usdt !== "undefined" && data.available_trading_usdt !== null){
latestAvailableUsdt = Number(data.available_trading_usdt); latestAvailableUsdt = Number(data.available_trading_usdt);
+11 -1
View File
@@ -1658,6 +1658,16 @@ function paintRealtimePnl(v){
pnlEl.classList.toggle("pnl-neg", n < 0); pnlEl.classList.toggle("pnl-neg", n < 0);
}); });
} }
let lastRealtimePnl = null;
function updateRealtimePnl(v){
if(v != null && !Number.isNaN(Number(v))){
lastRealtimePnl = Number(v);
paintRealtimePnl(v);
return;
}
if(lastRealtimePnl != null) return;
paintRealtimePnl(v);
}
function sumOrdersFloatPnl(orders){ function sumOrdersFloatPnl(orders){
if(!orders || !orders.length) return null; if(!orders || !orders.length) return null;
let total = 0, found = false; let total = 0, found = false;
@@ -1730,7 +1740,7 @@ function applyAccountSnapshot(data){
setFundsFieldText("options-trading-usdc", `${Number(data.options_trading_usdc).toFixed(2)} USDC`); setFundsFieldText("options-trading-usdc", `${Number(data.options_trading_usdc).toFixed(2)} USDC`);
} }
if(typeof data.unrealized_pnl !== "undefined"){ if(typeof data.unrealized_pnl !== "undefined"){
paintRealtimePnl(data.unrealized_pnl); updateRealtimePnl(data.unrealized_pnl);
} }
if(typeof data.available_trading_usdt !== "undefined" && data.available_trading_usdt !== null){ if(typeof data.available_trading_usdt !== "undefined" && data.available_trading_usdt !== null){
latestAvailableUsdt = Number(data.available_trading_usdt); latestAvailableUsdt = Number(data.available_trading_usdt);
+2
View File
@@ -53,6 +53,8 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
return {"ok": False, "enabled": True, "msg": reason or "期权 API 未配置"} return {"ok": False, "enabled": True, "msg": reason or "期权 API 未配置"}
try: try:
raw = cfg["fetch_option_positions"](ex) raw = cfg["fetch_option_positions"](ex)
if raw is None:
return {"ok": False, "enabled": True, "msg": "获取期权持仓失败"}
positions = [cfg["format_position_row"](p) for p in raw] positions = [cfg["format_position_row"](p) for p in raw]
upl_total = 0.0 upl_total = 0.0
has_upl = False has_upl = False
+40 -3
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import os import os
import threading import threading
import time
from typing import Any from typing import Any
from flask import Flask, jsonify, redirect, request, url_for from flask import Flask, jsonify, redirect, request, url_for
@@ -142,14 +143,34 @@ def _budget_full_usdc(cfg: dict[str, Any], ex: Any) -> tuple[float | None, str]:
return float(raw), "" return float(raw), ""
def _sync_options_trades(cfg: dict[str, Any]) -> None: _OPTIONS_SYNC_LOCK = threading.Lock()
_OPTIONS_SYNC_LAST_AT = 0.0
_OPTIONS_SYNC_INTERVAL_SEC = 15.0
def _sync_options_trades(
cfg: dict[str, Any],
*,
raw_positions: list[dict[str, Any]] | None = None,
force: bool = False,
) -> None:
ex = cfg.get("exchange_options") ex = cfg.get("exchange_options")
if ex is None: if ex is None:
return return
now = time.time()
with _OPTIONS_SYNC_LOCK:
if not force and now - _OPTIONS_SYNC_LAST_AT < _OPTIONS_SYNC_INTERVAL_SEC:
return
_OPTIONS_SYNC_LAST_AT = now
from lib.exchange.okx_options_lib import fetch_option_position_history from lib.exchange.okx_options_lib import 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 reconcile_live_open_trades, sync_open_options_trades
raw = cfg["fetch_option_positions"](ex) if raw_positions is None:
raw = cfg["fetch_option_positions"](ex)
if raw is None:
return
else:
raw = raw_positions
live_ids = {str(p.get("instId") or "") for p in raw if str(p.get("instId") or "")} live_ids = {str(p.get("instId") or "") for p in raw if str(p.get("instId") or "")}
def _hist(inst_id: str): def _hist(inst_id: str):
@@ -348,6 +369,10 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
conn.commit() conn.commit()
finally: finally:
conn.close() conn.close()
from lib.exchange.okx_options_lib import invalidate_option_positions_cache
invalidate_option_positions_cache()
_sync_options_trades(cfg, force=True)
return jsonify({"ok": True, "order": order, "sizing": sizing}) return jsonify({"ok": True, "order": order, "sizing": sizing})
@app.route("/api/options/positions") @app.route("/api/options/positions")
@@ -356,8 +381,10 @@ 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})
_sync_options_trades(cfg)
raw = cfg["fetch_option_positions"](ex) raw = cfg["fetch_option_positions"](ex)
if raw is None:
return jsonify({"ok": False, "msg": "获取期权持仓失败"})
_sync_options_trades(cfg, raw_positions=raw)
rows = [cfg["format_position_row"](p) for p in raw] rows = [cfg["format_position_row"](p) for p in raw]
conn = cfg["get_db"]() conn = cfg["get_db"]()
try: try:
@@ -396,6 +423,8 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
if not use_market and (bid is None or bid <= 0): if not use_market and (bid is None or bid <= 0):
return jsonify({"ok": False, "msg": "暂无买一价,无法限价平仓"}) return jsonify({"ok": False, "msg": "暂无买一价,无法限价平仓"})
raw_positions = cfg["fetch_option_positions"](ex) raw_positions = cfg["fetch_option_positions"](ex)
if raw_positions is None:
return jsonify({"ok": False, "msg": "获取期权持仓失败"})
pos = next((p for p in raw_positions if str(p.get("instId")) == inst_id), None) pos = next((p for p in raw_positions if str(p.get("instId")) == inst_id), None)
if not pos: if not pos:
return jsonify({"ok": False, "msg": "未找到持仓"}) return jsonify({"ok": False, "msg": "未找到持仓"})
@@ -465,6 +494,10 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
conn.commit() conn.commit()
finally: finally:
conn.close() conn.close()
from lib.exchange.okx_options_lib import invalidate_option_positions_cache
invalidate_option_positions_cache()
_sync_options_trades(cfg, force=True)
return jsonify({"ok": True, "order": order, "bid": bid, "sheets": close_sheets}) return jsonify({"ok": True, "order": order, "bid": bid, "sheets": close_sheets})
@app.route("/api/options/convert/quote", methods=["POST"]) @app.route("/api/options/convert/quote", methods=["POST"])
@@ -712,6 +745,8 @@ def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None:
if ex is None: if ex is None:
return [] return []
raw = cfg["fetch_option_positions"](ex) raw = cfg["fetch_option_positions"](ex)
if raw is None:
return []
return [cfg["format_position_row"](p) for p in raw] return [cfg["format_position_row"](p) for p in raw]
def _sync(conn): def _sync(conn):
@@ -722,6 +757,8 @@ def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None:
if ex is None: if ex is None:
return 0 return 0
raw = cfg["fetch_option_positions"](ex) raw = cfg["fetch_option_positions"](ex)
if raw is None:
return 0
live_ids = {str(p.get("instId") or "") for p in raw if str(p.get("instId") or "")} live_ids = {str(p.get("instId") or "") for p in raw if str(p.get("instId") or "")}
reconcile_live_open_trades(conn, live_inst_ids=live_ids) reconcile_live_open_trades(conn, live_inst_ids=live_ids)
return sync_open_options_trades( return sync_open_options_trades(
+1 -1
View File
@@ -137,4 +137,4 @@
</div> </div>
</div> </div>
<script src="/static/options_expiry_countdown.js?v=1"></script> <script src="/static/options_expiry_countdown.js?v=1"></script>
<script src="/static/options_panel.js?v=16"></script> <script src="/static/options_panel.js?v=17"></script>