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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user