feat: 股息率案例口径 + 策略库与图表统一 + 回测存档完整化

汇总三轮未提交的开发(每轮均在本机 MariaDB + 真实浏览器上验证):

1) 股息率案例(全市场股息率最高 n 只,默认 20,每 m 月择股)
   - 新增日频估值表 daily_basic + 迁移;股息率因子(dv_ratio / dividend_yield / TTM)
   - 名称历史表 stock_name_history:剔除 ST 按**择股日当时名称**判定,消除
     「曾高股息后 ST」的股息陷阱(实测 3.70pp 偏差)
   - 区间择股/调仓双周期(m 择股 / y 调仓)、指数成分与白名单、停牌近似剔除
   - 复权因子口径核对(4,164,742 行、缺失 0.0%)、收盘价成交与涨跌停拦单
   - 案例实测:2020-01-01~2026-09-04 总收益 +24.86%(年化 3.52%、回撤 -28.58%)

2) 策略库与前端统一
   - strategy 表 + CRUD/PUT 原地更新 + `describe_strategy` 按 spec 真实推导
     「一句话说明 + 计算公式 + 执行步骤 + 注意事项」(与引擎实执行规则同源)
   - 任何出现股票代码处都成对显示名称且可点击进个股页
   - 全站图表基座统一 TradingView Lightweight Charts(ECharts 依赖、
     锁文件、组件与文档标注一并清除),买卖点标记只落在真实交易日上

3) 回测存档完整化(可往复查看)
   - 同步端点(POST /api/backtests、/api/factor-tests)此前完全不落库 → 现在同样归档,
     归档 id 经响应头 X-Experiment-Id 返回(不破坏 response_model)
   - data_version 首次真实写入(数据快照指纹:最新交易日 + 各表规模)
   - 个股收益曲线默认**全量保存**(此前硬截断 60 只);超出体积预算才裁剪,
     并写 archive_meta(机器可读)+ unimplemented(人可读)如实标注
   - 列表 kind/q 过滤 + X-Total-Count(此前 limit=50 静默截断)、DELETE 归档
   - 只读归档页 /experiments/{id}(Server Component,SSR 直出**选股条件**与
     **交易执行依据**);结果视图按 kind 分发(backtest/factor_test/selection),
     非回测归档不套用回测口径
   - 新增 CLI:prune_experiments(保留策略,默认 dry-run)、
     restore_experiment_from_job(从 Job 副本按原 id 重建被删的历史归档,默认 dry-run)

