diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js
index 1dbf6d3..7f18a98 100644
--- a/lib/common/static/options_panel.js
+++ b/lib/common/static/options_panel.js
@@ -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() {
diff --git a/lib/exchange/okx_options_lib.py b/lib/exchange/okx_options_lib.py
index 63fc51f..e9284e1 100644
--- a/lib/exchange/okx_options_lib.py
+++ b/lib/exchange/okx_options_lib.py
@@ -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
diff --git a/lib/instance/templates/embed_boot_scripts.html b/lib/instance/templates/embed_boot_scripts.html
index 47dc661..6068b74 100644
--- a/lib/instance/templates/embed_boot_scripts.html
+++ b/lib/instance/templates/embed_boot_scripts.html
@@ -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);
diff --git a/lib/instance/templates/index.html b/lib/instance/templates/index.html
index a1056d1..6e9124b 100644
--- a/lib/instance/templates/index.html
+++ b/lib/instance/templates/index.html
@@ -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);
diff --git a/lib/options/options_hub_lib.py b/lib/options/options_hub_lib.py
index caba01f..adb1f0d 100644
--- a/lib/options/options_hub_lib.py
+++ b/lib/options/options_hub_lib.py
@@ -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
diff --git a/lib/options/options_register.py b/lib/options/options_register.py
index 162fb04..fdbb1e4 100644
--- a/lib/options/options_register.py
+++ b/lib/options/options_register.py
@@ -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(
diff --git a/lib/options/templates/options_panel.html b/lib/options/templates/options_panel.html
index b7b488e..4e9bdcc 100644
--- a/lib/options/templates/options_panel.html
+++ b/lib/options/templates/options_panel.html
@@ -137,4 +137,4 @@
-
+