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 @@ - +