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
+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 };
})();