From b460c6c4e517042aa08c72eba777734030ff1f8e Mon Sep 17 00:00:00 2001 From: dekun Date: Mon, 29 Jun 2026 21:40:53 +0800 Subject: [PATCH] Enhance dashboard with contract symbols and risk control overview. Co-authored-by: Cursor --- dashboard_lib.py | 88 +++++++++++++++++++++++++- static/css/dashboard.css | 56 +++++++++++++++++ static/js/dashboard.js | 130 +++++++++++++++++++++++++++++++++++++-- templates/dashboard.html | 6 ++ 4 files changed, 272 insertions(+), 8 deletions(-) diff --git a/dashboard_lib.py b/dashboard_lib.py index 2c98ae3..4b09254 100644 --- a/dashboard_lib.py +++ b/dashboard_lib.py @@ -16,6 +16,83 @@ def _direction_label(direction: str) -> str: return "做多" if (direction or "").strip().lower() == "long" else "做空" +def _symbol_fields(ths_code: str) -> dict[str, Any]: + from symbols import position_symbol_meta + + sym = (ths_code or "").strip() + meta = position_symbol_meta(sym) + return { + "symbol_code": sym, + "symbol_name": meta.get("name") or sym, + "symbol_exchange": meta.get("exchange") or "", + "symbol_is_main": bool(meta.get("is_main")), + } + + +def build_risk_overview( + conn, + get_setting: Callable[[str, str], str], + *, + equity: Optional[float] = None, + margin_used: Optional[float] = None, +) -> dict[str, Any]: + from risk.account_risk_lib import ( + ensure_account_risk_schema, + get_risk_status, + manual_close_daily_limit, + max_active_positions, + risk_control_enabled, + cooling_hours_manual, + cooling_hours_manual_journal, + trading_day_label, + trading_day_reset_hour, + ) + from trading_context import ( + get_fixed_amount, + get_fixed_lots, + get_max_margin_pct, + get_roll_max_margin_pct, + get_risk_percent, + get_sizing_mode, + ) + + ensure_account_risk_schema(conn) + risk = dict(get_risk_status(conn) or {}) + row = conn.execute("SELECT * FROM account_risk_state WHERE id=1").fetchone() + td = trading_day_label() + stored_td = str(row["trading_day"] or "") if row else "" + manual_count = int(row["manual_close_count"] or 0) if row and stored_td == td else 0 + + margin_pct_used: Optional[float] = None + if equity and equity > 0 and margin_used is not None and margin_used >= 0: + margin_pct_used = round(float(margin_used) / float(equity) * 100, 2) + + max_margin = get_max_margin_pct(get_setting) + sizing = get_sizing_mode(get_setting) + sizing_label = "固定金额" if sizing == "amount" else "固定手数" + + return { + "enabled": risk_control_enabled(), + "status": risk, + "manual_close_count_today": manual_count, + "margin_pct_used": margin_pct_used, + "limits": { + "max_active_positions": max_active_positions(), + "manual_close_daily_limit": manual_close_daily_limit(), + "cooling_hours_manual": cooling_hours_manual(), + "cooling_hours_manual_journal": cooling_hours_manual_journal(), + "trading_day_reset_hour": trading_day_reset_hour(), + "max_margin_pct": max_margin, + "roll_max_margin_pct": get_roll_max_margin_pct(get_setting), + "risk_percent": get_risk_percent(get_setting), + "sizing_mode": sizing, + "sizing_label": sizing_label, + "fixed_lots": get_fixed_lots(get_setting), + "fixed_amount": get_fixed_amount(get_setting), + }, + } + + def build_dashboard_payload( *, get_db: Callable, @@ -94,6 +171,7 @@ def build_dashboard_payload( "id": r["id"], "symbol": sym, "symbol_name": r["symbol_name"] or sym, + **_symbol_fields(sym), "monitor_type": mtype, "direction": r["direction"] or "", "direction_label": _direction_label(r["direction"] or "long") @@ -121,10 +199,12 @@ def build_dashboard_payload( ).fetchall() closes: list[dict[str, Any]] = [] for r in close_rows: + sym_code = r["symbol"] or "" closes.append({ "id": r["id"], - "symbol": r["symbol_name"] or r["symbol"], - "symbol_code": r["symbol"], + "symbol": r["symbol_name"] or sym_code, + "symbol_code": sym_code, + **_symbol_fields(sym_code), "direction": r["direction"] or "long", "direction_label": _direction_label(r["direction"] or "long"), "lots": float(r["lots"] or 0), @@ -139,6 +219,9 @@ def build_dashboard_payload( }) now_iso = datetime.now(_TZ).strftime("%Y-%m-%d %H:%M:%S") + risk = build_risk_overview( + conn, get_setting, equity=equity, margin_used=margin_used, + ) return { "ok": True, "updated_at": now_iso, @@ -150,6 +233,7 @@ def build_dashboard_payload( "available": available, "capital_fallback": round(capital, 2), }, + "risk": risk, "keys": keys, "closes": closes, } diff --git a/static/css/dashboard.css b/static/css/dashboard.css index b5dff39..c4a4fe3 100644 --- a/static/css/dashboard.css +++ b/static/css/dashboard.css @@ -46,6 +46,62 @@ margin-bottom: 0.65rem; } +.dashboard-risk-card h2 { + margin-bottom: 0.45rem; +} + +.dashboard-risk-reason { + margin: 0 0 0.65rem; + font-size: 0.82rem; + line-height: 1.5; + color: var(--text-muted); +} + +.dashboard-risk-reason.is-blocked { + color: var(--loss); +} + +.dashboard-risk-reason.is-ok { + color: var(--profit); +} + +.dashboard-risk-grid { + margin-bottom: 0; +} + +.dashboard-risk-grid .stat-item { + min-width: 5.5rem; +} + +.dashboard-risk-grid .stat-item .value { + font-size: 0.82rem; +} + +.dash-symbol-cell { + min-width: 7.5rem; + white-space: normal; +} + +.dash-symbol-title { + font-weight: 600; + line-height: 1.35; +} + +.dash-symbol-sub { + font-size: 0.72rem; + line-height: 1.35; + margin-top: 0.12rem; +} + +.dash-main-badge { + font-size: 0.68rem; + vertical-align: middle; +} + +.dashboard-table .badge.dir { + font-size: 0.72rem; +} + .dashboard-table { width: 100%; border-collapse: collapse; diff --git a/static/js/dashboard.js b/static/js/dashboard.js index 2b2b3b0..e8743ed 100644 --- a/static/js/dashboard.js +++ b/static/js/dashboard.js @@ -13,12 +13,15 @@ var ctpBadge = document.getElementById('dash-ctp-badge'); var modeBadge = document.getElementById('dash-mode-badge'); var updatedEl = document.getElementById('dash-updated'); + var riskReasonEl = document.getElementById('dash-risk-reason'); + var riskGridEl = document.getElementById('dash-risk-grid'); var pollTimer = null; var positionSource = null; var posRowCache = {}; var lastKeyIds = ''; var lastCloseHeadId = null; + var lastRiskPayload = null; function fmtNum(v, digits) { if (v === null || v === undefined || v === '') return '—'; @@ -64,6 +67,118 @@ return parts.length ? parts.join(' · ') : '—'; } + function symbolCellHtml(row) { + var name = row.symbol_name || row.symbol || ''; + var code = row.symbol_code || ''; + var mainBadge = row.symbol_is_main + ? ' 主力' : ''; + var titleInner = escHtml(name) + mainBadge; + if (code && String(name).toLowerCase() !== String(code).toLowerCase()) { + titleInner += ' ' + escHtml(code) + ''; + } else if (!name && code) { + titleInner = '' + escHtml(code) + ''; + } + var sub = row.symbol_exchange || code || ''; + return ( + '
' + + '
' + titleInner + '
' + + (sub ? '
' + escHtml(sub) + '
' : '') + + '
' + ); + } + + function directionBadgeHtml(row) { + var label = row.direction_label || (row.direction === 'short' ? '做空' : '做多'); + return '' + escHtml(label) + ''; + } + + function fmtHours(h) { + if (h == null) return '—'; + var n = Number(h); + if (isNaN(n)) return '—'; + if (Math.abs(n - Math.round(n)) < 0.01) return String(Math.round(n)) + 'h'; + return n.toFixed(1) + 'h'; + } + + function fmtRemainSec(sec) { + if (sec == null || sec <= 0) return '—'; + var s = Math.max(0, Math.floor(sec)); + var h = Math.floor(s / 3600); + var m = Math.floor((s % 3600) / 60); + if (h > 0) return h + 'h ' + m + 'm'; + return m + 'm'; + } + + function applyRisk(risk, account) { + if (!riskGridEl || !risk) return; + if (risk.limits && Object.keys(risk.limits).length) { + lastRiskPayload = risk; + } else if (lastRiskPayload) { + risk = { + enabled: lastRiskPayload.enabled, + limits: lastRiskPayload.limits, + manual_close_count_today: lastRiskPayload.manual_close_count_today, + margin_pct_used: lastRiskPayload.margin_pct_used, + status: risk.status || lastRiskPayload.status, + }; + } + if (account && account.equity > 0 && account.margin_used != null) { + risk.margin_pct_used = Math.round(account.margin_used / account.equity * 10000) / 100; + } + var lim = risk.limits || {}; + var st = risk.status || {}; + var enabled = risk.enabled !== false; + var active = st.active_count != null ? st.active_count : '—'; + var maxPos = lim.max_active_positions != null ? lim.max_active_positions : st.max_active_positions; + var manualCnt = risk.manual_close_count_today != null ? risk.manual_close_count_today : 0; + var manualLim = lim.manual_close_daily_limit; + var marginPct = risk.margin_pct_used; + var maxMarginPct = lim.max_margin_pct; + var marginPctText = marginPct != null ? fmtNum(marginPct) + '%' : '—'; + if (maxMarginPct != null && marginPct != null) { + marginPctText += ' / ' + fmtNum(maxMarginPct) + '%'; + } + + if (riskReasonEl) { + var reason = st.reason || (enabled ? '可新开仓' : '风控已关闭'); + riskReasonEl.textContent = (st.status_label ? st.status_label + ' · ' : '') + reason; + riskReasonEl.className = 'dashboard-risk-reason'; + if (st.can_trade === false) riskReasonEl.classList.add('is-blocked'); + else if (st.can_trade) riskReasonEl.classList.add('is-ok'); + } + + var sizingDetail = lim.sizing_label || '—'; + if (lim.sizing_mode === 'amount') { + sizingDetail += ' · ' + fmtNum(lim.fixed_amount, 0) + ' 元'; + } else if (lim.fixed_lots != null) { + sizingDetail += ' · ' + lim.fixed_lots + ' 手'; + } + + var items = [ + { label: '风控开关', value: enabled ? '开启' : '关闭' }, + { label: '持仓限制', value: active + ' / ' + (maxPos != null ? maxPos : '—') }, + { label: '日手动平仓', value: manualCnt + ' / ' + (manualLim != null ? manualLim : '—') }, + { label: '冷静期(默认)', value: fmtHours(lim.cooling_hours_manual) }, + { label: '复盘后冷静', value: fmtHours(lim.cooling_hours_manual_journal) }, + { label: '冷静剩余', value: fmtRemainSec(st.freeze_remaining_sec) }, + { label: '保证金占比', value: marginPctText }, + { label: '保证金上限', value: maxMarginPct != null ? fmtNum(maxMarginPct) + '%' : '—' }, + { label: '滚仓保证金上限', value: lim.roll_max_margin_pct != null ? fmtNum(lim.roll_max_margin_pct) + '%' : '—' }, + { label: '计仓模式', value: sizingDetail }, + { label: '单笔风险', value: lim.risk_percent != null ? fmtNum(lim.risk_percent) + '%' : '—' }, + { label: '交易日切', value: lim.trading_day_reset_hour != null ? lim.trading_day_reset_hour + ':00' : '—' } + ]; + + riskGridEl.innerHTML = items.map(function (it) { + return ( + '
' + + '
' + escHtml(it.label) + '
' + + '
' + escHtml(it.value) + '
' + + '
' + ); + }).join(''); + } + function updateCtpBadge(st) { if (!ctpBadge || !st) return; var connected = !!st.connected; @@ -116,7 +231,7 @@ keysBody.innerHTML = keys.map(function (k) { return ( '' + - '' + escHtml(k.symbol_name || k.symbol) + '' + + '' + symbolCellHtml(k) + '' + '' + escHtml(k.monitor_type || '—') + '' + '' + escHtml(k.bar_period || '—') + '' + '' + fmtNum(k.upper) + '' + @@ -153,8 +268,8 @@ var pc = pnlClass(c.pnl_net != null ? c.pnl_net : c.pnl); return ( '' + - '' + escHtml(c.symbol) + '' + - '' + escHtml(c.direction_label || c.direction) + '' + + '' + symbolCellHtml(c) + '' + + '' + directionBadgeHtml(c) + '' + '' + fmtNum(c.lots) + '' + '' + fmtNum(c.entry_price) + '' + '' + fmtNum(c.close_price) + '' + @@ -213,11 +328,10 @@ posBody.innerHTML = rows.map(function (r) { var key = r.key || r.position_key || ((r.symbol_code || '') + ':' + (r.direction || '')); posRowCache[key] = true; - var name = r.symbol || r.symbol_name || r.symbol_code || '—'; return ( '' + - '' + escHtml(name) + '' + - '' + escHtml(r.direction_label || r.direction) + '' + + '' + symbolCellHtml(r) + '' + + '' + directionBadgeHtml(r) + '' + '' + escHtml(String(r.lots)) + '' + '' + fmtNum(r.entry_price) + '' + '' + (r.current_price != null ? fmtNum(r.current_price) : '—') + '' + @@ -256,6 +370,9 @@ function applyPositionsData(data) { if (!data) return; if (data.ctp_status) updateCtpBadge(data.ctp_status); + if (data.risk_status) { + applyRisk({ status: data.risk_status }); + } if (data.trading_mode_label && modeBadge) { modeBadge.textContent = data.trading_mode_label; } @@ -302,6 +419,7 @@ .then(function (data) { if (!data.ok) return; applyAccount(data); + applyRisk(data.risk, data.account); applyKeys(data.keys || []); applyCloses(data.closes || []); }) diff --git a/templates/dashboard.html b/templates/dashboard.html index 2c9e354..636518d 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -32,6 +32,12 @@ +
+

风控说明

+

加载中…

+
+
+

持仓信息