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