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:
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user