Enhance dashboard with contract symbols and risk control overview.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-29 21:40:53 +08:00
parent 28c54b1a3f
commit b460c6c4e5
4 changed files with 272 additions and 8 deletions
+86 -2
View File
@@ -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,
}
+56
View File
@@ -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;
+124 -6
View File
@@ -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
? ' <span class="badge planned dash-main-badge">主力</span>' : '';
var titleInner = escHtml(name) + mainBadge;
if (code && String(name).toLowerCase() !== String(code).toLowerCase()) {
titleInner += ' <span class="text-accent">' + escHtml(code) + '</span>';
} else if (!name && code) {
titleInner = '<span class="text-accent">' + escHtml(code) + '</span>';
}
var sub = row.symbol_exchange || code || '';
return (
'<div class="dash-symbol-cell">' +
'<div class="dash-symbol-title">' + titleInner + '</div>' +
(sub ? '<div class="dash-symbol-sub text-muted">' + escHtml(sub) + '</div>' : '') +
'</div>'
);
}
function directionBadgeHtml(row) {
var label = row.direction_label || (row.direction === 'short' ? '做空' : '做多');
return '<span class="badge dir">' + escHtml(label) + '</span>';
}
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 (
'<div class="stat-item">' +
'<div class="label">' + escHtml(it.label) + '</div>' +
'<div class="value">' + escHtml(it.value) + '</div>' +
'</div>'
);
}).join('');
}
function updateCtpBadge(st) {
if (!ctpBadge || !st) return;
var connected = !!st.connected;
@@ -116,7 +231,7 @@
keysBody.innerHTML = keys.map(function (k) {
return (
'<tr data-key-id="' + k.id + '">' +
'<td>' + escHtml(k.symbol_name || k.symbol) + '</td>' +
'<td>' + symbolCellHtml(k) + '</td>' +
'<td>' + escHtml(k.monitor_type || '—') + '</td>' +
'<td>' + escHtml(k.bar_period || '—') + '</td>' +
'<td>' + fmtNum(k.upper) + '</td>' +
@@ -153,8 +268,8 @@
var pc = pnlClass(c.pnl_net != null ? c.pnl_net : c.pnl);
return (
'<tr data-close-id="' + c.id + '">' +
'<td>' + escHtml(c.symbol) + '</td>' +
'<td>' + escHtml(c.direction_label || c.direction) + '</td>' +
'<td>' + symbolCellHtml(c) + '</td>' +
'<td>' + directionBadgeHtml(c) + '</td>' +
'<td>' + fmtNum(c.lots) + '</td>' +
'<td>' + fmtNum(c.entry_price) + '</td>' +
'<td>' + fmtNum(c.close_price) + '</td>' +
@@ -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 (
'<tr data-pos-key="' + escHtml(key) + '">' +
'<td>' + escHtml(name) + '</td>' +
'<td>' + escHtml(r.direction_label || r.direction) + '</td>' +
'<td>' + symbolCellHtml(r) + '</td>' +
'<td>' + directionBadgeHtml(r) + '</td>' +
'<td class="dash-p-lots">' + escHtml(String(r.lots)) + '</td>' +
'<td class="dash-p-entry">' + fmtNum(r.entry_price) + '</td>' +
'<td class="dash-p-mark">' + (r.current_price != null ? fmtNum(r.current_price) : '—') + '</td>' +
@@ -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 || []);
})
+6
View File
@@ -32,6 +32,12 @@
</div>
</div>
<div class="card dashboard-section dashboard-risk-card">
<h2>风控说明</h2>
<p class="dashboard-risk-reason" id="dash-risk-reason">加载中…</p>
<div class="stat-grid stat-grid-summary dashboard-risk-grid" id="dash-risk-grid"></div>
</div>
<div class="card dashboard-section">
<h2>持仓信息</h2>
<div class="card-scroll">