feat: add hub strategy docs page with MD and checklists

Central /strategy page shows per-exchange strategy markdown and read-only entry checklists; nav toggle in settings, print and HTML export per tab.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-06 03:12:09 +08:00
parent 8b069294b7
commit 7223b54ec4
12 changed files with 865 additions and 1 deletions
+33
View File
@@ -0,0 +1,33 @@
{
"exchange": "binance",
"title": "币安 · 开仓检查清单",
"version": "v0.2",
"groups": [
{
"title": "账户与方向",
"items": [
"本账户仅做多,不做空",
"所选币种为大级别多头结构、上方仍有空间",
"计仓模式为以损定仓(risk),关键位自动单已关闭"
]
},
{
"title": "开仓类型选型",
"items": [
"已明确:大分歧A / 大分歧B / 小分歧 三选一",
"大分歧 A/B → 趋势单;小分歧 → 波段单(系统自动)",
"第三次小分歧 → 不做新单",
"小分歧不追突破,仅低吸 / N 字 / 5m 三均线重新多头"
]
},
{
"title": "结构与杠杆",
"items": [
"大分歧A5m/15m 收敛且不创新低企稳",
"大分歧B:突破已确认,非仅刺破",
"杠杆:BTC/ETH 10x,其它山寨 5x(可选手改但须有理由)",
"止盈止损随行情人工设定,不在此清单量化 RR"
]
}
]
}
+38
View File
@@ -0,0 +1,38 @@
{
"exchange": "gate",
"title": "Gate · BTC 日内 · 开仓检查清单",
"version": "v0.2",
"groups": [
{
"title": "方向与均线过滤",
"items": [
"仅交易 BTC,同时仅 1 仓",
"15m 21/55/144 排列清晰(多或空),纠缠则不做",
"1H 方向与 15m 不冲突",
"21 均线关系满足:回踩支撑 / 站稳上方(多)或反弹承压 / 压在下方(空)"
]
},
{
"title": "开仓类型 A / B",
"items": [
"已选定:假破 或 结构突破(二选一)",
"假破:扫流动性后回到结构内,5m N 字 + 15m 顶/底分型齐全",
"结构突破:15m 收盘价站稳关键位,非仅刺破",
"止损带宽 0.4%1.5%,超出则不做",
"下单前空间至少 1:1,不足则不做"
]
},
{
"title": "一日节奏与笔数",
"items": [
"非周末;在早窗 / 晚窗计划时段内",
"今日笔数未达上限 3,连错未达 2 笔",
"宽幅震荡(S1)时降频或不做",
"0 点前须了结(系统强制清仓);本清单不含手动平仓"
]
}
],
"footnotes": [
"系统:FORCE_CLOSE_ENABLED 开启时,北京时间 0 点自动强制清仓(result=强制清仓)"
]
}
+32
View File
@@ -0,0 +1,32 @@
{
"exchange": "okx",
"title": "OKX · 开仓检查清单",
"version": "v0.2",
"groups": [
{
"title": "账户与方向",
"items": [
"已选定做多或做空,且与 4H/大级别方向一致",
"趋势户 profile(非 Gate 日内 BTC/ETH 白名单)",
"开仓类型已从 大分歧A / 大分歧B / 小分歧 中选定"
]
},
{
"title": "空侧镜像(若做空)",
"items": [
"大分歧A:不创新高企稳",
"大分歧B:向下突破确认",
"小分歧:二次探顶 / 倒 N / 5m 空头排列"
]
},
{
"title": "纪律与杠杆",
"items": [
"第三次小分歧不做新单",
"小分歧不追突破",
"杠杆:BTC/ETH 10x,其它 5x",
"止盈止损随行情人工设定,本文档不量化 RR"
]
}
]
}
+279
View File
@@ -0,0 +1,279 @@
"""中控「策略说明」:读取 docs/strategy MD + checklists JSON。"""
from __future__ import annotations
import json
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from lib.paths import REPO_ROOT
STRATEGY_EXCHANGES: tuple[str, ...] = ("binance", "okx", "gate")
STRATEGY_META: dict[str, dict[str, str]] = {
"binance": {
"label": "币安",
"title": "币安·山寨多头趋势",
"md_file": "binance-alt-trend-long.md",
},
"okx": {
"label": "OKX",
"title": "OKX·多空趋势",
"md_file": "okx-trend-both.md",
},
"gate": {
"label": "Gate",
"title": "Gate·BTC 日内",
"md_file": "gate-intraday.md",
},
}
def _strategy_dir() -> Path:
return REPO_ROOT / "docs" / "strategy"
def _checklist_path(exchange_key: str) -> Path:
return _strategy_dir() / "checklists" / f"{exchange_key.strip().lower()}.json"
def _md_path(exchange_key: str) -> Path:
meta = STRATEGY_META.get((exchange_key or "").strip().lower())
if not meta:
raise KeyError(exchange_key)
return _strategy_dir() / meta["md_file"]
def _parse_version(md_text: str) -> str:
m = re.search(r">\s*\*\*状态\*\*[:]\s*(v[\d.]+)", md_text)
if m:
return m.group(1)
m = re.search(r"\|\s*v([\d.]+)\s*\|", md_text)
if m:
return f"v{m.group(1)}"
return ""
def render_markdown_html(md_text: str) -> str:
try:
import markdown # type: ignore
return markdown.markdown(
md_text,
extensions=["tables", "fenced_code", "nl2br", "sane_lists"],
)
except Exception:
return _simple_md_html(md_text)
def _simple_md_html(md_text: str) -> str:
from html import escape
lines = md_text.replace("\r\n", "\n").replace("\r", "\n").splitlines()
out: list[str] = []
i = 0
in_code = False
code_buf: list[str] = []
list_buf: list[str] = []
list_ordered = False
def flush_list() -> None:
nonlocal list_buf, list_ordered
if not list_buf:
return
tag = "ol" if list_ordered else "ul"
out.append(f"<{tag}>")
for item in list_buf:
out.append(f"<li>{_inline_md(item)}</li>")
out.append(f"</{tag}>")
list_buf = []
def flush_code() -> None:
nonlocal code_buf, in_code
if not code_buf:
return
out.append(f"<pre><code>{escape(chr(10).join(code_buf))}</code></pre>")
code_buf = []
in_code = False
while i < len(lines):
line = lines[i]
if line.strip().startswith("```"):
flush_list()
if in_code:
flush_code()
else:
in_code = True
i += 1
continue
if in_code:
code_buf.append(line)
i += 1
continue
if re.match(r"^\s*\|", line) and i + 1 < len(lines) and re.match(r"^\s*\|?\s*[-:| ]+\|", lines[i + 1]):
flush_list()
header = [c.strip() for c in line.strip().strip("|").split("|")]
i += 2
rows: list[list[str]] = []
while i < len(lines) and re.match(r"^\s*\|", lines[i]):
rows.append([c.strip() for c in lines[i].strip().strip("|").split("|")])
i += 1
out.append("<table><thead><tr>" + "".join(f"<th>{_inline_md(h)}</th>" for h in header) + "</tr></thead><tbody>")
for row in rows:
out.append("<tr>" + "".join(f"<td>{_inline_md(c)}</td>" for c in row) + "</tr>")
out.append("</tbody></table>")
continue
if re.match(r"^#{1,3}\s+", line):
flush_list()
m = re.match(r"^(#{1,3})\s+(.*)$", line)
if m:
level = len(m.group(1))
out.append(f"<h{level}>{_inline_md(m.group(2))}</h{level}>")
i += 1
continue
if line.strip() == "---":
flush_list()
out.append("<hr>")
i += 1
continue
if line.startswith(">"):
flush_list()
out.append(f"<blockquote>{_inline_md(line.lstrip('>').strip())}</blockquote>")
i += 1
continue
m = re.match(r"^(\d+)\.\s+(.*)$", line.strip())
if m:
if list_buf and not list_ordered:
flush_list()
list_ordered = True
list_buf.append(m.group(2))
i += 1
continue
if re.match(r"^[-*]\s+", line.strip()):
if list_buf and list_ordered:
flush_list()
list_ordered = False
list_buf.append(re.sub(r"^[-*]\s+", "", line.strip()))
i += 1
continue
if not line.strip():
flush_list()
i += 1
continue
flush_list()
out.append(f"<p>{_inline_md(line.strip())}</p>")
i += 1
flush_list()
flush_code()
return "\n".join(out)
def _inline_md(text: str) -> str:
from html import escape
s = escape(text)
s = re.sub(r"`([^`]+)`", r"<code>\1</code>", s)
s = re.sub(r"\*\*([^*]+)\*\*", r"<strong>\1</strong>", s)
return s
def load_checklist(exchange_key: str) -> dict[str, Any]:
path = _checklist_path(exchange_key)
if not path.is_file():
return {"exchange": exchange_key, "title": "开仓检查清单", "groups": []}
data = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
return {"exchange": exchange_key, "title": "开仓检查清单", "groups": []}
return data
def load_strategy_payload(exchange_key: str) -> dict[str, Any]:
key = (exchange_key or "").strip().lower()
if key not in STRATEGY_META:
raise KeyError(exchange_key)
meta = STRATEGY_META[key]
md_path = _md_path(key)
md_text = md_path.read_text(encoding="utf-8") if md_path.is_file() else ""
checklist = load_checklist(key)
version = _parse_version(md_text) or str(checklist.get("version") or "")
return {
"ok": True,
"exchange_key": key,
"label": meta["label"],
"title": meta["title"],
"version": version,
"md_source": str(md_path.relative_to(REPO_ROOT)).replace("\\", "/"),
"strategy_html": render_markdown_html(md_text),
"checklist": checklist,
}
def strategy_meta_payload() -> dict[str, Any]:
tabs = [
{"key": k, "label": STRATEGY_META[k]["label"], "title": STRATEGY_META[k]["title"]}
for k in STRATEGY_EXCHANGES
]
return {"ok": True, "exchanges": tabs}
def build_export_html(exchange_key: str) -> str:
payload = load_strategy_payload(exchange_key)
key = payload["exchange_key"]
label = payload["label"]
title = payload["title"]
version = payload.get("version") or ""
checklist = payload.get("checklist") or {}
groups = checklist.get("groups") or []
now = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M")
checklist_html_parts = [
f"<h2>{escape_html(str(checklist.get('title') or '开仓检查清单'))}</h2>"
]
for grp in groups:
if not isinstance(grp, dict):
continue
gtitle = escape_html(str(grp.get("title") or ""))
checklist_html_parts.append(f"<h3>{gtitle}</h3><ul>")
for item in grp.get("items") or []:
checklist_html_parts.append(
f"<li><span class=\"box\">☐</span> {escape_html(str(item))}</li>"
)
checklist_html_parts.append("</ul>")
checklist_html = "\n".join(checklist_html_parts)
return f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>{escape_html(label)} · 策略说明</title>
<style>
body{{font-family:system-ui,sans-serif;margin:24px;color:#111;line-height:1.55}}
h1{{font-size:1.35rem;margin:0 0 8px}}
.meta{{color:#555;font-size:.85rem;margin-bottom:20px}}
.grid{{display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:start}}
.strategy-doc h2{{font-size:1.05rem;margin-top:1.2em}}
.strategy-doc table{{border-collapse:collapse;width:100%;font-size:.88rem}}
.strategy-doc th,.strategy-doc td{{border:1px solid #ccc;padding:6px 8px;text-align:left}}
.checklist ul{{list-style:none;padding:0;margin:0}}
.checklist li{{margin:8px 0;padding-left:0}}
.box{{display:inline-block;width:1em;margin-right:6px}}
@media print{{body{{margin:12mm}}}}
</style>
</head>
<body>
<h1>{escape_html(title)}</h1>
<div class="meta">{escape_html(label)} · {escape_html(version)} · 导出 {now}</div>
<div class="grid">
<div class="strategy-doc">{payload.get("strategy_html") or ""}</div>
<div class="checklist">{checklist_html}</div>
</div>
</body>
</html>"""
def escape_html(text: str) -> str:
from html import escape
return escape(text, quote=True)
+35 -1
View File
@@ -78,6 +78,11 @@ from lib.hub.hub_entry_plan_lib import (
meta_payload as entry_plan_meta_payload,
update_entry_plan,
)
from lib.hub.hub_strategy_lib import (
build_export_html,
load_strategy_payload,
strategy_meta_payload,
)
from lib.hub.hub_macro_calendar_lib import (
MACRO_EVENT_LABELS,
MACRO_EVENT_TYPES,
@@ -94,7 +99,7 @@ load_hub_dotenv()
import httpx
from fastapi import Body, FastAPI, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import FileResponse, JSONResponse
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
@@ -805,6 +810,7 @@ def root_redirect():
@app.get("/dashboard")
@app.get("/funds")
@app.get("/ai")
@app.get("/strategy")
@app.get("/settings")
def shell_pages():
return _shell_page()
@@ -914,6 +920,7 @@ class SettingsDisplayBody(BaseModel):
show_nav_archive: bool = True
show_nav_ai: bool = True
show_nav_calculator: bool = True
show_nav_strategy: bool = True
class SupervisorSettingsBody(BaseModel):
@@ -2951,6 +2958,33 @@ async def api_archive_sync():
return body
@app.get("/api/strategy/meta")
def api_strategy_meta():
return strategy_meta_payload()
@app.get("/api/strategy/{exchange_key}")
def api_strategy_detail(exchange_key: str):
try:
return load_strategy_payload(exchange_key.strip().lower())
except KeyError:
return JSONResponse({"ok": False, "msg": "unknown exchange"}, status_code=404)
@app.get("/api/strategy/{exchange_key}/export")
def api_strategy_export(exchange_key: str):
key = exchange_key.strip().lower()
try:
html = build_export_html(key)
except KeyError:
return JSONResponse({"ok": False, "msg": "unknown exchange"}, status_code=404)
filename = f"strategy-{key}.html"
return HTMLResponse(
content=html,
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@app.get("/api/entry-plans/meta")
def api_entry_plans_meta():
init_entry_plan_db()
+1
View File
@@ -5,3 +5,4 @@ httpx>=0.27,<1
ccxt>=4.2,<5
PySocks>=1.7,<2
psutil>=5.9,<8
# 可选:服务端 pip install markdown 后渲染更完整;无则使用内置轻量渲染
+1
View File
@@ -28,6 +28,7 @@ DEFAULT_DISPLAY = {
"show_nav_archive": True,
"show_nav_ai": True,
"show_nav_calculator": True,
"show_nav_strategy": True,
}
DEFAULT_EXCHANGES = [
+180
View File
@@ -7479,3 +7479,183 @@ body.funds-fullscreen-open {
}
}
/* --- 策略说明 --- */
.strategy-page-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.strategy-page-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.strategy-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.strategy-tabs {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.strategy-tab {
padding: 6px 14px;
border-radius: 999px;
border: 1px solid var(--border-soft);
background: var(--surface-2);
color: var(--text-soft);
cursor: pointer;
font-size: 0.88rem;
}
.strategy-tab.is-active {
background: var(--accent-soft);
border-color: var(--accent);
color: var(--text);
}
.strategy-layout {
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr);
gap: 14px;
align-items: start;
}
.strategy-col-title {
margin: 0 0 12px;
font-size: 0.95rem;
color: var(--text-soft);
}
.strategy-doc-body {
font-size: 0.88rem;
line-height: 1.55;
color: var(--text);
max-height: min(72vh, 900px);
overflow: auto;
padding-right: 4px;
}
.strategy-doc-body h2 {
font-size: 1rem;
margin: 1.2em 0 0.5em;
color: var(--text);
}
.strategy-doc-body h3 {
font-size: 0.92rem;
margin: 1em 0 0.4em;
}
.strategy-doc-body table {
width: 100%;
border-collapse: collapse;
font-size: 0.82rem;
margin: 8px 0;
}
.strategy-doc-body th,
.strategy-doc-body td {
border: 1px solid var(--border-soft);
padding: 6px 8px;
text-align: left;
}
.strategy-doc-body blockquote {
margin: 8px 0;
padding: 8px 12px;
border-left: 3px solid var(--accent);
color: var(--text-soft);
background: var(--surface-2);
}
.strategy-doc-body pre,
.strategy-doc-body code {
font-size: 0.8rem;
}
.strategy-doc-source {
margin: 12px 0 0;
font-size: 0.75rem;
color: var(--muted);
}
.strategy-checklist-body ul {
list-style: none;
margin: 0;
padding: 0;
}
.strategy-check-group {
margin-bottom: 14px;
}
.strategy-check-group h4 {
margin: 0 0 8px;
font-size: 0.88rem;
color: var(--accent);
}
.strategy-check-group li {
margin: 6px 0;
font-size: 0.86rem;
line-height: 1.45;
display: flex;
gap: 8px;
align-items: flex-start;
}
.strategy-check-box {
flex: 0 0 auto;
font-size: 1rem;
line-height: 1.2;
}
.strategy-checklist-footnotes {
margin: 12px 0 0;
padding-left: 18px;
font-size: 0.78rem;
color: var(--muted);
}
.strategy-checklist-footnotes.hidden {
display: none;
}
.strategy-empty {
color: var(--muted);
font-size: 0.85rem;
}
.strategy-print-header {
margin-bottom: 12px;
}
.strategy-print-header.hidden {
display: none;
}
.strategy-print-meta {
margin: 4px 0 0;
font-size: 0.85rem;
color: var(--muted);
}
@media (max-width: 960px) {
.strategy-layout {
grid-template-columns: 1fr;
}
.strategy-doc-body {
max-height: none;
}
}
@media print {
body.hub-strategy-printing .app-bg,
body.hub-strategy-printing .app-header,
body.hub-strategy-printing .no-print,
body.hub-strategy-printing .page:not(#page-strategy) {
display: none !important;
}
body.hub-strategy-printing #page-strategy {
display: block !important;
}
body.hub-strategy-printing .strategy-print-header {
display: block !important;
}
body.hub-strategy-printing .strategy-doc-body {
max-height: none;
overflow: visible;
}
body.hub-strategy-printing .card {
break-inside: avoid;
box-shadow: none;
border: 1px solid #ccc;
}
}
+18
View File
@@ -37,6 +37,10 @@
return displayPref("show_nav_calculator", true);
}
function showNavStrategyPref() {
return displayPref("show_nav_strategy", true);
}
function syncNavVisibility(data) {
const d = (data && data.display) || {};
const navFunds = document.getElementById("nav-funds");
@@ -45,12 +49,14 @@
const navArchive = document.getElementById("nav-archive");
const navAi = document.getElementById("nav-ai");
const navCalc = document.getElementById("nav-calculator");
const navStrategy = document.getElementById("nav-strategy");
if (navFunds) navFunds.classList.toggle("nav-hidden", d.show_nav_funds === false);
if (navDash) navDash.classList.toggle("nav-hidden", d.show_nav_dashboard === false);
if (navPlan) navPlan.classList.toggle("nav-hidden", d.show_nav_plan === false);
if (navArchive) navArchive.classList.toggle("nav-hidden", d.show_nav_archive === false);
if (navAi) navAi.classList.toggle("nav-hidden", d.show_nav_ai === false);
if (navCalc) navCalc.classList.toggle("nav-hidden", d.show_nav_calculator === false);
if (navStrategy) navStrategy.classList.toggle("nav-hidden", d.show_nav_strategy === false);
}
function pageNavAllowed(page) {
@@ -60,6 +66,7 @@
if (page === "archive") return showNavArchivePref();
if (page === "ai") return showNavAiPref();
if (page === "calculator") return showNavCalculatorPref();
if (page === "strategy") return showNavStrategyPref();
return true;
}
@@ -72,6 +79,7 @@
const archiveCb = document.getElementById("pref-show-nav-archive");
const aiCb = document.getElementById("pref-show-nav-ai");
const calcCb = document.getElementById("pref-show-nav-calculator");
const strategyCb = document.getElementById("pref-show-nav-strategy");
if (pnlCb) pnlCb.checked = d.show_account_pnl !== false;
if (fundsCb) fundsCb.checked = d.show_nav_funds !== false;
if (dashCb) dashCb.checked = d.show_nav_dashboard !== false;
@@ -79,6 +87,7 @@
if (archiveCb) archiveCb.checked = d.show_nav_archive !== false;
if (aiCb) aiCb.checked = d.show_nav_ai !== false;
if (calcCb) calcCb.checked = d.show_nav_calculator !== false;
if (strategyCb) strategyCb.checked = d.show_nav_strategy !== false;
syncNavVisibility(data);
}
@@ -1158,6 +1167,7 @@
if (p.includes("funds")) return "funds";
if (p.includes("plan")) return "plan";
if (p.includes("calculator")) return "calculator";
if (p.includes("strategy")) return "strategy";
if (p.includes("market")) return "market";
if (p.includes("/ai")) return "ai";
return "monitor";
@@ -1170,6 +1180,7 @@
if (page === "funds") return "page-funds";
if (page === "plan") return "page-plan";
if (page === "calculator") return "page-calculator";
if (page === "strategy") return "page-strategy";
if (page === "market") return "page-market";
if (page === "ai") return "page-ai";
return "page-monitor";
@@ -1221,6 +1232,11 @@
}
if (page === "funds" && window.hubFundsPage) {
window.hubFundsPage.init();
}
if (page === "strategy" && window.hubStrategyPage) {
window.hubStrategyPage.init();
} else if (window.hubStrategyPage && window.hubStrategyPage.destroy) {
window.hubStrategyPage.destroy();
} else if (window.hubFundsPage && window.hubFundsPage.destroy) {
window.hubFundsPage.destroy();
}
@@ -4245,6 +4261,7 @@
const archiveCb = document.getElementById("pref-show-nav-archive");
const aiCb = document.getElementById("pref-show-nav-ai");
const calcCb = document.getElementById("pref-show-nav-calculator");
const strategyCb = document.getElementById("pref-show-nav-strategy");
const supEnabled = document.getElementById("supervisor-enabled");
const supProg = document.getElementById("supervisor-wechat-program");
const supWebhook = document.getElementById("supervisor-wechat-webhook");
@@ -4264,6 +4281,7 @@
show_nav_archive: archiveCb ? !!archiveCb.checked : true,
show_nav_ai: aiCb ? !!aiCb.checked : true,
show_nav_calculator: calcCb ? !!calcCb.checked : true,
show_nav_strategy: strategyCb ? !!strategyCb.checked : true,
},
supervisor: {
enabled: supEnabled ? !!supEnabled.checked : true,
+41
View File
@@ -51,6 +51,7 @@
<a href="/funds" id="nav-funds">资金概况</a>
<a href="/plan" id="nav-plan">开仓计划</a>
<a href="/monitor" id="nav-monitor">监控区</a>
<a href="/strategy" id="nav-strategy">策略说明</a>
<a href="/market" id="nav-market">行情区</a>
<a href="/calculator" id="nav-calculator">计算器</a>
<a href="/archive" id="nav-archive">内照明心</a>
@@ -852,6 +853,41 @@
</div>
</div>
<div id="page-strategy" class="page hidden">
<div class="page-head strategy-page-head">
<div>
<h1><span class="head-tag">STR</span> 策略说明</h1>
<p class="page-desc">三所策略文档 · 开仓前检查清单 · 可打印 / 下载</p>
</div>
<div class="strategy-page-actions no-print">
<button type="button" id="strategy-btn-print" class="ghost">打印当前</button>
<button type="button" id="strategy-btn-download" class="primary">下载 HTML</button>
</div>
</div>
<div class="strategy-toolbar no-print">
<div id="strategy-tabs" class="strategy-tabs" role="tablist" aria-label="交易所策略"></div>
<span id="strategy-load-status" class="toolbar-meta"></span>
</div>
<div id="strategy-print-root" class="strategy-print-root">
<div id="strategy-print-header" class="strategy-print-header hidden">
<h2 id="strategy-print-title"></h2>
<p id="strategy-print-meta" class="strategy-print-meta"></p>
</div>
<div class="strategy-layout">
<section class="card strategy-doc-card">
<h3 class="strategy-col-title">策略说明</h3>
<div id="strategy-doc-body" class="strategy-doc-body prose"></div>
<p id="strategy-doc-source" class="strategy-doc-source"></p>
</section>
<section class="card strategy-checklist-card">
<h3 id="strategy-checklist-title" class="strategy-col-title">开仓检查清单</h3>
<div id="strategy-checklist-body" class="strategy-checklist-body"></div>
<ul id="strategy-checklist-footnotes" class="strategy-checklist-footnotes"></ul>
</section>
</div>
</div>
</div>
<div id="page-settings" class="page hidden">
<div class="page-head">
<h1><span class="head-tag">CFG</span> 系统设置</h1>
@@ -901,6 +937,10 @@
<input type="checkbox" id="pref-show-nav-calculator" checked />
顶栏显示「计算器」
</label>
<label class="chk-label settings-display-chk">
<input type="checkbox" id="pref-show-nav-strategy" checked />
顶栏显示「策略说明」
</label>
<p class="settings-display-hint">保存至 hub_settings.json,换浏览器同样生效。关闭导航后对应页面将不可从顶栏进入,直接访问 URL 会跳回监控区。</p>
</div>
</section>
@@ -1113,6 +1153,7 @@
<script src="/assets/archive.js?v=20260626-archive-layout"></script>
<script src="/assets/funds.js?v=20260609-hub-funds-fold"></script>
<script src="/assets/dashboard.js?v=20260612-dash-monitor-count"></script>
<script src="/assets/strategy.js?v=1"></script>
<script src="/assets/ai_review_render.js?v=3"></script>
<script src="/assets/time_close_ui.js?v=3"></script>
<script src="/assets/backup.js?v=1"></script>
+168
View File
@@ -0,0 +1,168 @@
/**
* 策略说明三所 MD + 开仓检查清单 JSON
*/
(function () {
const page = document.getElementById("page-strategy");
if (!page) return;
const tabsEl = document.getElementById("strategy-tabs");
const statusEl = document.getElementById("strategy-load-status");
const docBody = document.getElementById("strategy-doc-body");
const docSource = document.getElementById("strategy-doc-source");
const checklistTitle = document.getElementById("strategy-checklist-title");
const checklistBody = document.getElementById("strategy-checklist-body");
const footnotesEl = document.getElementById("strategy-checklist-footnotes");
const printTitle = document.getElementById("strategy-print-title");
const printMeta = document.getElementById("strategy-print-meta");
const printHeader = document.getElementById("strategy-print-header");
const btnPrint = document.getElementById("strategy-btn-print");
const btnDownload = document.getElementById("strategy-btn-download");
let activeKey = "binance";
let tabsMeta = [];
let cache = {};
let bound = false;
async function apiFetch(url, opts) {
const r = await fetch(url, { credentials: "same-origin", ...(opts || {}) });
const ct = (r.headers.get("content-type") || "").toLowerCase();
if (ct.includes("application/json")) {
const data = await r.json();
if (!r.ok) throw new Error((data && data.msg) || r.statusText || "请求失败");
return data;
}
if (!r.ok) throw new Error(r.statusText || "请求失败");
return r;
}
function esc(s) {
return String(s ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function renderTabs() {
if (!tabsEl) return;
tabsEl.innerHTML = tabsMeta
.map(
(t) =>
`<button type="button" class="strategy-tab${t.key === activeKey ? " is-active" : ""}" role="tab" aria-selected="${t.key === activeKey}" data-key="${esc(t.key)}">${esc(t.label)}</button>`
)
.join("");
tabsEl.querySelectorAll(".strategy-tab").forEach((btn) => {
btn.addEventListener("click", () => {
const key = btn.getAttribute("data-key");
if (!key || key === activeKey) return;
activeKey = key;
renderTabs();
void loadExchange(key);
});
});
}
function renderChecklist(checklist) {
const cl = checklist || {};
if (checklistTitle) {
checklistTitle.textContent = cl.title || "开仓检查清单";
}
if (!checklistBody) return;
const groups = cl.groups || [];
if (!groups.length) {
checklistBody.innerHTML = '<p class="strategy-empty">暂无检查清单</p>';
} else {
checklistBody.innerHTML = groups
.map((grp) => {
const items = (grp.items || [])
.map((item) => `<li><span class="strategy-check-box" aria-hidden="true">☐</span>${esc(item)}</li>`)
.join("");
return `<div class="strategy-check-group"><h4>${esc(grp.title || "")}</h4><ul>${items}</ul></div>`;
})
.join("");
}
if (footnotesEl) {
const notes = cl.footnotes || [];
footnotesEl.innerHTML = notes.map((n) => `<li>${esc(n)}</li>`).join("");
footnotesEl.classList.toggle("hidden", !notes.length);
}
}
function renderPayload(data) {
if (docBody) docBody.innerHTML = data.strategy_html || "";
if (docSource) {
const ver = data.version ? ` · ${data.version}` : "";
docSource.textContent = `文档:${data.md_source || ""}${ver}`;
}
renderChecklist(data.checklist);
if (printTitle) printTitle.textContent = data.title || data.label || "";
if (printMeta) {
const ver = data.version ? ` · ${data.version}` : "";
printMeta.textContent = `${data.label || activeKey}${ver}`;
}
if (printHeader) printHeader.classList.remove("hidden");
}
async function loadExchange(key) {
if (statusEl) statusEl.textContent = "加载中…";
try {
let data = cache[key];
if (!data) {
data = await apiFetch(`/api/strategy/${encodeURIComponent(key)}`);
cache[key] = data;
}
renderPayload(data);
if (statusEl) statusEl.textContent = "";
} catch (e) {
if (statusEl) statusEl.textContent = String(e);
if (docBody) docBody.innerHTML = "";
if (checklistBody) checklistBody.innerHTML = "";
}
}
async function loadMeta() {
const meta = await apiFetch("/api/strategy/meta");
tabsMeta = meta.exchanges || [];
if (tabsMeta.length && !tabsMeta.some((t) => t.key === activeKey)) {
activeKey = tabsMeta[0].key;
}
renderTabs();
}
function bindActions() {
if (bound) return;
bound = true;
if (btnPrint) {
btnPrint.addEventListener("click", () => {
document.body.classList.add("hub-strategy-printing");
window.print();
window.addEventListener(
"afterprint",
() => document.body.classList.remove("hub-strategy-printing"),
{ once: true }
);
});
}
if (btnDownload) {
btnDownload.addEventListener("click", () => {
window.location.href = `/api/strategy/${encodeURIComponent(activeKey)}/export`;
});
}
}
async function init() {
bindActions();
try {
await loadMeta();
await loadExchange(activeKey);
} catch (e) {
if (statusEl) statusEl.textContent = String(e);
}
}
function destroy() {
/* 保留 cache,切页再进更快 */
}
window.hubStrategyPage = { init, destroy };
})();
+39
View File
@@ -0,0 +1,39 @@
import json
import unittest
from pathlib import Path
from lib.hub.hub_strategy_lib import (
load_checklist,
load_strategy_payload,
strategy_meta_payload,
build_export_html,
)
class TestHubStrategyLib(unittest.TestCase):
def test_meta_has_three_exchanges(self):
meta = strategy_meta_payload()
keys = [x["key"] for x in meta["exchanges"]]
self.assertEqual(keys, ["binance", "okx", "gate"])
def test_load_binance_payload(self):
p = load_strategy_payload("binance")
self.assertTrue(p["ok"])
self.assertIn("strategy_html", p)
self.assertIn("groups", p["checklist"])
self.assertIn("<h2", p["strategy_html"].lower())
def test_checklist_files_valid_json(self):
root = Path(__file__).resolve().parent.parent / "docs" / "strategy" / "checklists"
for name in ("binance", "okx", "gate"):
data = json.loads((root / f"{name}.json").read_text(encoding="utf-8"))
self.assertTrue(data.get("groups"))
def test_export_html_contains_checklist(self):
html = build_export_html("gate")
self.assertIn("Gate", html)
self.assertIn("", html)
if __name__ == "__main__":
unittest.main()