From 35117ba84efc03ec5e3155bc5639e8b397455b6e Mon Sep 17 00:00:00 2001 From: dekun Date: Fri, 17 Jul 2026 12:23:25 +0800 Subject: [PATCH] Polish dashboard tables: hub-style orders, options source/expiry, hedge status. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show 进行中 in green for active hedges; options columns include source/target/expiry; merge live mark/contracts/pnl from price snapshot. Co-authored-by: Cursor --- lib/common/static/instance_dashboard.js | 197 ++++++++++++++++++++---- lib/common/static/instance_page.css | 5 + lib/hub/hub_bridge.py | 1 + lib/instance/instance_dashboard_lib.py | 176 +++++++++++++++------ lib/instance/templates/embed_shell.html | 5 +- lib/instance/templates/index.html | 5 +- tests/test_instance_dashboard_lib.py | 61 ++++++++ 7 files changed, 365 insertions(+), 85 deletions(-) diff --git a/lib/common/static/instance_dashboard.js b/lib/common/static/instance_dashboard.js index 9a0896f..5fce5c2 100644 --- a/lib/common/static/instance_dashboard.js +++ b/lib/common/static/instance_dashboard.js @@ -37,6 +37,52 @@ return '' + sign + n.toFixed(2) + "U"; } + function fmtPnlPlain(v) { + if (v == null || v === "") return "—"; + const n = Number(v); + if (!Number.isFinite(n)) return "—"; + const cls = n > 0 ? "pos-pnl-profit" : n < 0 ? "pos-pnl-loss" : ""; + return '' + n.toFixed(2) + ""; + } + + function dirCell(it) { + const d = String(it.direction || "").toLowerCase(); + const label = it.direction_label || (d === "short" ? "做空" : d === "long" ? "做多" : "-"); + const cls = d === "short" ? "inst-dash-dir-short" : d === "long" ? "inst-dash-dir-long" : ""; + return '' + escapeHtml(label) + ""; + } + + function fmtExpiry(ms) { + const n = Number(ms); + if (!Number.isFinite(n) || n <= 0) return "—"; + let fallback = "—"; + try { + const d = new Date(n); + if (!Number.isNaN(d.getTime())) { + const pad = function (x) { + return String(x).padStart(2, "0"); + }; + fallback = + d.getFullYear() + + "-" + + pad(d.getMonth() + 1) + + "-" + + pad(d.getDate()) + + " " + + pad(d.getHours()) + + ":" + + pad(d.getMinutes()); + } + } catch (_) {} + return ( + '' + + escapeHtml(fallback) + + "" + ); + } + function goTab(tab) { if (!tab) return; if (global.InstanceEmbed && typeof global.InstanceEmbed.loadTab === "function") { @@ -59,9 +105,11 @@ '
' + '' + "" + - headers.map(function (h) { - return ""; - }).join("") + + headers + .map(function (h) { + return ""; + }) + .join("") + "" + "" + rowsHtml + @@ -73,36 +121,84 @@ return ' class="inst-dash-row" data-dash-tab="' + escapeHtml(tab || "") + '" role="link" tabindex="0"'; } + function mergeOrderLive(items, orderPrices) { + const map = {}; + (orderPrices || []).forEach(function (p) { + if (p && p.id != null) map[String(p.id)] = p; + }); + return (items || []).map(function (it) { + const live = map[String(it.id)] || {}; + const mark = + live.exchange_mark_price != null + ? live.exchange_mark_price + : live.price != null + ? live.price + : it.mark_price; + const contracts = + live.contracts != null + ? live.contracts + : live.order_amount != null + ? live.order_amount + : it.contracts; + const entry = + live.avg_entry_price != null + ? live.avg_entry_price + : it.entry; + return Object.assign({}, it, { + entry: entry, + mark_price: mark, + mark_display: live.price_display || null, + contracts: contracts, + tp_profit: live.reward_at_tp_usdt != null ? live.reward_at_tp_usdt : it.tp_profit, + float_pnl: live.float_pnl != null ? live.float_pnl : it.float_pnl, + }); + }); + } + function renderOrdersTable(items) { const rows = items .map(function (it) { + const sym = it.symbol || "-"; + const mark = + it.mark_display != null && it.mark_display !== "" + ? escapeHtml(it.mark_display) + : fmtNum(it.mark_price); + const tpProfit = + it.tp_profit != null && Number.isFinite(Number(it.tp_profit)) + ? '' + Number(it.tp_profit).toFixed(2) + "U" + : "—"; return ( "" + - "" + - "" + - "" + + '" + + dirCell(it) + "" + "" + "" + + "" + + "" + + "" + "" ); }) .join(""); - return tableWrap(["合约", "方向", "类型", "入场", "止损", "止盈"], rows); + return tableWrap( + ["合约", "方向", "开仓价", "标记价", "张数", "盈利金额", "浮盈", "操作"], + rows + ); } function renderKeysTable(items) { @@ -115,9 +211,7 @@ "" + - "" + + dirCell(it) + "" + @@ -148,9 +242,7 @@ "" + - "" + + dirCell(it) + "" + @@ -167,12 +259,12 @@ function renderOptionsTable(items) { const rows = items .map(function (it) { - const opt = - String(it.opt_type || "").toUpperCase() === "C" + const opt = it.opt_type_label || + (String(it.opt_type || "").toUpperCase() === "C" ? "Call" : String(it.opt_type || "").toUpperCase() === "P" ? "Put" - : it.opt_type || "—"; + : it.opt_type || "—"); return ( "" + "" + + "" + "" + "" + + "" + + "" + "" ); }) .join(""); - return tableWrap(["合约", "类型", "张数", "盈亏"], rows); + return tableWrap(["合约", "来源", "类型", "张数", "到期时间", "目标监控", "盈亏"], rows); } function renderHedgeTable(items) { const rows = items .map(function (it) { + const stCls = it.status_active ? "inst-dash-status-active" : ""; + const stText = it.status_label || (it.status_active ? "进行中" : it.status || "—"); return ( "" + "" + + '" + "" + - "" + "" ); @@ -290,12 +395,28 @@ loading = true; if (status && !(opts && opts.silent)) status.textContent = "加载中…"; try { - const res = await fetch("/api/instance/dashboard", { credentials: "same-origin" }); - const data = await res.json().catch(function () { + const [dashRes, priceRes] = await Promise.all([ + fetch("/api/instance/dashboard", { credentials: "same-origin" }), + fetch("/api/price_snapshot", { credentials: "same-origin" }).catch(function () { + return null; + }), + ]); + const data = await dashRes.json().catch(function () { return {}; }); - if (!res.ok || !data.ok) { - throw new Error(data.msg || res.statusText || "加载失败"); + if (!dashRes.ok || !data.ok) { + throw new Error(data.msg || dashRes.statusText || "加载失败"); + } + let orderPrices = []; + if (priceRes && priceRes.ok) { + try { + const snap = await priceRes.json(); + orderPrices = snap.order_prices || []; + } catch (_) {} + } + if (data.orders && Array.isArray(data.orders.items)) { + data.orders.items = mergeOrderLive(data.orders.items, orderPrices); + data.orders.count = data.orders.items.length; } if (updated) updated.textContent = "更新 " + (data.updated_at || "—"); if (sections) { @@ -305,6 +426,14 @@ sections.innerHTML = html || '

当前无活跃监控与持仓

'; bindClicks(sections); + if (global.OptionsExpiryCountdown) { + if (typeof global.OptionsExpiryCountdown.tick === "function") { + global.OptionsExpiryCountdown.tick(sections); + } + if (typeof global.OptionsExpiryCountdown.ensureTimer === "function") { + global.OptionsExpiryCountdown.ensureTimer(); + } + } } if (status) status.textContent = ""; } catch (e) { diff --git a/lib/common/static/instance_page.css b/lib/common/static/instance_page.css index 00bca12..0b56f37 100644 --- a/lib/common/static/instance_page.css +++ b/lib/common/static/instance_page.css @@ -287,3 +287,8 @@ .inst-dash-table tbody tr:last-child td{border-bottom:none} .inst-dash-table tbody tr.inst-dash-row{cursor:pointer} .inst-dash-table tbody tr.inst-dash-row:hover{background:#1e2740} +.inst-dash-sym-link{color:#8fc8ff;text-decoration:underline} +.inst-dash-dir-long{color:#4cd97f;font-weight:600} +.inst-dash-dir-short{color:#ff6666;font-weight:600} +.inst-dash-status-active{color:#4cd97f;font-weight:600} +.inst-dash-table .pos-tp-profit{color:#cfd3ef} diff --git a/lib/hub/hub_bridge.py b/lib/hub/hub_bridge.py index 9ec93d8..26fac86 100644 --- a/lib/hub/hub_bridge.py +++ b/lib/hub/hub_bridge.py @@ -73,6 +73,7 @@ def install_instance_theme_static(app) -> None: "instance_live.js": "application/javascript; charset=utf-8", "instance_settings_prefs.js": "application/javascript; charset=utf-8", "instance_dashboard.js": "application/javascript; charset=utf-8", + "options_expiry_countdown.js": "application/javascript; charset=utf-8", "options_panel.js": "application/javascript; charset=utf-8", "order_entry_model.js": "application/javascript; charset=utf-8", "focus_chart_page.js": "application/javascript; charset=utf-8", diff --git a/lib/instance/instance_dashboard_lib.py b/lib/instance/instance_dashboard_lib.py index 846c8b7..8f5b8a6 100644 --- a/lib/instance/instance_dashboard_lib.py +++ b/lib/instance/instance_dashboard_lib.py @@ -67,12 +67,137 @@ def _format_order_item(od: dict[str, Any]) -> dict[str, Any]: "direction": direction, "direction_label": _dir_label(direction), "entry": entry, + "mark_price": None, + "contracts": _safe_float(od.get("order_amount")), + "tp_profit": None, + "float_pnl": None, "stop_loss": sl, "take_profit": tp, "status": od.get("status") or "active", } +OPTIONS_SOURCE_LABELS = { + "option": "纯期权", + "perp_options": "永期对冲", + "options_options": "期期对冲", +} + +HEDGE_ACTIVE_STATUSES = frozenset({"opening", "active", "partial"}) + + +def _resolve_options_source(conn, inst_id: str) -> tuple[str, str]: + """根据进行中对冲计划腿判定来源;默认纯期权.""" + if not inst_id or not _table_exists(conn, "hedge_plans") or not _table_exists(conn, "hedge_plan_legs"): + return "option", OPTIONS_SOURCE_LABELS["option"] + try: + row = conn.execute( + """ + SELECT p.plan_type + FROM hedge_plans p + JOIN hedge_plan_legs l ON l.plan_id = p.id + WHERE p.status IN ('opening', 'active', 'partial') + AND l.status = 'open' + AND l.inst_id = ? + ORDER BY p.id DESC + LIMIT 1 + """, + (inst_id,), + ).fetchone() + except Exception: + return "option", OPTIONS_SOURCE_LABELS["option"] + if not row: + return "option", OPTIONS_SOURCE_LABELS["option"] + pt = str((_row_dict(row).get("plan_type") if isinstance(row, dict) else row[0]) or "").strip() + if pt in OPTIONS_SOURCE_LABELS: + return pt, OPTIONS_SOURCE_LABELS[pt] + return "option", OPTIONS_SOURCE_LABELS["option"] + + +def _format_options_target(p: dict[str, Any]) -> str: + hedge = p.get("hedge_plan_target") if isinstance(p.get("hedge_plan_target"), dict) else None + opt_type = str(p.get("opt_type") or p.get("optType") or "").upper() + if hedge: + ot = str(hedge.get("opt_type") or opt_type).upper() + side = "Put ≤" if ot == "P" else "Call ≥" + tgt = _safe_float(hedge.get("target_index")) + pid = hedge.get("plan_id") + if tgt is not None: + return f"对冲#{pid} {side} {tgt:g}" if pid is not None else f"{side} {tgt:g}" + tgt = _safe_float(p.get("target_index")) + if tgt is not None and tgt > 0: + side = "Put ≤" if opt_type == "P" else "Call ≥" + return f"{side} {tgt:g}" + return "—" + + +def _format_options_item(p: dict[str, Any], *, conn=None) -> dict[str, Any]: + inst = str(p.get("inst_id") or p.get("instId") or "-").strip() or "-" + opt_type = str(p.get("opt_type") or p.get("optType") or "").upper() + label = "Call" if opt_type == "C" else "Put" if opt_type == "P" else (opt_type or "OPT") + upl = _safe_float(p.get("upl")) + net = None + try: + from lib.options.options_positions_lib import net_pnl_from_display_row + + net = net_pnl_from_display_row(p) + except Exception: + net = None + pnl = net if net is not None else upl + pos = _safe_float(p.get("pos")) + exp_ms = p.get("exp_time_ms") + if exp_ms is None: + exp_ms = p.get("exp_time") + try: + exp_ms = int(float(exp_ms)) if exp_ms not in (None, "") else None + except (TypeError, ValueError): + exp_ms = None + source_key, source_label = ( + _resolve_options_source(conn, inst) if conn is not None else ("option", OPTIONS_SOURCE_LABELS["option"]) + ) + return { + "id": inst, + "kind": "options", + "tab": "options", + "title": f"{inst} {label}", + "subtitle": f"张数 {pos if pos is not None else '-'}", + "inst_id": inst, + "opt_type": opt_type, + "opt_type_label": label, + "source": source_key, + "source_label": source_label, + "pos": pos, + "exp_time_ms": exp_ms, + "target_monitor": _format_options_target(p), + "pnl": round(pnl, 4) if pnl is not None else None, + } + + +def _format_hedge_item(plan: dict[str, Any]) -> dict[str, Any]: + pid = plan.get("id") + underlying = plan.get("underlying") or "-" + plan_type = plan.get("plan_type") or "" + status = str(plan.get("status") or "") + summary = plan.get("contracts_summary") or "" + plan_type_label = OPTIONS_SOURCE_LABELS.get(plan_type, plan_type) + active = status in HEDGE_ACTIVE_STATUSES + status_label = "进行中" if active else (status or "—") + return { + "id": pid, + "kind": "hedge_plan", + "tab": "hedge_plan", + "title": f"对冲 #{pid} {underlying}", + "subtitle": " · ".join(x for x in (plan_type_label, status_label, summary) if x), + "underlying": underlying, + "plan_type": plan_type, + "plan_type_label": plan_type_label, + "status": status, + "status_label": status_label, + "status_active": active, + "contracts_summary": summary, + } + + def _format_key_item(kd: dict[str, Any]) -> dict[str, Any]: sym = kd.get("exchange_symbol") or kd.get("symbol") or "-" direction = str(kd.get("direction") or "long").lower() @@ -137,51 +262,6 @@ def _format_roll_item(rd: dict[str, Any]) -> dict[str, Any]: } -def _format_options_item(p: dict[str, Any]) -> dict[str, Any]: - inst = p.get("inst_id") or p.get("instId") or "-" - opt_type = str(p.get("opt_type") or p.get("optType") or "").upper() - label = "Call" if opt_type == "C" else "Put" if opt_type == "P" else (opt_type or "OPT") - upl = _safe_float(p.get("upl")) - net = None - try: - from lib.options.options_positions_lib import net_pnl_from_display_row - - net = net_pnl_from_display_row(p) - except Exception: - net = None - pnl = net if net is not None else upl - pos = _safe_float(p.get("pos")) - return { - "id": inst, - "kind": "options", - "tab": "options", - "title": f"{inst} {label}", - "subtitle": f"张数 {pos if pos is not None else '-'}", - "inst_id": inst, - "opt_type": opt_type, - "pnl": round(pnl, 4) if pnl is not None else None, - "pos": pos, - } - - -def _format_hedge_item(plan: dict[str, Any]) -> dict[str, Any]: - pid = plan.get("id") - underlying = plan.get("underlying") or "-" - plan_type = plan.get("plan_type") or "" - status = plan.get("status") or "" - summary = plan.get("contracts_summary") or "" - return { - "id": pid, - "kind": "hedge_plan", - "tab": "hedge_plan", - "title": f"对冲 #{pid} {underlying}", - "subtitle": " · ".join(x for x in (plan_type, status, summary) if x), - "underlying": underlying, - "plan_type": plan_type, - "status": status, - } - - def _table_exists(conn, name: str) -> bool: try: row = conn.execute( @@ -253,6 +333,8 @@ def collect_hedge_plans(conn) -> list[dict[str, Any]]: def collect_options_items( fetch_options_positions: Optional[Callable[[], list[dict[str, Any]]]] = None, + *, + conn=None, ) -> list[dict[str, Any]]: if not callable(fetch_options_positions): return [] @@ -264,7 +346,7 @@ def collect_options_items( for p in raw: if not isinstance(p, dict): continue - out.append(_format_options_item(p)) + out.append(_format_options_item(p, conn=conn)) return out @@ -279,7 +361,7 @@ def build_instance_dashboard_payload( trends = collect_trends(conn) rolls = collect_rolls(conn) strategy_items = trends + rolls - options_items = collect_options_items(fetch_options_positions) + options_items = collect_options_items(fetch_options_positions, conn=conn) hedge_items = collect_hedge_plans(conn) if hedge_enabled else [] now = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S") return { diff --git a/lib/instance/templates/embed_shell.html b/lib/instance/templates/embed_shell.html index 4e63144..4ea40b5 100644 --- a/lib/instance/templates/embed_shell.html +++ b/lib/instance/templates/embed_shell.html @@ -6,7 +6,7 @@ - + @@ -110,7 +110,8 @@ const ORDER_ENTRY_MODEL_CODE_TO_CATEGORY = {{ entry_model_code_to_category | toj {% include 'embed_boot_scripts.html' %} - + + diff --git a/lib/instance/templates/index.html b/lib/instance/templates/index.html index 804e1bb..825fb18 100644 --- a/lib/instance/templates/index.html +++ b/lib/instance/templates/index.html @@ -16,7 +16,7 @@ {{ pwa_app_name }} - + @@ -1992,7 +1992,8 @@ tickOrderHoldDurations(); setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }}); - + +
" + escapeHtml(h) + "" + escapeHtml(h) + "
" + - escapeHtml(it.symbol || "-") + - "" + - escapeHtml(it.direction_label || it.direction || "-") + - "" + - escapeHtml(it.subtitle || "—") + - "' + + escapeHtml(sym) + + "" + fmtNum(it.entry) + "" + - fmtNum(it.stop_loss) + + mark + "" + - fmtNum(it.take_profit) + + fmtNum(it.contracts) + "" + + tpProfit + + "" + + fmtPnlPlain(it.float_pnl) + + "
" + escapeHtml(it.symbol || "-") + "" + - escapeHtml(it.direction_label || it.direction || "-") + - "" + escapeHtml(it.subtitle || "—") + "" + escapeHtml(it.symbol || "-") + "" + - escapeHtml(it.direction_label || it.direction || "-") + - "" + escapeHtml(it.status || "—") + "" + + escapeHtml(it.source_label || "纯期权") + + "" + escapeHtml(opt) + "" + fmtNum(it.pos) + "" + + fmtExpiry(it.exp_time_ms) + + "" + + escapeHtml(it.target_monitor || "—") + + "" + fmtPnl(it.pnl) + "
" + - escapeHtml(it.plan_type || "—") + + escapeHtml(it.plan_type_label || it.plan_type || "—") + + "' + + escapeHtml(stText) + "" + - escapeHtml(it.status || "—") + - "" + - escapeHtml(it.subtitle || "—") + + escapeHtml(it.contracts_summary || it.subtitle || "—") + "