diff --git a/crypto_monitor_binance/app.py b/crypto_monitor_binance/app.py index ed57e72..0a2adf7 100644 --- a/crypto_monitor_binance/app.py +++ b/crypto_monitor_binance/app.py @@ -7414,6 +7414,12 @@ def stats_page(): return render_main_page("stats") +@app.route("/dashboard") +@login_required +def dashboard_page(): + return render_main_page("dashboard") + + @app.route("/risk_policy") @login_required def risk_policy_page(): @@ -9447,6 +9453,15 @@ register_trade_records_api( app_tz=APP_TZ, ) +from lib.instance.instance_dashboard_register import register_instance_dashboard_routes + +register_instance_dashboard_routes( + app, + login_required=login_required, + get_db=get_db, + hedge_enabled=False, +) + @app.route("/api/journals") @login_required diff --git a/crypto_monitor_gate/app.py b/crypto_monitor_gate/app.py index e9ae24d..42b4c6b 100644 --- a/crypto_monitor_gate/app.py +++ b/crypto_monitor_gate/app.py @@ -7230,6 +7230,12 @@ def stats_page(): return render_main_page("stats") +@app.route("/dashboard") +@login_required +def dashboard_page(): + return render_main_page("dashboard") + + @app.route("/risk_policy") @login_required def risk_policy_page(): @@ -9314,6 +9320,15 @@ register_trade_records_api( app_tz=APP_TZ, ) +from lib.instance.instance_dashboard_register import register_instance_dashboard_routes + +register_instance_dashboard_routes( + app, + login_required=login_required, + get_db=get_db, + hedge_enabled=False, +) + @app.route("/api/journals") @login_required diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py index cdba7fb..197c15f 100644 --- a/crypto_monitor_okx/app.py +++ b/crypto_monitor_okx/app.py @@ -6858,6 +6858,15 @@ def stats_page(): return render_main_page("stats") +@app.route("/dashboard") +@login_required +def dashboard_page(): + redir = redirect_to_embed_shell_if_enabled("dashboard") + if redir is not None: + return redir + return render_main_page("dashboard") + + @app.route("/risk_policy") @login_required def risk_policy_page(): @@ -9003,6 +9012,34 @@ register_trade_records_api( ) +def _dashboard_fetch_options_positions(): + if not OKX_OPTIONS_ENABLED: + return [] + cfg = app.extensions.get("options_cfg") + if not isinstance(cfg, dict) or not cfg.get("enabled"): + return [] + try: + from lib.options.options_hub_lib import build_options_hub_snapshot + + snap = build_options_hub_snapshot(cfg) + except Exception: + return [] + if not snap.get("ok"): + return [] + return list(snap.get("positions") or []) + + +from lib.instance.instance_dashboard_register import register_instance_dashboard_routes + +register_instance_dashboard_routes( + app, + login_required=login_required, + get_db=get_db, + fetch_options_positions=_dashboard_fetch_options_positions, + hedge_enabled=os.getenv("HEDGE_PLAN_ENABLED", "false").lower() in ("1", "true", "yes", "on"), +) + + @app.route("/api/journals") @login_required def api_journals(): diff --git a/docs/系统设置说明.md b/docs/系统设置说明.md index 3c4164a..6ef8f2b 100644 --- a/docs/系统设置说明.md +++ b/docs/系统设置说明.md @@ -26,15 +26,16 @@ ### 2.1 顶栏导航开关 -| 开关 | 对应 Tab | -|------|----------| -| 策略交易 | 策略交易 | -| 策略交易记录 | 策略交易记录 | -| 交易记录与复盘 | 交易记录与复盘 | -| 统计分析 | 统计分析 | -| 风控说明 | 风控说明 | -| env 配置 | env 配置 | -| 期权 | 期权(仅 OKX 等有期权模块时有效) | +| 开关 | 对应 Tab | 默认 | +|------|----------|------| +| 数据看板 | 数据看板(本户活跃监控总览) | **关闭** | +| 策略交易 | 策略交易 | 开 | +| 策略交易记录 | 策略交易记录 | 开 | +| 交易记录与复盘 | 交易记录与复盘 | 开 | +| 统计分析 | 统计分析 | 开 | +| 风控说明 | 风控说明 | 开 | +| env 配置 | env 配置 | 开 | +| 期权 | 期权(仅 OKX 等有期权模块时有效) | 开 | 保存后 **立即生效**,无需重启.中控 iframe 内嵌导航同步生效. diff --git a/lib/common/static/instance_dashboard.js b/lib/common/static/instance_dashboard.js new file mode 100644 index 0000000..5f905e4 --- /dev/null +++ b/lib/common/static/instance_dashboard.js @@ -0,0 +1,204 @@ +/** + * 实例数据看板:拉 /api/instance/dashboard 渲染只读区块. + */ +(function (global) { + const SECTION_ORDER = ["orders", "keys", "strategy", "options", "hedge_plan"]; + let loading = false; + let timer = null; + + function root() { + const active = document.querySelector('.embed-tab-pane.is-active-pane [data-inst-dashboard="1"]'); + if (active) return active; + return document.getElementById("instance-dashboard"); + } + + function escapeHtml(s) { + return String(s == null ? "" : s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function fmtNum(v) { + if (v == null || v === "") return "—"; + const n = Number(v); + if (!Number.isFinite(n)) return escapeHtml(v); + return String(n); + } + + function fmtPnl(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" : ""; + const sign = n > 0 ? "+" : ""; + return '' + sign + n.toFixed(2) + "U"; + } + + function goTab(tab) { + if (!tab) return; + if (global.InstanceEmbed && typeof global.InstanceEmbed.loadTab === "function") { + global.InstanceEmbed.loadTab(tab); + return; + } + const pathMap = { + trade: "/trade", + key_monitor: "/key_monitor", + strategy: "/strategy", + options: "/options", + hedge_plan: "/hedge-plan", + }; + const path = pathMap[tab] || "/" + tab; + location.href = path; + } + + function renderItems(items) { + if (!items || !items.length) { + return '