门禁:pytest 388 passed、ruff All checks passed、tsc 0 错误、图表单测 7 passed、
next build 成功、契约脚本 verify_strategy_workspace 59/59(含按 kind 逐类验证归档页)。
This commit is contained in:
Simon
2026-09-20 07:31:04 +08:00
parent 7e15b7251e
commit 23972e7063
112 changed files with 17908 additions and 3893 deletions
+414 -250
View File
@@ -1,88 +1,280 @@
"use client";
import { useEffect, useState } from "react";
import { apiGet } from "@/lib/api";
import { submitJob, waitJob } from "@/lib/jobs";
import type { BacktestResult, FactorMeta, ResearchSpec } from "@/lib/types";
/**
* 选股回测页 —— 研究闭环里的「定规则 + 验证」环节。
*
* 本页承担三种进入方式(都可分享 URL):
* - 直接进入:从默认参数开始调;
* - `?strategy=STG-xxx`:从策略库载入一个已保存策略(可微调、可另存);
* - `?from_selection=SEL-xxx`:从选股结果直通过来,预填当时的条件/因子/TopN
* (解决「选出来的股票要手工抄到回测页」的断点);
* - `?from_experiment=EXP-xxx`:从实验详情「以此参数再跑一次」进来。
*
* 交互改进:参数区与说明/公式同屏(说明随参数实时更新,由后端推导);
* 「保存为策略」把当前参数存进策略库;结果区所有股票代码都带名称且可点进个股页。
*/
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { apiGet, apiPost } from "@/lib/api";
import { STAGE_LABEL, submitJob, waitJob } from "@/lib/jobs";
import { recentRange } from "@/lib/dates";
import type {
ActionRecord,
BacktestResult,
FactorMeta,
ResearchCondition,
ResearchSpec,
SelectionResult,
StrategyDefinition,
SymbolCurve,
} from "@/lib/types";
import { PageHeader, Card, Pill, Btn, Banner, Empty, Loading } from "@/components/ui";
import {
PageHeader,
Card,
Pill,
Field,
Btn,
Banner,
Progress,
Empty,
BacktestMetrics,
MonthlyReturnsTable,
UnimplementedNote,
} from "@/components/ui";
import { LineChart } from "@/components/LineChart";
StrategyParamsForm,
casePreset,
emptyParams,
paramsFromSpec,
paramsFromStrategy,
strategyFromParams,
validateParams,
type StrategyParams,
} from "@/components/StrategyParamsForm";
import { StrategyDocCard } from "@/components/StrategyDocCard";
import { BacktestResultView } from "@/components/BacktestResultView";
import { adjustLabel } from "@/lib/labels";
import { useStrategyDoc } from "@/lib/strategy";
import { SymbolLink, useSymbolNames } from "@/lib/symbols";
type LoadState = { kind: "none" | "strategy" | "selection" | "experiment"; label: string; note?: string };
/**
* Next 15 要求使用 `useSearchParams` 的组件处于 Suspense 边界内,
* 否则静态渲染阶段会报错(本页用 query 决定「从策略/选股/实验载入」)。
*/
export default function BacktestPage() {
return (
<Suspense fallback={<Loading label="加载回测页…" />}>
<BacktestInner />
</Suspense>
);
}
function BacktestInner() {
const search = useSearchParams();
const strategyId = search.get("strategy");
const selectionId = search.get("from_selection");
const experimentId = search.get("from_experiment");
const range = useMemo(() => recentRange(), []);
const [params, setParams] = useState<StrategyParams | null>(null);
const [factors, setFactors] = useState<FactorMeta[]>([]);
const [factor, setFactor] = useState("momentum_60");
const [topN, setTopN] = useState(5);
const [rebalance, setRebalance] = useState<"monthly" | "weekly">("monthly");
const [start, setStart] = useState("");
const [end, setEnd] = useState("");
const [excludeSt, setExcludeSt] = useState(true);
const [commission, setCommission] = useState(0.03); // %
const [stamp, setStamp] = useState(0.05);
const [slippage, setSlippage] = useState(0.1);
const [capital, setCapital] = useState(1_000_000);
const [loaded, setLoaded] = useState<LoadState>({ kind: "none", label: "" });
const [result, setResult] = useState<BacktestResult | null>(null);
const [running, setRunning] = useState(false);
const [jobId, setJobId] = useState("");
const [stage, setStage] = useState("");
const [elapsed, setElapsed] = useState(0);
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const [saving, setSaving] = useState(false);
const [lastExp, setLastExp] = useState<string | null>(null);
const [archiveId, setArchiveId] = useState("");
const [restoring, setRestoring] = useState(false);
const bootstrapped = useRef(false);
/* ---------- 初始化:因子表 + 默认/来源参数 ---------- */
useEffect(() => {
let alive = true;
apiGet<FactorMeta[]>("/factors")
.then((list) => {
if (!alive) return;
setFactors(list);
if (list.length > 0 && !list.some((f) => f.name === factor)) {
setFactor(list[0].name);
}
})
.then((list) => alive && setFactors(list))
.catch((e: Error) => alive && setError(e.message));
const { start: s, end: e } = recentRange();
setStart(s);
setEnd(e);
return () => {
alive = false;
};
}, []);
useEffect(() => {
if (bootstrapped.current) return;
bootstrapped.current = true;
const base = emptyParams(range);
try {
const prev = window.localStorage.getItem("qlib:last-backtest-experiment");
if (prev) setLastExp(prev);
} catch {
/* localStorage 不可用(隐私模式):只是少一个便捷入口,不影响主流程 */
}
(async () => {
// 1) 策略库载入
if (strategyId) {
try {
const st = await apiGet<StrategyDefinition>(`/strategies/${encodeURIComponent(strategyId)}`);
setParams({ ...paramsFromStrategy(st, base), start: base.start, end: base.end });
setLoaded({
kind: "strategy",
label: `来自策略库:${st.name}(${st.id})`,
note: "参数已载入;改完可「另存为新策略」或直接运行回测。",
});
return;
} catch (e) {
setError(`策略 ${strategyId} 载入失败:${(e as Error).message}`);
}
}
// 2) 选股结果直通:把当时的口径原样搬过来
if (selectionId) {
try {
const sel = await apiGet<SelectionResult>(`/selections/${encodeURIComponent(selectionId)}`);
// 选股把当时的查询原样存进 config_snapshot(复现用),这里据此预填
const snapQ = (sel.config_snapshot ?? {}) as {
universe?: { exclude_st?: boolean; min_listing_days?: number };
price_adjustment?: "none" | "qfq" | "hfq";
factors?: { name: string; weight: number }[];
conditions?: ResearchCondition[];
top_n?: number | null;
};
const topNQ = snapQ.top_n ?? base.topN;
setParams({
...base,
factors: snapQ.factors?.length ? snapQ.factors.map((f) => ({ ...f })) : base.factors,
conditions: (snapQ.conditions ?? []).map((c) => ({ ...c })),
topN: topNQ,
holdX: Math.max(1, Math.min(topNQ, base.holdX)),
excludeSt: snapQ.universe?.exclude_st ?? base.excludeSt,
minListingDays: snapQ.universe?.min_listing_days ?? base.minListingDays,
priceAdjustment: snapQ.price_adjustment ?? base.priceAdjustment,
start: base.start,
end: base.end,
});
setLoaded({
kind: "selection",
label: `来自选股 ${sel.as_of_date}(${sel.method})`,
note:
"已把该次选股的**规则**(条件/因子/TopN/口径)预填为回测参数。" +
"注意:回测会在每个择股日按同一规则重新选股,而不是固定持有该次选出的这批股票。",
});
return;
} catch (e) {
setError(`选股结果 ${selectionId} 载入失败:${(e as Error).message}`);
}
}
// 3) 实验参数复用
if (experimentId) {
try {
const exp = await apiGet<{ result?: BacktestResult & { config_snapshot?: Record<string, unknown> } }>(
`/experiments/${encodeURIComponent(experimentId)}`
);
const snap = exp.result?.config_snapshot as Partial<ResearchSpec> | undefined;
if (snap) {
setParams(paramsFromSpec(snap, base));
setLoaded({
kind: "experiment",
label: `来自实验 ${experimentId}`,
note: exp.result?.equity_curve?.length
? `已复刻该实验参数,并直接展示它的归档结果(未重跑)。归档快照请到 /experiments/${experimentId} 查看;改参数后可再跑一次并与它对比。`
: "已复刻该实验的参数,可改后重跑并与原实验对比。",
});
// 实验已归档完整结果:直接展示,省一次几分钟的重跑
if (exp.result?.equity_curve?.length) {
setResult(exp.result);
setLastExp(experimentId);
setArchiveId(experimentId); // 提示条里给「打开归档」入口
}
return;
}
setError(`实验 ${experimentId} 没有可复用的参数快照`);
} catch (e) {
const msg = (e as Error).message;
setError(`实验 ${experimentId} 载入失败:${msg}`);
// 归档可能已被删除:清掉 localStorage 里的指针,避免「载入上次结果」每次都给坏链接。
// 只清指针(不影响参数区),并且只有在确实是 404 时才清。
if (msg.includes("404")) {
try {
if (window.localStorage.getItem("qlib:last-backtest-experiment") === experimentId) {
window.localStorage.removeItem("qlib:last-backtest-experiment");
setLastExp(null);
}
} catch {
/* localStorage 不可用:无需清理 */
}
}
}
}
setParams(base);
})();
// 仅首次进入时按 URL 载入
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const errors = params ? validateParams(params, { requireMeta: params.name.trim() !== "" }) : {};
const blocking = ["topN", "holdX", "factors", "mMonths", "yMonths", "costs", "capital", "period", "conditions"].filter(
(k) => errors[k]
);
const doc = useStrategyDoc(params);
async function run() {
if (!factor) return;
if (!params) return;
if (blocking.length) {
setError(errors[blocking[0]]);
return;
}
setRunning(true);
setError("");
setNotice("");
setJobId("");
setArchiveId("");
setResult(null);
try {
const spec: ResearchSpec = {
type: "backtest",
universe: { exclude_st: excludeSt, min_listing_days: 0 },
factors: [{ name: factor, weight: 1 }],
selection: { top_n: topN },
rebalance,
costs: {
commission_rate: commission / 100,
stamp_tax_rate: stamp / 100,
slippage_rate: slippage / 100,
const spec = {
type: "backtest" as const,
universe: { exclude_st: params.excludeSt, min_listing_days: params.minListingDays },
price_adjustment: params.priceAdjustment,
factors: params.factors.filter((f) => f.name.trim() !== ""),
conditions: params.conditions.filter((c) => c.field.trim() !== ""),
selection: {
top_n: params.topN,
hold_top_x: params.holdX,
allow_substitute: params.fillPolicy === "substitute",
defer_buy: params.fillPolicy === "defer",
},
initial_capital: capital,
period: [start, end],
rebalance: params.rebalance,
selection_interval_months:
params.mMonths > 0 ? params.mMonths : params.yMonths > 0 ? params.yMonths : null,
rebalance_interval_months:
params.yMonths > 0 ? params.yMonths : params.mMonths > 0 ? params.mMonths : null,
costs: {
commission_rate: params.commission / 100,
stamp_tax_rate: params.stamp / 100,
slippage_rate: params.slippage / 100,
min_commission: params.minCommission,
},
initial_capital: params.capital,
period: [params.start, params.end] as [string, string],
};
// 异步 Job:后台执行(全市场可能数十秒),轮询到终态
const { job_id } = await submitJob(spec);
setJobId(job_id);
const out = await waitJob<BacktestResult>(job_id);
setStage("queued");
const out = await waitJob<BacktestResult>(job_id, 900_000, (info) => {
setStage(info.stage);
setElapsed(info.elapsedMs);
});
if (out.status === "success" && out.result) {
setResult(out.result);
const exp = out.experimentId ?? null;
if (exp) {
setLastExp(exp);
setArchiveId(exp);
try {
window.localStorage.setItem("qlib:last-backtest-experiment", exp);
} catch {
/* 忽略:仅影响下次进入的便捷入口 */
}
}
setNotice(
exp
? `回测完成,已归档为实验 ${exp}(可在「实验对比」页与其它版本对比)。`
: "回测完成,已自动归档到「实验对比」页。"
);
} else {
setError(`任务${out.status}${out.error ? `:${out.error}` : ""}`);
}
@@ -94,229 +286,201 @@ export default function BacktestPage() {
}
}
/** 载入上次回测结果:避免刷新页面后必须重跑一个几分钟的作业 */
async function loadLast() {
if (!lastExp) return;
setRestoring(true);
setError("");
try {
const exp = await apiGet<{ result?: BacktestResult }>(`/experiments/${encodeURIComponent(lastExp)}`);
if (exp.result?.equity_curve?.length) {
setResult(exp.result);
setArchiveId(lastExp);
setNotice(`已载入上次归档结果(${lastExp}),未重新执行作业。`);
} else {
setError(`实验 ${lastExp} 没有可载入的回测结果`);
}
} catch (e) {
setError((e as Error).message);
} finally {
setRestoring(false);
}
}
async function saveAsStrategy() {
if (!params) return;
const need = validateParams(params, { requireMeta: true });
if (Object.keys(need).length) {
setError(Object.values(need)[0]);
return;
}
setSaving(true);
setError("");
try {
const saved = await apiPost<StrategyDefinition>("/strategies", strategyFromParams(params));
setNotice(`已保存到策略库:「${saved.name}」(${saved.id})—— 可在策略库一键回测/编辑。`);
setLoaded({ kind: "strategy", label: `已保存为策略 ${saved.name}(${saved.id})` });
} catch (e) {
setError((e as Error).message);
} finally {
setSaving(false);
}
}
return (
<>
<PageHeader
title="选股回测"
sub="以单个因子的横截面排序构建 Top N 组合并完整回测:净值 / 回撤 / 月度收益 / 持仓明细。"
sub="两级截断择股(候选池 n → 持仓 x)+ 择股/调仓双周期(m/y),收盘价撮合,买卖点与个股收益曲线可视化,结果自动归档可复现。"
actions={
result ? (
<Pill tone="pos" icon="check">
最近一次回测已完成
</Pill>
) : null
<div className="row" style={{ gap: 8 }}>
<Link href="/strategies" className="btn">
<span>策略库</span>
</Link>
<Link href="/experiments" className="btn">
<span>实验对比</span>
</Link>
</div>
}
/>
<Card title="研究参数(Research Specification)" icon="gauge">
<div className="form-grid">
<Field label="因子">
<select
className="input"
value={factor}
onChange={(e) => setFactor(e.target.value)}
>
{factors.map((f) => (
<option key={f.name} value={f.name}>
{f.name}
</option>
))}
</select>
</Field>
<Field label="Top N">
<input
type="number"
className="input"
min={1}
max={50}
value={topN}
onChange={(e) => setTopN(Number(e.target.value))}
/>
</Field>
<Field label="调仓频率">
<select
className="input"
value={rebalance}
onChange={(e) => setRebalance(e.target.value as "monthly" | "weekly")}
>
<option value="monthly">月度</option>
<option value="weekly">周度</option>
</select>
</Field>
<Field label="手续费率 %">
<input className="input" type="number" step="0.01" min={0} value={commission}
onChange={(e) => setCommission(Number(e.target.value))} />
</Field>
<Field label="印花税率 %">
<input className="input" type="number" step="0.01" min={0} value={stamp}
onChange={(e) => setStamp(Number(e.target.value))} />
</Field>
<Field label="滑点率 %">
<input className="input" type="number" step="0.01" min={0} value={slippage}
onChange={(e) => setSlippage(Number(e.target.value))} />
</Field>
<Field label="初始资金(元)">
<input className="input" type="number" step={100000} min={10000} value={capital}
onChange={(e) => setCapital(Number(e.target.value))} />
</Field>
<Field label="开始日期">
<input type="date" className="input" value={start} onChange={(e) => setStart(e.target.value)} />
</Field>
<Field label="结束日期">
<input type="date" className="input" value={end} onChange={(e) => setEnd(e.target.value)} />
</Field>
<Field label="标的范围">
<label className="row" style={{ gap: 6, color: "var(--text-2)", fontSize: 13, cursor: "pointer" }}>
<input type="checkbox" checked={excludeSt} onChange={(e) => setExcludeSt(e.target.checked)} />
剔除 ST
</label>
</Field>
<Btn variant="primary" icon="play" loading={running} disabled={running} onClick={run}>
{running ? "后台运行中…" : "运行回测"}
{lastExp && !result && !running ? (
<div className="sticky-bar" style={{ position: "static" }}>
<Pill tone="accent" icon="archive">
上次结果
</Pill>
<span className="hint">
本机上次回测已归档为实验 <span className="mono">{lastExp}</span>;
直接载入可省去重跑(全市场 8 年区间约 3~5 分钟)。
</span>
<Btn size="sm" icon="refresh" loading={restoring} disabled={restoring} onClick={loadLast}>
载入上次结果
</Btn>
<Btn
size="sm"
icon="x"
onClick={() => {
setLastExp(null);
try {
window.localStorage.removeItem("qlib:last-backtest-experiment");
} catch {
/* 忽略 */
}
}}
>
忽略
</Btn>
</div>
) : null}
{loaded.kind !== "none" ? (
<Banner tone="info">
{loaded.label}
{loaded.note ? ` · ${loaded.note}` : ""}
</Banner>
) : null}
{error ? <Banner tone="error">{error}</Banner> : null}
{notice ? (
<Banner tone="info">
{notice}
{archiveId ? (
<>
{" "}
<Link href={`/experiments/${encodeURIComponent(archiveId)}`} className="btn btn--sm">
<span>打开归档(完整快照)</span>
</Link>
</>
) : null}
</Banner>
) : null}
<Card
icon="gauge"
title="研究参数(Research Specification)"
tools={
<div className="row" style={{ gap: 8 }}>
<Btn icon="target" disabled={running || !params} onClick={() => params && setParams({ ...casePreset(range), name: params.name, description: params.description })}>
载入高股息案例默认参数
</Btn>
<Btn icon="book" loading={saving} disabled={running || saving || !params} onClick={saveAsStrategy}>
保存为策略
</Btn>
</div>
}
>
{params ? (
<>
<StrategyParamsForm
value={params}
onChange={setParams}
factorOptions={factors}
showMeta
showPeriod
disabled={running}
errors={errors}
/>
<div className="sticky-bar" style={{ marginTop: 14, position: "static" }}>
<Btn variant="primary" icon="play" loading={running} disabled={running || blocking.length > 0} onClick={run}>
{running ? "后台运行中…" : "运行回测"}
</Btn>
<span className="hint">
n={params.topN} → x={params.holdX} · m={params.mMonths} / y={params.yMonths} ·{" "}
{adjustLabel(params.priceAdjustment)} · {params.start} ~ {params.end}
</span>
{blocking.length ? (
<Pill tone="warn" icon="alert">
有 {blocking.length} 处参数需要修正
</Pill>
) : (
<Pill tone="pos" icon="check">
参数校验通过
</Pill>
)}
</div>
</>
) : (
<Loading label="准备参数…" />
)}
{running ? (
<div style={{ marginTop: 14 }}>
<Progress
value={35}
label={
<>
任务 <span className="mono">{jobId || "排队中…"}</span> 后台执行中,请稍候…
</>
}
/>
<div className="stages" style={{ marginTop: 12 }}>
{["data_loading", "selection", "backtesting", "analysis"].map((st) => {
const order = ["queued", "data_loading", "selection", "backtesting", "analysis", "done"];
const cur = order.indexOf(stage);
const mine = order.indexOf(st);
const cls = mine < cur ? "stage is-done" : mine === cur ? "stage is-active" : "stage";
return (
<span className={cls} key={st}>
{mine < cur ? "✓" : mine === cur ? "●" : "○"} {STAGE_LABEL[st] ?? st}
</span>
);
})}
<span className="hint">
作业 <span className="mono">{jobId || "排队中…"}</span> · 已用{" "}
{Math.round(elapsed / 1000)}s(全市场 8 年区间约 3~5 分钟,可离开本页,结果会归档)
</span>
</div>
) : null}
{error ? <div style={{ marginTop: 12 }}><Banner tone="error">{error}</Banner></div> : null}
</Card>
<StrategyDocCard
doc={doc.doc}
loading={doc.loading}
error={doc.error}
title="策略说明与计算公式(随参数实时更新)"
/>
{!result && !running && !error ? (
<Card>
<Empty
icon="gauge"
title="尚未运行回测"
hint="设置上方参数后运行;完成展示净值曲线、回撤、月度收益与最新持仓。"
hint="可点「载入高股息案例默认参数」一键填充;调好后点「保存为策略」即可在策略库复用、对比。"
/>
</Card>
) : null}
{result ? <ResultView result={result} /> : null}
{result ? <BacktestResultView result={result} name={params?.name ?? ""} /> : null}
</>
);
}
function ResultView({ result }: { result: BacktestResult }) {
const s = result.summary;
const last = result.positions.at(-1);
const holdings = result.positions.filter((p) => p.date === last?.date);
return (
<>
<div className="between" style={{ margin: "6px 0 14px" }}>
<div className="row">
<Pill tone="pos" icon="check">完成</Pill>
<Pill tone="violet" icon="target">Top N 等权</Pill>
<Pill>{s.start} ~ {s.end}</Pill>
</div>
<div className="row" style={{ fontSize: 22, fontWeight: 700 }}>
总收益 <SignedText value={s.total_return_pct} />
</div>
</div>
<BacktestMetrics s={s} />
<div className="chart-grid">
<Card icon="chartLine" title="净值曲线" tools={<Pill tone="pos">期末 {s.final_equity.toLocaleString()}</Pill>}>
<LineChart data={result.equity_curve} color="#3ddc97" fill height={300} xFmt={(d) => d.slice(5)} yFmt={(v) => v.toFixed(2)} />
</Card>
<Card icon="chartLine" title="回撤(%)" tools={<Pill tone="neg">最大 {s.max_drawdown_pct.toFixed(2)}%</Pill>}>
<LineChart data={result.drawdown} color="#ff7a7a" height={300} xFmt={(d) => d.slice(5)} yFmt={(v) => v.toFixed(2) + "%"} />
</Card>
</div>
<Card icon="calendar" title="月度收益(%)">
<MonthlyReturnsTable rows={result.monthly_returns} />
</Card>
<Card icon="target" title={`最新持仓 · ${last?.date ?? "—"}`}>
{holdings.length === 0 ? (
<Empty title="无持仓记录" hint="回测区间内没有产生持仓。" />
) : (
<div className="chips">
{holdings.map((p) => (
<span className="chip" key={p.symbol}>
<b className="mono">{p.symbol}</b>
{(p.weight * 100).toFixed(0)}%
</span>
))}
</div>
)}
</Card>
{result.yearly_returns.length ? (
<Card icon="calendar" title="年度收益(%)" tools={<Pill>均值 {(result.yearly_returns.reduce((a, y) => a + y.return_pct, 0) / result.yearly_returns.length).toFixed(2)}%</Pill>}>
<div className="chips">
{result.yearly_returns.map((y) => (
<span className="chip" key={y.year}>
<b>{y.year}</b>
<span className={y.return_pct >= 0 ? "tone-pos" : "tone-neg"}>
{y.return_pct.toFixed(2)}%
</span>
</span>
))}
</div>
</Card>
) : null}
{result.trades.length ? (
<Card
icon="scale"
title={`成交明细 · ${result.trades.length} 笔`}
tools={<Pill>{result.turnover_pct.toFixed(2)}% 累计换手</Pill>}
>
<div className="table-wrap">
<table className="tbl">
<thead>
<tr>
<th>买入日</th>
<th>卖出日</th>
<th>代码</th>
<th>买价</th>
<th>卖价</th>
<th>收益</th>
</tr>
</thead>
<tbody>
{result.trades.map((t) => (
<tr key={`${t.symbol}-${t.entry_date}-${t.exit_date}`}>
<td className="mono dim">{t.entry_date}</td>
<td className="mono dim">{t.exit_date}</td>
<td className="mono"><b>{t.symbol}</b></td>
<td className="mono">{t.entry_price?.toFixed(2) ?? "-"}</td>
<td className="mono">{t.exit_price?.toFixed(2) ?? "-"}</td>
<td className={t.return_pct >= 0 ? "tone-pos" : "tone-neg"}>
{t.return_pct.toFixed(2)}%
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
) : null}
<UnimplementedNote items={result.unimplemented} />
</>
);
}
function SignedText({ value }: { value: number }) {
const cls = value >= 0 ? "tone-pos" : "tone-neg";
return (
<span className={cls}>
{value > 0 ? "+" : ""}
{value.toFixed(2)}%
</span>
);
}
+610
View File
@@ -0,0 +1,610 @@
/**
* 归档详情页(`/experiments/{id}`)—— 「往复查看」的落点。
*
* 为什么单独一页,而不复用回测页:
* 回测页是**可编辑参数**的工作台,从它看归档会被误认为「当前参数就是归档参数」;
* 归档需要的是一个**只读、口径固定、可分享**的视图:URL 即快照地址,刷新/换设备都能看,
* 且必须明确回答两个问题 ——
* ① **选股条件**:这次回测到底按什么规则选股(因子/权重/条件/股票池/两级截断/周期/补位)
* ② **交易执行依据**:成交时点、价格口径、成本、涨跌停与停牌怎么处理、期末是否平仓
* 这两块来自归档里存的 **spec(复现依据)**,不来自当前页面状态,因此永不漂移;
* 同时用后端 `describe_strategy`(与引擎实执行规则同源)生成完整说明与计算公式。
*
* 结果区交给 `ArchiveResultView` 按归档 `kind` 分发(backtest/factor_test/selection 结构不同,
* 不能假定回测字段)——回测归档与刚跑完时看到的**完全同一套图表与表格**,
* 避免「归档里少一张图」的隐性不一致。
*
* **这里是 Server Component**:归档是只读内容,选股条件/执行依据/元数据必须在
* 服务端直出(否则不带 JS 的抓取与链接预览拿到的只是一个 Loading 骨架——
* 实测 SSR HTML 里搜不到「选股条件」)。图表与删除/导出等交互由客户端子组件承担。
*/
import { notFound } from "next/navigation";
import Link from "next/link";
import { ArchiveActions } from "@/components/ArchiveActions";
import { StrategyDocCard } from "@/components/StrategyDocCard";
import { ArchiveResultView } from "@/components/ArchiveResultView";
import { archivedFieldSummary } from "@/lib/archive";
import { PageHeader, Card, Pill, Empty, Banner } from "@/components/ui";
import { adjustLabel, experimentKindLabel, rebalanceLabel } from "@/lib/labels";
import { RichText } from "@/components/RichText";
import type { ExperimentDetail, FactorMeta, ResearchCondition, ResearchSpec, StrategyDoc } from "@/lib/types";
export const dynamic = "force-dynamic"; // 归档可能被删除/新增,禁止静态化缓存
/** 服务端访问后端:与 next.config.ts 的代理同一默认地址(服务端没有「同源」可用) */
const BACKEND = (process.env.BACKEND_API_URL ?? "http://127.0.0.1:8000").replace(/\/$/, "");
async function serverGet<T>(path: string): Promise<T | null> {
try {
const r = await fetch(`${BACKEND}/api${path}`, { cache: "no-store" });
if (!r.ok) return null;
return (await r.json()) as T;
} catch {
return null;
}
}
async function serverPost<T>(path: string, body: unknown): Promise<T | null> {
try {
const r = await fetch(`${BACKEND}/api${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
cache: "no-store",
});
if (!r.ok) return null;
return (await r.json()) as T;
} catch {
return null;
}
}
export default async function ExperimentArchivePage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id: rawId } = await params;
const id = decodeURIComponent(rawId ?? "");
const [detail, factors] = await Promise.all([
serverGet<ExperimentDetail>(`/experiments/${encodeURIComponent(id)}`),
serverGet<FactorMeta[]>("/factors"),
]);
// 归档不存在 → 交给 Next 的 404(比渲染一个空壳页更诚实)
if (!detail) notFound();
const spec = (detail.spec ?? null) as Partial<ResearchSpec> | null;
const result = detail.result;
// 关键字段计数按 kind 给不同口径(用回测字段去数因子/选股结果是错的)
const fieldSummary = archivedFieldSummary(detail.kind, result);
// 与引擎执行规则同源的说明(后端按归档 spec 推导;推导失败时如实显示错误)
// 只有回测归档才需要「完整说明与计算公式」:describe 推导的是回测策略口径,
// 因子测试/选股用同一 spec 结构但规则并未执行,拉回来展示等于误导(且白花一次请求)。
const doc = spec && detail.kind === "backtest"
? await serverPost<StrategyDoc & { doc?: StrategyDoc }>("/strategies/describe", spec)
: null;
const docBody = doc ? ((doc as { doc?: StrategyDoc }).doc ?? doc) : null;
return (
<>
<PageHeader
title={`归档 ${detail.id}`}
sub={`${experimentKindLabel(detail.kind)} · ${detail.summary_text ?? "无摘要"}`}
/>
<ArchiveActions id={detail.id} spec={detail.spec} />
<ArchivedSpecCards detail={detail} spec={spec} factors={factors ?? []} />
{detail.kind === "backtest" ? (
<StrategyDocCard
doc={docBody}
loading={false}
error={spec && !docBody ? "后端未能给出该 spec 的说明(describe 接口失败)" : ""}
title="完整说明与计算公式(后端按归档 spec 推导,与引擎执行规则同源)"
/>
) : null}
<Card icon="archive" title="归档元数据(复现依据)">
<div className="kv-grid">
<KV label="实验 ID" value={detail.id} mono />
<KV label="类型" value={experimentKindLabel(detail.kind)} />
<KV label="归档时间" value={fmtTime(detail.created_at)} mono />
<KV label="代码版本" value={detail.code_version ?? "—"} mono />
<KV
label="数据版本"
value={detail.data_version ?? "未记录(该归档早于数据指纹上线)"}
mono
warn={!detail.data_version}
/>
<KV label="来源作业" value={detail.job_id ?? "同步接口调用(无作业 id)"} mono />
<KV
label="归档体积"
value={detail.result_bytes ? `${(detail.result_bytes / 1024).toFixed(0)} KB` : "—"}
/>
<KV
label="区间"
value={detail.period ? `${detail.period[0]} ~ ${detail.period[1]}` : "—"}
mono
/>
</div>
<div className="hint" style={{ marginTop: 10 }}>
<RichText text="数据版本由后端在归档时写入(数据快照指纹):**同样的代码版本 + 同样的数据版本 + 同样的 spec,才应当复现出同样的结果**。若数据版本显示「未记录」,说明该归档产生于指纹上线之前 —— 此时不要把它当作可严格复现的依据。" />
</div>
<div className="row" style={{ marginTop: 10, gap: 8 }}>
{fieldSummary ? <span className="hint">{fieldSummary}</span> : null}
</div>
</Card>
{result ? (
<ArchiveResultView
kind={detail.kind}
result={result}
archive={{
id: detail.id,
created_at: detail.created_at,
code_version: detail.code_version,
data_version: detail.data_version,
job_id: detail.job_id,
}}
/>
) : (
<Card>
<Empty
icon="archive"
title="该归档没有可展示的结果"
hint={
detail.kind === "factor_test"
? "因子测试归档没有净值曲线(结果结构不同);结果为空时只能导出 JSON 查看原始内容。"
: "结果为空(可能是历史版本归档)。可用上方「导出完整 JSON」查看原始内容。"
}
/>
</Card>
)}
</>
);
}
/* ------------------------------------------------------------------ */
function KV({
label,
value,
mono,
warn,
}: {
label: string;
value: string;
mono?: boolean;
warn?: boolean;
}) {
return (
<div className="kv">
<span className="kv__k">{label}</span>
<span className={`kv__v${mono ? " mono" : ""}${warn ? " kv__v--warn" : ""}`}>{value}</span>
</div>
);
}
function fmtTime(v?: string | null): string {
if (!v) return "—";
const d = new Date(v);
if (Number.isNaN(d.getTime())) return v;
const p = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
function directionText(name: string, factors: FactorMeta[]): string {
const f = factors.find((x) => x.name === name);
if (!f) return "—";
return f.direction === "lower_is_better" ? "越低越好" : "越高越好";
}
function opText(op?: string): string {
switch (op) {
case "gt":
return ">";
case "gte":
return "≥";
case "lt":
return "<";
case "lte":
return "≤";
case "eq":
return "=";
case "ne":
return "≠";
case "in":
return "∈";
case "not_in":
return "∉";
default:
return op ?? "?";
}
}
function condText(c: ResearchCondition): string {
const right = c.ref ? `(字段 ${c.ref})` : `${c.value ?? ""}`;
return `${c.field} ${opText(c.op)} ${right}`;
}
/**
* 因子测试归档的 spec 卡:只展示**真正生效**的字段。
*
* 诚实性要点(AGENT §7/§24):因子测试按横截面算 IC / RankIC / 分层收益,
* **不做两级截断、不下单、不计成本**。但归档 spec 沿用了与回测相同的请求结构
* (含 `selection` / `costs` / `initial_capital`)—— 把这些字段按回测口径展示,
* 会让人以为这次跑过调仓与撮合。所以这里只列生效项,并显式说明哪些字段未生效。
*/
function FactorTestSpecCards({
spec,
factors,
}: {
spec: Partial<ResearchSpec>;
factors: FactorMeta[];
}) {
const uni = (spec.universe ?? {}) as Record<string, unknown>;
const factorList = (spec.factors ?? []) as { name: string; weight: number }[];
const period = spec.period ?? [];
return (
<>
<Card
icon="flask"
title="因子测试配置(本次实际生效的字段)"
tools={<Pill tone="violet">来自归档 spec,非当前页面参数</Pill>}
>
<div className="spec-grid">
<SpecBlock title="被测因子(横截面 z-score 加权)">
{factorList.length === 0 ? (
<SpecRow k="因子" v="—" />
) : (
factorList.map((f) => (
<SpecRow
key={f.name}
k={f.name}
v={`权重 ${f.weight} ${directionText(f.name, factors)}`}
/>
))
)}
</SpecBlock>
<SpecBlock title="横截面范围(universe)">
<SpecRow k="市场" v={String(uni.market ?? "CN_A")} />
<SpecRow k="剔除 ST" v={uni.exclude_st ? "是(按当时名称判定)" : "否"} />
<SpecRow
k="剔除停牌"
v={uni.exclude_suspended ? "是(近似:当日无行情)" : "否"}
/>
<SpecRow
k="上市天数下限"
v={uni.min_listing_days ? `${String(uni.min_listing_days)} 天` : "无限制"}
/>
</SpecBlock>
<SpecBlock title="测试区间">
<SpecRow
k="区间"
v={period.length === 2 ? `${period[0]} ~ ${period[1]}` : "—"}
mono
/>
</SpecBlock>
</div>
<div className="hint" style={{ marginTop: 10 }}>
<RichText text="本次归档类型是**因子测试**:按横截面计算 IC / RankIC / 分层收益,**不做两级截断(n→x)、不下单、不计交易成本**。归档 spec 里沿用了与回测相同的请求结构(`selection` / `costs` / `initial_capital` 等),但这些字段本次**未生效** —— 不要把它们当作执行依据。" />
</div>
</Card>
</>
);
}
/**
* 选股归档的 spec 卡:一次性选股的规则(无调仓、无成交)。
*/
function SelectionSpecCards({
spec,
factors,
}: {
spec: Partial<ResearchSpec>;
factors: FactorMeta[];
}) {
const uni = (spec.universe ?? {}) as Record<string, unknown>;
const factorList = (spec.factors ?? []) as { name: string; weight: number }[];
const conds = ((spec as Record<string, unknown>).conditions ?? []) as ResearchCondition[];
const method = String((spec as Record<string, unknown>).method ?? "score");
const topN = (spec as Record<string, unknown>).top_n;
const topPct = (spec as Record<string, unknown>).top_pct;
return (
<Card
icon="target"
title="选股条件(本次一次性选股实际使用的规则)"
tools={<Pill tone="violet">来自归档 spec,非当前页面参数</Pill>}
>
<div className="spec-grid">
<SpecBlock title="时点与方式">
<SpecRow
k="选股时点"
v={String((spec as Record<string, unknown>).as_of ?? "最近交易日")}
mono
/>
<SpecRow k="方式" v={method === "condition" ? "条件筛选" : "因子评分"} />
<SpecRow
k="复权口径"
v={spec.price_adjustment === "hfq" ? "后复权(hfq)" : "不复权(none)"}
/>
</SpecBlock>
<SpecBlock title={method === "condition" ? "筛选条件(AND)" : "打分因子(横截面 z-score 加权)"}>
{method === "condition"
? conds.length === 0
? <SpecRow k="条件" v="—" />
: conds.map((c) => <SpecRow key={condText(c)} k={c.field} v={condText(c)} />)
: factorList.length === 0
? <SpecRow k="因子" v="—" />
: factorList.map((f) => (
<SpecRow
key={f.name}
k={f.name}
v={`权重 ${f.weight} ${directionText(f.name, factors)}`}
/>
))}
</SpecBlock>
<SpecBlock title="股票池与选出数量">
<SpecRow k="市场" v={String(uni.market ?? "CN_A")} />
<SpecRow k="剔除 ST" v={uni.exclude_st ? "是(按当时名称判定)" : "否"} />
<SpecRow
k="剔除停牌"
v={uni.exclude_suspended ? "是(近似:当日无行情)" : "否"}
/>
<SpecRow
k="选出数量"
v={
topN
? `Top ${String(topN)}`
: topPct
? `前 ${String(topPct)}%`
: "—"
}
/>
</SpecBlock>
</div>
<div className="hint" style={{ marginTop: 10 }}>
<RichText text="本次归档类型是**一次性选股**:只在上述**单个交易日**求值一次,**没有调仓周期、没有成交与成本**(不会买卖任何东西)。归档 spec 里若出现 `costs` / `rebalance`,那只是同一请求结构的字段,本次未生效。" />
</div>
</Card>
);
}
/** 选股条件 + 交易执行依据:直接用归档 spec 渲染,回答「这次回测到底怎么选的、怎么成交的」 */
function ArchivedSpecCards({
detail,
spec,
factors,
}: {
detail: ExperimentDetail;
spec: Partial<ResearchSpec> | null;
factors: FactorMeta[];
}) {
if (!spec) {
return (
<Card icon="book" title="选股条件与交易执行依据">
<div className="hint">
该归档没有存储 spec(早于 spec 归档上线),无法回溯当时的规则;只能看下方结果。
</div>
</Card>
);
}
// 按归档类型分发:**只有回测**才有「选股条件 + 交易执行依据」这一对。
// 因子测试/选股与回测共用同一请求结构(spec 里也有 selection/costs/initial_capital),
// 但那些字段在因子测试/选股里**从未生效** —— 沿用回测口径展示等于谎报执行依据。
if (detail.kind === "factor_test") {
return <FactorTestSpecCards spec={spec} factors={factors} />;
}
if (detail.kind === "selection") {
return <SelectionSpecCards spec={spec} factors={factors} />;
}
if (detail.kind !== "backtest") {
return (
<Card icon="book" title={`归档 spec(类型:${detail.kind})`}>
<div className="hint">
<RichText text="该类型的 spec 展示口径尚未定义,这里**不猜测**它的含义;原始 spec 可用上方「查看原始 spec(JSON)」逐字查看。" />
</div>
</Card>
);
}
const uni = (spec.universe ?? {}) as Record<string, unknown>;
const sel = (spec.selection ?? {}) as Record<string, unknown>;
const costs = (spec.costs ?? {}) as Record<string, unknown>;
const conds = (spec.conditions ?? []) as ResearchCondition[];
const factorList = (spec.factors ?? []) as { name: string; weight: number }[];
const fillPolicy =
sel.defer_buy === true
? "顺延买入(等它到之后首个不涨停的交易日按收盘价买)"
: sel.allow_substitute === false
? "不补位(买不进就空着,实际持仓可能少于目标数)"
: "替补买入(从候选池之外按复合分往下找可买标的)";
return (
<>
<Card
icon="target"
title="选股条件(本次回测实际使用的规则)"
tools={<Pill tone="violet">来自归档 spec,非当前页面参数</Pill>}
>
<div className="spec-grid">
<SpecBlock title="股票池(universe)">
<SpecRow k="市场" v={String(uni.market ?? "CN_A")} />
<SpecRow
k="剔除 ST"
v={uni.exclude_st ? "是(按择股日**当时名称**判定)" : "否"}
/>
<SpecRow
k="上市天数下限"
v={uni.min_listing_days ? `${uni.min_listing_days} 个自然日` : "无限制"}
/>
<SpecRow k="指数成分" v={uni.index_code ? String(uni.index_code) : "不限定"} />
<SpecRow
k="白名单"
v={
Array.isArray(uni.symbols) && uni.symbols.length
? `${uni.symbols.length} 只`
: "全市场"
}
/>
<SpecRow k="剔除停牌" v={uni.exclude_suspended ? "是(近似:当日无行情)" : "否"} />
</SpecBlock>
<SpecBlock title="打分因子(横截面 z-score 加权)">
{factorList.length === 0 ? (
<div className="hint">无(纯条件选股)</div>
) : (
factorList.map((f) => (
<SpecRow
key={f.name}
k={`${f.name} 权重 ${f.weight}`}
v={directionText(f.name, factors)}
mono
/>
))
)}
<div className="hint" style={{ marginTop: 6 }}>
复合分 = Σ(权重 × 方向 × 当日截面 z-score),降序取候选池。
</div>
</SpecBlock>
<SpecBlock title="过滤条件(AND,逐择股日求值)">
{conds.length === 0 ? (
<div className="hint">无附加条件</div>
) : (
conds.map((c, i) => <SpecRow key={`${c.field}-${i}`} k={`条件 ${i + 1}`} v={condText(c)} mono />)
)}
</SpecBlock>
<SpecBlock title="两级截断与周期">
<SpecRow k="候选池 n" v={sel.top_n != null ? `${sel.top_n} 只` : "—"} />
<SpecRow
k="实际持仓 x"
v={
sel.hold_top_x != null && sel.hold_top_x !== sel.top_n
? `${sel.hold_top_x} 只`
: `= n${sel.top_n != null ? `(${sel.top_n} 只)` : ""}`
}
/>
<SpecRow
k="择股周期 m"
v={
spec.selection_interval_months
? `每 ${spec.selection_interval_months} 个月重新择股`
: "每次调仓都重新择股"
}
/>
<SpecRow
k="调仓周期 y"
v={
spec.rebalance_interval_months
? `每 ${spec.rebalance_interval_months} 个月调仓`
: `跟随调仓频率(${rebalanceLabel(spec.rebalance)})`
}
/>
<SpecRow k="买不进时" v={fillPolicy} />
<SpecRow k="资金分配" v="等权(可用现金 / 目标持仓数)" />
</SpecBlock>
</div>
</Card>
<Card icon="scale" title="交易执行依据(撮合价 / 成本 / 约束)">
<div className="spec-grid">
<SpecBlock title="成交口径">
<SpecRow k="价格口径" v={adjustLabel(spec.price_adjustment as string)} />
<SpecRow k="成交时点" v="调仓日**收盘**(调仓在当日收盘生效,自次日起计收益)" />
<SpecRow
k="买入价"
v={`收盘价 × (1 + 滑点 ${pct(costs.slippage_rate)})`}
/>
<SpecRow
k="卖出价"
v={`收盘价 × (1 − 滑点 ${pct(costs.slippage_rate)})`}
/>
<SpecRow k="回测区间" v={detail.period ? `${detail.period[0]} ~ ${detail.period[1]}` : "—"} mono />
<SpecRow
k="初始资金"
v={
spec.initial_capital != null
? `${Number(spec.initial_capital).toLocaleString("zh-CN")} 元`
: "—"
}
/>
</SpecBlock>
<SpecBlock title="成本">
<SpecRow k="佣金" v={`${pct(costs.commission_rate)}(买入与卖出各计一次)`} />
<SpecRow k="印花税" v={`${pct(costs.stamp_tax_rate)}(仅卖出)`} />
<SpecRow k="滑点" v={pct(costs.slippage_rate)} />
<SpecRow
k="最低佣金"
v={
Number(costs.min_commission ?? 0) > 0
? `${costs.min_commission} 元/笔(max(按比例, 最低))`
: "未启用(0)"
}
/>
<SpecRow
k="对照基准"
v={String(costs.benchmark ?? "000300.SH") + "(仅展示,不参与交易)"}
/>
</SpecBlock>
<SpecBlock title="成交约束(近似建模)">
<SpecRow k="涨停" v="收盘价 / 上一有效收盘 ≥ 板块涨停系数 → 不可买入" />
<SpecRow k="跌停" v="→ 不可卖出(持仓保留到下一次调仓)" />
<SpecRow k="停牌" v="当日无行情 → 不可买不可卖" />
<SpecRow k="顺延买入" v={sel.defer_buy ? "启用(到下次调仓仍未成交则作废,资金留作现金)" : "未启用"} />
<SpecRow k="期末平仓" v="否 —— 最后持仓按最后交易日收盘估值,浮盈浮亏计入净值但不产生 Trade 记录" />
</SpecBlock>
<SpecBlock title="复现提示">
<div className="hint">
① 同样的 spec + 同样的代码版本 + 同样的数据版本才应复现同样结果;
② 复权口径为 {adjustLabel(spec.price_adjustment as string)},
{spec.price_adjustment === "none"
? "现金分红未计入收益、除权日价格下移会记为亏损 —— 股息类策略建议用 hfq 重跑对比。"
: "分红按复权因子隐含再投资处理。"}
③ 未建模项(涨跌停开盘路径、停牌明细、流动性冲击等)写在该归档结果的
<RichText text="`unimplemented`" /> 列表里,下方结果区已如实展示。
</div>
</SpecBlock>
</div>
{detail.kind !== "backtest" ? (
<div className="hint" style={{ marginTop: 10 }}>
该归档类型为「{experimentKindLabel(detail.kind)}」,其 spec 中的择股/调仓/成本字段不参与计算。
</div>
) : null}
</Card>
</>
);
}
function SpecBlock({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="spec-block">
<div className="spec-block__title">{title}</div>
{children}
</div>
);
}
function SpecRow({ k, v, mono }: { k: string; v: string; mono?: boolean }) {
return (
<div className="spec-row">
<span className="spec-row__k">{k}</span>
<span className={`spec-row__v${mono ? " mono" : ""}`}>
<RichText text={v} />
</span>
</div>
);
}
function pct(v: unknown): string {
const n = Number(v);
if (!Number.isFinite(n)) return "未记录";
return `${(n * 100).toFixed(3).replace(/0+$/, "").replace(/\.$/, "")}%`;
}
+536 -77
View File
@@ -1,8 +1,24 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { apiGet, apiPost } from "@/lib/api";
import type { BacktestResult } from "@/lib/types";
/**
* 实验归档 + **对比**(微调闭环的核心)。
*
* 过去这一页只能「看详情 / 复跑」,微调时只能翻列表比数字。现在:
* - 勾选 2~3 个实验 → 净值曲线归一化为「累计收益率 %」叠加(消除初始资金差异),
* 因此可以直接看出「改了哪个参数、曲线变好还是变坏」;
* - 指标对比表:每个指标一行、每个实验一列,最后一列是与第一个实验的**差值**;
* - 参数 diff 表:把 config_snapshot 扁平化后逐项对比,默认只显示「有差异」的项 ——
* 微调时一眼看出「这次到底改了什么」。
*
* 曲线用 TradingView Lightweight Charts(全站统一图表基座)。
*/
import { Suspense, useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { apiGet, apiGetWithHeaders, apiPost } from "@/lib/api";
import { experimentKindLabel } from "@/lib/labels";
import type { BacktestResult, ExperimentDetail, ExperimentMeta } from "@/lib/types";
import { isBacktestDetail, type BacktestDetail } from "@/lib/archive";
import {
PageHeader,
Card,
@@ -12,70 +28,98 @@ import {
Empty,
Metric,
SkeletonLines,
Loading,
} from "@/components/ui";
import { Icon } from "@/components/icons";
import { LwChart, type LwSeries } from "@/components/charts/LwChart";
import { CHART, seriesColor } from "@/components/charts/theme";
interface ExperimentMeta {
id: string;
kind: string;
factors: string[];
period?: [string, string] | null;
rebalance?: string;
top_n?: number | null;
summary_text?: string | null;
code_version?: string | null;
created_at?: string;
}
interface ExperimentDetail extends ExperimentMeta {
spec: { universe?: Record<string, unknown> };
result: BacktestResult | null;
}
const KIND_MAP: Record<string, { label: string; tone: "accent" | "violet" | "default" }> = {
backtest: { label: "回测", tone: "accent" },
factor_test: { label: "因子测试", tone: "violet" },
/**
* 类型徽标的**色调**(措辞统一走 `lib/labels.ts::experimentKindLabel`)。
*
* 之前这里自己维护了一份 `KIND_MAP` 文案,只覆盖 backtest/factor_test,
* 于是 `selection` 在表格里显示成英文原值 `selection`,而同一页的筛选下拉写「选股」、
* 归档页写「选股」—— 同一口径三种叫法。现在文案只有一份,这里只管颜色。
*/
const KIND_TONE: Record<string, "accent" | "violet" | "default"> = {
backtest: "accent",
factor_test: "violet",
selection: "default",
};
function kindView(kind: string) {
const k = KIND_MAP[kind] ?? { label: kind, tone: "default" as const };
return <Pill tone={k.tone}>{k.label}</Pill>;
return <Pill tone={KIND_TONE[kind] ?? "default"}>{experimentKindLabel(kind)}</Pill>;
}
function statusView(status: string) {
switch (status) {
case "success":
return (
<Pill tone="pos" icon="check">
完成
</Pill>
);
case "running":
return (
<Pill tone="accent" icon="spinner">
运行中
</Pill>
);
case "queued":
return (
<Pill tone="warn" icon="clock">
排队
</Pill>
);
return <Pill tone="accent">排队中</Pill>;
case "running":
return <Pill tone="accent" icon="spinner">执行中</Pill>;
case "success":
return <Pill tone="pos" icon="check">完成</Pill>;
case "failed":
return (
<Pill tone="neg" icon="alert">
失败
</Pill>
);
case "cancelled":
return <Pill tone="warn">已取消</Pill>;
return <Pill tone="neg" icon="alert">失败</Pill>;
default:
return <Pill>{status}</Pill>;
return <Pill>{status || "—"}</Pill>;
}
}
/** config_snapshot 扁平化:嵌套对象转成 a.b.c 路径,便于逐项 diff */
function flatten(obj: unknown, prefix = "", out: Record<string, string> = {}): Record<string, string> {
if (obj === null || obj === undefined) {
out[prefix || "—"] = "—";
return out;
}
if (Array.isArray(obj)) {
out[prefix] = obj.length ? JSON.stringify(obj) : "[]";
return out;
}
if (typeof obj === "object") {
const entries = Object.entries(obj as Record<string, unknown>);
if (!entries.length) {
out[prefix || "—"] = "{}";
return out;
}
for (const [k, v] of entries) flatten(v, prefix ? `${prefix}.${k}` : k, out);
return out;
}
out[prefix || "—"] = String(obj);
return out;
}
/** 数据指纹在列表里只显示关键前缀(完整值放 title,悬停可见) */
function shortDataVersion(v: string): string {
return v.length > 28 ? `${v.slice(0, 28)}…` : v;
}
const METRICS: { key: keyof BacktestResult["summary"]; label: string; fmt: (v: number) => string; better?: "high" | "low" }[] = [
{ key: "total_return_pct", label: "区间收益", fmt: (v) => `${v >= 0 ? "+" : ""}${v.toFixed(2)}%`, better: "high" },
{ key: "annual_return_pct", label: "年化收益", fmt: (v) => `${v >= 0 ? "+" : ""}${v.toFixed(2)}%`, better: "high" },
{ key: "sharpe", label: "Sharpe", fmt: (v) => v.toFixed(3), better: "high" },
{ key: "max_drawdown_pct", label: "最大回撤", fmt: (v) => `${v.toFixed(2)}%`, better: "low" },
{ key: "volatility_pct", label: "年化波动", fmt: (v) => `${v.toFixed(2)}%`, better: "low" },
{ key: "win_rate_pct", label: "胜率", fmt: (v) => `${v.toFixed(2)}%`, better: "high" },
{ key: "total_trades", label: "成交笔数", fmt: (v) => String(v) },
{ key: "avg_turnover_pct", label: "平均换手", fmt: (v) => `${v.toFixed(2)}%` },
{ key: "final_equity", label: "期末权益", fmt: (v) => v.toLocaleString("zh-CN", { maximumFractionDigits: 0 }) },
];
/**
* Next 15 要求 `useSearchParams` 处于 Suspense 边界内。
* `?exp=EXP-x,EXP-y` 用于从其它页面直接带实验进对比(策略库一键回测后「去对比」)。
*/
export default function ExperimentsPage() {
return (
<Suspense fallback={<Loading label="加载实验页…" />}>
<ExperimentsInner />
</Suspense>
);
}
function ExperimentsInner() {
const search = useSearchParams();
const [exps, setExps] = useState<ExperimentMeta[]>([]);
const [loading, setLoading] = useState(true);
const [detail, setDetail] = useState<ExperimentDetail | null>(null);
@@ -84,22 +128,69 @@ export default function ExperimentsPage() {
const [jobStatus, setJobStatus] = useState("");
const [error, setError] = useState("");
// 对比状态
const [picked, setPicked] = useState<string[]>([]);
const [compare, setCompare] = useState<BacktestDetail[] | null>(null);
const [comparing, setComparing] = useState(false);
const [onlyDiff, setOnlyDiff] = useState(true);
// 列表过滤:后端支持 kind/q(并把过滤后总数放在 X-Total-Count 响应头)。
// 初始值取自 URL(`?q=`/`?kind=`),且每次改动**回写 URL**:筛选条件可分享、刷新不丢,
// 也让「当前看到的是哪一批」有据可查(而不是只存在于组件状态里)。
const [q, setQ] = useState(() => search.get("q") ?? "");
const [kind, setKind] = useState(() => search.get("kind") ?? "");
const [total, setTotal] = useState<number | null>(null);
const syncUrl = useCallback(
(nextQ: string, nextKind: string) => {
const params = new URLSearchParams();
if (nextKind) params.set("kind", nextKind);
if (nextQ.trim()) params.set("q", nextQ.trim());
// 保留已勾选的对比实验,避免筛一下就丢掉选择
const exp = search.get("exp");
if (exp) params.set("exp", exp);
const qs = params.toString();
window.history.replaceState(null, "", qs ? `/experiments?${qs}` : "/experiments");
},
[search]
);
const load = useCallback(() => {
apiGet<ExperimentMeta[]>("/experiments")
.then(setExps)
const params = new URLSearchParams();
if (kind) params.set("kind", kind);
if (q.trim()) params.set("q", q.trim());
const qs = params.toString();
apiGetWithHeaders<ExperimentMeta[]>(`/experiments${qs ? `?${qs}` : ""}`)
.then(({ data, headers }) => {
setExps(data);
const t = headers.get("X-Total-Count");
setTotal(t ? Number(t) : data.length);
})
.catch((e: Error) => setError(e.message))
.finally(() => setLoading(false));
}, []);
}, [q, kind]);
useEffect(load, [load]);
// 从 URL 预勾选要对比的实验(最多 3 个,与手动勾选同一上限)
useEffect(() => {
const raw = search.get("exp");
if (!raw) return;
const ids = raw
.split(",")
.map((x) => x.trim())
.filter(Boolean)
.slice(0, 3);
if (ids.length) setPicked(ids);
// 仅在首次进入时读一次,避免用户手动改选后被 URL 覆盖
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function open(id: string) {
setDetailLoading(true);
setDetail(null);
apiGet<ExperimentDetail>(`/experiments/${id}`)
.then((d) => {
setDetail(d);
})
.then((d) => setDetail(d))
.catch((e: Error) => setError(e.message))
.finally(() => setDetailLoading(false));
}
@@ -134,31 +225,135 @@ export default function ExperimentsPage() {
}, 1200);
}
const s = detail?.result?.summary;
function togglePick(id: string) {
setError("");
setPicked((prev) => {
if (prev.includes(id)) return prev.filter((x) => x !== id);
if (prev.length >= 3) {
setError("最多同时对比 3 个实验(再多曲线会互相遮挡,也不利于读图)");
return prev;
}
return [...prev, id];
});
}
async function runCompare() {
setComparing(true);
setError("");
try {
const rows = await Promise.all(
picked.map((id) => apiGet<ExperimentDetail>(`/experiments/${id}`))
);
const usable = rows.filter(isBacktestDetail);
if (usable.length < 2) {
setError("所选实验里可对比的净值曲线不足 2 条(因子测试没有净值曲线)。");
setCompare(null);
} else {
setCompare(usable);
}
} catch (e) {
setError((e as Error).message);
} finally {
setComparing(false);
}
}
const s = detail && isBacktestDetail(detail) ? detail.result.summary : undefined;
return (
<>
<PageHeader
title="实验归档"
sub="每次因子测试与回测自动归档(含 Research Spec 与代码版本),保证可复现;支持一键复跑。"
actions={exps.length > 0 ? <Pill tone="accent" icon="archive">共 {exps.length} 个实验</Pill> : undefined}
title="实验对比"
sub="每次因子测试与回测自动归档(含 Research Spec 与代码版本),保证可复现;勾选 2~3 个即可叠加净值曲线、对比指标与参数差异。"
actions={
exps.length > 0 ? (
<Pill tone="accent" icon="archive">
共 {exps.length} 个实验
</Pill>
) : undefined
}
/>
{error ? <Banner tone="error">{error}</Banner> : null}
{picked.length > 0 ? (
<div className="sticky-bar">
<Pill tone="violet" icon="layers">
已选 {picked.length} 个
</Pill>
<span className="hint">{picked.join(" · ")}</span>
<Btn variant="primary" icon="chartLine" loading={comparing} disabled={comparing || picked.length < 2} onClick={runCompare}>
对比选中的实验
</Btn>
<Btn icon="x" onClick={() => { setPicked([]); setCompare(null); }}>
清空
</Btn>
{picked.length < 2 ? <span className="hint">再选 1 个即可对比</span> : null}
</div>
) : null}
{compare ? (
<CompareView rows={compare} onlyDiff={onlyDiff} setOnlyDiff={setOnlyDiff} onClose={() => setCompare(null)} />
) : null}
{jobId ? (
<Card
icon="refresh"
title={`复跑任务 ${jobId}`}
tools={statusView(jobStatus === "submitting" ? "queued" : jobStatus)}
>
<div className="hint">
任务状态实时轮询;完成后列表自动刷新,可直接展开查看最新结果。
</div>
<div className="hint">任务状态实时轮询;完成后列表自动刷新,可直接展开查看最新结果。</div>
</Card>
) : null}
<Card title="实验列表" icon="archive" flush>
<Card
id="list"
icon="archive"
title="实验列表"
sub={
total !== null
? `显示 ${exps.length} 条 / 共 ${total} 条${
total > exps.length ? "(后端单页上限内未列全,可用搜索或类型筛选缩小范围)" : ""
}`
: "勾选左侧方框可选 2~3 个做对比"
}
tools={
<div className="row" style={{ gap: 8 }}>
<input
className="input"
style={{ width: 200 }}
placeholder="搜索 ID / 因子 / 摘要"
value={q}
onChange={(e) => {
setQ(e.target.value);
syncUrl(e.target.value, kind);
}}
aria-label="搜索实验"
/>
<select
className="input"
style={{ width: 130 }}
value={kind}
onChange={(e) => {
setKind(e.target.value);
syncUrl(q, e.target.value);
}}
aria-label="按类型筛选"
>
<option value="">全部类型</option>
<option value="backtest">回测</option>
<option value="factor_test">因子测试</option>
<option value="selection">选股</option>
</select>
{q || kind ? (
<Btn size="sm" icon="x" onClick={() => { setQ(""); setKind(""); syncUrl("", ""); }}>
清除
</Btn>
) : null}
</div>
}
flush
>
{loading ? (
<div style={{ padding: 18 }}>
<SkeletonLines n={6} />
@@ -167,25 +362,35 @@ export default function ExperimentsPage() {
<Empty
icon="archive"
title="暂无实验"
hint="在「回测」或「因子研究」中运行一次,即会自动归档于此。"
hint="在「选股回测」或「因子研究」中运行一次,即会自动归档于此。"
/>
) : (
<div className="table-wrap">
<table className="tbl">
<thead>
<tr>
<th style={{ width: 36 }} aria-label="选择对比" />
<th>ID</th>
<th>类型</th>
<th>因子</th>
<th>区间</th>
<th>摘要</th>
<th>代码版本</th>
<th style={{ width: 150, textAlign: "right" }}>操作</th>
<th>版本(代码 / 数据)</th>
<th>体积</th>
<th style={{ width: 260, textAlign: "right" }}>操作</th>
</tr>
</thead>
<tbody>
{exps.map((e) => (
<tr key={e.id} className={detail?.id === e.id ? "row-active" : undefined}>
<td>
<input
type="checkbox"
checked={picked.includes(e.id)}
onChange={() => togglePick(e.id)}
aria-label={`选择 ${e.id} 用于对比`}
/>
</td>
<td className="cell-mono cell-strong">{e.id}</td>
<td>{kindView(e.kind)}</td>
<td>
@@ -199,13 +404,43 @@ export default function ExperimentsPage() {
</td>
<td className="cell-mono">{e.period ? `${e.period[0]}~${e.period[1]}` : "—"}</td>
<td className="hint">{e.summary_text ?? "—"}</td>
<td>{e.code_version ? <span className="tag tag--mono">{e.code_version}</span> : <span className="hint">—</span>}</td>
<td>
<span className="row" style={{ gap: 4 }}>
{e.code_version ? (
<span className="tag tag--mono" title="代码版本">{e.code_version}</span>
) : (
<span className="hint">—</span>
)}
{e.data_version ? (
<span className="tag tag--mono" title={`数据快照:${e.data_version}`}>
{shortDataVersion(e.data_version)}
</span>
) : (
<span className="hint" title="该归档早于数据指纹上线,不可严格复现">无数据版本</span>
)}
</span>
</td>
<td className="mono dim">
{e.result_bytes ? `${(e.result_bytes / 1024).toFixed(0)} KB` : "—"}
</td>
<td style={{ textAlign: "right" }}>
<span className="row" style={{ justifyContent: "flex-end", gap: 6 }}>
<Link href={`/experiments/${encodeURIComponent(e.id)}`} className="btn btn--sm">
<span>打开归档</span>
</Link>
<Btn size="sm" icon="search" onClick={() => open(e.id)}>
详情
</Btn>
<Btn size="sm" variant="primary" icon="refresh" onClick={() => rerun(e)} disabled={jobStatus === "running" || jobStatus === "queued" || jobStatus === "submitting"}>
<Link href={`/backtest?from_experiment=${encodeURIComponent(e.id)}`} className="btn btn--sm">
<span>以此参数再跑</span>
</Link>
<Btn
size="sm"
variant="primary"
icon="refresh"
onClick={() => rerun(e)}
disabled={jobStatus === "running" || jobStatus === "queued" || jobStatus === "submitting"}
>
复跑
</Btn>
</span>
@@ -224,10 +459,15 @@ export default function ExperimentsPage() {
title={`${detail.id} · ${detail.factors.join("、")}`}
sub={detail.period ? `${detail.period[0]} ~ ${detail.period[1]}` : undefined}
tools={
<Btn size="sm" onClick={() => setDetail(null)}>
<Icon name="x" size={13} />
关闭
</Btn>
<div className="row" style={{ gap: 8 }}>
<Link href={`/experiments/${encodeURIComponent(detail.id)}`} className="btn btn--sm">
<span>打开归档(完整视图)</span>
</Link>
<Btn size="sm" onClick={() => setDetail(null)}>
<Icon name="x" size={13} />
关闭
</Btn>
</div>
}
>
{detailLoading ? (
@@ -252,12 +492,36 @@ export default function ExperimentsPage() {
) : (
<div className="hint">该实验没有可展示的汇总结果(可能是因子测试或历史版本)。</div>
)}
{detail.spec ? (
<div className="hint" style={{ marginTop: 12 }}>
spec(可复现输入):<span className="mono">{JSON.stringify(detail.spec).slice(0, 500)}</span>
{JSON.stringify(detail.spec).length > 500 ? "…" : ""}
{detail && isBacktestDetail(detail) && detail.result.equity_curve.length ? (
<div style={{ marginTop: 12 }}>
<LwChart
series={[
{
key: "eq",
label: "累计收益率(%)",
type: "area",
color: CHART.accent,
data: normalize(detail.result),
lastValueVisible: true,
},
]}
height={260}
valueFormat={(v) => `${v.toFixed(2)}%`}
zeroLine
ariaLabel={`${detail.id} 累计收益率曲线`}
/>
</div>
) : null}
{detail.spec ? (
<details style={{ marginTop: 12 }}>
<summary className="hint" style={{ cursor: "pointer" }}>
查看 spec(可复现输入)
</summary>
<pre className="mono" style={{ whiteSpace: "pre-wrap", fontSize: 12 }}>
{JSON.stringify(detail.spec, null, 2)}
</pre>
</details>
) : null}
</>
)}
</Card>
@@ -265,3 +529,198 @@ export default function ExperimentsPage() {
</>
);
}
/* ------------------------------------------------------------------ */
/** 净值曲线 → 累计收益率 %(以首个点为基准,消除初始资金差异后可直接叠加) */
function normalize(r: BacktestResult): { time: string; value: number }[] {
const base = r.equity_curve[0]?.value || 1;
return r.equity_curve.map((p) => ({ time: p.date, value: (p.value / base - 1) * 100 }));
}
/** 参数签名:一眼看出这一版的关键旋钮(微调时最重要) */
function sig(r: BacktestResult): string {
const c = r.config_snapshot as {
selection?: { top_n?: number; hold_top_x?: number | null };
selection_interval_months?: number | null;
rebalance_interval_months?: number | null;
price_adjustment?: string;
factors?: { name: string; weight: number }[];
costs?: { commission_rate?: number; min_commission?: number };
universe?: { exclude_st?: boolean; min_listing_days?: number };
};
const parts = [
(c.factors ?? []).map((f) => f.name).join("+") || "—",
`n=${c.selection?.top_n ?? "—"}/x=${c.selection?.hold_top_x ?? "—"}`,
`m=${c.selection_interval_months ?? "—"}/y=${c.rebalance_interval_months ?? "—"}`,
c.price_adjustment ?? "—",
c.universe?.exclude_st ? "剔ST" : "含ST",
`佣金${(((c.costs?.commission_rate ?? 0) * 100) as number).toFixed(3)}%`,
];
return parts.join(" · ");
}
function CompareView({
rows,
onlyDiff,
setOnlyDiff,
onClose,
}: {
rows: BacktestDetail[];
onlyDiff: boolean;
setOnlyDiff: (v: boolean) => void;
onClose: () => void;
}) {
const series = useMemo<LwSeries[]>(
() =>
rows.map((r, i) => ({
key: r.id,
label: `${r.id}(${sig(r.result)})`,
color: seriesColor(i),
data: normalize(r.result),
type: "line" as const,
lineWidth: 2 as const,
lastValueVisible: i === 0,
})),
[rows]
);
// 参数 diff:扁平化后并集,只显示有差异的项(默认)
const diffRows = useMemo(() => {
const flat = rows.map((r) => flatten(r.result?.config_snapshot ?? {}));
const keys = Array.from(new Set(flat.flatMap((f) => Object.keys(f)))).sort();
return keys
// price_basis.* 是回测运行元数据(数据快照/复权缺口核对),不是用户可调的旋钮:
// 混进「参数差异」表只会制造噪声(复权口径本身在顶层 price_adjustment 里已展示)
.filter((k) => !k.startsWith("price_basis."))
.map((k) => ({ key: k, vals: flat.map((f) => f[k] ?? "—") }))
.filter((row) => (onlyDiff ? new Set(row.vals).size > 1 : true));
}, [rows, onlyDiff]);
const base = rows[0].result.summary;
return (
<Card
icon="chartLine"
title={`实验对比 · ${rows.length} 个`}
sub={rows.map((r) => r.id).join(" vs ")}
tools={
<div className="row" style={{ gap: 8 }}>
<label className="cmp-badge" style={{ cursor: "pointer" }}>
<input type="checkbox" checked={onlyDiff} onChange={(e) => setOnlyDiff(e.target.checked)} />
参数只显示有差异项
</label>
<Btn size="sm" onClick={onClose}>
<Icon name="x" size={13} />
关闭对比
</Btn>
</div>
}
>
<LwChart
series={series}
height={360}
valueFormat={(v) => `${v.toFixed(2)}%`}
zeroLine
ariaLabel="实验净值曲线叠加对比"
/>
<div className="hint" style={{ marginTop: 6 }}>
曲线已归一化为**累计收益率 %**(各自以首个净值为 0%),因此初始资金不同也能直接比。
点击图例可临时隐藏某条曲线(只算收益差的实验建议隐藏基准再比)。
</div>
<div className="table-wrap" style={{ marginTop: 14 }}>
<table className="tbl">
<thead>
<tr>
<th>指标</th>
{rows.map((r) => (
<th key={r.id}>{r.id}</th>
))}
<th>相对 {rows[0].id} 的差值</th>
</tr>
</thead>
<tbody>
{METRICS.map((m) => {
const vals = rows.map((r) => Number(r.result!.summary[m.key] ?? 0));
const delta = vals[vals.length - 1] - vals[0];
const good =
m.better === undefined
? null
: m.better === "high"
? delta > 0
: delta < 0;
return (
<tr key={String(m.key)}>
<td className="cell-strong">{m.label}</td>
{vals.map((v, i) => (
<td key={rows[i].id} className="num">
{m.fmt(v)}
{i === 0 ? <span className="hint"> (基准)</span> : null}
</td>
))}
<td className={delta === 0 ? "cmp-diff-same" : good ? "cmp-diff-add" : "cmp-diff-del"}>
{m.better === undefined
? m.fmt(delta)
: `${delta >= 0 ? "+" : ""}${m.fmt(delta)}`}
{good !== null && delta !== 0 ? (good ? " ✅" : " ⚠️") : ""}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="hint" style={{ marginTop: 6 }}>
差额列 = 最后一个实验 − 基准({rows[0].id})。✅ 表示该指标改善方向,⚠️ 表示变差
(收益/夏普/胜率越高越好,回撤/波动越低越好)。基准区间收益{" "}
{base.total_return_pct.toFixed(2)}%。
</div>
<div style={{ marginTop: 16 }}>
<b style={{ fontSize: 13 }}>参数差异(config_snapshot 逐项对比)</b>
{diffRows.length === 0 ? (
<div className="hint" style={{ marginTop: 6 }}>
这几个实验的参数快照完全一致(说明结果是**同一配置**下的数据变动,或只是复跑)。
已排除 price_basis.* 运行元数据。
</div>
) : (
<div className="table-wrap" style={{ marginTop: 6 }}>
<table className="tbl">
<thead>
<tr>
<th>参数</th>
{rows.map((r) => (
<th key={r.id}>{r.id}</th>
))}
</tr>
</thead>
<tbody>
{diffRows.map((row) => (
<tr key={row.key}>
<td className="cell-mono">{row.key}</td>
{row.vals.map((v, i) => (
<td
key={rows[i].id}
className={
new Set(row.vals).size > 1 && v !== row.vals[0] ? "cmp-diff-add num" : "num"
}
>
{v}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
{onlyDiff ? (
<div className="hint" style={{ marginTop: 6 }}>
已隐藏所有实验取值相同的参数(取消上方勾选可看全量)。
</div>
) : null}
</div>
</Card>
);
}
+38 -3
View File
@@ -18,7 +18,8 @@ import {
MonthlyReturnsTable,
UnimplementedNote,
} from "@/components/ui";
import { LineChart } from "@/components/LineChart";
import { LwChart, type LwSeries } from "@/components/charts/LwChart";
import { CHART, fmtNum, fmtPct } from "@/components/charts/theme";
import { Icon } from "@/components/icons";
interface Pick {
@@ -291,6 +292,27 @@ function ResultView({
params: { picks: Pick[]; topN: number; rebalance: string; excludeSt: boolean; start: string; end: string };
}) {
const s = result.summary;
// 曲线数据点:LwChart 的 time 用 "YYYY-MM-DD" 字符串,向后端 CurvePoint 的 date 直接映射
const equitySeries: LwSeries[] = [
{
key: "equity",
label: "净值",
type: "area",
color: CHART.pos,
data: result.equity_curve.map((p) => ({ time: p.date, value: p.value })),
},
];
const drawdownSeries: LwSeries[] = [
{
key: "drawdown",
label: "回撤",
type: "line",
color: CHART.neg,
data: result.drawdown.map((p) => ({ time: p.date, value: p.value })),
},
];
return (
<>
<div className="between" style={{ margin: "6px 0 14px" }}>
@@ -312,10 +334,23 @@ function ResultView({
<div className="chart-grid">
<Card icon="chartLine" title="净值曲线" tools={<Pill tone="pos">期末 {s.final_equity.toLocaleString()}</Pill>}>
<LineChart data={result.equity_curve} color="#3ddc97" fill height={300} xFmt={(d) => d.slice(5)} yFmt={(v) => v.toFixed(2)} />
<LwChart
series={equitySeries}
height={300}
valueFormat={(v) => fmtNum(v, 2)}
ariaLabel="净值曲线"
emptyHint="该区间没有净值数据"
/>
</Card>
<Card icon="chartLine" title="回撤(%)" tools={<Pill tone="neg">最大 {s.max_drawdown_pct.toFixed(2)}%</Pill>}>
<LineChart data={result.drawdown} color="#ff7a7a" height={300} xFmt={(d) => d.slice(5)} yFmt={(v) => v.toFixed(2) + "%"} />
<LwChart
series={drawdownSeries}
height={300}
zeroLine
valueFormat={(v) => fmtPct(v, 2)}
ariaLabel="回撤曲线"
emptyHint="该区间没有回撤数据"
/>
</Card>
</div>
+515
View File
@@ -1290,3 +1290,518 @@ table.tbl {
flex-direction: column;
}
}
/* ============================================================
图表(TradingView Lightweight Charts)—— 图例 / tooltip / 容器
============================================================ */
.lw-wrap {
display: flex;
flex-direction: column;
gap: var(--sp-2);
min-width: 0;
}
.lw-legend {
display: flex;
flex-wrap: wrap;
gap: var(--sp-1) var(--sp-2);
align-items: center;
}
.lw-legend-item {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 8px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--surface-2);
color: var(--text-2);
font-size: var(--fs-xs);
cursor: pointer;
transition: opacity 0.12s ease, border-color 0.12s ease, background 0.12s ease;
}
.lw-legend-item:hover {
border-color: var(--line-strong);
background: var(--surface-3);
}
.lw-legend-item:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 1px;
}
.lw-legend-item i {
width: 9px;
height: 9px;
border-radius: 2px;
flex: 0 0 auto;
}
.lw-legend-item b {
color: var(--text-1);
font-weight: 600;
}
.lw-legend-item.is-off {
opacity: 0.42;
text-decoration: line-through;
}
.lw-canvas {
position: relative;
width: 100%;
min-width: 0;
}
/* tooltip:LW 无内置实现,由 LwChart 订阅十字光标渲染 */
.lw-tip {
position: absolute;
z-index: 5;
pointer-events: none;
min-width: 132px;
padding: 7px 9px;
border: 1px solid rgba(150, 165, 195, 0.28);
border-radius: var(--r-sm);
background: rgba(20, 29, 48, 0.96);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
font-size: var(--fs-xs);
}
.lw-tip-time {
color: var(--text-3);
margin-bottom: 4px;
}
.lw-tip-row {
display: flex;
align-items: center;
gap: 6px;
line-height: 1.6;
}
.lw-tip-row i {
width: 8px;
height: 8px;
border-radius: 2px;
flex: 0 0 auto;
}
.lw-tip-label {
color: var(--text-2);
flex: 1 1 auto;
white-space: nowrap;
}
.lw-tip-row b {
color: var(--text-1);
}
/* ============================================================
股票代码 + 名称(任何出现代码的地方都必须成对出现且可点击)
============================================================ */
.symlink {
display: inline-flex;
align-items: baseline;
gap: 6px;
max-width: 100%;
padding: 1px 3px;
margin: -1px -3px;
border-radius: var(--r-xs);
color: inherit;
text-decoration: none;
transition: background 0.12s ease;
}
.symlink:hover {
background: var(--accent-soft);
text-decoration: none;
}
.symlink:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 1px;
}
.symlink .sym-code {
font-family: var(--font-mono);
font-size: 0.92em;
color: var(--text-2);
white-space: nowrap;
}
.symlink:hover .sym-code {
color: var(--accent);
}
.symlink .sym-name {
color: var(--text-1);
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 9em;
}
.symlink .sym-name--missing {
color: var(--text-3);
font-weight: 400;
}
/* 名称列优先:窄屏时允许名称换行而不是把代码挤掉 */
.sym-cell {
min-width: 132px;
}
/* ============================================================
策略说明与计算公式
============================================================ */
.doc-block {
display: flex;
flex-direction: column;
gap: var(--sp-2);
}
.doc-summary {
display: flex;
align-items: flex-start;
gap: var(--sp-2);
padding: 10px 12px;
border: 1px solid var(--line);
border-left: 3px solid var(--accent);
border-radius: var(--r-sm);
background: linear-gradient(90deg, rgba(63, 182, 255, 0.08), transparent 70%);
color: var(--text-1);
line-height: 1.65;
}
.doc-formula {
display: flex;
flex-direction: column;
gap: 6px;
}
.doc-formula pre {
margin: 0;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: var(--r-sm);
background: var(--bg-1);
color: var(--text-1);
font-family: var(--font-mono);
font-size: var(--fs-xs);
line-height: 1.75;
overflow-x: auto;
white-space: pre;
}
.doc-steps {
margin: 0;
padding-left: 18px;
color: var(--text-2);
font-size: var(--fs-sm);
line-height: 1.8;
}
.doc-steps li b {
color: var(--text-1);
}
/* 策略卡片 */
.strategy-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
gap: var(--sp-4);
}
.strategy-desc {
color: var(--text-2);
line-height: 1.7;
font-size: var(--fs-sm);
}
.strategy-actions {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2);
}
/* ============================================================
实验对比
============================================================ */
.cmp-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--sp-2);
}
.cmp-diff-add {
color: var(--pos);
}
.cmp-diff-del {
color: var(--neg);
}
.cmp-diff-same {
color: var(--text-3);
}
.cmp-badge {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: var(--fs-xs);
color: var(--text-3);
}
/* ============================================================
易用性:粘性操作条 / 结果锚点 / 键盘提示
============================================================ */
.sticky-bar {
position: sticky;
top: 0;
z-index: 20;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--sp-3);
padding: 10px 12px;
margin-bottom: var(--sp-3);
border: 1px solid var(--line);
border-radius: var(--r-md);
background: rgba(10, 15, 28, 0.92);
backdrop-filter: blur(8px);
}
.kbd {
display: inline-block;
padding: 1px 5px;
border: 1px solid var(--line-strong);
border-bottom-width: 2px;
border-radius: 4px;
background: var(--surface-2);
color: var(--text-2);
font-family: var(--font-mono);
font-size: 11px;
line-height: 1.5;
}
/* 表单校验:错误态 + 行内提示 */
.field--invalid input,
.field--invalid select {
border-color: var(--neg);
}
.field-err {
color: var(--neg);
font-size: var(--fs-xs);
margin-top: 4px;
}
/* 可点击行(表格) */
.tbl tbody tr.row-clickable {
cursor: pointer;
}
/* 进度阶段列表 */
.stages {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2);
align-items: center;
}
.stage {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 9px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--surface-2);
color: var(--text-3);
font-size: var(--fs-xs);
}
.stage.is-done {
color: var(--pos);
border-color: rgba(61, 220, 151, 0.35);
}
.stage.is-active {
color: var(--accent);
border-color: rgba(63, 182, 255, 0.45);
background: var(--accent-soft);
}
/* 面包屑 / 返回 */
.crumb {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--text-3);
font-size: var(--fs-sm);
}
.crumb a {
color: var(--text-2);
}
.crumb a:hover {
color: var(--accent);
}
/* ---------- 补充:既有页面已在用但此前没有样式规则的类 ---------- */
/* .dim:次要信息(日期/计数),此前无规则导致与正文同色,层级不清 */
.dim {
color: var(--text-3);
}
/* .input--invalid:单个输入框的错误态(.field--invalid 是整字段态) */
.input--invalid,
.input--invalid:focus {
border-color: var(--neg);
box-shadow: 0 0 0 3px var(--neg-soft);
}
/* ---------- 单选组(买不进补位策略:三选一) ---------- */
.radio-col {
display: flex;
flex-direction: column;
gap: 6px;
}
.radio-row {
display: flex;
align-items: flex-start;
gap: 7px;
font-size: 13px;
color: var(--text-2);
cursor: pointer;
line-height: 1.45;
}
.radio-row input {
margin-top: 2px;
flex: none;
}
.radio-row:hover {
color: var(--text-1);
}
/* ---------- 归档详情:元数据条 / KV / 规格表 / 代码块 ---------- */
/* 归档元数据与完整度提示条:信息密度高但不抢图表注意力 */
.archive-bar {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
padding: 9px 12px;
margin: 0 0 10px;
border: 1px solid var(--line);
border-radius: var(--r-sm);
background: linear-gradient(180deg, var(--surface-2), var(--surface-1));
}
.kv-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 8px 18px;
}
.kv {
display: flex;
align-items: baseline;
gap: 8px;
font-size: 13px;
min-width: 0;
}
.kv__k {
flex: none;
color: var(--text-3);
}
.kv__v {
color: var(--text-1);
word-break: break-all;
}
.kv__v--warn {
color: var(--warn);
}
/* 规格表:选股条件 / 交易执行依据 */
.spec-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 14px;
}
.spec-block {
border: 1px solid var(--line);
border-radius: var(--r-sm);
padding: 10px 12px;
background: var(--surface-1);
}
.spec-block__title {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
color: var(--text-2);
margin-bottom: 8px;
padding-bottom: 6px;
border-bottom: 1px dashed var(--line);
}
.spec-row {
display: flex;
gap: 10px;
align-items: baseline;
font-size: 13px;
padding: 3px 0;
line-height: 1.5;
}
.spec-row__k {
flex: none;
min-width: 96px;
color: var(--text-3);
}
.spec-row__v {
color: var(--text-1);
min-width: 0;
word-break: break-word;
}
.codeblock {
max-height: 420px;
overflow: auto;
font-size: 12px;
line-height: 1.55;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: var(--r-sm);
background: var(--bg-1);
white-space: pre-wrap;
word-break: break-word;
}
/* 行内代码:后端说明文本用 `…` 标记字段/命令名 */
.inline-code {
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
font-size: 0.92em;
padding: 1px 5px;
border: 1px solid var(--line);
border-radius: var(--r-xs);
background: var(--bg-2);
color: var(--text-1);
}
+5 -1
View File
@@ -1,5 +1,6 @@
import type { Metadata, Viewport } from "next";
import { AppShell } from "@/components/app-shell";
import { SymbolNamesProvider } from "@/lib/symbols";
import "./globals.css";
export const metadata: Metadata = {
@@ -15,7 +16,10 @@ export default function RootLayout({ children }: { children: React.ReactNode })
return (
<html lang="zh-CN">
<body>
<AppShell>{children}</AppShell>
{/* 全站股票名称缓存:任何页面渲染代码时都能就地拿到名称 */}
<SymbolNamesProvider>
<AppShell>{children}</AppShell>
</SymbolNamesProvider>
</body>
</html>
);
+38 -15
View File
@@ -14,7 +14,44 @@ interface LoadState {
error: string;
}
/**
* 快速入口按**研究闭环的真实顺序**排列:
* 出候选(筛选)→ 定规则(策略库)→ 验规则(回测)→ 复盘(实验对比)。
* 顺序即引导:新用户按从左到右走一遍就是完整流程。
*
* ④ 之后是**归档回看**:每次回测(含同步接口调用)都会自动写一份完整归档,
* 入口在实验列表的「打开归档」、以及回测跑完后的提示条里;归档页是只读快照,
* 明确写出这次的**选股条件**与**交易执行依据**。
*/
const LINKS = [
{
href: "/selection",
title: "① 股票筛选",
desc: "因子评分 TopN 或条件选股,支持当前/历史时点,结果可解释、可查历史",
icon: "target" as const,
tone: "quick-card--violet",
},
{
href: "/strategies",
title: "② 策略库",
desc: "把选股规则存成命名策略:一句话说明 + 计算公式,可一键回测、可复用对比",
icon: "book" as const,
tone: "quick-card--pos",
},
{
href: "/backtest",
title: "③ 选股回测",
desc: "两级截断(候选池 n → 持仓 x)+ 择股/调仓双周期(m/y),买卖点标注在曲线上",
icon: "gauge" as const,
tone: "quick-card--warn",
},
{
href: "/experiments",
title: "④ 实验对比与归档",
desc: "勾选 2~3 次回测做对比;每次回测都已完整存档,点「打开归档」可随时回看选股条件与交易执行依据",
icon: "archive" as const,
tone: "quick-card--violet",
},
{
href: "/factors",
title: "因子研究",
@@ -29,20 +66,6 @@ const LINKS = [
icon: "layers" as const,
tone: "quick-card--pos",
},
{
href: "/backtest",
title: "选股回测",
desc: "单因子完整回测:净值 / 回撤 / 月度收益 / 持仓",
icon: "gauge" as const,
tone: "quick-card--warn",
},
{
href: "/selection",
title: "股票筛选",
desc: "因子评分 TopN 或条件选股,支持当前/历史时点,结果可解释",
icon: "target" as const,
tone: "quick-card--violet",
},
{
href: "/signals",
title: "交易信号",
@@ -124,7 +147,7 @@ export default function DashboardPage() {
</div>
)}
<Card title="快速开始" icon="spark" sub="四个研究入口,从数据到结论" flush={false}>
<Card title="快速开始" icon="spark" sub="按 ①→④ 走一遍即是完整闭环:出候选 → 定规则 → 验规则 → 复盘微调" flush={false}>
<div className="quick-cards" style={{ marginBottom: 0 }}>
{LINKS.map((l) => (
<Link key={l.href} href={l.href} className={`quick-card ${l.tone}`}>
+53 -5
View File
@@ -4,8 +4,10 @@
* 评分模式(多因子加权 TopN)与条件模式(结构化条件 AND);
* 支持当前/历史 as_of;结果落库可查(selection_id),右侧展示最近选股历史。
*/
import Link from "next/link";
import { useEffect, useState } from "react";
import { apiGet, apiPost } from "@/lib/api";
import { SymbolLink } from "@/lib/symbols";
import { waitJob } from "@/lib/jobs";
import type {
FactorMeta,
@@ -39,6 +41,22 @@ const FIELD_OPTIONS: { value: string; label: string; kind: "num" | "str" | "ref"
];
const OPS = ["gt", "gte", "lt", "lte", "eq", "ne", "in"];
/**
* 读取后端 payload 里的 name(若存在)。
*
* 为什么用宽松读取而非直接 `c.name`:`lib/types.ts` 的 SelectionCandidate 目前没有
* 声明 name 字段(该文件由其他改动维护),直接访问会类型报错;但后端可能已经返回更权威的
* 中文名。这里只做「有且非空则用」的读取,取不到就交给 SymbolLink 渲染占位「—」,
* 绝不编造名称(AGENT §24)。
*/
function payloadName(row: unknown): string | undefined {
if (row && typeof row === "object" && "name" in row) {
const n = (row as { name?: unknown }).name;
if (typeof n === "string" && n.trim()) return n;
}
return undefined;
}
function emptyCondition(): SelectionCondition {
return { field: "static.industry", op: "eq", value: "白酒" };
}
@@ -53,6 +71,14 @@ export default function SelectionPage() {
const [conds, setConds] = useState<SelectionCondition[]>([emptyCondition()]);
const [result, setResult] = useState<SelectionResult | null>(null);
const [selectionId, setSelectionId] = useState("");
/**
* 已落库的 selection_id —— 只有「同步选股(SelectionRun.selection_id)」和「从历史列表打开(id)」两条路径才有。
*
* 为什么不能直接用 selectionId:异步路径(POST /selections/jobs)拿到的是 JOB id,
* 后端不会为它写 selection 记录(backend/app/api/selections.py 只在同步接口里 selection_repo.save),
* 拿它拼 /backtest?from_selection=<JOB id> 会让接收端 GET /api/selections/{id} 404 → 坏链接。
*/
const [savedSelectionId, setSavedSelectionId] = useState("");
const [running, setRunning] = useState(false);
const [asyncBusy, setAsyncBusy] = useState(false);
const [error, setError] = useState("");
@@ -101,9 +127,12 @@ export default function SelectionPage() {
setRunning(true);
setError("");
setResult(null);
// 新一轮结果尚未落库/尚未返回:先清掉旧的 selection_id,避免「结果 A + 直通到记录 B」的错配链接
setSavedSelectionId("");
try {
const run = await apiPost<SelectionRun>("/selections", buildQuery());
setSelectionId(run.selection_id);
setSavedSelectionId(run.selection_id);
setResult(run.result);
apiGet<SelectionMeta[]>("/selections?limit=8").then(setHistory).catch(() => undefined);
} catch (e) {
@@ -118,6 +147,8 @@ export default function SelectionPage() {
setAsyncBusy(true);
setError("");
setResult(null);
// 同上:异步任务不落库选股记录,直通回测必须隐藏按钮(不给坏链接)
setSavedSelectionId("");
try {
const { job_id } = await apiPost<{ job_id: string }>("/selections/jobs", buildQuery());
const out = await waitJob<SelectionResult>(job_id);
@@ -140,6 +171,7 @@ export default function SelectionPage() {
try {
const detail = await apiGet<SelectionResult>(`/selections/${id}`);
setSelectionId(id);
setSavedSelectionId(id);
setResult(detail);
} catch (e) {
setError((e as Error).message);
@@ -319,7 +351,7 @@ export default function SelectionPage() {
{!result && !running && history === null ? <SkeletonLines n={4} /> : null}
{result ? <ResultView result={result} /> : null}
{result ? <ResultView result={result} selectionId={savedSelectionId} /> : null}
{history && history.length > 0 ? (
<Card title="最近选股记录" icon="archive">
@@ -343,7 +375,7 @@ export default function SelectionPage() {
);
}
function ResultView({ result }: { result: SelectionResult }) {
function ResultView({ result, selectionId }: { result: SelectionResult; selectionId: string }) {
const s = result.statistics;
if (result.candidates.length === 0) {
return (
@@ -365,15 +397,30 @@ function ResultView({ result }: { result: SelectionResult }) {
<Pill>范围 {s.universe_size}</Pill>
<Pill>评分 {s.evaluated}</Pill>
<Pill tone="pos">选出 {s.selected}</Pill>
{/* 选股 → 回测直通:只带「已落库」的 selection_id;没有它(如异步任务未落库)就不渲染按钮,避免坏链接 */}
{selectionId ? (
<Link
href={`/backtest?from_selection=${encodeURIComponent(selectionId)}`}
className="btn btn--sm"
title="把这次的规则(条件/因子/TopN/口径)预填到回测页;回测会在每个择股日按同一规则重新选股,不是固定持有这批股票"
>
<span>按此条件回测</span>
</Link>
) : null}
</>
}
>
{selectionId ? (
<div className="hint" style={{ marginBottom: 10 }}>
按此条件回测:把这次的规则(条件 / 因子 / TopN / 口径)预填到回测页;回测会在每个择股日按同一规则重新选股,<b>不是固定持有这批股票</b>。
</div>
) : null}
<div className="table-wrap">
<table className="tbl">
<thead>
<tr>
<th>#</th>
<th>代码</th>
<th>代码 / 名称</th>
<th>得分</th>
<th>因子值</th>
<th>入选理由</th>
@@ -383,8 +430,9 @@ function ResultView({ result }: { result: SelectionResult }) {
{result.candidates.map((c) => (
<tr key={c.symbol}>
<td className="mono dim">{c.rank}</td>
<td className="mono">
<b>{c.symbol}</b>
<td>
{/* 后端 payload 若已带 name 就用它(更权威),否则回落到全局名称表 */}
<SymbolLink symbol={c.symbol} name={payloadName(c)} />
</td>
<td className="mono">{c.score.toFixed(4)}</td>
<td style={{ fontSize: 12 }}>
+18 -2
View File
@@ -5,6 +5,7 @@
*/
import { useEffect, useState } from "react";
import { apiGet, apiPost } from "@/lib/api";
import { SymbolLink } from "@/lib/symbols";
import type {
FactorMeta,
SelectionQuery,
@@ -23,6 +24,21 @@ import {
SkeletonLines,
} from "@/components/ui";
/**
* 读取后端 payload 里的 name(若存在)。
*
* 为什么用宽松读取而非直接 `e.name`:`lib/types.ts` 的 SignalEvent 目前没有声明 name
* 字段(该文件由其他改动维护);后端可能已返回更权威的中文名。取不到就交给 SymbolLink
* 渲染占位「—」,不编造名称(AGENT §24)。
*/
function payloadName(row: unknown): string | undefined {
if (row && typeof row === "object" && "name" in row) {
const n = (row as { name?: unknown }).name;
if (typeof n === "string" && n.trim()) return n;
}
return undefined;
}
export default function SignalsPage() {
const [factors, setFactors] = useState<FactorMeta[]>([]);
const [factor, setFactor] = useState("momentum_60");
@@ -165,7 +181,7 @@ function ResultView({ result }: { result: SignalResult }) {
<thead>
<tr>
<th>类型</th>
<th>代码</th>
<th>代码 / 名称</th>
<th>得分</th>
<th>价格</th>
<th>触发理由</th>
@@ -179,7 +195,7 @@ function ResultView({ result }: { result: SignalResult }) {
{e.signal_type}
</Pill>
</td>
<td className="mono"><b>{e.symbol}</b></td>
<td><SymbolLink symbol={e.symbol} name={payloadName(e)} /></td>
<td className="mono">{e.score?.toFixed(4)}</td>
<td className="mono">{e.price ?? "-"}</td>
<td style={{ fontSize: 12 }}>
+11 -12
View File
@@ -8,6 +8,7 @@
import { useEffect, useMemo, useState } from "react";
import { useParams } from "next/navigation";
import { apiGet } from "@/lib/api";
import { SymbolLink } from "@/lib/symbols";
import type { ChartMarker, ChartResult, Stock } from "@/lib/types";
import {
PageHeader,
@@ -19,7 +20,7 @@ import {
SkeletonLines,
} from "@/components/ui";
import type { CandleDatum } from "@/components/StockChart/chartTypes";
import { StockChart, type ChartLibrary } from "@/components/StockChart/StockChart";
import { StockChart } from "@/components/StockChart/StockChart";
export default function StockResearchPage() {
const params = useParams<{ symbol: string }>();
@@ -29,7 +30,6 @@ export default function StockResearchPage() {
const [signalMarkers, setSignalMarkers] = useState<ChartMarker[]>([]);
const [selectionMarkers, setSelectionMarkers] = useState<ChartMarker[]>([]);
const [adjust, setAdjust] = useState<"none" | "qfq" | "hfq">("none");
const [chartLib, setChartLib] = useState<ChartLibrary>("lightweight");
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
@@ -77,16 +77,11 @@ export default function StockResearchPage() {
title={stock ? `${stock.name} ${stock.symbol}` : symbol}
sub={
stock
? `行业 ${stock.industry ?? "-"} · 市场 ${stock.market ?? "-"} · 上市 ${stock.list_date} · 状态 ${stock.status}`
? `名称 ${stock.name} · 行业 ${stock.industry ?? "-"} · 市场 ${stock.market ?? "-"} · 上市 ${stock.list_date} · 状态 ${stock.status}`
: "个股研究视图(K 线 / 信号 / 成交)"
}
actions={
<div className="row" style={{ gap: 6 }}>
{(["echarts", "lightweight"] as const).map((l) => (
<Btn key={l} variant={chartLib === l ? "primary" : undefined} onClick={() => setChartLib(l)}>
{l === "echarts" ? "ECharts" : "Lightweight"}
</Btn>
))}
{(["none", "qfq", "hfq"] as const).map((m) => (
<Btn
key={m}
@@ -107,7 +102,7 @@ export default function StockResearchPage() {
<>
<Card
icon="chartLine"
title={`K 线 · ${chart?.metadata.adjust_mode ?? "-"} · ${chart?.metadata.name ?? ""}`}
title={`K 线 · ${chart?.metadata.adjust_mode ?? "-"} · ${chart?.metadata.name ?? stock?.name ?? ""}`}
tools={
<>
<Pill>共 {chart?.metadata.bar_count ?? 0} 根</Pill>
@@ -121,13 +116,12 @@ export default function StockResearchPage() {
<Empty icon="chartLine" title="无 K 线数据" hint="该区间/口径没有行情(可能停牌或未同步)。" />
) : (
<StockChart
library={chartLib}
data={candles}
volume={chart?.volume ?? []}
indicators={chart?.indicators ?? {}}
fills={chart?.fills ?? []}
signals={chart?.signals ?? []}
name={symbol}
name={stock ? `${stock.symbol} ${stock.name}` : symbol}
/>
)}
</Card>
@@ -135,7 +129,12 @@ export default function StockResearchPage() {
{chart && (chart.signals.length || chart.fills.length) ? (
<Card
icon="scale"
title={`回测成交与信号 · ${symbol}`}
title={
<>
回测成交与信号 ·{" "}
<SymbolLink symbol={symbol} name={stock?.name} plain />
</>
}
tools={
<>
<Pill tone="pos">成交 {chart.fills.length}</Pill>
+4 -5
View File
@@ -1,8 +1,8 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useRef, useState } from "react";
import { apiGet } from "@/lib/api";
import { SymbolLink } from "@/lib/symbols";
import type { Stock } from "@/lib/types";
import {
PageHeader,
@@ -118,8 +118,7 @@ export default function StocksPage() {
<table className="tbl">
<thead>
<tr>
<th>代码</th>
<th>名称</th>
<th>代码 / 名称</th>
<th>市场</th>
<th>行业</th>
<th>地区</th>
@@ -130,8 +129,8 @@ export default function StocksPage() {
<tbody>
{stocks.map((s) => (
<tr key={s.symbol}>
<td className="cell-mono cell-strong"><Link href={`/stocks/${s.symbol}`} style={{ textDecoration: "none" }}>{s.symbol}</Link></td>
<td>{s.name}</td>
{/* 代码列直接合并名称(后端 payload 的 name 更权威),避免与独立名称列重复展示 */}
<td><SymbolLink symbol={s.symbol} name={s.name} /></td>
<td>
<span className="tag tag--mono">{marketOf(s.symbol)}</span>
</td>
+518
View File
@@ -0,0 +1,518 @@
"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<StrategyDefinition[] | null>(null);
const [factors, setFactors] = useState<FactorMeta[]>([]);
const [query, setQuery] = useState("");
const [err, setErr] = useState("");
const [msg, setMsg] = useState("");
const [editing, setEditing] = useState<StrategyParams | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [docId, setDocId] = useState<string | null>(null);
const range = useMemo(() => recentRange(), []);
const load = useCallback(async () => {
try {
const rows = await apiGet<StrategyDefinition[]>("/strategies");
setList(rows);
setErr("");
} catch (e) {
setErr((e as Error).message);
setList([]);
}
}, []);
useEffect(() => {
void load();
let alive = true;
apiGet<FactorMeta[]>("/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("");
}
function startEdit(s: StrategyDefinition) {
setEditingId(s.id ?? null);
setEditing(paramsFromStrategy(s, { ...emptyParams(range) }));
setDocId(s.id ?? null);
setMsg("");
setErr("");
}
async function save() {
if (!editing) return;
const errors = validateParams(editing, { requireMeta: true });
if (Object.keys(errors).length) {
setErr(Object.values(errors)[0]);
return;
}
setBusy(true);
setErr("");
try {
const body = strategyFromParams(editing, editingId ?? undefined);
if (editingId) {
await apiPut<StrategyDefinition>(`/strategies/${encodeURIComponent(editingId)}`, body);
setMsg(`已更新策略「${body.name}」`);
} else {
const saved = await apiPost<StrategyDefinition>("/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 (
<>
<PageHeader
title="策略库"
sub="把调好的参数存成命名策略:每个策略都有「一句话说明 + 计算公式 + 执行步骤」,可一键回测、可复现、可对比。"
actions={
<div className="row" style={{ gap: 8 }}>
<Link href="/experiments" className="btn">
<span>查看实验</span>
</Link>
<Btn variant="primary" icon="plus" onClick={startCreate} disabled={busy}>
新建策略
</Btn>
</div>
}
/>
{err ? <Banner tone="error">{err}</Banner> : null}
{msg ? <Banner tone="info">{msg}</Banner> : null}
{/* ---------- 编辑器 ---------- */}
{editing ? (
<Card
icon={editingId ? "edit" : "plus"}
title={editingId ? `编辑策略 · ${editingId}` : "新建策略"}
tools={
<div className="row" style={{ gap: 8 }}>
<Btn onClick={() => { setEditing(null); setEditingId(null); setDocId(null); }} disabled={busy}>
取消
</Btn>
<Btn variant="primary" icon="check" loading={busy} onClick={save} disabled={busy}>
{editingId ? "保存修改" : "保存到策略库"}
</Btn>
</div>
}
>
<StrategyParamsForm
value={editing}
onChange={setEditing}
factorOptions={factors}
showMeta
showPeriod={false}
disabled={busy}
errors={validateParams(editing, { requireMeta: true })}
/>
<div className="hint" style={{ marginTop: 10 }}>
策略只保存「怎么选股/怎么调仓/怎么收费」,**不含回测区间与初始资金** ——
这两项在运行回测时才指定,因此同一策略可用于不同区间的复现与对比。
</div>
<div className="row" style={{ marginTop: 12, gap: 8 }}>
<Btn
icon="target"
disabled={busy}
onClick={() =>
setEditing({
...casePreset(range),
name: editing.name,
description: editing.description,
})
}
>
载入高股息案例默认参数
</Btn>
</div>
</Card>
) : null}
{editing ? (
<EditingDoc params={editing} savedId={editingId} />
) : null}
{docId && !editing ? <SavedDoc id={docId} onClose={() => setDocId(null)} /> : null}
{/* ---------- 列表 ---------- */}
<Card
icon="archive"
title={`已保存策略${list ? ` · ${list.length}` : ""}`}
tools={
<input
className="input"
style={{ width: 220 }}
placeholder="搜索策略名 / 说明 / 因子 / 条件"
value={query}
onChange={(e) => setQuery(e.target.value)}
aria-label="搜索策略"
/>
}
>
{filtered === null ? (
<Loading label="读取策略库…" />
) : filtered.length === 0 ? (
<Empty
icon="archive"
title={list && list.length ? "没有匹配的策略" : "策略库还是空的"}
hint={
list && list.length
? "换个关键词试试。"
: "点右上角「新建策略」保存第一个策略;也可以先去回测页调好参数,再点「保存为策略」。"
}
/>
) : (
<div className="strategy-grid">
{filtered.map((s) => (
<StrategyCard
key={s.id ?? s.name}
s={s}
range={range}
busy={busy}
onEdit={() => startEdit(s)}
onDelete={() => remove(s)}
onDoc={() => setDocId(s.id ?? null)}
/>
))}
</div>
)}
</Card>
</>
);
}
/* ------------------------------------------------------------------ */
/** 未保存参数的实时说明预览 */
function EditingDoc({ params, savedId }: { params: StrategyParams; savedId: string | null }) {
const live = useStrategyDoc(params, !savedId);
const saved = useStrategyDocById(savedId);
const state = savedId ? saved : live;
return (
<StrategyDocCard
doc={state.doc}
loading={state.loading}
error={state.error}
title="策略说明与计算公式(随参数实时更新)"
/>
);
}
function SavedDoc({ id, onClose }: { id: string; onClose: () => void }) {
const state = useStrategyDocById(id);
return (
<Card
icon="book"
title={`策略说明 · ${id}`}
tools={<Btn size="sm" onClick={onClose}>收起</Btn>}
>
{state.doc ? (
<StrategyDocBody doc={state.doc} />
) : (
<div className="hint">
{state.loading
? "正在生成说明与公式…"
: `说明暂不可用${state.error ? `:${state.error}` : "(后端 /api/strategies/{id}/describe 未就绪)"}。`}
</div>
)}
</Card>
);
}
/** 单个策略卡片:说明 + 参数摘要 + 一键回测 */
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<BacktestResult | null>(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<Record<string, unknown>>(
`/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<BacktestResult> = await waitJob<BacktestResult>(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 (
<div className="card" style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div className="between">
<div style={{ minWidth: 0 }}>
<div className="card__title" style={{ marginBottom: 2 }}>
{s.name}
</div>
<div className="row" style={{ gap: 6, flexWrap: "wrap" }}>
<span className="mono hint">{s.id}</span>
{s.created_at ? <span className="hint">创建 {String(s.created_at).slice(0, 10)}</span> : null}
</div>
</div>
<Pill tone="violet" icon="target">
{s.factors?.map((f) => f.name).join(" + ") || "—"}
</Pill>
</div>
<div className="strategy-desc">{s.description || "(无说明:建议补一句话说明,便于日后识别)"}</div>
<div className="chips">
<span className="chip">
候选池 <b>{sel.top_n ?? "—"}</b> → 持仓 <b>{sel.hold_top_x ?? sel.top_n ?? "—"}</b>
</span>
<span className="chip">
择股 <b>{s.selection_interval_months ?? "跟随 y"}</b> 月 / 调仓{" "}
<b>{s.rebalance_interval_months ?? "跟随 m"}</b> 月
</span>
<span className="chip">
复权 <b>{adjLabel(s.price_adjustment)}</b>
</span>
<span className="chip">
{s.universe?.exclude_st ? "剔除 ST" : "含 ST"} · 费率{" "}
<b>{((costs.commission_rate ?? 0) * 100).toFixed(3)}%</b> + 印花{" "}
<b>{((costs.stamp_tax_rate ?? 0) * 100).toFixed(2)}%</b>
</span>
{(s.conditions ?? []).length ? (
<span className="chip">
条件 <b>{(s.conditions ?? []).map((c) => `${c.field} ${opLabel(c.op)} ${c.value ?? ""}`).join(" 且 ")}</b>
</span>
) : null}
</div>
<div className="strategy-actions">
<Btn size="sm" variant="primary" icon="play" loading={running} disabled={running || busy} onClick={run}>
{running ? "后台运行中…" : "一键回测"}
</Btn>
<Link href={`/backtest?strategy=${encodeURIComponent(s.id ?? "")}`} className="btn btn--sm">
<span>载入回测页(可微调)</span>
</Link>
<Btn size="sm" icon="book" onClick={onDoc} disabled={busy}>
看说明/公式
</Btn>
<Btn size="sm" icon="edit" onClick={onEdit} disabled={busy}>
编辑
</Btn>
<Btn size="sm" variant="danger" icon="trash" onClick={onDelete} disabled={busy}>
删除
</Btn>
</div>
{/* 运行区间(默认 2020-01-01 ~ 最近交易日) */}
<details open={open} onToggle={(e) => setOpen((e.target as HTMLDetailsElement).open)}>
<summary className="hint" style={{ cursor: "pointer" }}>
回测区间与资金(一键回测使用)
</summary>
<div className="form-grid" style={{ marginTop: 8 }}>
<Field label="开始日期">
<input type="date" className="input" value={start} onChange={(e) => setStart(e.target.value)} disabled={running} />
</Field>
<Field label="结束日期">
<input type="date" className="input" value={end} onChange={(e) => setEnd(e.target.value)} disabled={running} />
</Field>
<Field label="初始资金(元)">
<input
type="number"
className="input"
step={100000}
min={10000}
value={capital}
onChange={(e) => setCapital(Number(e.target.value))}
disabled={running}
/>
</Field>
</div>
</details>
{running ? (
<Progress
value={35}
label={
<>
任务 <span className="mono">{jobId || "排队中…"}</span> 后台执行中(全市场回测通常 1~5 分钟)
</>
}
/>
) : null}
{error ? <Banner tone="error">{error}</Banner> : null}
{result ? (
<div>
<div className="between" style={{ marginBottom: 8 }}>
<Pill tone="pos" icon="check">
回测完成
</Pill>
<Link
href={expId ? `/experiments?exp=${encodeURIComponent(expId)}` : "/experiments"}
className="btn btn--sm"
>
<span>在实验中对比</span>
</Link>
{expId ? (
<>
<Link href={`/experiments/${encodeURIComponent(expId)}`} className="btn btn--sm">
<span>打开归档(完整快照)</span>
</Link>
<Link href={`/backtest?from_experiment=${encodeURIComponent(expId)}`} className="btn btn--sm">
<span>以此参数再跑</span>
</Link>
</>
) : null}
</div>
<BacktestMetrics s={result.summary} />
</div>
) : null}
</div>
);
}
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
);
}
@@ -0,0 +1,99 @@
"use client";
/**
* 归档页的**交互动作**(数据在服务端渲染,这里只管「页面之外」的动作)。
*
* 拆出来的原因:归档详情页改为 Server Component(只读内容必须能被 SSR 直出,
* 否则不带 JS 的抓取/分享预览看不到「选股条件」与「交易执行依据」);
* 而删除、导出、折叠 spec 这些必须有事件处理,就集中放在这个客户端组件里。
*
* 删除的确认文案必须写清后果:结果只存归档一份(`job.result_json` 对新记录为 NULL,
* `GET /api/jobs/{id}` 是从归档回读的),所以删归档 = 该次回测结果彻底消失。
* 先前的文案写成「不影响作业记录」,那是**误导**。
*/
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { apiDelete, apiGetText } from "@/lib/api";
import { Banner, Btn, Card } from "@/components/ui";
/**
* 刻意只收 `id` / `spec`(**不接收完整 result**):归档结果可达数 MB,而服务端已经把它
* 传给图表组件了;再传一份给本组件会让 RSC 载荷翻倍。导出时按需拉取。
*/
export function ArchiveActions({ id, spec }: { id: string; spec: unknown }) {
const router = useRouter();
const [deleting, setDeleting] = useState(false);
const [error, setError] = useState("");
const [showSpec, setShowSpec] = useState(false);
async function downloadJson() {
setError("");
try {
const text = await apiGetText(`/experiments/${encodeURIComponent(id)}`);
const blob = new Blob([text], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${id}-archive.json`;
a.click();
URL.revokeObjectURL(url);
} catch (e) {
setError(`导出失败:${(e as Error).message}`);
}
}
async function remove() {
const ok = window.confirm(
`删除归档 ${id}?\n\n` +
`· 删除后这份快照(结果 + spec)无法再查看,也不可恢复;\n` +
`· 结果只存归档这一份 —— 删除后连该次执行的作业记录也读不回结果\n` +
` (完整存档上线前的早期归档在作业表里另有副本,但不应依赖);\n` +
`· 想留底请先点左侧「导出完整 JSON」。\n\n确认删除?`
);
if (!ok) return;
setDeleting(true);
setError("");
try {
await apiDelete(`/experiments/${encodeURIComponent(id)}`);
router.push("/experiments");
} catch (e) {
setError((e as Error).message);
setDeleting(false);
}
}
return (
<>
{error ? <Banner tone="error">{error}</Banner> : null}
<div className="row" style={{ gap: 8, marginBottom: 12, flexWrap: "wrap" }}>
<Link href="/experiments" className="btn">
<span>实验对比</span>
</Link>
<Link href={`/experiments?exp=${encodeURIComponent(id)}`} className="btn">
<span>加入对比</span>
</Link>
<Link
href={`/backtest?from_experiment=${encodeURIComponent(id)}`}
className="btn btn--primary"
title="在回测页复用这份归档的参数(并直接载入该归档的结果),可微调后重跑"
>
<span>以此参数再跑</span>
</Link>
<Btn size="sm" icon="download" onClick={downloadJson}>
导出完整 JSON
</Btn>
<Btn size="sm" icon="book" onClick={() => setShowSpec((v) => !v)}>
{showSpec ? "收起原始 spec" : "查看原始 spec(JSON)"}
</Btn>
<Btn size="sm" icon="trash" variant="danger" loading={deleting} disabled={deleting} onClick={remove}>
删除归档
</Btn>
</div>
{showSpec ? (
<Card title="归档 spec(JSON 原文,复现依据)">
<pre className="mono codeblock">{JSON.stringify(spec ?? {}, null, 2)}</pre>
</Card>
) : null}
</>
);
}
@@ -0,0 +1,222 @@
"use client";
/**
* 归档结果的**按类型分发**视图:归档页只依赖这一个入口,不在页面里猜结果结构。
*
* 为什么必须存在(真实缺陷驱动):归档详情页原先在 `result` 非空时**无条件**渲染
* `BacktestResultView`,而 `factor_test` / `selection` 归档的结果结构与回测完全不同
* (没有 `summary` / `equity_curve` / `positions`)。实测后果:库里 4 条因子测试归档
* 与 1 条选股归档,从列表点「打开归档」**整页 500**(SSR 阶段 `result.positions.at(-1)`
* 抛 TypeError,且没有 error.tsx 兜底)——列表对每一行都给链接,所以这是必现路径。
*
* 现在按后端 `kind` 显式分发:
* - `backtest` → `BacktestResultView`(净值/回撤/个股曲线/持仓/成交/月度年度/未建模)
* - `factor_test` → IC / RankIC / ICIR / 正收益占比 / 样本日数 + **分层收益柱状图**
* - `selection` → 选股统计 + 候选明细表(代码**带名称**且可点击进个股页)
* - 其它未知类型 → 如实说明「暂不支持预览」并引导导出 JSON,**不假装能渲染**
*
* 纯派生逻辑(字段计数)不在这里,而在 `lib/archive.ts` —— Server Component 不能调用
* `"use client"` 模块导出的普通函数,放错位置会让归档页整页 500(已实测踩过)。
*
* 与 `/factors`、`/selection` 页面各自的渲染器并存是刻意的:那两个页面带交互
* (运行、直通回测、导出),归档页是**只读快照**,两类用途的文案与动作不同。
*/
import type { BacktestResult, FactorTestReport, SelectionResult } from "@/lib/types";
import type { AnyArchiveResult } from "@/lib/archive";
import { BacktestResultView } from "@/components/BacktestResultView";
import { RichText } from "@/components/RichText";
import { Card, Empty, Metric, UnimplementedNote } from "@/components/ui";
import { SymbolLink } from "@/lib/symbols";
export interface ArchiveInfo {
id: string;
created_at?: string | null;
code_version?: string | null;
data_version?: string | null;
job_id?: string | null;
}
export function ArchiveResultView({
kind,
result,
archive,
}: {
kind: string;
result: AnyArchiveResult;
archive: ArchiveInfo;
}) {
if (kind === "backtest") {
return <BacktestResultView result={result as BacktestResult} archive={archive} />;
}
if (kind === "factor_test") {
return <FactorReportView report={result as FactorTestReport} />;
}
if (kind === "selection") {
return <SelectionSnapshotView result={result as SelectionResult} />;
}
return (
<Card icon="archive" title={`归档结果(类型:${kind})`}>
<Empty
icon="info"
title="暂不支持预览该类型的归档结果"
hint={`归档类型「${kind}」没有对应的展示视图。结果本身已完整存档,可用上方「导出完整 JSON」查看原始内容 —— 这里不猜测结构、也不假装能渲染。`}
/>
</Card>
);
}
/* ------------------------------------------------------------------ */
function FactorReportView({ report }: { report: FactorTestReport }) {
const qs = report.quantile_returns ?? [];
const maxAbs = Math.max(0.001, ...qs.map((q) => Math.abs(q.return_pct)));
return (
<div className="stack" style={{ gap: 16 }}>
<Card
icon="chartLine"
title={`因子测试结果(${report.factor_name})`}
tools={<span className="hint">归档快照,只读</span>}
>
<div className="metric-grid" style={{ marginBottom: 0 }}>
<Metric
label="IC 均值"
icon="chartLine"
value={report.ic_mean.toFixed(4)}
tone={report.ic_mean >= 0 ? "pos" : "neg"}
/>
<Metric
label="RankIC 均值"
value={report.rank_ic_mean.toFixed(4)}
tone={report.rank_ic_mean >= 0 ? "pos" : "neg"}
/>
<Metric
label="ICIR"
value={report.icir.toFixed(3)}
tone={report.icir >= 1 ? "pos" : "plain"}
sub="越大越稳定"
/>
<Metric
label="正收益占比"
value={`${report.positive_ratio_pct.toFixed(1)}%`}
tone={report.positive_ratio_pct >= 50 ? "pos" : "warn"}
/>
<Metric label="样本日数" value={report.sample_days} icon="calendar" />
</div>
{qs.length > 0 ? (
<div style={{ marginTop: 14 }}>
<div className="field__label" style={{ marginBottom: 2 }}>
分层表现(Q1 最低因子值 → Q{qs.length} 最高)
</div>
<div className="vbars" role="img" aria-label="分层收益柱状图">
{qs.map((q) => {
const v = q.return_pct;
const h = Math.max(4, Math.round((Math.abs(v) / maxAbs) * 72));
return (
<div className="vbars__item" key={q.quantile}>
<span className="vbars__val">
{v >= 0 ? "+" : ""}
{v.toFixed(2)}%
</span>
<div
className={`vbars__bar ${v >= 0 ? "is-pos" : "is-neg"}`}
style={{ height: h }}
/>
<span className="vbars__cap">Q{q.quantile + 1}</span>
</div>
);
})}
</div>
</div>
) : null}
<div className="hint" style={{ marginTop: 12 }}>
<RichText text="读数:IC / RankIC 为正表示与未来收益正相关,ICIR 越大越稳定;分层收益若高分层显著高于低分层说明单调性好。**单因子测试 ≠ 策略有效**,需结合样本外与稳健性分析。" />
</div>
</Card>
<UnimplementedNote items={report.unimplemented ?? []} />
</div>
);
}
function SelectionSnapshotView({ result }: { result: SelectionResult }) {
const c = result.candidates ?? [];
return (
<div className="stack" style={{ gap: 16 }}>
<Card
icon="grid"
title={`选股结果(as_of ${result.as_of_date},方式 ${result.method === "score" ? "因子评分" : "条件筛选"})`}
tools={<span className="hint">归档快照,只读</span>}
>
<div className="metric-grid" style={{ marginBottom: 0 }}>
<Metric
label="股票池"
icon="database"
value={result.statistics?.universe_size ?? 0}
/>
<Metric
label="有效评分"
icon="chartLine"
value={result.statistics?.evaluated ?? 0}
/>
<Metric
label="选出"
icon="check"
value={result.statistics?.selected ?? 0}
tone="accent"
/>
<Metric label="候选明细" icon="grid" value={c.length} />
</div>
{c.length > 0 ? (
<div className="table-wrap" style={{ marginTop: 12 }}>
<table className="tbl">
<thead>
<tr>
<th>#</th>
<th>代码 / 名称</th>
<th>得分</th>
<th>因子值</th>
<th>入选理由</th>
</tr>
</thead>
<tbody>
{c.map((row) => (
<tr key={row.symbol}>
<td className="mono dim">{row.rank}</td>
<td>
<SymbolLink symbol={row.symbol} name={row.name ?? undefined} />
</td>
<td className="mono">{row.score.toFixed(4)}</td>
<td style={{ fontSize: 12 }}>
{Object.entries(row.factor_values ?? {})
.map(([k, v]) =>
`${k}=${typeof v === "number" ? v.toFixed(4) : v}`
)
.join(" ")}
</td>
<td>
<div className="chips">
{(row.selection_reason ?? []).map((r) => (
<span className="chip" key={r}>
{r.slice(0, 40)}
</span>
))}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="hint" style={{ marginTop: 12 }}>
该归档没有候选明细(结果为空或历史版本)。
</div>
)}
</Card>
<UnimplementedNote items={result.unimplemented ?? []} />
</div>
);
}
@@ -0,0 +1,656 @@
"use client";
/**
* 回测结果视图(**回测页**与**实验归档页**共用同一组件)。
*
* 共用是刻意的:归档要能被「往复查看」,就必须和刚跑完时看到的形态完全一致——
* 两处各写一套迟早会漂移,导致「归档里少了一张图/多了一列」这种隐性不一致。
*
* 三处模式差异用 props 表达:
* - `name`:策略名(运行时的策略);归档页传归档里记录的策略名(若有)。
* - `archive`:传入后额外展示归档元数据(实验 id / 数据版本 / 代码版本 / 归档时间)
* 与**归档完整度**(`result.archive_meta.truncated` 为真时显式提示曲线被裁剪,不静默)。
*/
import { useMemo, useRef, useState } from "react";
import {
Card,
Pill,
Btn,
Empty,
BacktestMetrics,
MonthlyReturnsTable,
UnimplementedNote,
} from "@/components/ui";
import { LwChart, type LwMarker, type LwSeries } from "@/components/charts/LwChart";
import { CHART, fmtNum, fmtPct } from "@/components/charts/theme";
import Link from "next/link";
import { SymbolLink, useSymbolNames } from "@/lib/symbols";
import { adjustLabel } from "@/lib/labels";
import type { ActionRecord, BacktestResult, SymbolCurve } from "@/lib/types";
export interface ArchiveInfo {
id: string;
created_at?: string | null;
code_version?: string | null;
data_version?: string | null;
job_id?: string | null;
}
function pad(n: number): string {
return String(n).padStart(2, "0");
}
function fmtDateTime(v?: string | null): string {
if (!v) return "—";
const d = new Date(v);
if (Number.isNaN(d.getTime())) return v;
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
export function BacktestResultView({
result,
name,
archive,
}: {
result: BacktestResult;
name?: string;
archive?: ArchiveInfo;
}) {
const s = result.summary;
const last = result.positions.at(-1);
const holdings = result.positions.filter((p) => p.date === last?.date);
const curves = result.symbol_curves ?? [];
const [active, setActive] = useState<string>(curves[0]?.symbol ?? "");
const [curveQuery, setCurveQuery] = useState("");
// 老实验(后端填充 name 之前归档的结果)里 curve.name 为空:
// 名称统一从 useSymbolNames 缓存取,保证「有代码必有名称」在任何历史结果上都成立
const { names: nameCache } = useSymbolNames();
const nameOf = (c: SymbolCurve) => c.name ?? nameCache[c.symbol] ?? "";
const topRef = useRef<HTMLDivElement | null>(null);
// 组合净值上的买卖点:同一日的成交合并成一个标记,落在当日净值上
const equitySeries = useMemo<LwSeries[]>(
() => [
{
key: "equity",
label: "组合净值(元)",
type: "area",
color: CHART.pos,
data: result.equity_curve.map((p) => ({ time: p.date, value: p.value })),
lastValueVisible: true,
},
],
[result.equity_curve]
);
const equityMarkers = useMemo<LwMarker[]>(() => {
const byDate = new Map<string, { BUY: boolean; SELL: boolean }>();
for (const f of result.fills ?? []) {
const cur = byDate.get(f.date) ?? { BUY: false, SELL: false };
cur[f.signal] = true;
byDate.set(f.date, cur);
}
const equity = new Set(result.equity_curve.map((p) => p.date));
const out: LwMarker[] = [];
for (const [d, kinds] of byDate) {
if (!equity.has(d)) continue;
if (kinds.BUY) out.push({ time: d, kind: "BUY", text: "买" });
if (kinds.SELL) out.push({ time: d, kind: "SELL", text: "卖" });
}
return out;
}, [result.fills, result.equity_curve]);
const filteredCurves = useMemo(() => {
const q = curveQuery.trim().toLowerCase();
if (!q) return curves;
return curves.filter(
(c) => c.symbol.toLowerCase().includes(q) || nameOf(c).toLowerCase().includes(q)
);
// nameOf 只依赖 nameCache 与 c,二者都在依赖里
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [curves, curveQuery, nameCache]);
const activeCurve = curves.find((c) => c.symbol === active) ?? filteredCurves[0] ?? curves[0];
const buyDays = equityMarkers.filter((m) => m.kind === "BUY").length;
const sellDays = equityMarkers.filter((m) => m.kind === "SELL").length;
const SECTIONS = [
{ id: "sec-equity", label: "整体收益" },
{ id: "sec-symbols", label: "个股曲线" },
{ id: "sec-monthly", label: "月度/年度" },
{ id: "sec-holdings", label: "持仓" },
{ id: "sec-trades", label: "成交明细" },
];
return (
<>
<div ref={topRef} />
<div className="sticky-bar" style={{ gap: 10 }}>
<b style={{ fontSize: 13 }}>结果分区</b>
{SECTIONS.map((s) => (
<a key={s.id} href={`#${s.id}`} className="btn btn--sm">
<span>{s.label}</span>
</a>
))}
<span className="hint">共 {result.equity_curve.length} 个交易日 · {result.trades.length} 笔成交</span>
</div>
{archive ? <ArchiveMetaBar archive={archive} /> : null}
<ArchiveCompletenessNote result={result} archive={archive} />
<div className="between" style={{ margin: "6px 0 14px" }}>
<div className="row" style={{ flexWrap: "wrap" }}>
<Pill tone="pos" icon="check">
完成
</Pill>
{name ? (
<Pill tone="violet" icon="book">
{name}
</Pill>
) : null}
<Pill tone="violet" icon="target">
{poolLabel(result)}
</Pill>
<Pill>{adjustLabelFromSnapshot(result.config_snapshot)} 口径</Pill>
{stBasisNote(result) && (
<Pill tone="warn" icon="alert">
{stBasisNote(result)}
</Pill>
)}
<Pill>
{s.start} ~ {s.end}
</Pill>
</div>
<div className="row" style={{ fontSize: 22, fontWeight: 700 }}>
总收益 <SignedText value={s.total_return_pct} />
</div>
</div>
<BacktestMetrics s={s} />
<div className="chart-grid" id="sec-equity">
<Card
icon="chartLine"
title="整体收益趋势(含买卖点)"
tools={
<Pill tone="pos">
期末 {fmtNum(s.final_equity)} · 买入 {buyDays} 日 / 卖出 {sellDays} 日
</Pill>
}
>
<LwChart
series={equitySeries}
markers={equityMarkers}
height={320}
valueFormat={(v) => fmtNum(v)}
ariaLabel="组合净值曲线与买卖点"
/>
<div className="hint" style={{ marginTop: 6 }}>
▲ 绿 = 当日有买入成交,▼ 红 = 当日有卖出成交;点位取当日组合净值。
悬停可看任意日期的净值;拖动/滚轮可缩放区间,双击图例可临时隐藏曲线。
</div>
</Card>
<Card
icon="chartLine"
title="回撤(%)"
tools={<Pill tone="neg">最大 {s.max_drawdown_pct.toFixed(2)}%</Pill>}
>
<LwChart
series={[
{
key: "dd",
label: "回撤(%)",
type: "area",
color: CHART.neg,
data: result.drawdown.map((p) => ({ time: p.date, value: p.value })),
lastValueVisible: true,
},
]}
height={320}
valueFormat={(v) => `${v.toFixed(2)}%`}
zeroLine
ariaLabel="回撤曲线"
/>
</Card>
</div>
<Card
id="sec-symbols"
icon="target"
title="个股收益率趋势(持仓期累计收益,含买卖点)"
tools={
<div className="row" style={{ gap: 8 }}>
<input
className="input"
style={{ width: 180 }}
placeholder="搜索代码或名称"
value={curveQuery}
onChange={(e) => setCurveQuery(e.target.value)}
aria-label="搜索个股"
/>
<Pill>{curves.length} 只有成交</Pill>
</div>
}
>
{curves.length === 0 ? (
<Empty title="无个股曲线" hint="回测区间内没有产生成交。" />
) : (
<>
<div className="row" style={{ gap: 8, marginBottom: 10, flexWrap: "wrap" }}>
<select
className="input"
style={{ width: 280 }}
value={activeCurve?.symbol ?? ""}
onChange={(e) => setActive(e.target.value)}
>
{filteredCurves.map((c) => {
const nm = nameOf(c);
return (
<option key={c.symbol} value={c.symbol}>
{c.symbol} {nm || "—"}({fmtPct(c.final_return_pct)})
</option>
);
})}
</select>
<SymbolLink
symbol={activeCurve?.symbol ?? ""}
name={activeCurve ? nameOf(activeCurve) : undefined}
/>
<span className="hint">
曲线口径:该股被持有期间按日复利累计(建仓当日为 0%);未持有期间不绘制,
分段间以直线连接,请以买卖点区分持仓区间。
</span>
</div>
{activeCurve ? <SymbolCurveChart curve={activeCurve} /> : null}
<div className="table-wrap" style={{ marginTop: 12 }}>
<table className="tbl">
<thead>
<tr>
<th>股票</th>
<th>持仓期累计收益</th>
<th>买点</th>
<th>卖点</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{filteredCurves.map((c) => (
<tr key={c.symbol} className={c.symbol === activeCurve?.symbol ? "row-active" : ""}>
<td className="sym-cell">
<SymbolLink symbol={c.symbol} name={c.name} />
</td>
<td className={c.final_return_pct >= 0 ? "tone-pos" : "tone-neg"}>
{c.final_return_pct.toFixed(2)}%
</td>
<td className="mono dim">
{(c.marks ?? []).filter((m) => m.signal === "BUY").length}
</td>
<td className="mono dim">
{(c.marks ?? []).filter((m) => m.signal === "SELL").length}
</td>
<td>
<Btn size="sm" onClick={() => setActive(c.symbol)}>
查看曲线
</Btn>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</Card>
<Card id="sec-monthly" icon="calendar" title="月度收益(%)">
<LwChart
series={[
{
key: "monthly",
label: "月度收益(%)",
type: "bar",
color: CHART.accent,
data: result.monthly_returns.map((m) => ({
time: `${m.year}-${String(m.month).padStart(2, "0")}-01`,
value: m.return_pct,
})),
},
]}
height={200}
valueFormat={(v) => `${v.toFixed(2)}%`}
zeroLine
legend={false}
ariaLabel="月度收益柱状图"
/>
<details style={{ marginTop: 8 }}>
<summary className="hint" style={{ cursor: "pointer" }}>
查看月度收益明细表
</summary>
<MonthlyReturnsTable rows={result.monthly_returns} />
</details>
</Card>
<Card
id="sec-holdings"
icon="target"
title={`最新持仓 · ${last?.date ?? "—"}`}
tools={<Pill>{holdings.length} 只</Pill>}
>
{holdings.length === 0 ? (
<Empty title="无持仓记录" hint="回测区间内没有产生持仓。" />
) : (
<div className="table-wrap">
<table className="tbl">
<thead>
<tr>
<th>股票</th>
<th>权重</th>
</tr>
</thead>
<tbody>
{holdings.map((p) => (
<tr key={p.symbol}>
<td className="sym-cell">
<SymbolLink symbol={p.symbol} name={p.name} />
</td>
<td className="mono">{(p.weight * 100).toFixed(2)}%</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
{result.yearly_returns.length ? (
<Card
icon="calendar"
title="年度收益(%)"
tools={
<Pill>
均值{" "}
{(
result.yearly_returns.reduce((a, y) => a + y.return_pct, 0) /
result.yearly_returns.length
).toFixed(2)}
%
</Pill>
}
>
<div className="chips">
{result.yearly_returns.map((y) => (
<span className="chip" key={y.year}>
<b>{y.year}</b>
<span className={y.return_pct >= 0 ? "tone-pos" : "tone-neg"}>
{y.return_pct.toFixed(2)}%
</span>
</span>
))}
</div>
</Card>
) : null}
{result.trades.length ? (
<Card
id="sec-trades"
icon="scale"
title={`成交明细 · ${result.trades.length} 笔`}
tools={<Pill>{result.turnover_pct.toFixed(2)}% 累计换手</Pill>}
>
<div className="table-wrap">
<table className="tbl">
<thead>
<tr>
<th>买入日</th>
<th>卖出日</th>
<th>股票</th>
<th>买价</th>
<th>卖价</th>
<th>收益</th>
</tr>
</thead>
<tbody>
{result.trades.map((t) => (
<tr key={`${t.symbol}-${t.entry_date}-${t.exit_date}`}>
<td className="mono dim">{t.entry_date}</td>
<td className="mono dim">{t.exit_date}</td>
<td className="sym-cell">
<SymbolLink symbol={t.symbol} name={t.name} />
</td>
<td className="mono">{t.entry_price?.toFixed(2) ?? "-"}</td>
<td className="mono">{t.exit_price?.toFixed(2) ?? "-"}</td>
<td className={t.return_pct >= 0 ? "tone-pos" : "tone-neg"}>
{t.return_pct.toFixed(2)}%
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
) : null}
<NotFilledCard signals={result.signal_history ?? []} />
<UnimplementedNote items={result.unimplemented} />
</>
);
}
/** 未成交意图(涨停/停牌/顺延)透明化,避免「信号有了却没买」无法解释。 */
function NotFilledCard({ signals }: { signals: ActionRecord[] }) {
const rejects = signals.filter((a) => !a.filled && a.reject_reason);
if (rejects.length === 0) return null;
const byReason = new Map<string, number>();
for (const r of rejects) {
const key = (r.reject_reason ?? "").replace(/,?顺延.*$/, "(顺延)");
byReason.set(key, (byReason.get(key) ?? 0) + 1);
}
return (
<Card icon="info" title={`未成交意图 · ${rejects.length} 条`}>
<div className="chips">
{[...byReason.entries()].map(([reason, n]) => (
<span className="chip" key={reason}>
{reason} <b>{n}</b>
</span>
))}
</div>
<div className="hint" style={{ marginTop: 8 }}>
涨停/停牌导致的未成交按「信号已记录、成交未发生」处理(v3 §20.3):顺延买入会在之后首个
可成交交易日按收盘价成交;到下一次调仓仍未成交则作废。
</div>
</Card>
);
}
function SymbolCurveChart({ curve }: { curve: SymbolCurve }) {
const valueByDate = useMemo(() => new Map(curve.points.map((p) => [p.date, p.value])), [curve]);
const markers = useMemo<LwMarker[]>(
() =>
(curve.marks ?? [])
.filter((a) => valueByDate.has(a.date))
.map((a) => ({
time: a.date,
kind: a.signal,
text: a.signal === "BUY" ? "买" : "卖",
})),
[curve, valueByDate]
);
return (
<LwChart
series={[
{
key: "sym",
label: `${curve.symbol} 持仓期累计收益(%)`,
type: "area",
color: CHART.accent,
data: curve.points.map((p) => ({ time: p.date, value: p.value })),
lastValueVisible: true,
},
]}
markers={markers}
height={300}
valueFormat={(v) => `${v.toFixed(2)}%`}
zeroLine
ariaLabel={`${curve.symbol} 持仓期收益曲线与买卖点`}
/>
);
}
function poolLabel(result: BacktestResult): string {
const sel = result.config_snapshot?.selection as
| { top_n?: number; hold_top_x?: number | null }
| undefined;
if (!sel) return "候选池 —";
return `候选池 ${sel.top_n ?? "—"} → 持仓 ${sel.hold_top_x ?? sel.top_n ?? "—"}`;
}
/**
* ST(风险警示)剔除口径提示。
*
* 后端逐择股日按**当时名称**判定 exclude_st(依赖 `sync namechange`);未同步名称历史时
* 回退「最新名称快照」,会把「曾为高股息、后来才 ST/退市」的股息陷阱样本整段排除,
* 收益被高估(实测案例 +24.86% → +35.71%)。此处把口径显式暴露给使用者,避免误读。
*/
function stBasisNote(result: BacktestResult): string | null {
const basis = result.config_snapshot?.price_basis as
| { name_basis?: { point_in_time?: boolean } | null }
| undefined;
const nb = basis?.name_basis;
if (!nb) return null; // 未启用 exclude_st
return nb.point_in_time ? "ST 按时点名称判定" : "ST 用最新名称(可能高估)";
}
function adjustLabelFromSnapshot(snapshot: Record<string, unknown>): string {
const basis = snapshot?.price_basis as { adjust_mode?: string } | undefined;
const mode = basis?.adjust_mode ?? "none";
return mode === "hfq" ? "后复权" : mode === "qfq" ? "前复权" : "不复权";
}
function SignedText({ value }: { value: number }) {
const cls = value >= 0 ? "tone-pos" : "tone-neg";
return (
<span className={cls}>
{value > 0 ? "+" : ""}
{value.toFixed(2)}%
</span>
);
}
/**
* 归档元数据条:**归档能否被信任地复现,取决于这几项**,所以放在最显眼的位置。
* - 代码版本:跑出这个结果时的 git short rev
* - 数据版本:当时的数据快照指纹(由后端在归档时写入;老归档可能为空 → 如实显示「—」)
* - 实验 / 作业 id:可追到执行记录
*/
function ArchiveMetaBar({ archive }: { archive: ArchiveInfo }) {
return (
<div className="archive-bar">
<Pill tone="violet" icon="archive">
归档 {archive.id}
</Pill>
<span className="hint">
归档时间 <b className="mono">{fmtDateTime(archive.created_at)}</b> · 代码版本{" "}
<b className="mono">{archive.code_version ?? "—"}</b> · 数据版本{" "}
<b className="mono">{archive.data_version ?? "未记录"}</b>
{archive.job_id ? (
<>
{" "}
· 作业 <b className="mono">{archive.job_id}</b>
</>
) : null}
</span>
</div>
);
}
/**
* 归档完整度提示。
*
* `archive_meta.truncated` 为真表示:归档时结果 JSON 超过安全预算,个股曲线被裁剪
* (后端会在 `unimplemented` 里同时留下说明)。**必须显式告知**,否则用户会以为
* 「这只股票没被买过」,而实际只是没存下来。
*/
function ArchiveCompletenessNote({
result,
archive,
}: {
result: BacktestResult;
archive?: ArchiveInfo;
}) {
const meta = result.archive_meta as
| { curves_stored?: number; curves_total?: number; truncated?: boolean; budget_chars?: number }
| undefined;
const legacyNote = (result.unimplemented ?? []).find((u) => u.includes("个股收益曲线"));
// 情况 1:归档带完整度元数据(本轮之后产生的归档)
if (meta && meta.curves_total !== undefined) {
const total = meta.curves_total ?? 0;
const stored = meta.curves_stored ?? 0;
return (
<div className="archive-bar">
{meta.truncated ? (
<Pill tone="warn" icon="alert">
归档不完整
</Pill>
) : (
<Pill tone="pos" icon="check">
归档完整
</Pill>
)}
<span className="hint">
个股曲线已存 <b className="mono">{stored}</b> / 期内持有 <b className="mono">{total}</b> 只
{meta.truncated
? "(超出结果体积预算被裁剪:请在下方用「成交明细 / 未成交意图」查缺失个股,或缩短区间后重跑以得到完整曲线)"
: "(全部持有过的股票都可在此查看)"}
</span>
</div>
);
}
// 情况 2:老归档没有元数据,但结果里留了截断说明 → **不能装作完整**
if (legacyNote) {
const held = /期内共持有\s*(\d+)\s*只/.exec(legacyNote)?.[1];
const kept = /最大的\s*(\d+)\s*只/.exec(legacyNote)?.[1];
return (
<div className="archive-bar">
<Pill tone="warn" icon="alert">
归档不完整
</Pill>
<span className="hint">
该归档产生于「完整存档」上线之前:个股曲线只有 <b className="mono">{kept ?? "60"}</b> 只(期内共持有{" "}
<b className="mono">{held ?? "更多"}</b> 只),换手但收益排名不靠前的个股在这里看不到曲线。
成交明细 / 未成交意图仍是完整的。要看全部个股曲线,请
{archive ? (
<>
{" "}
<Link href={`/backtest?from_experiment=${encodeURIComponent(archive.id)}`} className="btn btn--sm">
<span>以此参数重跑</span>
</Link>{" "}
生成一份完整归档。
</>
) : (
"以此参数重跑一次(新归档将保存全部持有过的个股曲线)。"
)}
</span>
</div>
);
}
// 情况 3:既无元数据也无截断说明 → 无从判断,不猜(只在老归档上出现)
if (archive) {
return (
<div className="archive-bar">
<Pill icon="info">
完整度未标注
</Pill>
<span className="hint">
该归档早于「归档完整度元数据」上线,无法确认个股曲线是否被裁剪过
(成交明细 / 未成交意图始终是完整的)。
</span>
</div>
);
}
return null;
}
-117
View File
@@ -1,117 +0,0 @@
"use client";
/** 基于 ECharts 的折线图(净值 / 回撤),按需引入模块以压缩 bundle。 */
import { useEffect, useRef } from "react";
import * as echarts from "echarts/core";
import { LineChart as EChartsLine } from "echarts/charts";
import { GridComponent, TitleComponent, TooltipComponent } from "echarts/components";
import { CanvasRenderer } from "echarts/renderers";
echarts.use([EChartsLine, GridComponent, TitleComponent, TooltipComponent, CanvasRenderer]);
export interface XY {
date: string;
value: number;
}
const C = {
text: "#a8b3c9",
faint: "#8b98b2",
line: "rgba(150,165,195,0.13)",
tooltipBg: "#141d30",
tooltipBorder: "rgba(150,165,195,0.28)",
};
export function LineChart({
title,
data,
color = "#3fb6ff",
fill = false,
height = 320,
yFmt,
xFmt,
}: {
title?: string;
data: XY[];
color?: string;
/** 线下渐变面积(净值类曲线推荐开启) */
fill?: boolean;
height?: number;
yFmt?: (v: number) => string;
xFmt?: (v: string) => string;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!ref.current || data.length === 0) return;
const chart = echarts.init(ref.current);
const areaColor = {
type: "linear",
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: color + "3d" },
{ offset: 1, color: color + "00" },
],
};
chart.setOption({
backgroundColor: "transparent",
title: title
? {
text: title,
textStyle: { color: "#e9eef7", fontSize: 13, fontWeight: 600 },
}
: undefined,
tooltip: {
trigger: "axis",
backgroundColor: C.tooltipBg,
borderColor: C.tooltipBorder,
textStyle: { color: "#e9eef7", fontSize: 12 },
valueFormatter: (v: unknown) =>
typeof v === "number" ? (yFmt ? yFmt(v) : String(v)) : String(v),
},
grid: { left: 8, right: 14, top: title ? 42 : 18, bottom: 8, containLabel: true },
xAxis: {
type: "category",
boundaryGap: false,
data: data.map((p) => p.date),
axisLine: { lineStyle: { color: C.line } },
axisTick: { show: false },
axisLabel: {
color: C.faint,
fontSize: 10,
formatter: (v: string) => (xFmt ? xFmt(v) : v),
},
},
yAxis: {
type: "value",
scale: true,
axisLabel: { color: C.faint, fontSize: 10, formatter: yFmt },
splitLine: { lineStyle: { color: C.line } },
},
series: [
{
type: "line",
showSymbol: false,
symbol: "circle",
data: data.map((p) => p.value),
lineStyle: { color, width: 1.8 },
itemStyle: { color },
areaStyle: fill ? { color: areaColor } : undefined,
emphasis: { focus: "series" },
},
],
});
const onResize = () => chart.resize();
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("resize", onResize);
chart.dispose();
};
}, [data, color, fill, title, height, yFmt, xFmt]);
if (data.length === 0) return <div className="hint">暂无数据</div>;
return <div ref={ref} style={{ height, width: "100%" }} />;
}
+36
View File
@@ -0,0 +1,36 @@
import type { ReactNode } from "react";
/**
* 富文本渲染:把后端说明文本里的 **粗体** 与 `行内代码` 渲染成真实样式。
*
* 为什么需要:`describe_strategy`(以及引擎的 `unimplemented` 说明)用 Markdown 的
* `**…**` / `` `…` `` 做**强调协议**——纯文本接口下这是合理的表达方式。但 React 不会
* 解析 Markdown,直接 `{text}` 会把星号和反引号**原样显示**出来(实测归档页/策略页
* 有 10 处这样的漏字),让「说明」看起来像没写完的草稿。
*
* 为什么不引 markdown 渲染器:说明文本来自后端(可信),但仍不需要为两种标记引入
* `dangerouslySetInnerHTML` 与整个解析器;这里只做**结构化切分后生成 React 元素**,
* 不产生 HTML 注入面,也不支持链接/图片等其它语法(不支持就按字面显示,不猜)。
*/
export function RichText({ text }: { text?: string | null }): ReactNode {
if (!text) return null;
// 一次切分同时处理 **粗体** 与 `代码`(分隔符保留在结果里以便识别)
const parts = text.split(/(\*\*[^*]+\*\*|`[^`]+`)/g).filter((p) => p !== "");
return (
<>
{parts.map((p, i) => {
if (p.length > 4 && p.startsWith("**") && p.endsWith("**")) {
return <b key={i}>{p.slice(2, -2)}</b>;
}
if (p.length > 2 && p.startsWith("`") && p.endsWith("`")) {
return (
<code key={i} className="inline-code">
{p.slice(1, -1)}
</code>
);
}
return <span key={i}>{p}</span>;
})}
</>
);
}
@@ -1,200 +0,0 @@
"use client";
/**
* K 线图(v3 §20 Chart):蜡烛图 + 成交量 + MA 指标 + 事件标记。
*
* 标记语义(v3 §20.3 Signal↔Fill 严格区分):
* - fills(实际成交):实心标记 —— fill_buy 红色 ▲ / fill_sell 绿色 ▼
* - signals(未成交意图):空心标记 —— signal_buy 红圈 ▲ / signal_sell 绿圈
* 组件只做展示,绝不自行计算(数据来自后端 Chart API)。
*
* 基于 ECharts candlestick(ECharts 实现);另一套实现见 CandleChartLW.tsx(TradingView
* Lightweight Charts),两套共用同一 props 接口(chartTypes.ts)供对比。
*/
import { useEffect, useRef } from "react";
import * as echarts from "echarts/core";
import { BarChart, CandlestickChart, LineChart, ScatterChart } from "echarts/charts";
import { GridComponent, TooltipComponent } from "echarts/components";
import { CanvasRenderer } from "echarts/renderers";
import type { ChartMarker } from "@/lib/types";
import type { CandleChartProps } from "./chartTypes";
export type { CandleDatum } from "./chartTypes";
echarts.use([
CandlestickChart,
LineChart,
BarChart,
ScatterChart,
GridComponent,
TooltipComponent,
CanvasRenderer,
]);
const C = {
faint: "#8b98b2",
line: "rgba(150,165,195,0.13)",
tooltipBg: "#141d30",
tooltipBorder: "rgba(150,165,195,0.28)",
up: "#e5484d",
down: "#2fb36b",
ma20: "#3fb6ff",
ma60: "#d4a72c",
};
export function CandleChart({
data,
volume = [],
indicators = {},
fills = [],
signals = [],
height = 480,
name = "",
}: CandleChartProps) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!ref.current || data.length === 0) return;
const chart = echarts.init(ref.current);
const dates = data.map((d) => d.time);
const vol = volume ?? [];
// echarts option 结构较宽,series 用宽松类型(组件层实现细节)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const series: any[] = [
{
name: "K线",
type: "candlestick",
data: data.map((d) => [d.open, d.close, d.low, d.high]),
itemStyle: {
color: C.up, // 阳线(收盘>开盘)红涨
color0: C.down,
borderColor: C.up,
borderColor0: C.down,
},
},
];
for (const [name, pts] of Object.entries(indicators)) {
const vals = dates.map((t) => {
const p = pts.find((x) => x.time === t);
return p?.value ?? null;
});
series.push({
name,
type: "line",
data: vals,
smooth: false,
showSymbol: false,
lineStyle: { width: 1.2 },
itemStyle: { color: C[name as keyof typeof C] ?? "#b98bff" },
connectNulls: true,
});
}
// 成交标记(实心)与未成交信号(空心):以散点叠加在价格坐标
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mk = (m: ChartMarker, filled: boolean): any => {
const buy = m.kind.endsWith("buy");
return {
value: [m.time, m.price ?? 0],
symbol: filled ? (buy ? "triangle" : "triangle") : buy ? "triangle" : "triangle",
symbolRotate: buy ? 0 : 180,
symbolSize: 13,
itemStyle: {
color: filled ? (buy ? C.up : C.down) : "transparent",
borderColor: buy ? C.up : C.down,
borderWidth: 1.6,
},
tooltip: { formatter: m.text.join("<br/>") },
};
};
series.push(
{ name: "成交", type: "scatter", data: fills.map((m) => mk(m, true)), z: 6 },
{ name: "信号(未成交)", type: "scatter", data: signals.map((m) => mk(m, false)), z: 5 }
);
const hasVol = vol.length > 0;
const mainGridBottom = hasVol ? 58 : 14;
series.push({
name: "成交量",
type: "bar",
xAxisIndex: 1,
yAxisIndex: 1,
data: dates.map((t) => {
const p = vol.find((x) => x.time === t);
return p?.value ?? 0;
}),
itemStyle: { color: "rgba(150,165,195,0.35)" },
});
chart.setOption({
backgroundColor: "transparent",
tooltip: {
trigger: "axis",
axisPointer: { type: "cross" },
backgroundColor: C.tooltipBg,
borderColor: C.tooltipBorder,
textStyle: { color: "#e9eef7", fontSize: 12 },
},
axisPointer: { link: [{ xAxisIndex: "all" }] },
grid: [
{ left: 8, right: 18, top: 16, height: hasVol ? "62%" : "82%", containLabel: true },
...(hasVol
? [{ left: 8, right: 18, top: hasVol ? "76%" : undefined, height: "14%", containLabel: true }]
: []),
],
xAxis: [
{
type: "category",
data: dates,
boundaryGap: true,
axisLine: { lineStyle: { color: C.line } },
axisTick: { show: false },
axisLabel: { color: C.faint, fontSize: 10 },
splitLine: { show: false },
},
...(hasVol
? [{
type: "category",
gridIndex: 1,
data: dates,
boundaryGap: true,
axisLine: { lineStyle: { color: C.line } },
axisTick: { show: false },
axisLabel: { show: false },
splitLine: { show: false },
}]
: []),
],
yAxis: [
{
type: "value",
scale: true,
splitNumber: 4,
axisLabel: { color: C.faint, fontSize: 10 },
splitLine: { lineStyle: { color: C.line } },
},
...(hasVol
? [{
type: "value",
gridIndex: 1,
scale: true,
axisLabel: { color: C.faint, fontSize: 9 },
splitLine: { show: false },
}]
: []),
],
series,
legend: {
show: false,
},
});
const onResize = () => chart.resize();
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("resize", onResize);
chart.dispose();
};
}, [data, volume, indicators, fills, signals, height]);
if (data.length === 0) return <div className="hint">暂无 K 线数据</div>;
return <div ref={ref} style={{ height, width: "100%" }} aria-label={`${name} K 线图`} />;
}
@@ -1,12 +1,16 @@
"use client";
/** K 线图统一入口:可切换 ECharts / TradingView Lightweight Charts 两套实现(对比期)。 */
/**
* K 线图统一入口(TradingView Lightweight Charts)。
*
* 为什么去掉 `library` 参数:ECharts 版(CandleChart.tsx)已随「图表统一到
* lightweight-charts」下线并删除;如果继续保留切换参数,调用方会以为还有第二套实现可用
* (AGENT §24:未实现必须如实标注,不许假装)。props 仍沿用 chartTypes.ts 的 CandleChartProps,
* 单只股票页与后续调用方无需改接口。
*/
import type { CandleChartProps } from "./chartTypes";
import { CandleChart } from "./CandleChart";
import { CandleChartLW } from "./CandleChartLW";
export type ChartLibrary = "echarts" | "lightweight";
export function StockChart({ library = "lightweight", ...props }: CandleChartProps & { library?: ChartLibrary }) {
return library === "echarts" ? <CandleChart {...props} /> : <CandleChartLW {...props} />;
export function StockChart(props: CandleChartProps) {
return <CandleChartLW {...props} />;
}
@@ -0,0 +1,97 @@
"use client";
/**
* 策略说明卡片:一句话说明 + 计算公式 + 执行步骤 + 注意事项。
*
* 内容全部来自后端 describe_strategy(依 spec 真实推导);本组件只负责呈现,
* 并用 `RichText` 渲染后端使用的 **粗体** / `代码` 强调协议(否则星号会字面漏出)。
* 接口不可用时**如实显示「说明暂不可用」并给出原因**,不伪造文案(AGENT §24)。
*/
import { RichText } from "@/components/RichText";
import { Card, Pill } from "@/components/ui";
import type { StrategyDoc } from "@/lib/types";
export function StrategyDocBody({ doc }: { doc: StrategyDoc }) {
return (
<div className="doc-block">
<div className="doc-summary">
<span aria-hidden>💡</span>
<span><RichText text={doc.summary} /></span>
</div>
{doc.formula ? (
<div className="doc-formula">
<b style={{ fontSize: 13 }}>计算公式</b>
<pre className="mono"><RichText text={doc.formula} /></pre>
</div>
) : null}
{doc.steps?.length ? (
<div>
<b style={{ fontSize: 13 }}>执行步骤</b>
<ol className="doc-steps">
{doc.steps.map((s, i) => (
<li key={i}><RichText text={s} /></li>
))}
</ol>
</div>
) : null}
{doc.warnings?.length ? (
<div className="chips">
{doc.warnings.map((w, i) => (
<span className="chip" key={i} title={w}>
<span className="tone-warn">注意</span> <RichText text={w} />
</span>
))}
</div>
) : null}
</div>
);
}
export function StrategyDocCard({
doc,
loading,
error,
title = "策略说明与计算公式",
compact = false,
}: {
doc: StrategyDoc | null;
loading: boolean;
error: string;
title?: string;
compact?: boolean;
}) {
return (
<Card
icon="book"
title={title}
tools={
loading ? (
<Pill tone="accent" icon="spinner">
生成中
</Pill>
) : doc ? (
<Pill tone="pos" icon="check">
由后端依参数推导
</Pill>
) : null
}
>
{doc ? (
<StrategyDocBody doc={doc} />
) : loading ? (
<div className="hint">正在生成说明与公式…</div>
) : (
<div className="hint">
说明暂不可用{error ? `:${error}` : "(后端 /api/strategies/describe 未就绪)"}。
策略仍可正常保存与运行;说明与公式由后端依 spec 推导,前端不自行拼造文案。
</div>
)}
{!compact && doc && (
<div className="hint" style={{ marginTop: 10 }}>
<RichText text="本说明由后端 `describe_strategy` 从策略定义推导,与引擎实际执行的规则同源," />
因此不会出现「文档写一套、代码跑另一套」。
</div>
)}
</Card>
);
}
@@ -0,0 +1,717 @@
"use client";
/**
* 策略参数表单(受控组件)—— 策略库 / 回测页 / 选股直通 共用同一份参数模型。
*
* 为什么抽出来:策略库要「新建/编辑策略」、回测页要「保存为策略/从策略载入」、
* 选股页要「按此条件回测」,三处字段与校验完全同构。若各写一遍,必然出现
* 「选股页能设的条件在回测页设不了」这类口径漂移(本平台的核心风险)。
* 因此参数只有一个模型 `StrategyParams`,一个表单组件,一套校验。
*
* 组件**不持有业务状态**:value/onChange 由父组件控制,父组件负责提交与落库。
*/
import type { FactorMeta, ResearchCondition, ResearchSpec, StrategyDefinition } from "@/lib/types";
import { Btn, Field } from "@/components/ui";
export type Op = ResearchCondition["op"];
export const OPS: { value: Op; label: string }[] = [
{ value: "gt", label: ">" },
{ value: "gte", label: "≥" },
{ value: "lt", label: "<" },
{ value: "lte", label: "≤" },
{ value: "eq", label: "=" },
{ value: "ne", label: "≠" },
{ value: "in", label: "属于" },
{ value: "not_in", label: "不属于" },
];
export interface StrategyParams {
name: string;
description: string;
/** 加权因子列表(打分公式 = Σ weight × factor) */
factors: { name: string; weight: number }[];
priceAdjustment: "none" | "qfq" | "hfq";
/** n:候选池(择股条件选出的股数) */
topN: number;
/** x:实际持仓数,必须 ≤ n */
holdX: number;
/** m:择股间隔(月),0 = 不单独设(跟随 y) */
mMonths: number;
/** y:调仓间隔(月),0 = 跟随 m */
yMonths: number;
rebalance: "monthly" | "weekly";
/**
* 买不进(涨停/停牌)时的补位策略 —— 三态,与后端两个互斥字段一一对应:
* - `substitute`:allow_substitute=true, defer_buy=false(换一只买)
* - `defer`:allow_substitute=false, defer_buy=true(顺延到之后首个不涨停的交易日)
* - `none`:两者皆 false(不补位,可能少持几只)
* 后端拒绝两者同时为 true,因此这里不提供「都选」的组合。
*/
fillPolicy: FillPolicy;
conditions: ResearchCondition[];
excludeSt: boolean;
minListingDays: number;
commission: number; // %
stamp: number; // %
slippage: number; // %
minCommission: number; // 元/笔
capital: number;
start: string;
end: string;
}
/** 用户案例默认参数:全市场股息率最高的 n 只 → 持仓前 x 只,每 m 个月择股、每 y 个月调仓。 */
export type FillPolicy = "substitute" | "defer" | "none";
export const CASE_PRESET: Omit<StrategyParams, "start" | "end" | "name" | "description"> = {
factors: [{ name: "dividend_yield", weight: 1 }],
priceAdjustment: "hfq",
topN: 20,
holdX: 20,
mMonths: 6,
yMonths: 6,
rebalance: "monthly",
fillPolicy: "defer",
conditions: [{ field: "dv_ratio", op: "lte", value: 30 }],
excludeSt: true,
minListingDays: 250,
commission: 0.03,
stamp: 0.05,
slippage: 0.1,
minCommission: 5,
capital: 1_000_000,
};
export function emptyParams(range: { start: string; end: string }): StrategyParams {
return {
...CASE_PRESET,
name: "",
description: "",
factors: [{ name: "momentum_60", weight: 1 }],
conditions: [],
start: range.start,
end: range.end,
};
}
export function casePreset(range: { start: string; end: string }): StrategyParams {
return { ...CASE_PRESET, name: "", description: "", start: "2020-01-01", end: range.end };
}
/** 参数 → ResearchSpec(回测提交体)。m/y 的 0 语义与后端一致。 */
export function paramsToSpec(p: StrategyParams): ResearchSpec {
const factors = p.factors.filter((f) => f.name.trim() !== "");
return {
type: "backtest",
universe: { exclude_st: p.excludeSt, min_listing_days: p.minListingDays },
price_adjustment: p.priceAdjustment,
factors: factors.length ? factors : [{ name: "momentum_60", weight: 1 }],
conditions: p.conditions.filter((c) => c.field.trim() !== ""),
selection: {
top_n: p.topN,
hold_top_x: p.holdX,
allow_substitute: p.fillPolicy === "substitute",
defer_buy: p.fillPolicy === "defer",
},
rebalance: p.rebalance,
// m=0 表示「每次调仓都择股」:若同时给了 y>0,则择股间隔跟随 y
// (后端禁止只给 y 而不给 m —— 无锚点无法确定择股日集合)
selection_interval_months: p.mMonths > 0 ? p.mMonths : p.yMonths > 0 ? p.yMonths : null,
rebalance_interval_months: p.yMonths > 0 ? p.yMonths : p.mMonths > 0 ? p.mMonths : null,
costs: {
commission_rate: p.commission / 100,
stamp_tax_rate: p.stamp / 100,
slippage_rate: p.slippage / 100,
min_commission: p.minCommission,
},
initial_capital: p.capital,
period: [p.start, p.end],
};
}
/** ResearchSpec → 参数(从实验详情「以此参数回测」时使用) */
export function paramsFromSpec(
spec: Partial<ResearchSpec> & { config_snapshot?: Record<string, unknown> },
base: StrategyParams
): StrategyParams {
const snap = (spec.config_snapshot ?? {}) as Partial<ResearchSpec>;
const s = (snap.factors ? snap : spec) as Partial<ResearchSpec>;
const sel: NonNullable<ResearchSpec["selection"]> = s.selection ?? { top_n: base.topN };
const costs: NonNullable<ResearchSpec["costs"]> = s.costs ?? {};
return {
...base,
factors: s.factors?.length ? s.factors.map((f) => ({ ...f })) : base.factors,
priceAdjustment: s.price_adjustment ?? base.priceAdjustment,
topN: sel.top_n ?? base.topN,
holdX: sel.hold_top_x ?? sel.top_n ?? base.holdX,
mMonths: s.selection_interval_months ?? base.mMonths,
yMonths: s.rebalance_interval_months ?? base.yMonths,
rebalance: s.rebalance ?? base.rebalance,
fillPolicy: sel.defer_buy ? "defer" : sel.allow_substitute === false ? "none" : "substitute",
conditions: (s.conditions ?? []).map((c) => ({ ...c })),
excludeSt: s.universe?.exclude_st ?? base.excludeSt,
minListingDays: s.universe?.min_listing_days ?? base.minListingDays,
commission: (costs.commission_rate ?? base.commission / 100) * 100,
stamp: (costs.stamp_tax_rate ?? base.stamp / 100) * 100,
slippage: (costs.slippage_rate ?? base.slippage / 100) * 100,
minCommission: costs.min_commission ?? base.minCommission,
capital: s.initial_capital ?? base.capital,
start: s.period?.[0] ?? base.start,
end: s.period?.[1] ?? base.end,
};
}
/** 已保存策略 → 参数 */
export function paramsFromStrategy(st: StrategyDefinition, base: StrategyParams): StrategyParams {
const sel = st.selection ?? {};
const costs = st.costs ?? {};
return {
...base,
name: st.name,
description: st.description ?? "",
factors: st.factors?.length ? st.factors.map((f) => ({ ...f })) : base.factors,
priceAdjustment: st.price_adjustment ?? base.priceAdjustment,
topN: sel.top_n ?? base.topN,
holdX: sel.hold_top_x ?? sel.top_n ?? base.holdX,
mMonths: st.selection_interval_months ?? base.mMonths,
yMonths: st.rebalance_interval_months ?? base.yMonths,
rebalance: st.rebalance ?? base.rebalance,
fillPolicy: sel.defer_buy ? "defer" : sel.allow_substitute === false ? "none" : "substitute",
conditions: (st.conditions ?? []).map((c) => ({ ...c })),
excludeSt: st.universe?.exclude_st ?? base.excludeSt,
minListingDays: st.universe?.min_listing_days ?? base.minListingDays,
commission: (costs.commission_rate ?? base.commission / 100) * 100,
stamp: (costs.stamp_tax_rate ?? base.stamp / 100) * 100,
slippage: (costs.slippage_rate ?? base.slippage / 100) * 100,
minCommission: costs.min_commission ?? base.minCommission,
};
}
/** 参数 → 策略定义(保存到策略库;period/capital 不入库,回测时再补) */
export function strategyFromParams(p: StrategyParams, id?: string): StrategyDefinition {
const factors = p.factors.filter((f) => f.name.trim() !== "");
return {
...(id ? { id } : {}),
name: p.name.trim(),
description: p.description.trim(),
spec_type: "backtest",
universe: { exclude_st: p.excludeSt, min_listing_days: p.minListingDays },
price_adjustment: p.priceAdjustment,
factors: factors.length ? factors : [{ name: "momentum_60", weight: 1 }],
conditions: p.conditions.filter((c) => c.field.trim() !== ""),
selection: {
top_n: p.topN,
hold_top_x: p.holdX,
allow_substitute: p.fillPolicy === "substitute",
defer_buy: p.fillPolicy === "defer",
},
rebalance: p.rebalance,
selection_interval_months: p.mMonths > 0 ? p.mMonths : p.yMonths > 0 ? p.yMonths : null,
rebalance_interval_months: p.yMonths > 0 ? p.yMonths : p.mMonths > 0 ? p.mMonths : null,
costs: {
commission_rate: p.commission / 100,
stamp_tax_rate: p.stamp / 100,
slippage_rate: p.slippage / 100,
min_commission: p.minCommission,
},
portfolio: {},
};
}
/** 表单校验(返回 field → 错误文案;空对象 = 通过) */
export function validateParams(
p: StrategyParams,
opts: { requireMeta?: boolean } = {}
): Record<string, string> {
const e: Record<string, string> = {};
if (opts.requireMeta) {
if (!p.name.trim()) e.name = "策略名必填(便于在策略库中识别)";
else if (p.name.trim().length > 64) e.name = "策略名最多 64 字";
if (!p.description.trim()) e.description = "一句话说明必填:说清这个策略做什么";
// strategy.description 落库列为 String(300):超长会被 MySQL 严格模式拒绝,
// 因此在表单层就拦住并说明原因(而不是让用户在保存时吃一个 500)
else if (p.description.trim().length > 300)
e.description = `一句话说明最长 300 字(当前 ${p.description.trim().length} 字)`;
}
if (!p.factors.some((f) => f.name.trim())) e.factors = "至少选择一个因子";
else {
// 后端 ResearchSpec 校验会拒绝重复因子名(z-score 叠加两次没有意义且易误读),
// 这里提前拦住,避免用户填完参数后才吃一个 400
const used = p.factors.map((f) => f.name.trim()).filter(Boolean);
const dup = used.find((n, i) => used.indexOf(n) !== i);
if (dup) e.factors = `因子「${dup}」重复了:同一因子只应出现一次(想加权重请调权重值)`;
}
if (p.topN < 1) e.topN = "候选池 n 至少为 1";
if (p.holdX < 1) e.holdX = "持仓数 x 至少为 1";
if (p.holdX > p.topN) e.holdX = `持仓数 x=${p.holdX} 不能大于候选池 n=${p.topN}`;
if (p.mMonths < 0 || p.mMonths > 60) e.mMonths = "m 需在 0~60 之间";
if (p.yMonths < 0 || p.yMonths > 60) e.yMonths = "y 需在 0~60 之间";
if (p.commission < 0 || p.stamp < 0 || p.slippage < 0) e.costs = "费率不能为负";
if (p.minCommission < 0) e.minCommission = "最低佣金不能为负";
if (p.capital < 10000) e.capital = "初始资金建议 ≥ 1 万";
if (!p.start || !p.end) e.period = "起止日期都必填";
else if (p.start >= p.end) e.period = "开始日期必须早于结束日期";
for (const c of p.conditions) {
if (!c.field.trim()) {
e.conditions = "存在空的条件字段:请填写字段名或删除该条件";
break;
}
}
return e;
}
export interface StrategyParamsFormProps {
value: StrategyParams;
onChange: (next: StrategyParams) => void;
factorOptions: FactorMeta[];
/** 显示策略名 + 一句话说明(策略库编辑/保存为策略时) */
showMeta?: boolean;
/** 显示回测区间与初始资金(回测执行时才需要) */
showPeriod?: boolean;
/** 显示操作按钮区(表单内提交按钮) */
disabled?: boolean;
errors?: Record<string, string>;
/** 因子选择是否允许加权多项(默认允许) */
multiFactor?: boolean;
}
export function StrategyParamsForm({
value: p,
onChange,
factorOptions,
showMeta = false,
showPeriod = true,
disabled = false,
errors = {},
multiFactor = true,
}: StrategyParamsFormProps) {
const set = (patch: Partial<StrategyParams>) => onChange({ ...p, ...patch });
const setFactor = (i: number, patch: Partial<{ name: string; weight: number }>) =>
set({ factors: p.factors.map((f, j) => (j === i ? { ...f, ...patch } : f)) });
return (
<>
{showMeta && (
<div className="form-grid">
<Field label="策略名(必填)" hint="在策略库中唯一">
<input
className="input"
value={p.name}
maxLength={64}
placeholder="例:高股息 6 月择股 · 低频"
disabled={disabled}
onChange={(e) => set({ name: e.target.value })}
/>
{errors.name && (
<div className="field-err" role="alert">
{errors.name}
</div>
)}
</Field>
<Field
label="一句话说明(必填)"
hint={`说清这个策略做什么、怎么选股(${p.description.length}/300,落库列宽上限)`}
>
<input
className="input"
value={p.description}
maxLength={300}
placeholder="例:全市场股息率最高的 20 只,每 6 个月重新择股并等权持有"
disabled={disabled}
onChange={(e) => set({ description: e.target.value })}
/>
{errors.description && (
<div className="field-err" role="alert">
{errors.description}
</div>
)}
</Field>
</div>
)}
{/* ---------- 因子(打分公式) ---------- */}
<div className="between" style={{ marginBottom: 8 }}>
<b style={{ fontSize: 13 }}>打分因子(score = Σ 权重 × 因子值,越大越优先)</b>
{multiFactor && (
<Btn
icon="layers"
disabled={disabled}
onClick={() => set({ factors: [...p.factors, { name: factorOptions[0]?.name ?? "", weight: 1 }] })}
>
添加因子
</Btn>
)}
</div>
<div className="row" style={{ flexWrap: "wrap", gap: 8, marginBottom: 6 }}>
{p.factors.map((f, i) => (
<div className="row" key={i} style={{ gap: 6 }}>
<select
className="input"
style={{ width: 220 }}
value={f.name}
disabled={disabled}
onChange={(e) => setFactor(i, { name: e.target.value })}
>
{!factorOptions.some((o) => o.name === f.name) && f.name ? (
<option value={f.name}>{f.name}(未在因子表)</option>
) : null}
{factorOptions.map((o) => (
<option key={o.name} value={o.name}>
{o.name}
</option>
))}
</select>
<input
className="input mono"
style={{ width: 84 }}
type="number"
step="0.1"
value={f.weight}
disabled={disabled}
title="权重"
onChange={(e) => setFactor(i, { weight: Number(e.target.value) })}
/>
{multiFactor && p.factors.length > 1 && (
<Btn
icon="x"
disabled={disabled}
onClick={() => set({ factors: p.factors.filter((_, j) => j !== i) })}
>
删除
</Btn>
)}
</div>
))}
</div>
{errors.factors && (
<div className="field-err" role="alert">
{errors.factors}
</div>
)}
{/* ---------- 选股规模与周期 ---------- */}
<div className="form-grid" style={{ marginTop: 12 }}>
<Field label="候选池 n(择股条件选出股数)">
<input
className="input"
type="number"
min={1}
max={500}
value={p.topN}
disabled={disabled}
onChange={(e) => set({ topN: Number(e.target.value) })}
/>
</Field>
<Field label="持仓数 x(≤ n)">
<input
className={`input${errors.holdX ? " input--invalid" : ""}`}
type="number"
min={1}
max={p.topN}
value={p.holdX}
disabled={disabled}
onChange={(e) => set({ holdX: Number(e.target.value) })}
/>
{errors.holdX && (
<div className="field-err" role="alert">
{errors.holdX}
</div>
)}
</Field>
<Field label="择股间隔 m(月,0=跟随 y)" hint="m:多久重新挑一次股">
<input
className="input"
type="number"
min={0}
max={60}
value={p.mMonths}
disabled={disabled}
onChange={(e) => set({ mMonths: Number(e.target.value) })}
/>
{errors.mMonths && (
<div className="field-err" role="alert">
{errors.mMonths}
</div>
)}
</Field>
<Field label="调仓间隔 y(月,0=跟随 m)" hint="y:多久按最新选股结果换一次仓">
<input
className="input"
type="number"
min={0}
max={60}
value={p.yMonths}
disabled={disabled}
onChange={(e) => set({ yMonths: Number(e.target.value) })}
/>
{errors.yMonths && (
<div className="field-err" role="alert">
{errors.yMonths}
</div>
)}
</Field>
<Field label="调仓频率(m、y 均为 0 时生效)">
<select
className="input"
value={p.rebalance}
disabled={disabled}
onChange={(e) => set({ rebalance: e.target.value as "monthly" | "weekly" })}
>
<option value="monthly">月度</option>
<option value="weekly">周度</option>
</select>
</Field>
<Field
label="买不进时的补位(涨停 / 停牌)"
hint="后端两个字段互斥,这里用三选一表达,避免出现「都不生效」的静默组合"
>
<div className="radio-col">
{(
[
["substitute", "换一只买", "从候选池之外按复合分往下找可买标的,补足持仓数"],
["defer", "顺延买入", "等它到之后首个不涨停的交易日再按收盘价买入(到下次调仓仍未成交则作废)"],
["none", "不补位", "买不进就空着,实际持仓可能少于持仓数 x"],
] as const
).map(([val, label, desc]) => (
<label key={val} className="radio-row" title={desc}>
<input
type="radio"
name="fill-policy"
checked={p.fillPolicy === val}
disabled={disabled}
onChange={() => set({ fillPolicy: val })}
/>
<span>
<b>{label}</b>
<span className="hint"> {desc}</span>
</span>
</label>
))}
</div>
</Field>
</div>
{/* ---------- 口径与成本 ---------- */}
<div className="form-grid" style={{ marginTop: 12 }}>
<Field label="复权口径" hint="股息类策略建议后复权 hfq(把分红再投资计入)">
<select
className="input"
value={p.priceAdjustment}
disabled={disabled}
onChange={(e) => set({ priceAdjustment: e.target.value as "none" | "qfq" | "hfq" })}
>
<option value="hfq">后复权 hfq(股息策略推荐)</option>
<option value="qfq">前复权 qfq</option>
<option value="none">不复权 none</option>
</select>
</Field>
<Field label="标的范围">
<label className="row" style={{ gap: 6, color: "var(--text-2)", fontSize: 13, cursor: "pointer" }}>
<input
type="checkbox"
checked={p.excludeSt}
disabled={disabled}
onChange={(e) => set({ excludeSt: e.target.checked })}
/>
剔除 ST(按择股日当时名称判定)
</label>
</Field>
<Field label="最少上市天数" hint="避免次新股噪声;0 = 不限制">
<input
className="input"
type="number"
min={0}
step={10}
value={p.minListingDays}
disabled={disabled}
onChange={(e) => set({ minListingDays: Number(e.target.value) })}
/>
</Field>
<Field label="手续费率 %">
<input
className="input"
type="number"
step="0.01"
min={0}
value={p.commission}
disabled={disabled}
onChange={(e) => set({ commission: Number(e.target.value) })}
/>
</Field>
<Field label="印花税率 %" hint="仅卖出收取">
<input
className="input"
type="number"
step="0.01"
min={0}
value={p.stamp}
disabled={disabled}
onChange={(e) => set({ stamp: Number(e.target.value) })}
/>
</Field>
<Field label="滑点率 %">
<input
className="input"
type="number"
step="0.01"
min={0}
value={p.slippage}
disabled={disabled}
onChange={(e) => set({ slippage: Number(e.target.value) })}
/>
</Field>
<Field label="最低佣金(元/笔)">
<input
className="input"
type="number"
step="1"
min={0}
value={p.minCommission}
disabled={disabled}
onChange={(e) => set({ minCommission: Number(e.target.value) })}
/>
{errors.minCommission && (
<div className="field-err" role="alert">
{errors.minCommission}
</div>
)}
</Field>
{showPeriod && (
<>
<Field label="初始资金(元)">
<input
className="input"
type="number"
step={100000}
min={10000}
value={p.capital}
disabled={disabled}
onChange={(e) => set({ capital: Number(e.target.value) })}
/>
{errors.capital && (
<div className="field-err" role="alert">
{errors.capital}
</div>
)}
</Field>
<Field label="开始日期">
<input
type="date"
className="input"
value={p.start}
disabled={disabled}
onChange={(e) => set({ start: e.target.value })}
/>
</Field>
<Field label="结束日期">
<input
type="date"
className="input"
value={p.end}
disabled={disabled}
onChange={(e) => set({ end: e.target.value })}
/>
</Field>
</>
)}
</div>
{errors.period && (
<div className="field-err" role="alert">
{errors.period}
</div>
)}
{/* ---------- 选股过滤条件 ---------- */}
<div style={{ marginTop: 14 }}>
<div className="between" style={{ marginBottom: 8 }}>
<b style={{ fontSize: 13 }}>选股过滤条件(AND,universe 之后、因子排序之前执行)</b>
<Btn
icon="layers"
disabled={disabled}
onClick={() => set({ conditions: [...p.conditions, { field: "", op: "gte", value: 0 }] })}
>
添加条件
</Btn>
</div>
{p.conditions.length === 0 ? (
<div className="hint">未设置条件:候选池 = universe 内因子分最高的 n 只。</div>
) : (
<div className="row" style={{ flexWrap: "wrap", gap: 8 }}>
{p.conditions.map((c, i) => (
<div className="row" key={i} style={{ gap: 6 }}>
<input
className="input"
style={{ width: 190 }}
placeholder="字段(dv_ratio / pe / ma60 …)"
value={c.field}
disabled={disabled}
onChange={(e) =>
set({
conditions: p.conditions.map((x, j) =>
j === i ? { ...x, field: e.target.value } : x
),
})
}
/>
<select
className="input"
style={{ width: 76 }}
value={c.op}
disabled={disabled}
onChange={(e) =>
set({
conditions: p.conditions.map((x, j) =>
j === i ? { ...x, op: e.target.value as Op } : x
),
})
}
>
{OPS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
<input
className="input"
style={{ width: 110 }}
value={String(c.value ?? "")}
disabled={disabled}
onChange={(e) => {
const raw = e.target.value;
const num = Number(raw);
set({
conditions: p.conditions.map((x, j) =>
j === i
? { ...x, value: raw !== "" && !Number.isNaN(num) ? num : raw }
: x
),
});
}}
/>
<Btn
icon="x"
disabled={disabled}
onClick={() => set({ conditions: p.conditions.filter((_, j) => j !== i) })}
>
删除
</Btn>
</div>
))}
</div>
)}
{errors.conditions && (
<div className="field-err" role="alert">
{errors.conditions}
</div>
)}
<div className="hint" style={{ marginTop: 6 }}>
可用字段:每日指标 dv_ratio / dv_ttm / pe / pb / total_mv、行情 close / volume /
amount、技术 ma20 / ma60、已注册因子名、static.industry 等、fundamental.roe 等
(财务按公告日 ≤ 择股日取用)。
</div>
</div>
</>
);
}
+19 -6
View File
@@ -12,6 +12,11 @@ interface NavItem {
icon: IconName;
}
/**
* 导航分组:按「研究闭环」的真实使用顺序排列 ——
* 找标的(股票池)→ 出候选(筛选)→ 定规则(策略库)→ 验规则(回测)→ 复盘(实验)。
* 「交易信号」「因子研究」是可选支路,放在同组但靠后;单独建组会让闭环读起来断裂。
*/
const GROUPS: { title: string; items: NavItem[] }[] = [
{
title: "研究",
@@ -19,15 +24,23 @@ const GROUPS: { title: string; items: NavItem[] }[] = [
{ href: "/", label: "总览", icon: "grid" },
{ href: "/stocks", label: "股票池", icon: "candles" },
{ href: "/selection", label: "股票筛选", icon: "target" },
{ href: "/signals", label: "交易信号", icon: "scale" },
{ href: "/factors", label: "因子研究", icon: "flask" },
{ href: "/factors/compose", label: "因子组合", icon: "layers" },
{ href: "/backtest", label: "选股回测", icon: "gauge" },
],
},
{
title: "归档",
items: [{ href: "/experiments", label: "实验", icon: "archive" }],
title: "策略",
items: [
{ href: "/strategies", label: "策略库", icon: "book" },
{ href: "/backtest", label: "选股回测", icon: "gauge" },
{ href: "/experiments", label: "实验对比", icon: "archive" },
],
},
{
title: "因子与信号",
items: [
{ href: "/factors", label: "因子研究", icon: "flask" },
{ href: "/factors/compose", label: "因子组合", icon: "layers" },
{ href: "/signals", label: "交易信号", icon: "scale" },
],
},
];
+382
View File
@@ -0,0 +1,382 @@
"use client";
/**
* 通用折线/面积/柱状图 —— TradingView Lightweight Charts 实现(唯一图表基座)。
*
* 为什么自建而不直接用 LW 原语:LW 没有内置 tooltip 与图例,且硬性要求
* ① 标记时间必须存在于序列数据中,② 标记必须按时间升序。
* 本组件把这两条约束在内部处理掉(过滤 + 排序),避免每个页面各写一遍而踩坑
* —— 回测买卖点必须精确落在净值曲线日期上(v3 §20.3)。
*
* 设计要点:
* - 图表实例只在「结构签名」(序列个数/类型/高度)变化时重建;数据变化走 setData
* + 数值校验和作为数据签名,避免每次渲染重建导致闪烁、丢失缩放位置。
* - 多序列共用价格轴(实验对比均为收益率 %,可直接叠加比较)。
* - 图例可点击隐藏/显示单条序列;tooltip 显示十字光标处全部序列数值。
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ColorType,
CrosshairMode,
LineStyle,
createChart,
type HistogramData,
type IChartApi,
type ISeriesApi,
type LineData,
type MouseEventParams,
type SeriesMarker,
type Time,
} from "lightweight-charts";
import { CHART, MARKER_COLORS } from "./theme";
import { isBuyMarker, prepareMarkers } from "./markers";
export interface LwPoint {
time: string;
value: number;
}
export interface LwSeries {
key: string;
label: string;
data: LwPoint[];
color?: string;
/** line=折线;area=带渐变面积;bar=柱状(月度收益等) */
type?: "line" | "area" | "bar";
lineWidth?: 1 | 2 | 3 | 4;
dashed?: boolean;
/** 是否在价格轴显示最新值标签(多序列对比时只留一条即可) */
lastValueVisible?: boolean;
}
export interface LwMarker {
time: string;
kind: keyof typeof MARKER_COLORS;
label?: string;
/** 覆盖默认文本(默认买 B / 卖 S) */
text?: string;
}
export interface LwChartProps {
series: LwSeries[];
markers?: LwMarker[];
height?: number;
/** 悬浮提示与价格轴的数值格式 */
valueFormat?: (v: number) => string;
/** 画一条 0 基准虚线(收益率曲线推荐开启) */
zeroLine?: boolean;
legend?: boolean;
ariaLabel?: string;
emptyHint?: string;
}
type AnySeries = ISeriesApi<"Line"> | ISeriesApi<"Area"> | ISeriesApi<"Histogram">;
function toTime(t: string): Time {
return t as Time;
}
/** 结构签名:序列个数/类型/高度 —— 变化才重建图表实例 */
function structureKey(series: LwSeries[], height: number): string {
return `${height}|${series.map((s) => `${s.key}:${s.type ?? "line"}`).join(",")}`;
}
/**
* 数据签名:长度 + 首末时间 + **数值校验和**。
* 必须含数值校验和:只改成本/滑点后曲线数值变了但日期与长度不变,
* 若签名只看长度就会漏更新(图表停留在上一次结果)。
*/
function dataKey(series: LwSeries[], markers: LwMarker[]): string {
const parts = series.map((s) => {
let sum = 0;
for (const p of s.data) sum += p.value;
return `${s.key}:${s.data.length}:${s.data[0]?.time ?? ""}:${s.data.at(-1)?.time ?? ""}:${sum.toFixed(4)}`;
});
const mk = markers.map((m) => `${m.time}${m.kind}`).join(",");
return `${parts.join("|")}#${mk}`;
}
export function LwChart({
series,
markers = [],
height = 320,
valueFormat,
zeroLine = false,
legend = true,
ariaLabel,
emptyHint = "暂无数据",
}: LwChartProps) {
const wrapRef = useRef<HTMLDivElement | null>(null);
const boxRef = useRef<HTMLDivElement | null>(null);
const chartRef = useRef<IChartApi | null>(null);
const seriesRef = useRef<Map<string, AnySeries>>(new Map());
const zeroLineDrawn = useRef(false);
/** 图例隐藏集合:tooltip 订阅里读取,用 ref 避免闭包过期 */
const hiddenRef = useRef<Set<string>>(new Set());
const fmtRef = useRef(valueFormat);
fmtRef.current = valueFormat;
const [hidden, setHidden] = useState<Set<string>>(new Set());
const [tip, setTip] = useState<{
x: number;
y: number;
time: string;
rows: { label: string; color: string; text: string }[];
} | null>(null);
hiddenRef.current = hidden;
const struct = structureKey(series, height);
const dkey = dataKey(series, markers);
const fmt = useCallback(
(v: number) => (fmtRef.current ? fmtRef.current(v) : v.toFixed(2)),
[]
);
/* ---------- 创建/销毁图表实例 ---------- */
useEffect(() => {
const el = boxRef.current;
if (!el) return;
const chart = createChart(el, {
autoSize: true,
height,
layout: {
background: { type: ColorType.Solid, color: CHART.background },
textColor: CHART.text,
fontSize: 11,
},
grid: {
vertLines: { color: CHART.grid },
horzLines: { color: CHART.grid },
},
rightPriceScale: {
borderColor: CHART.border,
scaleMargins: { top: 0.12, bottom: 0.12 },
},
timeScale: { borderColor: CHART.border, timeVisible: false, secondsVisible: false },
crosshair: {
mode: CrosshairMode.Normal,
vertLine: { color: CHART.crosshair, width: 1, style: LineStyle.Dashed, labelVisible: true },
horzLine: { color: CHART.crosshair, width: 1, style: LineStyle.Dashed, labelVisible: true },
},
});
chartRef.current = chart;
zeroLineDrawn.current = false;
const map = new Map<string, AnySeries>();
series.forEach((s, i) => {
const color = s.color ?? CHART.series[i % CHART.series.length];
let api: AnySeries;
if (s.type === "bar") {
api = chart.addHistogramSeries({
color,
priceLineVisible: false,
lastValueVisible: false,
});
} else if (s.type === "area") {
api = chart.addAreaSeries({
lineColor: color,
topColor: `${color}55`,
bottomColor: `${color}05`,
lineWidth: s.lineWidth ?? 2,
priceLineVisible: false,
lastValueVisible: s.lastValueVisible ?? false,
});
} else {
api = chart.addLineSeries({
color,
lineWidth: s.lineWidth ?? 2,
lineStyle: s.dashed ? LineStyle.Dashed : LineStyle.Solid,
priceLineVisible: false,
lastValueVisible: s.lastValueVisible ?? series.length === 1,
crosshairMarkerVisible: true,
crosshairMarkerRadius: 4,
});
}
map.set(s.key, api);
});
seriesRef.current = map;
// tooltip:LW 无内置 tooltip,订阅十字光标自行渲染
const onMove = (param: MouseEventParams) => {
if (!param.time || !param.point) {
setTip(null);
return;
}
const rows: { label: string; color: string; text: string }[] = [];
series.forEach((s, i) => {
const api = map.get(s.key);
if (!api || hiddenRef.current.has(s.key)) return;
const v = param.seriesData.get(api as unknown as ISeriesApi<"Line">) as
| LineData
| HistogramData
| undefined;
if (!v || typeof v.value !== "number") return;
rows.push({
label: s.label,
color: s.color ?? CHART.series[i % CHART.series.length],
text: fmt(v.value),
});
});
if (!rows.length) {
setTip(null);
return;
}
setTip({ x: param.point.x, y: param.point.y, time: String(param.time), rows });
};
chart.subscribeCrosshairMove(onMove);
const onLeave = () => setTip(null);
el.addEventListener("mouseleave", onLeave);
return () => {
el.removeEventListener("mouseleave", onLeave);
chart.unsubscribeCrosshairMove(onMove);
chart.remove();
chartRef.current = null;
seriesRef.current = new Map();
};
// 仅结构变化才重建;数据/标记由下方 effect 推入
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [struct, fmt]);
/* ---------- 推入数据与标记 ---------- */
useEffect(() => {
const chart = chartRef.current;
if (!chart) return;
series.forEach((s) => {
const api = seriesRef.current.get(s.key);
if (!api) return;
if (s.type === "bar") {
api.setData(
s.data.map((p) => ({ time: toTime(p.time), value: p.value })) as HistogramData[]
);
} else {
api.setData(s.data.map((p) => ({ time: toTime(p.time), value: p.value })) as LineData[]);
}
});
// 0 基准线:每个图表实例只画一次(重复调用会叠加多条价格线)
if (zeroLine && !zeroLineDrawn.current) {
const first = seriesRef.current.get(series[0]?.key ?? "");
first?.createPriceLine({
price: 0,
color: CHART.faint,
lineWidth: 1,
lineStyle: LineStyle.Dotted,
axisLabelVisible: false,
title: "",
});
zeroLineDrawn.current = true;
}
// 标记只能挂在某个序列上;统一挂第一条非柱状序列(净值/收益曲线)
const primaryKey = series.find((s) => s.type !== "bar")?.key ?? series[0]?.key;
const primary = primaryKey ? seriesRef.current.get(primaryKey) : undefined;
if (primary && "setMarkers" in primary) {
const validTimes = new Set(
series.find((s) => s.key === primaryKey)?.data.map((p) => p.time) ?? []
);
// LW 硬约束:标记时间必须存在且升序 → 先过滤再排序,否则买卖点会丢或抛错。
// 传空数组同样重要:结果切换后必须清掉上一次的标记。
const ms: SeriesMarker<Time>[] = prepareMarkers(markers, [...validTimes]).map((m) => ({
time: toTime(m.time),
position: isBuyMarker(m.kind) ? "belowBar" : "aboveBar",
color: MARKER_COLORS[m.kind],
shape: isBuyMarker(m.kind) ? "arrowUp" : "arrowDown",
size: 1,
text: m.text,
}));
(primary as ISeriesApi<"Line">).setMarkers(ms);
// 把「实际画上去的标记数」暴露成 DOM 契约:便于端到端断言,
// 也避免「页面传了 30 个买点、图上其实一个没显示」这类静默失败无从发现
if (wrapRef.current) wrapRef.current.dataset.markerCount = String(ms.length);
}
chart.timeScale().fitContent();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dkey, struct, zeroLine]);
/* ---------- 图例显隐 ---------- */
useEffect(() => {
series.forEach((s) => {
const api = seriesRef.current.get(s.key);
if (api) api.applyOptions({ visible: !hidden.has(s.key) });
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hidden, struct, dkey]);
const legendItems = useMemo(
() =>
series.map((s, i) => ({
key: s.key,
label: s.label,
color: s.color ?? CHART.series[i % CHART.series.length],
last: s.data.at(-1)?.value,
})),
[series]
);
const hasData = series.some((s) => s.data.length > 0);
return (
<div className="lw-wrap" ref={wrapRef}>
{legend && legendItems.length > 0 && (
<div className="lw-legend" role="group" aria-label="图例(点击可隐藏/显示)">
{legendItems.map((it) => (
<button
key={it.key}
type="button"
className={hidden.has(it.key) ? "lw-legend-item is-off" : "lw-legend-item"}
onClick={() =>
setHidden((prev) => {
const next = new Set(prev);
if (next.has(it.key)) next.delete(it.key);
else next.add(it.key);
return next;
})
}
aria-pressed={!hidden.has(it.key)}
title={`点击${hidden.has(it.key) ? "显示" : "隐藏"}「${it.label}」`}
>
<i style={{ background: it.color }} />
<span>{it.label}</span>
{typeof it.last === "number" && <b className="mono">{fmt(it.last)}</b>}
</button>
))}
</div>
)}
<div className="lw-canvas" style={{ height }}>
<div
ref={boxRef}
style={{ height, width: "100%" }}
role="img"
aria-label={ariaLabel ?? series.map((s) => s.label).join(" / ")}
/>
{tip && (
<div
className="lw-tip"
style={{
left: Math.max(8, Math.min(tip.x + 14, (boxRef.current?.clientWidth ?? 300) - 150)),
top: Math.max(8, tip.y - 12),
}}
>
<div className="lw-tip-time mono">{tip.time}</div>
{tip.rows.map((r) => (
<div key={r.label} className="lw-tip-row">
<i style={{ background: r.color }} />
<span className="lw-tip-label">{r.label}</span>
<b className="mono">{r.text}</b>
</div>
))}
</div>
)}
</div>
{!hasData && <div className="hint">{emptyHint}</div>}
</div>
);
}
@@ -0,0 +1,100 @@
/**
* 买卖点标记准备逻辑的单元测试。
*
* 为什么值得单独测:LW 对标记的时间存在性/升序是**硬约束**,违反时静默丢标记,
* 页面上表现为「明明有成交、图上没有买卖点」,靠肉眼看图很难发现。
*
* 运行:`npm run test:charts`(node --test + 原生 TS 类型擦除,无需额外依赖)
*/
import assert from "node:assert/strict";
import { test } from "node:test";
import { prepareMarkers } from "../markers.ts";
const TIMES = ["2024-01-02", "2024-01-03", "2024-01-04", "2024-01-05"];
test("过滤掉 series 中不存在的日期(LW 会因为这种标记整组不显示)", () => {
const out = prepareMarkers(
[
{ time: "2024-01-02", kind: "BUY" },
{ time: "2024-01-09", kind: "BUY" }, // 不存在
{ time: "2023-12-29", kind: "SELL" }, // 不存在
],
TIMES
);
assert.deepEqual(
out.map((m) => m.time),
["2024-01-02"]
);
});
test("输出严格按时间升序(即使输入是乱的)", () => {
const out = prepareMarkers(
[
{ time: "2024-01-05", kind: "SELL" },
{ time: "2024-01-02", kind: "BUY" },
{ time: "2024-01-04", kind: "SELL" },
{ time: "2024-01-03", kind: "BUY" },
],
TIMES
);
assert.deepEqual(
out.map((m) => m.time),
["2024-01-02", "2024-01-03", "2024-01-04", "2024-01-05"]
);
});
test("同一时间同一方向去重,但买卖同日各自保留(调仓日既卖又买)", () => {
const out = prepareMarkers(
[
{ time: "2024-01-03", kind: "BUY" },
{ time: "2024-01-03", kind: "BUY" },
{ time: "2024-01-03", kind: "SELL" },
{ time: "2024-01-03", kind: "SELL" },
],
TIMES
);
assert.equal(out.length, 2);
assert.deepEqual(
out.map((m) => m.kind),
["BUY", "SELL"]
);
});
test("未有显式文案时给默认 B/S", () => {
const out = prepareMarkers(
[
{ time: "2024-01-02", kind: "BUY" },
{ time: "2024-01-03", kind: "SELL" },
{ time: "2024-01-04", kind: "SIG_BUY", label: "信号" },
{ time: "2024-01-05", kind: "SELL", text: "清仓" },
],
TIMES
);
assert.deepEqual(
out.map((m) => m.text),
["B", "S", "信号", "清仓"]
);
});
test("空标记不会崩,且返回空数组(结果切换后靠它清掉上一次的标记)", () => {
assert.deepEqual(prepareMarkers([], TIMES), []);
});
test("series 无数据时全部标记被丢弃(而不是抛错)", () => {
assert.deepEqual(prepareMarkers([{ time: "2024-01-02", kind: "BUY" }], []), []);
});
test("SIG_BUY/SIG_SELL 也走 BUY/SELL 的默认文案规则", () => {
const out = prepareMarkers(
[
{ time: "2024-01-02", kind: "SIG_BUY" },
{ time: "2024-01-03", kind: "SIG_SELL" },
],
TIMES
);
assert.deepEqual(
out.map((m) => m.text),
["B", "S"]
);
});
+55
View File
@@ -0,0 +1,55 @@
/**
* 买卖点标记的准备逻辑(纯函数,无 DOM/无图表实例依赖)。
*
* 之所以单独成模块:Lightweight Charts 对标记有两条**硬约束**,
* 违反时不会报错、只会静默丢标记或抛异常——
* 1. 标记的 `time` 必须存在于它所挂载的那条 series 的数据中;
* 2. 标记数组必须按时间**升序**。
* 这两条属于「错了也看不出来」的坑,因此抽成纯函数并配单元测试
* (`components/charts/__tests__/markers.test.ts`,见 package.json 的 test:charts)。
*/
import type { LwMarker } from "./LwChart";
/**
* 判定「买入方向」。
*
* 注意不能用 `kind.startsWith("BUY")`:`"SIG_BUY"` 并不以 `"BUY"` 开头,
* 那样会把信号买点画成卖出箭头(aboveBar/arrowDown)—— 这正是本文件单元测试
* 捕获到的问题,因此这里显式枚举而不是做前缀匹配。
*/
export function isBuyMarker(kind: LwMarker["kind"]): boolean {
return kind === "BUY" || kind === "SIG_BUY";
}
/** 后端语义(BUY/SELL)与图表语义(箭头位置/颜色)的映射在 LwChart 内完成,这里只做几何准备 */
export interface PreparedMarker {
time: string;
/** 原始语义,交给调用方决定颜色/形状 */
kind: LwMarker["kind"];
text: string;
}
/**
* 过滤到真实存在的时间点、去重、按时间升序。
*
* @param markers 页面给的语义标记(顺序任意、可能带有 series 里不存在的日期)
* @param times 该 series 实际拥有的时间点(升序)
*/
export function prepareMarkers(markers: LwMarker[], times: string[]): PreparedMarker[] {
const valid = new Set(times);
const seen = new Set<string>();
const out: PreparedMarker[] = [];
for (const m of markers) {
if (!valid.has(m.time)) continue;
// 同一时间同一方向只保留一个(例如一天内多笔买入合并为一个买点)
const key = `${m.time}|${m.kind}`;
if (seen.has(key)) continue;
seen.add(key);
out.push({
time: m.time,
kind: m.kind,
text: m.text ?? m.label ?? (isBuyMarker(m.kind) ? "B" : "S"),
});
}
return out.sort((a, b) => (a.time < b.time ? -1 : a.time > b.time ? 1 : 0));
}
+81
View File
@@ -0,0 +1,81 @@
/**
* 图表统一主题(TradingView Lightweight Charts)。
*
* 单一来源:所有图表组件只从这里取色,避免各页面各自硬编码导致深浅不一的观感。
* 与 globals.css 的 CSS 变量保持同一套语义色(pos=涨/盈利口径按 A 股习惯为红涨绿跌,
* 但收益曲线的“正/负”沿用页面既有的绿正红负,故分 `up/down` 与 `pos/neg` 两组)。
*/
export const CHART = {
/** 透明底:跟随卡片背景,避免图表出现色块拼接 */
background: "transparent",
text: "#a8b3c9",
textStrong: "#e9eef7",
faint: "#8b98b2",
grid: "rgba(150,165,195,0.10)",
border: "rgba(150,165,195,0.22)",
crosshair: "rgba(150,165,195,0.45)",
tooltipBg: "rgba(20,29,48,0.96)",
tooltipBorder: "rgba(150,165,195,0.28)",
/** 收益语义(绿=正、红=负,与页面 Pill/Metric 一致) */
pos: "#3ddc97",
neg: "#ff7a7a",
accent: "#3fb6ff",
violet: "#b98bff",
amber: "#d4a72c",
/** A 股涨跌语义(红涨绿跌) */
up: "#e5484d",
down: "#2fb36b",
/** 多序列叠加配色(实验对比用,最多 8 条) */
series: [
"#3fb6ff",
"#3ddc97",
"#d4a72c",
"#b98bff",
"#ff7a7a",
"#4dd0e1",
"#f06292",
"#9ccc65",
],
} as const;
/** 买卖点标记语义色:与图例文字一致,买入=绿、卖出=红/紫 */
export const MARKER_COLORS = {
BUY: "#3ddc97",
SELL: "#ff5a5a",
SIG_BUY: "#b98bff",
SIG_SELL: "#8b7bd8",
} as const;
export function seriesColor(index: number): string {
return CHART.series[index % CHART.series.length];
}
/** 去掉 "YYYY-MM-DD" 的年份,用于横轴/表格紧凑显示 */
export function shortDate(d: string): string {
return d.length >= 10 ? d.slice(5) : d;
}
/** 千分位 + 指定小数位(净值/金额) */
export function fmtNum(v: number, digits = 0): string {
return v.toLocaleString("zh-CN", {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
}
/** 百分比(入参已是百分数,如 24.86 → "+24.86%") */
export function fmtPct(v: number, digits = 2): string {
return `${v > 0 ? "+" : ""}${v.toFixed(digits)}%`;
}
/** 紧凑金额:12.5 万 / 1.23 亿 */
export function fmtMoney(v: number): string {
const abs = Math.abs(v);
if (abs >= 1e8) return `${(v / 1e8).toFixed(2)} 亿`;
if (abs >= 1e4) return `${(v / 1e4).toFixed(2)} 万`;
return fmtNum(v, 0);
}
+33 -1
View File
@@ -36,7 +36,12 @@ export type IconName =
| "link"
| "spark"
| "clock"
| "filter";
| "filter"
| "plus"
| "edit"
| "trash"
| "download"
| "copy";
const P: Record<
IconName,
@@ -140,6 +145,33 @@ const P: Record<
</>
),
check: () => <path d="m5 12.5 4.5 4.5L19 7.5" strokeLinecap="round" strokeLinejoin="round" />,
plus: () => <path d="M12 5.5v13M5.5 12h13" strokeLinecap="round" />,
edit: () => (
<>
<path d="M4.5 19.5h4l10-10a2.12 2.12 0 0 0-3-3l-10 10v3Z" strokeLinejoin="round" />
<path d="m14.5 6.5 3 3" strokeLinecap="round" />
</>
),
trash: () => (
<>
<path d="M5 7.5h14" strokeLinecap="round" />
<path d="M9.5 7.5V5.5h5v2" strokeLinejoin="round" />
<path d="M6.5 7.5 7.4 19a1.5 1.5 0 0 0 1.5 1.4h6.2a1.5 1.5 0 0 0 1.5-1.4l.9-11.5" strokeLinejoin="round" />
</>
),
download: () => (
<>
<path d="M12 3.5v10.5" strokeLinecap="round" />
<path d="M7.5 10 12 14.5 16.5 10" strokeLinecap="round" strokeLinejoin="round" />
<path d="M4.5 19.5h15" strokeLinecap="round" />
</>
),
copy: () => (
<>
<rect x="9" y="9" width="10.5" height="10.5" rx="2" />
<path d="M15 6.5A2 2 0 0 0 13 4.5H6.5a2 2 0 0 0-2 2V13a2 2 0 0 0 2 2" strokeLinecap="round" />
</>
),
info: () => (
<>
<circle cx="12" cy="12" r="9" />
+8 -2
View File
@@ -3,6 +3,7 @@
* 按钮 / 表单字段 / 提示条 / 空态 / 骨架 / 进度 / 回测结果区块。
*/
import { useId } from "react";
import { RichText } from "@/components/RichText";
import type { ButtonHTMLAttributes, InputHTMLAttributes, ReactNode } from "react";
import type { BacktestSummary } from "@/lib/types";
import { Icon, type IconName } from "@/components/icons";
@@ -43,6 +44,7 @@ export function Card({
children,
flush = false,
className,
id,
}: {
title?: ReactNode;
sub?: ReactNode;
@@ -51,10 +53,12 @@ export function Card({
children: ReactNode;
flush?: boolean;
className?: string;
/** 锚点 id:长结果页的分区导航(#sec-equity 等)依赖它 */
id?: string;
}) {
const hasHead = title != null || tools != null;
return (
<section className={cx("card", className)}>
<section className={cx("card", className)} id={id} style={id ? { scrollMarginTop: 76 } : undefined}>
{hasHead ? (
<div className="card__head">
<div className="card__title">
@@ -441,7 +445,9 @@ export function UnimplementedNote({ items }: { items: string[] }) {
<Card title="未建模约束(如实标注)" icon="info">
<ul className="hint" style={{ margin: 0, paddingLeft: 18 }}>
{items.map((u, i) => (
<li key={i}>{u}</li>
<li key={i}>
<RichText text={u} />
</li>
))}
</ul>
</Card>
+49
View File
@@ -14,6 +14,33 @@ export async function apiGet<T>(path: string): Promise<T> {
return (await resp.json()) as T;
}
/**
* GET 并**同时拿到响应头**。
*
* 为什么需要:列表接口用 `X-Total-Count` 暴露「过滤后总数」,而 body 必须保持
* `list[...]` 形状(既有页面依赖)。只读 body 的 `apiGet` 拿不到这个信息,
* 会让前端无法区分「这就是全部」和「只是最近 N 条」——那正是 §7 要避免的静默截断。
*/
export async function apiGetWithHeaders<T>(
path: string
): Promise<{ data: T; headers: Headers }> {
const resp = await fetch(`${BASE}${path}`);
if (!resp.ok) throw new Error(`GET ${path} → ${resp.status}: ${await resp.text()}`);
return { data: (await resp.json()) as T, headers: resp.headers };
}
/**
* GET 原始文本。
*
* 导出归档时用:直接落盘**后端存储的那份 JSON**,而不是前端再 `JSON.stringify`
* 一遍(后者会因字段顺序/缩进差异而与归档原文不同,且多一次内存拷贝)。
*/
export async function apiGetText(path: string): Promise<string> {
const resp = await fetch(`${BASE}${path}`);
if (!resp.ok) throw new Error(`GET ${path} → ${resp.status}: ${await resp.text()}`);
return await resp.text();
}
export async function apiPost<T>(path: string, body: unknown): Promise<T> {
const resp = await fetch(`${BASE}${path}`, {
method: "POST",
@@ -27,6 +54,28 @@ export async function apiPost<T>(path: string, body: unknown): Promise<T> {
return (await resp.json()) as T;
}
export async function apiPut<T>(path: string, body: unknown): Promise<T> {
const resp = await fetch(`${BASE}${path}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!resp.ok) {
const text = await resp.text();
throw new Error(`PUT ${path} → ${resp.status}: ${text.slice(0, 300)}`);
}
return (await resp.json()) as T;
}
export async function apiDelete<T = { deleted?: string }>(path: string): Promise<T> {
const resp = await fetch(`${BASE}${path}`, { method: "DELETE" });
if (!resp.ok) {
const text = await resp.text();
throw new Error(`DELETE ${path} → ${resp.status}: ${text.slice(0, 300)}`);
}
return (await resp.json().catch(() => ({}))) as T;
}
export interface ApiError {
status: number;
message: string;
+62
View File
@@ -0,0 +1,62 @@
/**
* 归档结果的**纯派生逻辑**(无 React、无 "use client")。
*
* 为什么单独放 `lib/`:归档详情页是 Server Component(只读内容必须能 SSR 直出),
* 而 Server Component **不能调用** `"use client"` 模块里导出的普通函数
* (`Attempted to call X() from the server but X is on the client` → 整页 500,
* 实测踩过一次)。所以「按 kind 派生展示口径」这类纯函数必须待在非客户端模块里,
* 客户端组件与服务端组件都从 `lib/` 引用。
*/
import type {
BacktestResult,
ExperimentDetail,
FactorTestReport,
SelectionResult,
} from "@/lib/types";
/** 归档结果的联合类型(结构与 `kind` 一一对应)。 */
export type AnyArchiveResult =
| BacktestResult
| FactorTestReport
| SelectionResult;
/** 「带回测结果」的归档详情:对比视图与指标表只接受这一种。 */
export type BacktestDetail = ExperimentDetail & { result: BacktestResult };
/** 判断某条归档详情是否带回测结果(对比/指标只能用在回测归档上)。 */
export function isBacktestDetail(d: ExperimentDetail): d is BacktestDetail {
return d.kind === "backtest" && !!d.result && "equity_curve" in d.result;
}
/**
* 归档结果的**关键字段计数**,按 `kind` 给不同口径。
*
* 用回测字段(净值/曲线/成交)去数因子测试或选股结果会得到 0 或 undefined,
* 显示成「净值 0 点」属于误导;这里按类型分别统计,未知类型返回 null(不编造)。
*/
export function archivedFieldSummary(
kind: string,
result: AnyArchiveResult | null
): string | null {
if (!result) return null;
if (kind === "backtest") {
const r = result as BacktestResult;
return `结果字段:净值 ${r.equity_curve?.length ?? 0} 点 · 个股曲线 ${
r.symbol_curves?.length ?? 0
} 条 · 成交 ${r.trades?.length ?? 0} 笔`;
}
if (kind === "factor_test") {
const r = result as FactorTestReport;
return `结果字段:因子 ${r.factor_name} · 样本 ${r.sample_days} 日 · 分层 ${
r.quantile_returns?.length ?? 0
} 层`;
}
if (kind === "selection") {
const r = result as SelectionResult;
return `结果字段:as_of ${r.as_of_date} · 候选 ${
r.candidates?.length ?? 0
} 只 · 评估 ${r.statistics?.evaluated ?? 0} 只`;
}
return null;
}
+35 -2
View File
@@ -14,25 +14,48 @@ export interface JobSubmit {
export interface JobStatusResp {
job_id: string;
status: string;
/** 执行阶段(data_loading / backtesting / analysis…)—— 用于给用户真实进度而非假进度条 */
stage?: string | null;
/** 归档后的实验 id(后端在 Job 完成时写入) */
experiment_id?: string | null;
result?: unknown;
error?: string | null;
spec?: Record<string, unknown>;
started_at?: string | null;
finished_at?: string | null;
}
export function submitJob(spec: ResearchSpec): Promise<JobSubmit> {
return apiPost<JobSubmit>("/jobs", spec);
}
/** 任务终态(success / failed / cancelled / timeout)与结果 */
export interface JobOutcome<T> {
status: string;
result: T | null;
error?: string;
/** 归档实验 id:结果出来后前端可直接给出「去对比」入口 */
experimentId?: string | null;
}
/** 轮询直到 success / failed / cancelled,或超时(默认 10 分钟)。 */
export async function waitJob<T>(
jobId: string,
timeoutMs = 600_000,
): Promise<{ status: string; result: T | null; error?: string }> {
onStage?: (info: { stage: string; elapsedMs: number }) => void,
): Promise<JobOutcome<T>> {
const deadline = Date.now() + timeoutMs;
const t0 = Date.now();
while (Date.now() < deadline) {
const job = await apiGet<JobStatusResp>(`/jobs/${jobId}`);
// 阶段来自后端真实执行状态,避免前端用假进度条假装在跑
if (onStage && job.stage) onStage({ stage: job.stage, elapsedMs: Date.now() - t0 });
if (job.status === "success") {
return { status: job.status, result: (job.result as T) ?? null };
return {
status: job.status,
result: (job.result as T) ?? null,
experimentId: job.experiment_id ?? null,
};
}
if (job.status === "failed" || job.status === "cancelled") {
return { status: job.status, result: null, error: job.error ?? undefined };
@@ -41,3 +64,13 @@ export async function waitJob<T>(
}
return { status: "timeout", result: null, error: "等待结果超时,请稍后在「实验」页查看归档" };
}
/** 阶段中文名(后端 stage 枚举 → 用户可读) */
export const STAGE_LABEL: Record<string, string> = {
queued: "排队中",
data_loading: "加载行情与因子数据",
selection: "逐择股日选股",
backtesting: "撮合与净值结算",
analysis: "汇总指标与曲线",
done: "完成",
};
+38
View File
@@ -0,0 +1,38 @@
/**
* 展示用文案映射(跨页面共用的纯函数)。
*
* 放在独立模块的理由:同一个口径(复权模式、补位策略…)在回测页、归档页、策略库
* 都要显示一致的措辞,各写一份必然出现「后复权 / hfq / 后复权口径」三种叫法。
*/
/** 复权模式 → 中文口径名(未知值**原样返回**,不猜测) */
export function adjustLabel(mode?: string | null): string {
if (!mode) return "未记录";
if (mode === "hfq") return "后复权";
if (mode === "qfq") return "前复权";
if (mode === "none") return "不复权";
return mode;
}
/** 调仓频率(`rebalance` 字段)→ 中文 */
export function rebalanceLabel(v?: string | null): string {
if (!v) return "未记录";
if (v === "monthly") return "月度";
if (v === "weekly") return "周度";
if (v === "daily") return "每日";
return v;
}
/** 归档 kind → 中文 */
export function experimentKindLabel(kind?: string | null): string {
switch (kind) {
case "backtest":
return "回测";
case "factor_test":
return "因子测试";
case "selection":
return "选股";
default:
return kind ?? "未知";
}
}
+94
View File
@@ -0,0 +1,94 @@
"use client";
/**
* 策略说明的获取钩子。
*
* 说明/公式由后端 `describe_strategy` 从 spec **真实推导**(不是前端拼字符串):
* 这样「页面显示的公式」与「引擎实际执行的规则」只有一个来源,
* 不会出现文案与实现漂移(本平台最怕的问题)。
*
* 两个入口:
* - `useStrategyDoc(params)`:未保存的参数也能实时预览(POST /strategies/describe),
* 带去抖,避免每次按键都请求。
* - `useStrategyDocById(id)`:已保存策略(GET /strategies/{id}/describe)。
*/
import { useEffect, useMemo, useRef, useState } from "react";
import { apiGet, apiPost } from "@/lib/api";
import type { StrategyDoc } from "@/lib/types";
import { paramsToSpec, type StrategyParams } from "@/components/StrategyParamsForm";
export interface DocState {
doc: StrategyDoc | null;
loading: boolean;
error: string;
}
const EMPTY: DocState = { doc: null, loading: false, error: "" };
/** 未保存参数 → 说明(去抖 500ms) */
export function useStrategyDoc(params: StrategyParams | null, enabled = true): DocState {
const [state, setState] = useState<DocState>(EMPTY);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
// 只依赖会改变说明的字段,避免改「初始资金」也重新请求
const key = useMemo(() => {
if (!params) return "";
return JSON.stringify({
f: params.factors,
a: params.priceAdjustment,
n: params.topN,
x: params.holdX,
m: params.mMonths,
y: params.yMonths,
r: params.rebalance,
fp: params.fillPolicy,
c: params.conditions,
s: params.excludeSt,
l: params.minListingDays,
cost: [params.commission, params.stamp, params.slippage, params.minCommission],
});
}, [params]);
useEffect(() => {
if (!params || !enabled || !key) {
setState(EMPTY);
return;
}
let alive = true;
if (timer.current) clearTimeout(timer.current);
setState((s) => ({ ...s, loading: true }));
timer.current = setTimeout(() => {
apiPost<StrategyDoc>("/strategies/describe", paramsToSpec(params))
.then((doc) => alive && setState({ doc, loading: false, error: "" }))
.catch((e: Error) => alive && setState({ doc: null, loading: false, error: e.message }));
}, 500);
return () => {
alive = false;
if (timer.current) clearTimeout(timer.current);
};
// key 已覆盖所有影响说明的字段
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key, enabled]);
return state;
}
/** 已保存策略 → 说明 */
export function useStrategyDocById(id: string | null): DocState {
const [state, setState] = useState<DocState>(EMPTY);
useEffect(() => {
if (!id) {
setState(EMPTY);
return;
}
let alive = true;
setState({ doc: null, loading: true, error: "" });
apiGet<StrategyDoc>(`/strategies/${encodeURIComponent(id)}/describe`)
.then((doc) => alive && setState({ doc, loading: false, error: "" }))
.catch((e: Error) => alive && setState({ doc: null, loading: false, error: e.message }));
return () => {
alive = false;
};
}, [id]);
return state;
}
+204
View File
@@ -0,0 +1,204 @@
"use client";
/**
* 股票代码 ↔ 名称 的统一层。
*
* 需求(本轮):**任何出现股票代码的地方都必须同时显示股票名称,且可点击**
* 跳到个股页(基本信息 + 股价走势图)。
*
* 实现选择:不做 N+1 请求,而是启动时一次性拉全市场 `symbol → name` 映射
* (`GET /api/stocks/names`,约 6 千条),并用全局 Provider 缓存;
* 页面渲染只做 Map 查询。这样每个表格单元格都是纯查表,零额外网络开销。
*
* 降级策略(AGENT §24:不许假装):接口不可用时静默留空名称,但**不隐藏代码**,
* 也不阻塞页面;名称缺失时渲染「—」并在 title 里说明「名称未取到」,
* 避免出现「看起来有名字但其实是代码」的假象。
*/
import Link from "next/link";
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { apiGet } from "@/lib/api";
import type { Stock } from "@/lib/types";
type NameMap = Record<string, string>;
interface Ctx {
names: NameMap;
ready: boolean;
failed: boolean;
reload: () => void;
}
const SymbolCtx = createContext<Ctx>({ names: {}, ready: false, failed: false, reload: () => {} });
/** 模块级缓存:Provider 重挂载(如整页刷新)时不必重新拉取 */
let cache: NameMap | null = null;
export function SymbolNamesProvider({ children }: { children: React.ReactNode }) {
const [names, setNames] = useState<NameMap>(cache ?? {});
const [ready, setReady] = useState<boolean>(cache !== null);
const [failed, setFailed] = useState(false);
const [nonce, setNonce] = useState(0);
useEffect(() => {
let alive = true;
const run = async () => {
try {
const m = await apiGet<NameMap>("/stocks/names");
if (!alive) return;
cache = m;
setNames(m);
setReady(true);
setFailed(false);
} catch {
// 兜底:老接口(返回股票列表)。注意 `/stocks` 单页上限 500,
// 若只取首页会**静默漏掉 500 名之后的股票**(出现大量「—」而不报错),
// 因此这里翻页取全量,翻页失败就如实置 failed 而不是给出残缺映射。
try {
const m: NameMap = {};
for (let offset = 0; offset < 20_000; offset += 500) {
const rows = await apiGet<Stock[]>(`/stocks?limit=500&offset=${offset}`);
if (!alive) return;
for (const r of rows) if (r.name) m[r.symbol] = r.name;
if (rows.length < 500) break;
}
if (!Object.keys(m).length) throw new Error("股票列表为空");
cache = m;
setNames(m);
setReady(true);
setFailed(false);
} catch {
if (alive) {
setReady(true);
setFailed(true);
}
}
}
};
void run();
return () => {
alive = false;
};
}, [nonce]);
const reload = useCallback(() => {
cache = null;
setNonce((n) => n + 1);
}, []);
const value = useMemo(() => ({ names, ready, failed, reload }), [names, ready, failed, reload]);
return <SymbolCtx.Provider value={value}>{children}</SymbolCtx.Provider>;
}
export function useSymbolNames(): Ctx {
return useContext(SymbolCtx);
}
/** 取名称;未就绪或查不到都返回 null(调用方据此渲染占位) */
export function useSymbolName(symbol?: string | null): string | null {
const { names } = useContext(SymbolCtx);
if (!symbol) return null;
return names[symbol] ?? null;
}
export interface SymbolLinkProps {
symbol: string;
/** 优先使用调用方已有的名称(后端 payload 里带的更权威) */
name?: string | null;
/** 名称在前(个股页标题风格)还是代码在前(表格列风格) */
order?: "code-first" | "name-first";
/** 只显示名称(表格里已有独立代码列时用) */
nameOnly?: boolean;
/** 只显示代码(极少用:明确不需要名称时请说明理由) */
codeOnly?: boolean;
className?: string;
/** 不跳转(例如已经在个股页里) */
plain?: boolean;
}
/**
* 股票代码 + 名称,点击进入个股页。
*
* 用 `<Link>` 而非 `<a>`:保留 Next 客户端路由(不整页刷新)。
*/
export function SymbolLink({
symbol,
name,
order = "code-first",
nameOnly = false,
codeOnly = false,
className,
plain = false,
}: SymbolLinkProps) {
const resolved = useSymbolName(symbol);
const real = name ?? resolved;
const label = real || null;
const body = (
<>
{!nameOnly && <span className="sym-code">{symbol}</span>}
{!codeOnly &&
(label ? (
<span className="sym-name">{label}</span>
) : (
<span className="sym-name sym-name--missing" title="名称未取到(股票名称接口不可用或该代码不在股票池)">
—
</span>
))}
</>
);
const cls = ["symlink", className].filter(Boolean).join(" ");
const title = label ? `${symbol} ${label} · 查看基本信息与股价走势图` : `${symbol} · 查看基本信息与股价走势图`;
if (plain) {
return (
<span className={cls} title={label ? `${symbol} ${label}` : symbol}>
{body}
</span>
);
}
return (
<Link
href={`/stocks/${encodeURIComponent(symbol)}`}
className={cls}
title={title}
aria-label={label ? `${symbol} ${label},查看基本信息与走势图` : `${symbol},查看基本信息与走势图`}
style={{ flexDirection: order === "name-first" ? "row-reverse" : undefined }}
>
{body}
</Link>
);
}
/** 纯文本「代码 名称」(不可点击场景,如打印/图例) */
export function SymbolText({
symbol,
name,
order = "code-first",
}: {
symbol: string;
name?: string | null;
order?: "code-first" | "name-first";
}) {
const resolved = useSymbolName(symbol);
const label = name ?? resolved;
const code = <span className="sym-code">{symbol}</span>;
const nm = label ? <span className="sym-name">{label}</span> : null;
return (
<span className="symlink" title={label ? `${symbol} ${label}` : symbol}>
{order === "name-first" ? (
<>
{nm}
{code}
</>
) : (
<>
{code}
{nm}
</>
)}
</span>
);
}
+165 -4
View File
@@ -22,6 +22,13 @@ export interface FactorMeta {
direction: "higher_is_better" | "lower_is_better";
}
export interface ResearchCondition {
field: string;
op: "gt" | "gte" | "lt" | "lte" | "eq" | "ne" | "in" | "not_in";
value?: number | string | (number | string)[] | null;
ref?: string | null;
}
export interface ResearchSpec {
type: "factor_test" | "backtest";
universe: {
@@ -29,14 +36,32 @@ export interface ResearchSpec {
min_listing_days?: number;
symbols?: string[];
};
price_adjustment?: "none" | "qfq";
/** 行情口径:none 不复权 / qfq 前复权 / hfq 后复权(按 adjust_factor 折算) */
price_adjustment?: "none" | "qfq" | "hfq";
factors: { name: string; weight: number }[];
selection: { top_n: number };
/** 选股过滤条件(AND,可选):universe 之后、因子排序之前执行 */
conditions?: ResearchCondition[];
selection: {
/** n:候选池大小(择股条件选出来的股数) */
top_n: number;
/** x:实际持仓数,必须 ≤ top_n;省略 = top_n */
hold_top_x?: number | null;
/** true:从 n 名之外替补补足;false:不引入计划外标的 */
allow_substitute?: boolean;
/** true:买不进(涨停/停牌)时顺延到之后首个可成交日的收盘买入 */
defer_buy?: boolean;
};
rebalance: "weekly" | "monthly";
/** m:择股间隔(月);省略 = 每次调仓都择股 */
selection_interval_months?: number | null;
/** y:调仓间隔(月);省略 = m */
rebalance_interval_months?: number | null;
costs?: {
commission_rate?: number;
stamp_tax_rate?: number;
slippage_rate?: number;
/** 单笔最低佣金(元);0 = 不启用 */
min_commission?: number;
benchmark?: string;
};
portfolio?: { weighting?: string; max_position_pct?: number | null; max_industry_weight_pct?: number | null };
@@ -64,17 +89,65 @@ export interface BacktestSummary {
avg_turnover_pct: number;
}
/** 买卖点标注:value 由调用方按曲线口径换算到同一坐标 */
export interface MarkPoint {
date: string;
value: number;
kind: "BUY" | "SELL";
label?: string;
}
/** 交易意图与成交记录(Signal ↔ Fill,v3 §20.3)。signal 为空字符串表示组合级提示。 */
export interface ActionRecord {
date: string;
symbol: string;
/** 股票名称(后端填充,展示用;可能为 null —— 不要编造) */
name?: string | null;
signal: "BUY" | "SELL";
filled: boolean;
reject_reason?: string | null;
price?: number | null;
}
/** 个股收益率曲线 + 该股买卖点标注 */
export interface SymbolCurve {
symbol: string;
/** 股票名称(后端填充,展示用;可能为 null) */
name?: string | null;
points: CurvePoint[];
marks: ActionRecord[];
final_return_pct: number;
}
export interface BacktestResult {
summary: BacktestSummary;
equity_curve: CurvePoint[];
drawdown: CurvePoint[];
monthly_returns: { year: number; month: number; return_pct: number }[];
yearly_returns: { year: number; return_pct: number }[];
positions: { date: string; symbol: string; weight: number }[];
trades: { entry_date: string; exit_date: string; symbol: string; entry_price?: number; exit_price?: number; return_pct: number }[];
positions: { date: string; symbol: string; name?: string | null; weight: number }[];
trades: { entry_date: string; exit_date: string; symbol: string; name?: string | null; entry_price?: number; exit_price?: number; return_pct: number }[];
selection_history?: { date: string; symbol: string; name?: string | null; rank: number; score: number }[];
signal_history?: ActionRecord[];
fills?: ActionRecord[];
symbol_curves?: SymbolCurve[];
turnover_pct: number;
unimplemented: string[];
config_snapshot: Record<string, unknown>;
/** 归档元数据:曲线存了多少/共多少/是否因体积预算被裁剪(后端归档时写入) */
archive_meta?: {
curves_stored?: number;
curves_total?: number;
truncated?: boolean;
/** 预算(字符数口径,历史沿用键名) */
budget_chars?: number;
/** 预算实际执行口径:UTF-8 字节(MEDIUMTEXT 按字节计) */
budget_bytes?: number;
/** 裁剪后仍超预算(极端情况:连曲线都放不下) */
over_budget?: boolean;
result_chars?: number;
result_bytes?: number;
} | null;
}
export interface FactorTestReport {
@@ -115,6 +188,8 @@ export interface SelectionQuery {
export interface SelectionCandidate {
symbol: string;
/** 股票名称(后端填充,展示用;可能为 null) */
name?: string | null;
rank: number;
score: number;
factor_values: Record<string, number>;
@@ -228,3 +303,89 @@ export interface ChartResult {
fills: ChartMarker[];
factor_values?: Record<string, ChartSeriesPoint[]>;
}
/* ---- 策略库(M8.3,与 domain/entities/strategy.py 对应) ---- */
/**
* 命名策略:完整策略定义(universe + factor + selection + rebalance + costs + portfolio),
* 不含回测区间 period 与初始资金 —— 回测时补全后展开为 ResearchSpec。
* 后端以 JSON 整体持久化,因此新增字段无需迁移即可保存。
*/
export interface StrategyDefinition {
id?: string;
name: string;
/** 一句话说明(必填):说清这个策略做什么。为空时后端会用 describe_strategy 自动填充 */
description: string;
spec_type?: "backtest" | "factor_test";
universe?: { exclude_st?: boolean; min_listing_days?: number; symbols?: string[] };
price_adjustment?: "none" | "qfq" | "hfq";
factors: { name: string; weight: number }[];
selection?: {
top_n?: number;
hold_top_x?: number | null;
allow_substitute?: boolean;
defer_buy?: boolean;
};
rebalance?: "weekly" | "monthly";
conditions?: ResearchCondition[];
selection_interval_months?: number | null;
rebalance_interval_months?: number | null;
costs?: {
commission_rate?: number;
stamp_tax_rate?: number;
slippage_rate?: number;
min_commission?: number;
};
portfolio?: Record<string, unknown>;
version?: string;
created_at?: string | null;
}
/**
* 策略说明(后端 `describe_strategy` 依 spec 真实推导,前端只渲染不重算):
* summary=一句话功能说明,formula=计算公式,steps=执行步骤,warnings=如实标注的注意事项。
*/
export interface StrategyDoc {
summary: string;
formula: string;
steps: string[];
warnings: string[];
}
/* ------------------------------------------------------------------ */
/* Experiment 归档 */
/* ------------------------------------------------------------------ */
/** 归档列表项(`GET /api/experiments`);详情在此基础上多 `spec` 与 `result`。 */
export interface ExperimentMeta {
id: string;
kind: string;
factors: string[];
period?: [string, string] | null;
rebalance?: string | null;
top_n?: number | null;
summary_text?: string | null;
code_version?: string | null;
/** 数据快照指纹(归档时写入;2026-09 之前的老归档可能为空) */
data_version?: string | null;
/** 归档体积(result_json 字符数),特征可用于判断是否被裁剪 */
result_bytes?: number | null;
/** 产生该归档的作业 id(同步接口归档时为 null) */
job_id?: string | null;
created_at?: string;
}
export interface ExperimentDetail extends ExperimentMeta {
/** 归档时的完整 ResearchSpec(复现依据) */
spec?: Record<string, unknown> | null;
/**
* 完整结果,**结构随 `kind` 变化**(此前写死为 BacktestResult 等于谎报结构,
* 导致非回测归档在详情页按回测字段取用而整页 500):
* backtest → BacktestResult;factor_test → FactorTestReport;selection → SelectionResult。
* 也可能是 null(历史版本归档 / 结果为空)。
*/
result: BacktestResult | FactorTestReport | SelectionResult | null;
/** 归档元数据(仅回测结果带);非回测归档为 undefined */
archive_meta?: BacktestResult["archive_meta"];
}
+3 -1
View File
@@ -3,7 +3,9 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
reactStrictMode: true,
// 局域网/远程设备访问 dev server 时,/_next/* 资源按允许来源放行(Next 15.5+)
allowedDevOrigins: ["192.168.1.160", "127.0.0.1", "localhost"],
// 192.168.1.166 = 本机(macOS)LAN IP(前端监听 0.0.0.0:3000);192.168.1.160 = Pi5;
// 其它客户端来源按需追加,否则其 /_next/* 请求会被 Next 拒绝。
allowedDevOrigins: ["192.168.1.160", "192.168.1.166", "127.0.0.1", "localhost"],
// dev server 同源代理(rewrites)默认 30s 超时(http-proxy),而选股/因子研究等
// 同步后端接口需数十秒(全市场选股实测 ~50s),超过会被代理掐断成 500
// ("Failed to proxy ... socket hang up")。调大至 5 分钟,避免长研究请求被截断。
+2 -2
View File
@@ -6,10 +6,10 @@
"dev": "next dev -p 3000",
"build": "next build",
"start": "next start -p 3000",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test:charts": "node --test --experimental-strip-types 'components/charts/__tests__/*.test.ts'"
},
"dependencies": {
"echarts": "^5.5.1",
"lightweight-charts": "^4.2.3",
"next": "^15.1.0",
"react": "^19.0.0",
-23
View File
@@ -8,9 +8,6 @@ importers:
.:
dependencies:
echarts:
specifier: ^5.5.1
version: 5.6.0
lightweight-charts:
specifier: ^4.2.3
version: 4.2.3
@@ -286,9 +283,6 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
echarts@5.6.0:
resolution: {integrity: sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==}
fancy-canvas@2.1.0:
resolution: {integrity: sha512-nifxXJ95JNLFR2NgRV4/MxVP45G9909wJTEKz5fg/TZS20JJZA6hfgRVh/bC9bwl2zBtBNcYPjiBE4njQHVBwQ==}
@@ -371,9 +365,6 @@ packages:
babel-plugin-macros:
optional: true
tslib@2.3.0:
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@@ -385,9 +376,6 @@ packages:
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
zrender@5.6.1:
resolution: {integrity: sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==}
snapshots:
'@emnapi/runtime@1.11.3':
@@ -553,11 +541,6 @@ snapshots:
detect-libc@2.1.2:
optional: true
echarts@5.6.0:
dependencies:
tslib: 2.3.0
zrender: 5.6.1
fancy-canvas@2.1.0: {}
lightweight-charts@4.2.3:
@@ -651,14 +634,8 @@ snapshots:
client-only: 0.0.1
react: 19.2.8
tslib@2.3.0: {}
tslib@2.8.1: {}
typescript@5.9.3: {}
undici-types@6.21.0: {}
zrender@5.6.1:
dependencies:
tslib: 2.3.0
+25 -5
View File
@@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -13,9 +17,25 @@
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}