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:
+414
-250
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(/\.$/, "")}%`;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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}`}>
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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%" }} />;
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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"]
|
||||
);
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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: "完成",
|
||||
};
|
||||
|
||||
@@ -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 ?? "未知";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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,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 分钟,避免长研究请求被截断。
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
-23
@@ -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
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user