Fix composite margin ratio cap at 50% and add risk guide page with nav toggle.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-29 22:29:13 +08:00
parent 9a10ac8a51
commit 8b4b1a875c
14 changed files with 392 additions and 34 deletions
+15
View File
@@ -231,6 +231,7 @@ def _static_asset_v() -> str:
"static/css/responsive.css",
"static/css/trade.css",
"static/css/dashboard.css",
"static/css/doc.css",
"static/css/base.css",
)
mtimes = []
@@ -1644,6 +1645,20 @@ def dashboard():
return render_template("dashboard.html")
@app.route("/risk-guide")
@login_required
@require_nav("risk_guide")
def risk_guide():
from doc_render import read_doc, render_markdown
try:
_title, raw = read_doc("risk-guide")
except FileNotFoundError:
flash("文档不存在")
return redirect(url_for("positions"))
return render_template("risk_guide.html", doc_html=render_markdown(raw))
@app.route("/api/dashboard/live")
@login_required
def api_dashboard_live():
+2
View File
@@ -88,6 +88,8 @@ def build_risk_overview(
"daily_risk_used_pct": daily_risk_used,
"limits": {
"max_active_positions": max_active_positions(),
"position_mode": "single" if max_active_positions() <= 1 else "multi",
"position_mode_label": "单仓模式" if max_active_positions() <= 1 else "多仓模式",
"daily_position_limit": daily_position_limit(),
"daily_trading_risk_pct_limit": daily_trading_risk_pct_limit(),
"manual_close_daily_limit": manual_close_daily_limit(),
+170
View File
@@ -0,0 +1,170 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 详见 LICENSE.zh-CN.txt
"""将项目 docs 下的 Markdown 转为安全 HTML(无第三方依赖)。"""
from __future__ import annotations
import html
import re
from pathlib import Path
_DOCS_ROOT = Path(__file__).resolve().parent / "docs"
ALLOWED_DOCS: dict[str, str] = {
"risk-guide": "风控说明.md",
}
def docs_root() -> Path:
return _DOCS_ROOT
def read_doc(slug: str) -> tuple[str, str]:
"""返回 (title, raw_markdown)。"""
name = ALLOWED_DOCS.get(slug)
if not name:
raise FileNotFoundError(slug)
path = (_DOCS_ROOT / name).resolve()
if not path.is_file() or _DOCS_ROOT.resolve() not in path.parents:
raise FileNotFoundError(slug)
text = path.read_text(encoding="utf-8")
title = name
for line in text.splitlines():
s = line.strip()
if s.startswith("# "):
title = s[2:].strip()
break
return title, text
def _inline(text: str) -> str:
s = html.escape(text)
s = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", s)
s = re.sub(r"`([^`]+)`", r"<code>\1</code>", s)
s = re.sub(
r"\[([^\]]+)\]\(([^)]+)\)",
lambda m: _link_html(m.group(1), m.group(2)),
s,
)
return s
def _link_html(label: str, href: str) -> str:
h = html.escape(href)
lbl = _inline(label)
if href.startswith(("http://", "https://", "mailto:")):
return f'<a href="{h}" target="_blank" rel="noopener noreferrer">{lbl}</a>'
if href.endswith(".md") or href.startswith("./"):
return f'<span class="doc-xref">{lbl}</span>'
return f'<a href="{h}">{lbl}</a>'
def render_markdown(text: str) -> str:
lines = text.splitlines()
out: list[str] = []
i = 0
in_ul = False
in_ol = False
def close_lists() -> None:
nonlocal in_ul, in_ol
if in_ul:
out.append("</ul>")
in_ul = False
if in_ol:
out.append("</ol>")
in_ol = False
while i < len(lines):
line = lines[i]
stripped = line.strip()
if not stripped:
close_lists()
i += 1
continue
if stripped == "---":
close_lists()
out.append("<hr>")
i += 1
continue
if stripped.startswith("|") and stripped.endswith("|"):
close_lists()
table_lines: list[str] = []
while i < len(lines) and lines[i].strip().startswith("|"):
table_lines.append(lines[i].strip())
i += 1
out.append(_render_table(table_lines))
continue
if stripped.startswith("### "):
close_lists()
out.append(f"<h3>{_inline(stripped[4:])}</h3>")
i += 1
continue
if stripped.startswith("## "):
close_lists()
out.append(f"<h2>{_inline(stripped[3:])}</h2>")
i += 1
continue
if stripped.startswith("# "):
close_lists()
out.append(f"<h1>{_inline(stripped[2:])}</h1>")
i += 1
continue
if re.match(r"^[-*]\s+", stripped):
if not in_ul:
close_lists()
out.append("<ul>")
in_ul = True
item_text = re.sub(r"^[-*]\s+", "", stripped)
out.append(f"<li>{_inline(item_text)}</li>")
i += 1
continue
if re.match(r"^\d+\.\s+", stripped):
if not in_ol:
close_lists()
out.append("<ol>")
in_ol = True
item_text = re.sub(r"^\d+\.\s+", "", stripped)
out.append(f"<li>{_inline(item_text)}</li>")
i += 1
continue
close_lists()
para = stripped
i += 1
while i < len(lines):
nxt = lines[i].strip()
if not nxt or nxt == "---" or nxt.startswith("#") or nxt.startswith("|") or re.match(r"^[-*]\s+", nxt):
break
para += " " + nxt
i += 1
out.append(f"<p>{_inline(para)}</p>")
close_lists()
return "\n".join(out)
def _render_table(rows: list[str]) -> str:
if len(rows) < 2:
return ""
header = [c.strip() for c in rows[0].strip("|").split("|")]
body_rows = rows[2:] if len(rows) > 2 and re.match(r"^[\|\s:-]+$", rows[1]) else rows[1:]
parts = ["<table class=\"doc-table\">", "<thead><tr>"]
for cell in header:
parts.append(f"<th>{_inline(cell)}</th>")
parts.append("</tr></thead><tbody>")
for row in body_rows:
cells = [c.strip() for c in row.strip("|").split("|")]
parts.append("<tr>")
for cell in cells:
parts.append(f"<td>{_inline(cell)}</td>")
parts.append("</tr>")
parts.append("</tbody></table>")
return "".join(parts)
+2 -1
View File
@@ -20,7 +20,8 @@
| 板块 | 路径 | 文档 |
|------|------|------|
| 数据看板 | `/dashboard` | [风控说明.md](./风控说明.md) |
| 数据看板 | `/dashboard` | [风控说明.md](./风控说明.md)(看板内嵌摘要) |
| 风控说明 | `/risk-guide` | [风控说明.md](./风控说明.md)(完整页面) |
| 下单监控 | `/positions` | [ORDER_MONITOR.md](./ORDER_MONITOR.md) |
| 策略交易 | `/strategy` | [STRATEGY.md](./STRATEGY.md) |
| 开单计划 | `/plans` | [PLANS.md](./PLANS.md) |
+6 -6
View File
@@ -43,13 +43,13 @@
## 保证金占用上限
| 项 | 配置位置 | 默认值 |
|----|----------|--------|
| 新开仓上限 | 系统设置 `max_margin_pct` | 30% |
| 滚仓上限 | 系统设置 `roll_max_margin_pct` | 单独配置 |
| 项 | 配置位置 | 默认值 | 用途 |
|----|----------|--------|------|
| 单仓保证金上限 | 系统设置 `max_margin_pct` | 30% | **新开仓**:拟开 + 已有占用,占权益不得超过此值 |
| 综合保证金上限 | 系统设置 `roll_max_margin_pct` | 50% | **单仓模式**:滚仓/加仓合计上限;**多仓模式**:所有持仓合计上限 |
- 新开仓前计算:现有持仓保证金 + 拟开仓位保证金,占权益比例不得超过 `max_margin_pct`
- **滚仓/顺势加仓** 使用 `roll_max_margin_pct` 单独收紧手数(见 [STRATEGY.md](./STRATEGY.md))。
- 看板 **综合保证金占比** 的分母为 **50%(综合上限)**,不是 30%。详见 [风控说明.md](./风控说明.md#保证金占比核心规则)
- 新开仓前仍按 30% 收紧手数;滚仓/多仓合计按 50% 校验(见 [STRATEGY.md](./STRATEGY.md))。
---
+46 -18
View File
@@ -1,14 +1,35 @@
# 数据看板 · 风控说明
# 风控说明
**路径**`/dashboard`(数据看板)· 风控说明卡片
**页面**`/risk-guide`(顶栏「风控说明」)· 数据看板内嵌卡片同步展示摘要指标
本文说明看板 **风控说明** 区域各指标含义、颜色规则及对应配置。全局风控逻辑详见 [RISK.md](./RISK.md)。
本文说明账户 **保证金占比**、各风控指标含义、颜色规则及配置来源。全局风控逻辑详见 [RISK.md](./RISK.md)。
---
## 状态行(卡片顶部
## 保证金占比(核心规则
顶栏红色/绿色一行文字为 **当前风控结论**,例如:
系统设置中有两个保证金上限,默认 **单仓 30%**、**综合 50%**`max_margin_pct` / `roll_max_margin_pct`)。
| 模式 | 判定 | 30%(单仓上限) | 50%(综合上限) |
|------|------|-----------------|-----------------|
| **单仓模式** | `MAX_ACTIVE_POSITIONS = 1` | **单仓保证金上限**:新开仓时,拟开仓位 + 已有持仓占用保证金,占权益不得超过 30% | **滚仓保证金上限**:滚仓/顺势加仓时,总占用保证金占权益不得超过 50% |
| **多仓模式** | `MAX_ACTIVE_POSITIONS > 1` | **单仓保证金上限**:每一新开仓仍按 30% 约束该笔/该品种保证金 | **多仓保证金上限**:所有持仓合计占用保证金占权益不得超过 50% |
### 看板如何展示
| 看板指标 | 含义 | 对比上限 |
|----------|------|----------|
| **综合保证金占比** | 当前 **全部持仓** 占用保证金 ÷ 账户权益 | 斜杠后为 **50%**(单仓模式=滚仓上限,多仓模式=多仓上限) |
| **单仓保证金上限** | 新开仓单笔/单品种保证金天花板 | 固定显示 **30%**(系统设置) |
| **滚仓保证金上限** / **多仓保证金上限** | 单仓模式下为滚仓专用;多仓模式下为合计上限 | 固定显示 **50%**(系统设置) |
> **示例**:权益 10 万、占用保证金 2.55 万 → 综合保证金占比 **25.55% / 50%**(不是 30%)。新开仓仍受 30% 单仓上限约束;滚仓或多仓合计最高可到 50%。
---
## 状态行(看板卡片顶部)
顶栏一行文字为 **当前风控结论**,例如:
| 显示 | 含义 |
|------|------|
@@ -34,9 +55,9 @@
| **冷静期(默认)** | 超限后默认冻结时长 | `.env``RISK_COOLING_HOURS_MANUAL`(默认 4h |
| **复盘后冷静** | 填写复盘情绪日记后缩短的冷静期 | `.env``RISK_COOLING_HOURS_MANUAL_JOURNAL`(默认 1h |
| **冷静剩余** | 当前冷静期剩余时间 | 运行时计算 |
| **综合保证金占比** | 占用保证金占权益比例 / 单仓上限 | 系统设置 `max_margin_pct` |
| **单仓保证金上限** | 新开仓允许的保证金占权益上限 | 系统设置 `max_margin_pct`(默认 30% |
| **综合保证金上限** | 滚仓/加仓时允许的更高保证金占比 | 系统设置 `roll_max_margin_pct` |
| **综合保证金占比** | 占用保证金占权益 / **综合上限(50%** | 实时计算 + 系统设置 `roll_max_margin_pct` |
| **单仓保证金上限** | 新开仓保证金占权益上限 | 系统设置 `max_margin_pct`(默认 30% |
| **滚仓/多仓保证金上限** | 单仓=滚仓上限;多仓=合计上限 | 系统设置 `roll_max_margin_pct`(默认 50% |
| **计仓模式** | 固定金额(以损定仓)或固定手数 | 系统设置 |
| **交易日切** | 统计日重置时刻 | `.env``TRADING_DAY_RESET_HOUR`(默认 8:00 |
@@ -53,22 +74,22 @@
### 综合保证金占比
显示格式:`已用% / 单仓上限%`
显示格式:`已用% / 综合上限%`(综合上限默认 **50%**
| 已用占上限比例 | 已用部分颜色 |
|----------------|--------------|
| 已用占综合上限比例 | 已用部分颜色 |
|--------------------|--------------|
| &lt; 85% | **绿色**(安全) |
| 85% ~ 100% | **琥珀色**(接近上限) |
| ≥ 100% | **红色**(已达或超过单仓上限) |
| ≥ 100% | **红色**(已达或超过综合上限) |
斜杠后的 **上限数值******,与「仓保证金上限」一致。
斜杠后的 **50%****琥珀**,与「滚仓/多仓保证金上限」一致。
### 单仓保证金上限 / 综合保证金上限
### 单仓 / 综合保证金上限
| 指标 | 数值颜色 |
|------|----------|
| 单仓保证金上限 | **蓝色**(新开仓保证金天花板) |
| 综合保证金上限 | **琥珀色**(滚仓/加仓专用,通常高于单仓上限) |
| 单仓保证金上限30% | **蓝色** |
| 滚仓/多仓保证金上限(50% | **琥珀色** |
### 持仓方向(持仓信息、平仓记录)
@@ -79,10 +100,17 @@
---
## 导航与设置
- 顶栏 **风控说明** 即本页(`/risk-guide`),内容由 `docs/风控说明.md` 同步渲染。
- 可在 **系统设置 → 导航显示** 中关闭「风控说明」入口;关闭后顶栏隐藏,直接访问 URL 将跳回下单监控。
---
## 与全局风控的关系
- 看板 **实时展示** 账户风控状态;下单前各板块仍调用 `assert_can_open()` 做相同校验。
- **日持仓限制**、**日交易风险** 为新增维度,与「同时持仓上限」「冷静期」并列生效,任一超限即禁止新开仓。
- **日持仓限制**、**日交易风险** 与「同时持仓上限」「冷静期」并列生效,任一超限即禁止新开仓。
- **综合保证金占比** 使用 CTP 柜台权益与占用保证金实时计算;断线时可能短暂显示 `—`
---
@@ -92,6 +120,6 @@
| 文档 | 内容 |
|------|------|
| [RISK.md](./RISK.md) | 全局账户风控规则与 env 变量 |
| [SETTINGS.md](./SETTINGS.md) | 保证金上限、计仓模式等系统设置 |
| [SETTINGS.md](./SETTINGS.md) | 保证金上限、计仓模式、导航开关 |
| [ORDER_MONITOR.md](./ORDER_MONITOR.md) | 下单监控顶栏风控状态 |
| [INDEX.md](./INDEX.md) | 文档总索引 |
+1
View File
@@ -12,6 +12,7 @@ from typing import Callable
# 可在系统设置中开关的导航项
NAV_TOGGLES: dict[str, str] = {
"dashboard": "数据看板",
"risk_guide": "风控说明",
"fees": "手续费配置",
"plans": "开单计划",
"market": "行情K线",
+14 -2
View File
@@ -87,6 +87,18 @@
font-weight: 400;
}
.dash-risk-doc-link {
font-size: 0.74rem;
font-weight: 500;
color: var(--accent);
text-decoration: none;
margin-left: 0.15rem;
}
.dash-risk-doc-link:hover {
text-decoration: underline;
}
.dash-risk-doc-ref code {
font-size: 0.68rem;
}
@@ -161,12 +173,12 @@
}
.dashboard-risk-value .risk-margin-cap-inline {
color: #5eb8ff;
color: #c4a035;
font-weight: 600;
}
[data-theme="light"] .dashboard-risk-value .risk-margin-cap-inline {
color: #1d4ed8;
color: #b45309;
}
.dashboard-risk-grid .stat-item {
+108
View File
@@ -0,0 +1,108 @@
/* Copyright (c) 2025-2026 马建军. All rights reserved. 详见 LICENSE.zh-CN.txt */
.doc-page {
max-width: 52rem;
margin: 0 auto;
}
.doc-content {
font-size: 0.92rem;
line-height: 1.65;
color: var(--text-primary);
}
.doc-content h1 {
font-size: 1.35rem;
color: var(--text-title);
margin-bottom: 0.75rem;
padding-bottom: 0.45rem;
border-bottom: 1px solid var(--table-border);
}
.doc-content h2 {
font-size: 1.05rem;
color: var(--text-title);
margin: 1.35rem 0 0.55rem;
}
.doc-content h3 {
font-size: 0.95rem;
color: var(--text-title);
margin: 1rem 0 0.45rem;
}
.doc-content p {
margin: 0.45rem 0;
}
.doc-content hr {
border: none;
border-top: 1px solid var(--table-border);
margin: 1.1rem 0;
}
.doc-content ul,
.doc-content ol {
margin: 0.45rem 0 0.65rem 1.25rem;
}
.doc-content li {
margin: 0.25rem 0;
}
.doc-content code {
font-size: 0.84em;
padding: 0.08rem 0.32rem;
border-radius: 4px;
background: var(--card-inner);
color: var(--accent);
}
.doc-content a {
color: var(--accent);
text-decoration: none;
}
.doc-content a:hover {
text-decoration: underline;
}
.doc-content .doc-xref {
color: var(--text-muted);
}
.doc-content .doc-table {
width: 100%;
border-collapse: collapse;
margin: 0.65rem 0 0.85rem;
font-size: 0.86rem;
}
.doc-content .doc-table th,
.doc-content .doc-table td {
padding: 0.42rem 0.55rem;
border: 1px solid var(--table-border);
text-align: left;
vertical-align: top;
}
.doc-content .doc-table th {
background: var(--card-inner);
color: var(--text-title);
font-weight: 600;
}
.doc-content strong {
color: var(--text-title);
}
@media (max-width: 767px) {
.doc-content {
font-size: 0.86rem;
}
.doc-content .doc-table {
display: block;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
}
+7 -3
View File
@@ -196,6 +196,10 @@
}
var marginPct = risk.margin_pct_used;
var maxMarginPct = lim.max_margin_pct;
var rollMaxPct = lim.roll_max_margin_pct;
var isSingleMode = lim.position_mode === 'single'
|| (maxPos != null && Number(maxPos) <= 1);
var compositeCapLabel = isSingleMode ? '滚仓保证金上限' : '多仓保证金上限';
if (riskReasonEl) {
var reason = st.reason || (enabled ? '可新开仓' : '风控已关闭');
@@ -227,7 +231,7 @@
{ label: '冷静剩余', value: fmtRemainSec(st.freeze_remaining_sec) },
{
label: '综合保证金占比',
valueHtml: riskMarginPctHtml(marginPct, maxMarginPct),
valueHtml: riskMarginPctHtml(marginPct, rollMaxPct),
},
{
label: '单仓保证金上限',
@@ -235,8 +239,8 @@
valueClass: 'risk-cap-single',
},
{
label: '综合保证金上限',
value: lim.roll_max_margin_pct != null ? fmtNum(lim.roll_max_margin_pct) + '%' : '—',
label: compositeCapLabel,
value: rollMaxPct != null ? fmtNum(rollMaxPct) + '%' : '—',
valueClass: 'risk-cap-roll',
},
{ label: '计仓模式', value: sizingDetail },
+1
View File
@@ -82,6 +82,7 @@
<nav class="site-nav" id="site-nav">
<a href="{{ url_for('positions') }}" class="{% if request.endpoint in ('positions', 'trade_page', 'recommend_page') %}active{% endif %}">下单监控</a>
{% if nav_items.dashboard %}<a href="{{ url_for('dashboard') }}" class="{% if request.endpoint == 'dashboard' %}active{% endif %}">数据看板</a>{% endif %}
{% if nav_items.risk_guide %}<a href="{{ url_for('risk_guide') }}" class="{% if request.endpoint == 'risk_guide' %}active{% endif %}">风控说明</a>{% endif %}
{% if nav_items.strategy %}<a href="{{ url_for('strategy_page') }}" class="{% if request.endpoint in ('strategy_page', 'strategy_records_page') %}active{% endif %}">策略交易</a>{% endif %}
{% if nav_items.plans %}<a href="{{ url_for('plans') }}" class="{% if request.endpoint == 'plans' %}active{% endif %}">开单计划</a>{% endif %}
<a href="{{ url_for('keys') }}" class="{% if request.endpoint == 'keys' %}active{% endif %}">关键位监控</a>
+4
View File
@@ -35,7 +35,11 @@
<div class="card dashboard-section dashboard-risk-card">
<h2 class="dashboard-risk-heading">
风控说明
{% if nav_items.risk_guide %}
<a class="dash-risk-doc-link" href="{{ url_for('risk_guide') }}">完整说明</a>
{% else %}
<span class="text-muted dash-risk-doc-ref">· 详见 <code>docs/风控说明.md</code></span>
{% endif %}
</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>
+13
View File
@@ -0,0 +1,13 @@
{# Copyright (c) 2025-2026 马建军. All rights reserved. 专有软件,详见 LICENSE.zh-CN.txt #}
{% extends "base.html" %}
{% block title %}风控说明 - 国内期货 · 交易复盘系统{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/doc.css') }}?v={{ asset_v }}">
{% endblock %}
{% block content %}
<div class="card doc-page">
<article class="doc-content">
{{ doc_html|safe }}
</article>
</div>
{% endblock %}
+3 -4
View File
@@ -218,11 +218,11 @@
<input name="fixed_amount" type="number" step="1" min="1" value="{{ fixed_amount }}">
</div>
<div class="field">
<label>保证金占用上限(%</label>
<label>单仓保证金上限(%</label>
<input name="max_margin_pct" type="number" step="1" min="1" max="100" value="{{ max_margin_pct }}">
</div>
<div class="field">
<label>滚仓保证金占用上限(%</label>
<label>综合保证金上限(%</label>
<input name="roll_max_margin_pct" type="number" step="1" min="1" max="100" value="{{ roll_max_margin_pct }}">
</div>
<div class="field">
@@ -236,8 +236,7 @@
</div>
<button type="submit" class="btn-primary" style="margin-top:.75rem">保存交易设置</button>
<p class="hint" style="margin-top:.75rem;margin-bottom:0">
仓保证金上限用于开仓校验与品种最大手数估算(默认 30%)。固定金额计仓时<strong>先按止损算手数,再按保证金上限收紧</strong>
滚仓保证金上限为滚仓后<strong>总持仓</strong>占用上限(默认 50%,可在下方修改)。
仓保证金上限(默认 30%)用于<strong>新开仓</strong>校验与最大手数估算;综合保证金上限(默认 50%在单仓模式下为滚仓合计上限、多仓模式下为全部持仓合计上限。固定金额计仓时<strong>先按止损算手数,再按单仓上限收紧</strong>
<strong>移动保本</strong>:达 1R 后止损移至开仓价 ± N 跳。
<strong>挂单超时</strong>:限价开仓未成交时,超过设定分钟数自动向柜台撤单(1~60 分钟)。
<span class="text-muted">{{ small_account_margin_rec.label }}。</span>