暂无

'; + } + return ( + '
' + + items + .map(function (it) { + const meta = []; + if (it.entry != null) meta.push("入场 " + fmtNum(it.entry)); + if (it.stop_loss != null) meta.push("止损 " + fmtNum(it.stop_loss)); + if (it.take_profit != null) meta.push("止盈 " + fmtNum(it.take_profit)); + if (it.upper != null || it.lower != null) { + meta.push("上 " + fmtNum(it.upper) + " / 下 " + fmtNum(it.lower)); + } + const pnlHtml = fmtPnl(it.pnl); + if (pnlHtml) meta.push(pnlHtml); + return ( + '" + ); + }) + .join("") + + "
" + ); + } + + function renderSection(key, sec) { + if (!sec) return ""; + if ((key === "options" || key === "hedge_plan") && !sec.visible) return ""; + const count = Number(sec.count) || 0; + return ( + '
' + + '
' + + "

" + + escapeHtml(sec.title || key) + + ' ' + + count + + "

" + + '' + + "
" + + renderItems(sec.items || []) + + "
" + ); + } + + function bindClicks(el) { + if (!el) return; + el.querySelectorAll("[data-dash-tab]").forEach(function (btn) { + btn.addEventListener("click", function () { + goTab(btn.getAttribute("data-dash-tab")); + }); + }); + } + + async function load(opts) { + const el = root(); + if (!el) return; + const status = el.querySelector("#inst-dash-status") || document.getElementById("inst-dash-status"); + const sections = el.querySelector("#inst-dash-sections") || document.getElementById("inst-dash-sections"); + const updated = el.querySelector("#inst-dash-updated") || document.getElementById("inst-dash-updated"); + if (loading) return; + 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 () { + return {}; + }); + if (!res.ok || !data.ok) { + throw new Error(data.msg || res.statusText || "加载失败"); + } + if (updated) updated.textContent = "更新 " + (data.updated_at || "—"); + if (sections) { + sections.innerHTML = SECTION_ORDER.map(function (k) { + return renderSection(k, data[k]); + }).join(""); + bindClicks(sections); + } + if (status) status.textContent = ""; + } catch (e) { + if (status) status.textContent = e.message || "加载失败"; + } finally { + loading = false; + } + } + + function stopAuto() { + if (timer) { + clearInterval(timer); + timer = null; + } + } + + function startAuto() { + stopAuto(); + timer = setInterval(function () { + const el = root(); + if (!el) return; + const pane = el.closest(".embed-tab-pane"); + if (pane && !pane.classList.contains("is-active-pane")) return; + load({ silent: true }); + }, 15000); + } + + function init(force) { + const el = root(); + if (!el) return; + if (!force && el.getAttribute("data-dash-booted") === "1") { + load({ silent: true }); + startAuto(); + return; + } + el.setAttribute("data-dash-booted", "1"); + const btn = el.querySelector("#inst-dash-refresh") || document.getElementById("inst-dash-refresh"); + if (btn && !btn.getAttribute("data-bound")) { + btn.setAttribute("data-bound", "1"); + btn.addEventListener("click", function () { + load({}); + }); + } + load({}); + startAuto(); + } + + function refreshSoft(opts) { + load(Object.assign({ silent: true }, opts || {})); + } + + global.InstanceDashboard = { + init: init, + refreshSoft: refreshSoft, + load: load, + stopAuto: stopAuto, + }; +})(window); diff --git a/lib/common/static/instance_embed.js b/lib/common/static/instance_embed.js index 13f378a..cc3bef0 100644 --- a/lib/common/static/instance_embed.js +++ b/lib/common/static/instance_embed.js @@ -4,6 +4,7 @@ */ (function (global) { const TAB_PATH = { + dashboard: "/dashboard", key_monitor: "/key_monitor", trade: "/trade", strategy: "/strategy", @@ -99,6 +100,9 @@ if (!revisit && tab === "key_monitor" && global.KeyMonitorForm && typeof global.KeyMonitorForm.init === "function") { global.KeyMonitorForm.init(); } + if (tab === "dashboard" && global.InstanceDashboard && typeof global.InstanceDashboard.init === "function") { + global.InstanceDashboard.init(!!revisit); + } if (!revisit && tab === "strategy" && typeof global.initStrategyRollForm === "function") { global.initStrategyRollForm(); } diff --git a/lib/common/static/instance_live.js b/lib/common/static/instance_live.js index b948542..f942c0d 100644 --- a/lib/common/static/instance_live.js +++ b/lib/common/static/instance_live.js @@ -30,6 +30,9 @@ if (tab === "options" && global.OptionsPanelLive && typeof global.OptionsPanelLive.refreshSoft === "function") { global.OptionsPanelLive.refreshSoft(options); } + if (tab === "dashboard" && global.InstanceDashboard && typeof global.InstanceDashboard.refreshSoft === "function") { + global.InstanceDashboard.refreshSoft(options); + } } function scheduleRefresh(opts) { diff --git a/lib/common/static/instance_page.css b/lib/common/static/instance_page.css index a777ef6..d41025e 100644 --- a/lib/common/static/instance_page.css +++ b/lib/common/static/instance_page.css @@ -269,3 +269,20 @@ .stats-period-block .sub{font-size:.78rem;color:#8892b0;margin-bottom:10px;line-height:1.4} #embed-page-root{min-height:120px;position:relative} .embed-tab-pane[hidden]{display:none!important} +.inst-dash-card{grid-column:1/-1} +.inst-dash-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:8px} +.inst-dash-desc{margin:0;font-size:.82rem} +.inst-dash-head-actions{display:flex;align-items:center;gap:10px;flex-wrap:wrap} +.inst-dash-status{min-height:1.2em;margin:0 0 10px} +.inst-dash-sections{display:flex;flex-direction:column;gap:14px} +.inst-dash-section{padding:12px;background:#141923;border:1px solid #2a3150;border-radius:10px} +.inst-dash-section-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:10px} +.inst-dash-section-head h3{margin:0;font-size:.95rem;color:#dbe4ff} +.inst-dash-count{display:inline-block;min-width:1.4em;padding:1px 7px;margin-left:4px;border-radius:999px;background:#1f3a5a;color:#8fc8ff;font-size:.75rem;font-weight:600} +.inst-dash-empty{margin:0;padding:12px;text-align:center;border:1px dashed #2a3348;border-radius:8px;font-size:.84rem} +.inst-dash-list{display:flex;flex-direction:column;gap:8px} +.inst-dash-item{display:block;width:100%;text-align:left;padding:10px 12px;border-radius:8px;border:1px solid #2a3150;background:#1a2034;color:#eaeaea;cursor:pointer} +.inst-dash-item:hover{border-color:#3d4f78;background:#1e2740} +.inst-dash-item-title{font-weight:600;font-size:.9rem;color:#dbe4ff} +.inst-dash-item-sub{font-size:.78rem;margin-top:3px} +.inst-dash-item-meta{font-size:.78rem;margin-top:6px;color:#a8b0d8;font-variant-numeric:tabular-nums} diff --git a/lib/common/static/instance_settings_prefs.js b/lib/common/static/instance_settings_prefs.js index cf235fb..e43676d 100644 --- a/lib/common/static/instance_settings_prefs.js +++ b/lib/common/static/instance_settings_prefs.js @@ -19,8 +19,18 @@ return data; } + /** 默认关闭的导航开关:缺失时按 false,不能用 !== false */ + const NAV_DEFAULT_OFF = { show_nav_dashboard: true }; + + function navPrefShow(display, key) { + if (!key) return true; + if (NAV_DEFAULT_OFF[key]) return display[key] === true; + return display[key] !== false; + } + function applyDisplayToNav(display) { const map = { + dashboard: "show_nav_dashboard", strategy: "show_nav_strategy", strategy_records: "show_nav_strategy_records", records: "show_nav_records", @@ -37,7 +47,7 @@ const tab = a.getAttribute("data-embed-tab") || (a.getAttribute("href") || "").replace(/^\//, "").split("?")[0]; const key = map[tab]; if (!key) return; - const show = display[key] !== false; + const show = navPrefShow(display, key); a.classList.toggle("nav-hidden", !show); a.style.display = show ? "" : "none"; }); @@ -47,6 +57,7 @@ function pageNavAllowed(tab) { const d = DISPLAY(); const map = { + dashboard: "show_nav_dashboard", strategy: "show_nav_strategy", strategy_records: "show_nav_strategy_records", records: "show_nav_records", @@ -61,7 +72,7 @@ }; const key = map[tab]; if (!key) return true; - return d[key] !== false; + return navPrefShow(d, key); } function displayPrefsRoot() { @@ -129,7 +140,9 @@ const cb = document.createElement("input"); cb.type = "checkbox"; cb.dataset.prefKey = item.key; - cb.checked = display[item.key] !== false; + cb.checked = NAV_DEFAULT_OFF[item.key] + ? display[item.key] === true + : display[item.key] !== false; label.appendChild(cb); label.appendChild(document.createTextNode(" " + item.label)); grid.appendChild(label); diff --git a/lib/hub/hub_bridge.py b/lib/hub/hub_bridge.py index ae62133..9ec93d8 100644 --- a/lib/hub/hub_bridge.py +++ b/lib/hub/hub_bridge.py @@ -72,6 +72,7 @@ def install_instance_theme_static(app) -> None: "instance_stats.js": "application/javascript; charset=utf-8", "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_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 new file mode 100644 index 0000000..846c8b7 --- /dev/null +++ b/lib/instance/instance_dashboard_lib.py @@ -0,0 +1,312 @@ +"""实例数据看板:本户活跃监控 / 持仓只读聚合.""" +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, Callable, Optional + + +def _row_dict(row: Any) -> dict[str, Any]: + if row is None: + return {} + if isinstance(row, dict): + return dict(row) + try: + return dict(row) + except Exception: + return {} + + +def _safe_float(v: Any) -> Optional[float]: + try: + if v is None or v == "": + return None + return float(v) + except (TypeError, ValueError): + return None + + +def _dir_label(direction: Any) -> str: + d = str(direction or "").strip().lower() + if d == "short": + return "做空" + if d == "long": + return "做多" + return str(direction or "-") + + +def _format_order_item(od: dict[str, Any]) -> dict[str, Any]: + try: + from lib.strategy.strategy_trade_labels import apply_order_monitor_source_labels + + od = apply_order_monitor_source_labels(od) + except Exception: + pass + try: + from lib.trade.entry_model_lib import enrich_entry_model_display + + enrich_entry_model_display(od) + except Exception: + pass + sym = od.get("exchange_symbol") or od.get("symbol") or "-" + direction = str(od.get("direction") or "long").lower() + mt = od.get("monitor_type_display") or od.get("monitor_type") or "" + kst = od.get("key_signal_type") or "" + title = f"{sym} {_dir_label(direction)}" + bits = [x for x in (mt, kst) if x] + subtitle = " · ".join(bits) if bits else "" + entry = _safe_float(od.get("trigger_price")) + sl = _safe_float(od.get("stop_loss")) + tp = _safe_float(od.get("take_profit")) + return { + "id": od.get("id"), + "kind": "order", + "tab": "trade", + "title": title, + "subtitle": subtitle, + "symbol": sym, + "direction": direction, + "direction_label": _dir_label(direction), + "entry": entry, + "stop_loss": sl, + "take_profit": tp, + "status": od.get("status") or "active", + } + + +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() + signal = kd.get("signal_type") or kd.get("key_signal_type") or kd.get("monitor_type") or "" + upper = _safe_float(kd.get("upper")) + lower = _safe_float(kd.get("lower")) + subtitle_parts = [] + if signal: + subtitle_parts.append(str(signal)) + if upper is not None or lower is not None: + subtitle_parts.append( + f"上{upper if upper is not None else '-'} / 下{lower if lower is not None else '-'}" + ) + return { + "id": kd.get("id"), + "kind": "key", + "tab": "key_monitor", + "title": f"{sym} {_dir_label(direction)}", + "subtitle": " · ".join(subtitle_parts), + "symbol": sym, + "direction": direction, + "direction_label": _dir_label(direction), + "upper": upper, + "lower": lower, + "status": kd.get("status") or "active", + } + + +def _format_trend_item(td: dict[str, Any]) -> dict[str, Any]: + sym = td.get("exchange_symbol") or td.get("symbol") or "-" + direction = str(td.get("direction") or "long").lower() + status = td.get("status") or "active" + entry = _safe_float(td.get("entry_price") or td.get("trigger_price")) + return { + "id": td.get("id"), + "kind": "trend", + "tab": "strategy", + "title": f"趋势回调 {sym} {_dir_label(direction)}", + "subtitle": f"状态 {status}", + "symbol": sym, + "direction": direction, + "direction_label": _dir_label(direction), + "entry": entry, + "status": status, + } + + +def _format_roll_item(rd: dict[str, Any]) -> dict[str, Any]: + sym = rd.get("exchange_symbol") or rd.get("symbol") or "-" + direction = str(rd.get("direction") or "long").lower() + status = rd.get("status") or "active" + return { + "id": rd.get("id"), + "kind": "roll", + "tab": "strategy", + "title": f"顺势加仓 {sym} {_dir_label(direction)}", + "subtitle": f"状态 {status}", + "symbol": sym, + "direction": direction, + "direction_label": _dir_label(direction), + "status": status, + } + + +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( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1", + (name,), + ).fetchone() + return bool(row) + except Exception: + return False + + +def collect_orders(conn) -> list[dict[str, Any]]: + if not _table_exists(conn, "order_monitors"): + return [] + rows = conn.execute( + "SELECT * FROM order_monitors WHERE status='active' ORDER BY id DESC" + ).fetchall() + return [_format_order_item(_row_dict(r)) for r in rows] + + +def collect_keys(conn) -> list[dict[str, Any]]: + if not _table_exists(conn, "key_monitors"): + return [] + rows = conn.execute("SELECT * FROM key_monitors ORDER BY id DESC").fetchall() + return [_format_key_item(_row_dict(r)) for r in rows] + + +def collect_trends(conn) -> list[dict[str, Any]]: + if not _table_exists(conn, "trend_pullback_plans"): + return [] + try: + rows = conn.execute( + "SELECT * FROM trend_pullback_plans WHERE status='active' ORDER BY id DESC" + ).fetchall() + except Exception: + return [] + return [_format_trend_item(_row_dict(r)) for r in rows] + + +def collect_rolls(conn) -> list[dict[str, Any]]: + if not _table_exists(conn, "roll_groups") or not _table_exists(conn, "order_monitors"): + return [] + try: + rows = conn.execute( + """SELECT g.* FROM roll_groups g + INNER JOIN order_monitors m ON m.id = g.order_monitor_id AND m.status='active' + WHERE g.status='active' ORDER BY g.id DESC""" + ).fetchall() + except Exception: + return [] + return [_format_roll_item(_row_dict(r)) for r in rows] + + +def collect_hedge_plans(conn) -> list[dict[str, Any]]: + if not _table_exists(conn, "hedge_plans"): + return [] + try: + from lib.hedge_plan.hedge_plan_db import attach_legs_to_plans, list_plans + + rows: list[dict[str, Any]] = [] + for status in ("opening", "active", "partial"): + rows.extend(list_plans(conn, status=status, limit=80)) + rows.sort(key=lambda row: int(row.get("id") or 0), reverse=True) + plans = attach_legs_to_plans(conn, rows) + return [_format_hedge_item(p) for p in plans] + except Exception: + return [] + + +def collect_options_items( + fetch_options_positions: Optional[Callable[[], list[dict[str, Any]]]] = None, +) -> list[dict[str, Any]]: + if not callable(fetch_options_positions): + return [] + try: + raw = fetch_options_positions() or [] + except Exception: + return [] + out: list[dict[str, Any]] = [] + for p in raw: + if not isinstance(p, dict): + continue + out.append(_format_options_item(p)) + return out + + +def build_instance_dashboard_payload( + conn, + *, + fetch_options_positions: Optional[Callable[[], list[dict[str, Any]]]] = None, + hedge_enabled: bool = False, +) -> dict[str, Any]: + orders = collect_orders(conn) + keys = collect_keys(conn) + trends = collect_trends(conn) + rolls = collect_rolls(conn) + strategy_items = trends + rolls + options_items = collect_options_items(fetch_options_positions) + hedge_items = collect_hedge_plans(conn) if hedge_enabled else [] + now = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S") + return { + "ok": True, + "updated_at": now, + "orders": {"title": "实盘下单", "count": len(orders), "items": orders, "tab": "trade"}, + "keys": {"title": "关键位监控", "count": len(keys), "items": keys, "tab": "key_monitor"}, + "strategy": { + "title": "策略交易", + "count": len(strategy_items), + "items": strategy_items, + "trends": trends, + "rolls": rolls, + "tab": "strategy", + }, + "options": { + "title": "期权持仓", + "count": len(options_items), + "items": options_items, + "visible": len(options_items) > 0, + "tab": "options", + }, + "hedge_plan": { + "title": "对冲计划", + "count": len(hedge_items), + "items": hedge_items, + "visible": len(hedge_items) > 0, + "tab": "hedge_plan", + }, + } diff --git a/lib/instance/instance_dashboard_register.py b/lib/instance/instance_dashboard_register.py new file mode 100644 index 0000000..d38142a --- /dev/null +++ b/lib/instance/instance_dashboard_register.py @@ -0,0 +1,31 @@ +"""注册 GET /api/instance/dashboard(三所共用).""" +from __future__ import annotations + +from typing import Any, Callable, Optional + +from flask import Flask, jsonify + + +def register_instance_dashboard_routes( + app: Flask, + *, + login_required: Callable, + get_db: Callable, + fetch_options_positions: Optional[Callable[[], list[dict[str, Any]]]] = None, + hedge_enabled: bool = False, +) -> None: + from lib.instance.instance_dashboard_lib import build_instance_dashboard_payload + + @app.route("/api/instance/dashboard") + @login_required + def api_instance_dashboard(): + conn = get_db() + try: + payload = build_instance_dashboard_payload( + conn, + fetch_options_positions=fetch_options_positions, + hedge_enabled=bool(hedge_enabled), + ) + return jsonify(payload) + finally: + conn.close() diff --git a/lib/instance/instance_display_prefs_lib.py b/lib/instance/instance_display_prefs_lib.py index f3f4d4e..f75b985 100644 --- a/lib/instance/instance_display_prefs_lib.py +++ b/lib/instance/instance_display_prefs_lib.py @@ -8,6 +8,7 @@ from lib.instance.runtime_settings_lib import runtime_get_prefix, runtime_set_ma DISPLAY_RUNTIME_PREFIX = "display." DEFAULT_INSTANCE_DISPLAY: dict[str, bool] = { + "show_nav_dashboard": False, "show_nav_strategy": True, "show_nav_strategy_records": True, "show_nav_records": True, @@ -25,6 +26,7 @@ DEFAULT_INSTANCE_DISPLAY: dict[str, bool] = { } DISPLAY_LABELS: dict[str, str] = { + "show_nav_dashboard": "数据看板", "show_nav_strategy": "策略交易", "show_nav_strategy_records": "策略交易记录", "show_nav_records": "交易记录与复盘", @@ -42,6 +44,7 @@ DISPLAY_LABELS: dict[str, str] = { } NAV_TAB_ALLOWED: dict[str, str] = { + "dashboard": "show_nav_dashboard", "strategy": "show_nav_strategy", "strategy_records": "show_nav_strategy_records", "records": "show_nav_records", @@ -103,6 +106,7 @@ def tab_allowed(tab: str, display: Optional[dict[str, bool]] = None) -> bool: def display_meta_for_ui() -> list[dict[str, Any]]: nav_keys = [ + "show_nav_dashboard", "show_nav_strategy", "show_nav_strategy_records", "show_nav_records", diff --git a/lib/instance/instance_embed_lib.py b/lib/instance/instance_embed_lib.py index 812900b..0753ee7 100644 --- a/lib/instance/instance_embed_lib.py +++ b/lib/instance/instance_embed_lib.py @@ -11,6 +11,7 @@ from flask import Flask, Response, jsonify, redirect, request, session from jinja2 import ChoiceLoader, FileSystemLoader EMBED_TABS: tuple[str, ...] = ( + "dashboard", "key_monitor", "trade", "strategy", @@ -28,6 +29,7 @@ EMBED_TABS: tuple[str, ...] = ( PATH_TO_EMBED_TAB: dict[str, str] = { "/": "trade", "/trade": "trade", + "/dashboard": "dashboard", "/key_monitor": "key_monitor", "/strategy": "strategy", "/strategy/trend": "strategy", diff --git a/lib/instance/templates/dashboard_panel.html b/lib/instance/templates/dashboard_panel.html new file mode 100644 index 0000000..2447aea --- /dev/null +++ b/lib/instance/templates/dashboard_panel.html @@ -0,0 +1,15 @@ +{# 实例数据看板:只读活跃监控总览 #} +
+
+
+

