From 189b27e0766a13c7177c8ee4bfd844177fbd1b79 Mon Sep 17 00:00:00 2001 From: dekun Date: Wed, 8 Jul 2026 11:14:30 +0800 Subject: [PATCH] Add live expiry countdown for options positions on instance page and trading hub. Co-authored-by: Cursor --- crypto_monitor_okx/app.py | 8 +++ lib/common/static/instance_theme.css | 10 ++++ lib/common/static/options_expiry_countdown.js | 58 +++++++++++++++++++ lib/common/static/options_panel.js | 8 +++ lib/exchange/okx_options_lib.py | 33 ++++++++++- lib/options/templates/options_panel.html | 3 +- manual_trading_hub/hub.py | 11 ++++ manual_trading_hub/static/app.css | 14 +++++ manual_trading_hub/static/app.js | 15 ++++- manual_trading_hub/static/dashboard.js | 12 +++- manual_trading_hub/static/index.html | 5 +- tests/test_options_pricing.py | 14 +++++ 12 files changed, 185 insertions(+), 6 deletions(-) create mode 100644 lib/common/static/options_expiry_countdown.js diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py index 863a7b8..57c664c 100644 --- a/crypto_monitor_okx/app.py +++ b/crypto_monitor_okx/app.py @@ -8936,6 +8936,7 @@ _AI_REVIEW_RENDER_JS = os.path.join(_REPO_STATIC_DIR, "ai_review_render.js") _FORM_SUBMIT_GUARD_JS = os.path.join(_REPO_STATIC_DIR, "form_submit_guard.js") _MANUAL_ORDER_RR_PREVIEW_JS = os.path.join(_REPO_STATIC_DIR, "manual_order_rr_preview.js") _OPTIONS_PANEL_JS = os.path.join(_REPO_STATIC_DIR, "options_panel.js") +_OPTIONS_EXPIRY_COUNTDOWN_JS = os.path.join(_REPO_STATIC_DIR, "options_expiry_countdown.js") _OPTIONS_SETTINGS_JS = os.path.join(_REPO_STATIC_DIR, "options_settings.js") @@ -8967,6 +8968,13 @@ def static_options_panel_js(): return send_file(_OPTIONS_PANEL_JS, mimetype="application/javascript; charset=utf-8") +@app.route("/static/options_expiry_countdown.js") +def static_options_expiry_countdown_js(): + if not os.path.isfile(_OPTIONS_EXPIRY_COUNTDOWN_JS): + return Response("not found", status=404, mimetype="text/plain; charset=utf-8") + return send_file(_OPTIONS_EXPIRY_COUNTDOWN_JS, mimetype="application/javascript; charset=utf-8") + + @app.route("/static/options_settings.js") def static_options_settings_js(): if not os.path.isfile(_OPTIONS_SETTINGS_JS): diff --git a/lib/common/static/instance_theme.css b/lib/common/static/instance_theme.css index cb92e6a..642f245 100644 --- a/lib/common/static/instance_theme.css +++ b/lib/common/static/instance_theme.css @@ -2890,6 +2890,16 @@ html[data-theme="light"] .options-estimate-row { .opt-pos-card { margin-bottom: 10px; } +.opt-expiry-cd { + font-variant-numeric: tabular-nums; + font-weight: 600; +} +.opt-expiry-cd--urgent { + color: #ffb347; +} +.opt-expiry-cd--expired { + color: var(--muted); +} .settings-card--compact .options-settings-section { margin-bottom: 8px; } diff --git a/lib/common/static/options_expiry_countdown.js b/lib/common/static/options_expiry_countdown.js new file mode 100644 index 0000000..118aab1 --- /dev/null +++ b/lib/common/static/options_expiry_countdown.js @@ -0,0 +1,58 @@ +/** + * 期权到期倒计时(实例期权页 + 中控监控/看板共用) + */ +(function (global) { + function normalizeExpMs(v) { + if (v == null || v === "") return null; + var n = Number(v); + if (!Number.isFinite(n) || n <= 0) return null; + if (n < 1e12) n *= 1000; + return n; + } + + function formatCountdown(expMs, nowMs) { + var ms = normalizeExpMs(expMs); + if (ms == null) return "—"; + var now = nowMs != null ? nowMs : Date.now(); + var rem = Math.max(0, Math.floor((ms - now) / 1000)); + if (rem <= 0) return "已到期"; + var d = Math.floor(rem / 86400); + var h = Math.floor((rem % 86400) / 3600); + var m = Math.floor((rem % 3600) / 60); + var s = rem % 60; + var pad = function (x) { + return String(x).padStart(2, "0"); + }; + if (d > 0) return d + "天 " + pad(h) + ":" + pad(m) + ":" + pad(s); + return pad(h) + ":" + pad(m) + ":" + pad(s); + } + + function tick(root) { + var scope = root && root.querySelectorAll ? root : document; + var now = Date.now(); + scope.querySelectorAll("[data-opt-exp-ms]").forEach(function (el) { + var exp = el.getAttribute("data-opt-exp-ms"); + var text = formatCountdown(exp, now); + el.textContent = text; + var expMs = normalizeExpMs(exp); + el.classList.toggle("opt-expiry-cd--urgent", expMs != null && expMs - now > 0 && expMs - now < 3600000); + el.classList.toggle("opt-expiry-cd--expired", text === "已到期"); + }); + } + + var timer = null; + function ensureTimer() { + tick(); + if (timer) return; + timer = setInterval(function () { + tick(); + }, 1000); + } + + global.OptionsExpiryCountdown = { + normalizeExpMs: normalizeExpMs, + format: formatCountdown, + tick: tick, + ensureTimer: ensureTimer, + }; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js index 21bf756..b7cf16d 100644 --- a/lib/common/static/options_panel.js +++ b/lib/common/static/options_panel.js @@ -431,6 +431,8 @@ const upl = p.upl; const uplCls = upl > 0 ? "pos-pnl-profit" : upl < 0 ? "pos-pnl-loss" : ""; const sideCls = (p.opt_type || "").toUpperCase() === "P" ? "pos-side-short" : "pos-side-long"; + const expMs = p.exp_time_ms != null ? p.exp_time_ms : p.exp_time; + const expAttr = expMs != null && expMs !== "" ? String(expMs) : ""; return ( '
' + '
' + @@ -442,6 +444,9 @@ '
' + '行权价: ' + fmt(p.strike, 0) + "" + '张数: ' + fmt(p.pos, 0) + " · 币量 " + fmt(p.eth_amount, 4) + "" + + (expAttr + ? '到期倒计时: ' + : "") + "
" + '
' + '
权利金' + fmt(p.premium_paid, 4) + " USDC
" + @@ -509,6 +514,9 @@ closePosition(btn.getAttribute("data-inst"), btn); }); }); + if (window.OptionsExpiryCountdown && OptionsExpiryCountdown.ensureTimer) { + OptionsExpiryCountdown.ensureTimer(); + } } async function refreshStats() { diff --git a/lib/exchange/okx_options_lib.py b/lib/exchange/okx_options_lib.py index f88b6e4..32991db 100644 --- a/lib/exchange/okx_options_lib.py +++ b/lib/exchange/okx_options_lib.py @@ -227,6 +227,35 @@ def option_fields_from_inst_id(inst_id: str) -> tuple[str | None, float | None]: return opt_type, strike +def expiry_ms_from_inst_id(inst_id: str) -> int | None: + """从 instId 日期段解析到期时刻(OKX 期权默认 08:00 UTC)。""" + parts = (inst_id or "").strip().split("-") + if len(parts) < 3: + return None + date_part = parts[-3] + if not re.fullmatch(r"\d{6}", date_part): + return None + try: + from datetime import datetime, timezone + + yy, mm, dd = int(date_part[0:2]), int(date_part[2:4]), int(date_part[4:6]) + dt = datetime(2000 + yy, mm, dd, 8, 0, 0, tzinfo=timezone.utc) + return int(dt.timestamp() * 1000) + except (ValueError, OSError): + return None + + +def normalize_option_exp_ms(exp_time: Any, inst_id: str = "") -> int | None: + """统一期权到期毫秒时间戳(优先 API expTime,否则从 instId 推算)。""" + raw = _safe_float(exp_time) + if raw is not None and raw > 0: + ms = int(raw) + if ms < 10_000_000_000: + ms *= 1000 + return ms + return expiry_ms_from_inst_id(inst_id) + + def fetch_option_instrument_meta(ex: ccxt.okx, inst_id: str) -> dict[str, Any] | None: family = inst_family_from_inst_id(inst_id) if not family: @@ -839,6 +868,7 @@ def format_position_row(pos: dict[str, Any], ct_mult: float = 0.01) -> dict[str, pos=sheets, ct_mult=ct_mult, ) + exp_time_ms = normalize_option_exp_ms(pos.get("expTime"), inst_id) return { "inst_id": inst_id or pos.get("instId"), "pos": sheets, @@ -849,7 +879,8 @@ def format_position_row(pos: dict[str, Any], ct_mult: float = 0.01) -> dict[str, "premium_paid": premium_paid, "upl": upl, "upl_ratio_pct": round(upl_ratio * 100, 2) if upl_ratio is not None else None, - "exp_time": pos.get("expTime"), + "exp_time": exp_time_ms, + "exp_time_ms": exp_time_ms, "opt_type": opt_type, "strike": strike, "avail_pos": _safe_float(pos.get("availPos")), diff --git a/lib/options/templates/options_panel.html b/lib/options/templates/options_panel.html index 40aa664..9f8a775 100644 --- a/lib/options/templates/options_panel.html +++ b/lib/options/templates/options_panel.html @@ -136,4 +136,5 @@
- + + diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py index 303c4c5..d1238ff 100644 --- a/manual_trading_hub/hub.py +++ b/manual_trading_hub/hub.py @@ -771,6 +771,7 @@ _TRADE_STATS_CALENDAR_CSS = _REPO_STATIC / "trade_stats_calendar.css" _TRADE_STATS_CALENDAR_JS = _REPO_STATIC / "trade_stats_calendar.js" _ACCOUNT_RISK_BADGE_CSS = _REPO_STATIC / "account_risk_badge.css" _ACCOUNT_RISK_BADGE_JS = _REPO_STATIC / "account_risk_badge.js" +_OPTIONS_EXPIRY_COUNTDOWN_JS = _REPO_STATIC / "options_expiry_countdown.js" @app.get("/assets/account_risk_badge.css") @@ -795,6 +796,16 @@ def hub_account_risk_badge_js(): ) +@app.get("/assets/options_expiry_countdown.js") +def hub_options_expiry_countdown_js(): + if not _OPTIONS_EXPIRY_COUNTDOWN_JS.is_file(): + raise HTTPException(status_code=404, detail="options_expiry_countdown.js not found") + return FileResponse( + str(_OPTIONS_EXPIRY_COUNTDOWN_JS), + media_type="application/javascript; charset=utf-8", + ) + + @app.get("/assets/ai_review_render.js") def hub_ai_review_render_js(): """与三所实例共用仓库根 static/ai_review_render.js(须在 /assets mount 之前注册)。""" diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css index 13d7aa1..9f7c8a9 100644 --- a/manual_trading_hub/static/app.css +++ b/manual_trading_hub/static/app.css @@ -8161,6 +8161,20 @@ html[data-theme="light"] .hub-monitor-options .hub-monitor-block-label { margin-top: 6px; } +.opt-expiry-cd { + font-variant-numeric: tabular-nums; + font-weight: 600; + white-space: nowrap; +} + +.opt-expiry-cd--urgent { + color: #ffb347; +} + +.opt-expiry-cd--expired { + color: var(--muted); +} + html[data-theme="light"] .hub-options-table th { color: #3a5068; } diff --git a/manual_trading_hub/static/app.js b/manual_trading_hub/static/app.js index 5e06497..5caae99 100644 --- a/manual_trading_hub/static/app.js +++ b/manual_trading_hub/static/app.js @@ -2250,6 +2250,9 @@ TimeCloseUI.tickLocalCountdowns(); } ensureHubHoldDurationTimer(); + if (window.OptionsExpiryCountdown && OptionsExpiryCountdown.ensureTimer) { + OptionsExpiryCountdown.ensureTimer(); + } if (expandedExchangeId && fs && fsInner) { const row = rows.find((r) => String(r.id) === String(expandedExchangeId)); @@ -2264,6 +2267,9 @@ TimeCloseUI.tickLocalCountdowns(); } ensureHubHoldDurationTimer(); + if (window.OptionsExpiryCountdown && OptionsExpiryCountdown.ensureTimer) { + OptionsExpiryCountdown.ensureTimer(); + } fsInner.querySelectorAll(".btn-expand-back").forEach((btn) => { btn.onclick = (ev) => { ev.stopPropagation(); @@ -3472,10 +3478,16 @@ return s.slice(0, 10) + "…" + s.slice(-8); } + function optionsExpiryCdHtml(expMs) { + const ms = expMs != null && expMs !== "" ? String(expMs) : ""; + if (!ms) return "—"; + return ``; + } + function renderOptionsPositionsTable(pos) { if (!pos.length) return '
暂无期权持仓
'; let html = '
'; - html += ""; + html += ""; html += ""; pos.forEach((p) => { const optType = (p.opt_type || "").toUpperCase() === "C" ? "Call" : (p.opt_type || "").toUpperCase() === "P" ? "Put" : (p.opt_type || "—"); @@ -3483,6 +3495,7 @@ + diff --git a/manual_trading_hub/static/dashboard.js b/manual_trading_hub/static/dashboard.js index c9df236..7f83ff4 100644 --- a/manual_trading_hub/static/dashboard.js +++ b/manual_trading_hub/static/dashboard.js @@ -118,6 +118,12 @@ return chips; } + function dashOptionsExpiryCd(expMs) { + const ms = expMs != null && expMs !== "" ? String(expMs) : ""; + if (!ms) return "—"; + return ``; + } + function renderDashboardOptionsTable(positions) { const pos = Array.isArray(positions) ? positions : []; if (!pos.length) return ""; @@ -132,6 +138,7 @@ return ` + @@ -144,7 +151,7 @@
合约类型张数指数到期平衡平掉回本浮盈浮盈%合约类型张数到期倒计时指数到期平衡平掉回本浮盈浮盈%
${esc(shortOptionsInst(p.inst_id))} ${esc(optType)} ${esc(p.pos)}${optionsExpiryCdHtml(p.exp_time_ms != null ? p.exp_time_ms : p.exp_time)} ${p.idx_px != null ? fmt(p.idx_px, 0) : "—"} ${p.expiry_be_px != null ? fmt(p.expiry_be_px, 0) : "—"} ${p.close_be_px != null ? fmt(p.close_be_px, 0) : "—"}
${esc(shortDashInst(p.inst_id))} ${esc(optType)}${dashOptionsExpiryCd(p.exp_time_ms != null ? p.exp_time_ms : p.exp_time)} ${p.idx_px != null ? fmt(p.idx_px, 0) : "—"} ${p.expiry_be_px != null ? fmt(p.expiry_be_px, 0) : "—"} ${p.close_be_px != null ? fmt(p.close_be_px, 0) : "—"}
- + ${rows}
合约类型指数到期平衡平掉回本浮盈合约类型到期倒计时指数到期平衡平掉回本浮盈
@@ -303,6 +310,9 @@ }) .join(""); bindDashboardExpand(); + if (window.OptionsExpiryCountdown && OptionsExpiryCountdown.ensureTimer) { + OptionsExpiryCountdown.ensureTimer(); + } } function renderTrades(trades, accounts) { diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index 83a1451..343dd30 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -1146,11 +1146,12 @@ - + + - + diff --git a/tests/test_options_pricing.py b/tests/test_options_pricing.py index df94e80..bf1c518 100644 --- a/tests/test_options_pricing.py +++ b/tests/test_options_pricing.py @@ -219,6 +219,20 @@ def test_format_position_row_premium_and_inst_parse(): assert row["opt_type"] == "P" assert row["strike"] == 1700.0 assert row["premium_paid"] == 1.24 + assert row["exp_time_ms"] is not None + assert row["exp_time_ms"] > 0 + + +def test_expiry_ms_from_inst_id(): + from lib.exchange.okx_options_lib import expiry_ms_from_inst_id, normalize_option_exp_ms + + ms = expiry_ms_from_inst_id("ETH-USD_UM-260709-1700-P") + assert ms is not None + from datetime import datetime, timezone + + dt = datetime.fromtimestamp(ms / 1000, tz=timezone.utc) + assert dt.year == 2026 and dt.month == 7 and dt.day == 9 and dt.hour == 8 + assert normalize_option_exp_ms(None, "ETH-USD_UM-260709-1700-P") == ms def test_format_position_row_breakeven():