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,
};
let lastGoodPositions = null;
let lastGoodPositionsAt = 0;
let positionsRefreshSeq = 0;
let refreshAllTimer = null;
const POSITIONS_STALE_MS = 45000;
function fmt(v, d) {
if (v === null || v === undefined || Number.isNaN(Number(v))) return "—";
return Number(v).toFixed(d == null ? 2 : d);
@@ -570,20 +576,38 @@
}
}
async function refreshPositions() {
const d = await apiJson("/api/options/positions");
function resolvePositionsList(d) {
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 empty = document.getElementById("opt-pos-empty");
const livePane = document.getElementById("opt-pos-live");
const list = (d.ok && d.positions) || [];
if (!wrap) return;
wrap.innerHTML = "";
if (!list.length) {
empty.style.display = "";
if (empty) empty.style.display = "";
state.expandedPosInst = null;
if (livePane) livePane.classList.remove("options-pos-live-pane--accordion");
return;
}
empty.style.display = "none";
if (empty) empty.style.display = "none";
const multi = list.length >= 2;
wrap.classList.toggle("opt-pos-cards--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() {
const d = await apiJson("/api/options/stats");
const winEl = document.getElementById("opt-stats-winrate");
@@ -704,9 +735,13 @@
}
function refreshAllPositions() {
refreshPositions();
refreshStats();
refreshHistory();
if (refreshAllTimer) clearTimeout(refreshAllTimer);
refreshAllTimer = setTimeout(function () {
refreshAllTimer = null;
refreshPositions();
refreshStats();
refreshHistory();
}, 120);
}
function bootOptionsPanel() {
+38 -3
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import json
import math
import re
import threading
import time
from typing import Any, Callable
@@ -628,7 +629,26 @@ def place_option_market_order(
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:
rows = ex.private_get_account_positions({"instType": "OPTION"}).get("data") or []
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:
continue
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
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(
@@ -700,9 +732,12 @@ def resolve_option_close_from_history(
def fetch_options_unrealized_pnl_usdc(ex: ccxt.okx) -> float | None:
"""期权持仓未实现盈亏合计(USDC,统计口径与 USDT 1:1)."""
positions = fetch_option_positions(ex)
if positions is None:
return None
total = 0.0
found = False
for pos in fetch_option_positions(ex):
for pos in positions:
upl = _safe_float(pos.get("upl"))
if upl is None:
continue
+11 -1
View File
@@ -1081,6 +1081,16 @@ function paintRealtimePnl(v){
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){
if(!orders || !orders.length) return null;
let total = 0, found = false;
@@ -1153,7 +1163,7 @@ function applyAccountSnapshot(data){
setFundsFieldText("options-trading-usdc", `${Number(data.options_trading_usdc).toFixed(2)} USDC`);
}
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){
latestAvailableUsdt = Number(data.available_trading_usdt);
+11 -1
View File
@@ -1658,6 +1658,16 @@ function paintRealtimePnl(v){
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){
if(!orders || !orders.length) return null;
let total = 0, found = false;
@@ -1730,7 +1740,7 @@ function applyAccountSnapshot(data){
setFundsFieldText("options-trading-usdc", `${Number(data.options_trading_usdc).toFixed(2)} USDC`);
}
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){
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 未配置"}
try:
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]
upl_total = 0.0
has_upl = False
+40 -3
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import os
import threading
import time
from typing import Any
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), ""
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")
if ex is None:
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.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 "")}
def _hist(inst_id: str):
@@ -348,6 +369,10 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
conn.commit()
finally:
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})
@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)
if ex is None:
return jsonify({"ok": False, "msg": err})
_sync_options_trades(cfg)
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]
conn = cfg["get_db"]()
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):
return jsonify({"ok": False, "msg": "暂无买一价,无法限价平仓"})
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)
if not pos:
return jsonify({"ok": False, "msg": "未找到持仓"})
@@ -465,6 +494,10 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
conn.commit()
finally:
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})
@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:
return []
raw = cfg["fetch_option_positions"](ex)
if raw is None:
return []
return [cfg["format_position_row"](p) for p in raw]
def _sync(conn):
@@ -722,6 +757,8 @@ def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None:
if ex is None:
return 0
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 "")}
reconcile_live_open_trades(conn, live_inst_ids=live_ids)
return sync_open_options_trades(
+1 -1
View File
@@ -137,4 +137,4 @@
</div>
</div>
<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>