数据看板

+

本户活跃监控总览 · 只读 · 期权/对冲有数据才显示

+
+
+ + +
+
+

+
+
diff --git a/lib/instance/templates/embed_page_fragment.html b/lib/instance/templates/embed_page_fragment.html index 2e135ea..d21cf90 100644 --- a/lib/instance/templates/embed_page_fragment.html +++ b/lib/instance/templates/embed_page_fragment.html @@ -81,7 +81,9 @@ {% endmacro %}
- {% if page == 'key_monitor' %} + {% if page == 'dashboard' %} + {% include 'dashboard_panel.html' %} + {% elif page == 'key_monitor' %} {% include 'key_monitor_panel.html' %} {% elif page == 'trade' %}
diff --git a/lib/instance/templates/embed_shell.html b/lib/instance/templates/embed_shell.html index a067834..55b34ff 100644 --- a/lib/instance/templates/embed_shell.html +++ b/lib/instance/templates/embed_shell.html @@ -6,7 +6,7 @@ - + @@ -29,6 +29,7 @@

加密货币|交易监控 + AI复盘一体化

+ 关键位监控 实盘下单 {% if not intraday_discipline and display.show_nav_strategy %} @@ -155,7 +156,9 @@ {% endif %}
- {% if page == 'key_monitor' %} + {% if page == 'dashboard' %} + {% include 'dashboard_panel.html' %} + {% elif page == 'key_monitor' %} {% include 'key_monitor_panel.html' %} {% elif page == 'trade' %}
@@ -1989,9 +1992,15 @@ tickOrderHoldDurations(); setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }}); + - + \ No newline at end of file diff --git a/tests/test_instance_dashboard_lib.py b/tests/test_instance_dashboard_lib.py new file mode 100644 index 0000000..10c4521 --- /dev/null +++ b/tests/test_instance_dashboard_lib.py @@ -0,0 +1,111 @@ +"""instance_dashboard_lib 单元测试.""" +from __future__ import annotations + +import sqlite3 +import unittest + +from lib.instance.instance_dashboard_lib import build_instance_dashboard_payload + + +def _mem_conn() -> sqlite3.Connection: + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + conn.executescript( + """ + CREATE TABLE order_monitors ( + id INTEGER PRIMARY KEY, + symbol TEXT, + exchange_symbol TEXT, + direction TEXT, + status TEXT, + monitor_type TEXT, + key_signal_type TEXT, + trigger_price REAL, + stop_loss REAL, + take_profit REAL + ); + CREATE TABLE key_monitors ( + id INTEGER PRIMARY KEY, + symbol TEXT, + exchange_symbol TEXT, + direction TEXT, + signal_type TEXT, + upper REAL, + lower REAL, + status TEXT + ); + CREATE TABLE trend_pullback_plans ( + id INTEGER PRIMARY KEY, + symbol TEXT, + exchange_symbol TEXT, + direction TEXT, + status TEXT, + entry_price REAL + ); + CREATE TABLE roll_groups ( + id INTEGER PRIMARY KEY, + order_monitor_id INTEGER, + symbol TEXT, + exchange_symbol TEXT, + direction TEXT, + status TEXT + ); + """ + ) + return conn + + +class TestInstanceDashboardLib(unittest.TestCase): + def test_empty_sections_and_conditional_hidden(self): + conn = _mem_conn() + payload = build_instance_dashboard_payload(conn, hedge_enabled=True) + self.assertTrue(payload["ok"]) + self.assertEqual(payload["orders"]["count"], 0) + self.assertEqual(payload["keys"]["count"], 0) + self.assertEqual(payload["strategy"]["count"], 0) + self.assertFalse(payload["options"]["visible"]) + self.assertFalse(payload["hedge_plan"]["visible"]) + conn.close() + + def test_orders_keys_strategy_and_options_visible(self): + conn = _mem_conn() + conn.execute( + "INSERT INTO order_monitors (symbol, exchange_symbol, direction, status, monitor_type) " + "VALUES ('BTC/USDT', 'BTC/USDT:USDT', 'long', 'active', 'manual')" + ) + conn.execute( + "INSERT INTO key_monitors (symbol, direction, signal_type, upper, lower, status) " + "VALUES ('ETH/USDT', 'short', '箱体突破', 3000, 2800, 'active')" + ) + conn.execute( + "INSERT INTO trend_pullback_plans (symbol, direction, status, entry_price) " + "VALUES ('SOL/USDT', 'long', 'active', 100)" + ) + conn.execute( + "INSERT INTO order_monitors (id, symbol, direction, status) VALUES (9, 'XRP/USDT', 'short', 'active')" + ) + conn.execute( + "INSERT INTO roll_groups (order_monitor_id, symbol, direction, status) " + "VALUES (9, 'XRP/USDT', 'short', 'active')" + ) + conn.commit() + + def fetch_opts(): + return [{"inst_id": "ETH-USD-260731-3000-C", "opt_type": "C", "pos": 1, "upl": 1.5}] + + payload = build_instance_dashboard_payload( + conn, + fetch_options_positions=fetch_opts, + hedge_enabled=False, + ) + self.assertEqual(payload["orders"]["count"], 2) + self.assertEqual(payload["keys"]["count"], 1) + self.assertEqual(payload["strategy"]["count"], 2) + self.assertTrue(payload["options"]["visible"]) + self.assertEqual(payload["options"]["count"], 1) + self.assertFalse(payload["hedge_plan"]["visible"]) + conn.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_instance_display_env_settings.py b/tests/test_instance_display_env_settings.py index b942816..d185e40 100644 --- a/tests/test_instance_display_env_settings.py +++ b/tests/test_instance_display_env_settings.py @@ -15,12 +15,19 @@ class TestInstanceDisplayPrefs(unittest.TestCase): prefs = normalize_display_prefs({}) self.assertTrue(prefs["show_nav_env_config"]) self.assertTrue(prefs["show_settings_password"]) + self.assertFalse(prefs["show_nav_dashboard"]) def test_tab_allowed_respects_prefs(self): prefs = normalize_display_prefs({"show_nav_stats": False}) self.assertFalse(tab_allowed("stats", prefs)) self.assertTrue(tab_allowed("trade", prefs)) + def test_dashboard_nav_default_off(self): + prefs = normalize_display_prefs({}) + self.assertFalse(tab_allowed("dashboard", prefs)) + on = normalize_display_prefs({"show_nav_dashboard": True}) + self.assertTrue(tab_allowed("dashboard", on)) + class TestEnvFileLib(unittest.TestCase): def test_upsert_and_read(self):