diff --git a/app.py b/app.py
index 9dcb70f..cd1e5af 100644
--- a/app.py
+++ b/app.py
@@ -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():
diff --git a/dashboard_lib.py b/dashboard_lib.py
index ae55f15..99e3071 100644
--- a/dashboard_lib.py
+++ b/dashboard_lib.py
@@ -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(),
diff --git a/doc_render.py b/doc_render.py
new file mode 100644
index 0000000..5623b99
--- /dev/null
+++ b/doc_render.py
@@ -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"\1 ", s)
+ s = re.sub(r"`([^`]+)`", r"\1", 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'{lbl} '
+ if href.endswith(".md") or href.startswith("./"):
+ return f'{lbl} '
+ return f'{lbl} '
+
+
+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("")
+ in_ul = False
+ if in_ol:
+ out.append("")
+ 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("
")
+ 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"{_inline(stripped[4:])} ")
+ i += 1
+ continue
+ if stripped.startswith("## "):
+ close_lists()
+ out.append(f"{_inline(stripped[3:])} ")
+ i += 1
+ continue
+ if stripped.startswith("# "):
+ close_lists()
+ out.append(f"{_inline(stripped[2:])} ")
+ i += 1
+ continue
+
+ if re.match(r"^[-*]\s+", stripped):
+ if not in_ul:
+ close_lists()
+ out.append("")
+ in_ul = True
+ item_text = re.sub(r"^[-*]\s+", "", stripped)
+ out.append(f"{_inline(item_text)} ")
+ i += 1
+ continue
+
+ if re.match(r"^\d+\.\s+", stripped):
+ if not in_ol:
+ close_lists()
+ out.append("")
+ in_ol = True
+ item_text = re.sub(r"^\d+\.\s+", "", stripped)
+ out.append(f"{_inline(item_text)} ")
+ 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"{_inline(para)}
")
+
+ 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 = ["", ""]
+ for cell in header:
+ parts.append(f"{_inline(cell)} ")
+ parts.append(" ")
+ for row in body_rows:
+ cells = [c.strip() for c in row.strip("|").split("|")]
+ parts.append("")
+ for cell in cells:
+ parts.append(f"{_inline(cell)} ")
+ parts.append(" ")
+ parts.append("
")
+ return "".join(parts)
diff --git a/docs/INDEX.md b/docs/INDEX.md
index f6e7156..c06fadf 100644
--- a/docs/INDEX.md
+++ b/docs/INDEX.md
@@ -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) |
diff --git a/docs/RISK.md b/docs/RISK.md
index bc6a4a6..f86f23a 100644
--- a/docs/RISK.md
+++ b/docs/RISK.md
@@ -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))。
---
diff --git a/docs/风控说明.md b/docs/风控说明.md
index 8251297..47d3b90 100644
--- a/docs/风控说明.md
+++ b/docs/风控说明.md
@@ -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%**)
-| 已用占上限比例 | 已用部分颜色 |
-|----------------|--------------|
+| 已用占综合上限比例 | 已用部分颜色 |
+|--------------------|--------------|
| < 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) | 文档总索引 |
diff --git a/nav_settings.py b/nav_settings.py
index 6d3258a..fcd337c 100644
--- a/nav_settings.py
+++ b/nav_settings.py
@@ -12,6 +12,7 @@ from typing import Callable
# 可在系统设置中开关的导航项
NAV_TOGGLES: dict[str, str] = {
"dashboard": "数据看板",
+ "risk_guide": "风控说明",
"fees": "手续费配置",
"plans": "开单计划",
"market": "行情K线",
diff --git a/static/css/dashboard.css b/static/css/dashboard.css
index 09cb1b5..5d54b24 100644
--- a/static/css/dashboard.css
+++ b/static/css/dashboard.css
@@ -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 {
diff --git a/static/css/doc.css b/static/css/doc.css
new file mode 100644
index 0000000..e15bca3
--- /dev/null
+++ b/static/css/doc.css
@@ -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;
+ }
+}
diff --git a/static/js/dashboard.js b/static/js/dashboard.js
index 4590c26..9c7e67b 100644
--- a/static/js/dashboard.js
+++ b/static/js/dashboard.js
@@ -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 },
diff --git a/templates/base.html b/templates/base.html
index 58124e6..0ae12ac 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -82,6 +82,7 @@
下单监控
{% if nav_items.dashboard %}数据看板 {% endif %}
+ {% if nav_items.risk_guide %}风控说明 {% endif %}
{% if nav_items.strategy %}策略交易 {% endif %}
{% if nav_items.plans %}开单计划 {% endif %}
关键位监控
diff --git a/templates/dashboard.html b/templates/dashboard.html
index f690f6a..a016865 100644
--- a/templates/dashboard.html
+++ b/templates/dashboard.html
@@ -35,7 +35,11 @@
风控说明
+ {% if nav_items.risk_guide %}
+ 完整说明
+ {% else %}
· 详见 docs/风控说明.md
+ {% endif %}
加载中…
diff --git a/templates/risk_guide.html b/templates/risk_guide.html
new file mode 100644
index 0000000..250234a
--- /dev/null
+++ b/templates/risk_guide.html
@@ -0,0 +1,13 @@
+{# Copyright (c) 2025-2026 马建军. All rights reserved. 专有软件,详见 LICENSE.zh-CN.txt #}
+{% extends "base.html" %}
+{% block title %}风控说明 - 国内期货 · 交易复盘系统{% endblock %}
+{% block extra_css %}
+
+{% endblock %}
+{% block content %}
+
+
+ {{ doc_html|safe }}
+
+
+{% endblock %}
diff --git a/templates/settings.html b/templates/settings.html
index bf35c46..51d6d78 100644
--- a/templates/settings.html
+++ b/templates/settings.html
@@ -218,11 +218,11 @@
- 保证金占用上限(%)
+ 单仓保证金上限(%)
- 滚仓保证金占用上限(%)
+ 综合保证金上限(%)
@@ -236,8 +236,7 @@
保存交易设置
- 开仓保证金上限用于开仓校验与品种最大手数估算(默认 30%)。固定金额计仓时先按止损算手数,再按保证金上限收紧 。
- 滚仓保证金上限为滚仓后总持仓 占用上限(默认 50%,可在下方修改)。
+ 单仓保证金上限(默认 30%)用于新开仓 校验与最大手数估算;综合保证金上限(默认 50%)在单仓模式下为滚仓合计上限、多仓模式下为全部持仓合计上限。固定金额计仓时先按止损算手数,再按单仓上限收紧 。
移动保本 :达 1R 后止损移至开仓价 ± N 跳。
挂单超时 :限价开仓未成交时,超过设定分钟数自动向柜台撤单(1~60 分钟)。
{{ small_account_margin_rec.label }}。