"use client"; /** * 策略库(/strategies)—— 策略的命名资产中心。 * * 职责: * - 列表 + 检索:每个策略显示**一句话说明**与**计算公式**(后端 describe_strategy 推导)。 * - 新建 / 编辑:复用 StrategyParamsForm(与回测页同一份参数模型与校验)。 * - 一键回测:`POST /strategies/{id}/expand`(补 period + 资金)→ `POST /jobs` → 轮询 → * 就地展示核心指标,并提供「查看实验详情 / 回到回测页看曲线」。 * - 去回测页:`/backtest?strategy={id}`(回测页载入后可保存为策略、可微调)。 * * 设计取舍:编辑不弹窗而是就地展开表单(页面滚动位置不丢,参数多,弹窗太挤)。 */ import Link from "next/link"; import { useCallback, useEffect, useMemo, useState } from "react"; import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api"; import { STAGE_LABEL, submitJob, waitJob, type JobOutcome } from "@/lib/jobs"; import { recentRange } from "@/lib/dates"; import type { BacktestResult, FactorMeta, StrategyDefinition } from "@/lib/types"; import { Btn, Banner, Card, Empty, Loading, PageHeader, Pill, Progress, BacktestMetrics, Field, } from "@/components/ui"; import { StrategyParamsForm, casePreset, emptyParams, paramsFromStrategy, strategyFromParams, validateParams, type StrategyParams, } from "@/components/StrategyParamsForm"; import { StrategyDocBody, StrategyDocCard } from "@/components/StrategyDocCard"; import { useStrategyDoc, useStrategyDocById } from "@/lib/strategy"; export default function StrategiesPage() { const [list, setList] = useState(null); const [factors, setFactors] = useState([]); const [query, setQuery] = useState(""); const [err, setErr] = useState(""); const [msg, setMsg] = useState(""); const [editing, setEditing] = useState(null); const [editingId, setEditingId] = useState(null); // 保存被拦下过 → 立即展开全部校验错误(见 StrategyParamsFormProps.revealErrors) const [revealErrors, setRevealErrors] = useState(false); const [busy, setBusy] = useState(false); const [docId, setDocId] = useState(null); const range = useMemo(() => recentRange(), []); const load = useCallback(async () => { try { const rows = await apiGet("/strategies"); setList(rows); setErr(""); } catch (e) { setErr((e as Error).message); setList([]); } }, []); useEffect(() => { void load(); let alive = true; apiGet("/factors") .then((f) => alive && setFactors(f)) .catch(() => alive && setFactors([])); return () => { alive = false; }; }, [load]); const filtered = useMemo(() => { if (!list) return null; const q = query.trim().toLowerCase(); if (!q) return list; return list.filter((s) => { const hay = [ s.name, s.description, (s.factors ?? []).map((f) => f.name).join(" "), (s.conditions ?? []).map((c) => c.field).join(" "), ] .join(" ") .toLowerCase(); return hay.includes(q); }); }, [list, query]); /* ---------- 新建 / 编辑 ---------- */ function startCreate() { setEditingId(null); setEditing(emptyParams(range)); setDocId(null); setMsg(""); setErr(""); setRevealErrors(false); } function startEdit(s: StrategyDefinition) { setEditingId(s.id ?? null); setEditing(paramsFromStrategy(s, { ...emptyParams(range) })); setDocId(s.id ?? null); setMsg(""); setErr(""); setRevealErrors(false); } async function save() { if (!editing) return; const errors = validateParams(editing, { requireMeta: true }); if (Object.keys(errors).length) { setErr(Object.values(errors)[0]); setRevealErrors(true); // 一次把问题全列出来,而不是让用户逐个试 return; } setBusy(true); setErr(""); try { const body = strategyFromParams(editing, editingId ?? undefined); if (editingId) { await apiPut(`/strategies/${encodeURIComponent(editingId)}`, body); setMsg(`已更新策略「${body.name}」`); } else { const saved = await apiPost("/strategies", body); setMsg(`已保存策略「${saved.name}」(${saved.id})`); } setEditing(null); setEditingId(null); setDocId(null); await load(); } catch (e) { setErr((e as Error).message); } finally { setBusy(false); } } async function remove(s: StrategyDefinition) { if (!s.id) return; if (!window.confirm(`删除策略「${s.name}」?此操作不可撤销(已跑过的实验不受影响)。`)) return; setBusy(true); try { await apiDelete(`/strategies/${encodeURIComponent(s.id)}`); setMsg(`已删除策略「${s.name}」`); await load(); } catch (e) { setErr((e as Error).message); } finally { setBusy(false); } } return ( <> 查看实验 新建策略 } /> {err ? {err} : null} {msg ? {msg} : null} {/* ---------- 编辑器 ---------- */} {editing ? ( { setEditing(null); setEditingId(null); setDocId(null); }} disabled={busy}> 取消 {editingId ? "保存修改" : "保存到策略库"} } >
策略只保存「怎么选股/怎么调仓/怎么收费」,**不含回测区间与初始资金** —— 这两项在运行回测时才指定,因此同一策略可用于不同区间的复现与对比。
setEditing({ ...casePreset(range), name: editing.name, description: editing.description, }) } > 载入高股息案例默认参数
) : null} {editing ? ( ) : null} {docId && !editing ? setDocId(null)} /> : null} {/* ---------- 列表 ---------- */} setQuery(e.target.value)} aria-label="搜索策略" /> } > {filtered === null ? ( ) : filtered.length === 0 ? ( ) : (
{filtered.map((s) => ( startEdit(s)} onDelete={() => remove(s)} onDoc={() => setDocId(s.id ?? null)} /> ))}
)}
); } /* ------------------------------------------------------------------ */ /** 未保存参数的实时说明预览 */ function EditingDoc({ params, savedId }: { params: StrategyParams; savedId: string | null }) { const live = useStrategyDoc(params, !savedId); const saved = useStrategyDocById(savedId); const state = savedId ? saved : live; return ( ); } function SavedDoc({ id, onClose }: { id: string; onClose: () => void }) { const state = useStrategyDocById(id); return ( 收起} > {state.doc ? ( ) : (
{state.loading ? "正在生成说明与公式…" : `说明暂不可用${state.error ? `:${state.error}` : "(后端 /api/strategies/{id}/describe 未就绪)"}。`}
)}
); } /** 单个策略卡片:说明 + 参数摘要 + 一键回测 */ function StrategyCard({ s, range, busy, onEdit, onDelete, onDoc, }: { s: StrategyDefinition; range: { start: string; end: string }; busy: boolean; onEdit: () => void; onDelete: () => void; onDoc: () => void; }) { const [start, setStart] = useState("2020-01-01"); const [end, setEnd] = useState(range.end); const [capital, setCapital] = useState(1_000_000); const [running, setRunning] = useState(false); const [jobId, setJobId] = useState(""); const [result, setResult] = useState(null); const [stage, setStage] = useState(""); const [expId, setExpId] = useState(""); const [error, setError] = useState(""); const [open, setOpen] = useState(false); const sel = s.selection ?? {}; const costs = s.costs ?? {}; async function run() { if (!s.id) return; if (start >= end) { setError("开始日期必须早于结束日期"); return; } setRunning(true); setError(""); setResult(null); setExpId(""); try { const spec = await apiPost>( `/strategies/${encodeURIComponent(s.id)}/expand`, { period: [start, end], initial_capital: capital } ); const { job_id } = await submitJob(spec as never); setJobId(job_id); setStage("queued"); const out: JobOutcome = await waitJob(job_id, 900_000, (info) => setStage(info.stage) ); if (out.status === "success" && out.result) { setResult(out.result); setExpId(out.experimentId ?? ""); } else { setError(`任务${out.status}${out.error ? `:${out.error}` : ""}`); } } catch (e) { setError((e as Error).message); } finally { setRunning(false); setJobId(""); setStage(""); } } return (
{s.name}
{s.id} {s.created_at ? 创建 {String(s.created_at).slice(0, 10)} : null}
{s.factors?.map((f) => f.name).join(" + ") || "—"}
{s.description || "(无说明:建议补一句话说明,便于日后识别)"}
候选池 {sel.top_n ?? "—"} → 持仓 {sel.hold_top_x ?? sel.top_n ?? "—"} 择股 {s.selection_interval_months ?? "跟随 y"} 月 / 调仓{" "} {s.rebalance_interval_months ?? "跟随 m"} 月 复权 {adjLabel(s.price_adjustment)} {s.universe?.exclude_st ? "剔除 ST" : "含 ST"} · 费率{" "} {((costs.commission_rate ?? 0) * 100).toFixed(3)}% + 印花{" "} {((costs.stamp_tax_rate ?? 0) * 100).toFixed(2)}% {(s.conditions ?? []).length ? ( 条件 {(s.conditions ?? []).map((c) => `${c.field} ${opLabel(c.op)} ${c.value ?? ""}`).join(" 且 ")} ) : null}
{running ? "后台运行中…" : "一键回测"} 载入回测页(可微调) 看说明/公式 编辑 删除
{/* 运行区间(默认 2020-01-01 ~ 最近交易日) */}
setOpen((e.target as HTMLDetailsElement).open)}> 回测区间与资金(一键回测使用)
setStart(e.target.value)} disabled={running} /> setEnd(e.target.value)} disabled={running} /> setCapital(Number(e.target.value))} disabled={running} />
{running ? ( 任务 {jobId || "排队中…"} 后台执行中(全市场回测通常 1~5 分钟) } /> ) : null} {error ? {error} : null} {result ? (
回测完成 在实验中对比 {expId ? ( <> 打开归档(完整快照) 以此参数再跑 ) : null}
) : null}
); } function adjLabel(m?: string): string { return m === "hfq" ? "后复权" : m === "qfq" ? "前复权" : "不复权"; } function opLabel(op: string): string { return ( { gt: ">", gte: "≥", lt: "<", lte: "≤", eq: "=", ne: "≠", in: "属于", not_in: "不属于" }[op] ?? op ); }