问题不是"不好看",而是**可测量的错位**:同一行里原生 date 输入 38.8px、数字输入
36.8px、按钮 34px;16px 的勾选框与 36.8px 的下拉同排;因子行的下拉与权重框没有
可见标签;列表里的勾选框点不中;参数非法时「运行回测」直接置灰且不说原因。
根因:控件高度靠「上下 padding + 行高」拼出来,而 input / select / button 的原生行高
各不相同(Chrome 的 date 还会多 2px),必然参差;加上各处内联像素宽度
(style={{width:220}}、flex:1)与自搓布局,列自然对不齐。
改动:
- 新增控件高度令牌 --ctl-h-sm/md/lg(28/34/38px)与 --ctl-px,.input/select/.btn/
.icon-btn/date 统一显式 height(不再拼 padding);原生 checkbox/radio 统一 16px,
点击热区交给外层 label(.check/.radio-row/.check--cell),表格整格可点
- 因子行、选股条件行改为「表头 + CSS 栅格」成列对齐,列宽由样式决定,
去掉内联像素宽度与 flex 拉伸,配 aria-label 供读屏分辨重复行
- 表单友好化:错误提示改为**失焦或提交后**才出现(清空重填的瞬间不再标红);
提交被拦下时一次展开全部行内错误 + 自动聚焦并滚动到第一个问题字段;
运行/保存按钮不再因参数非法而置灰(灰按钮不说原因 = 看起来不可点却无响应),
改为可点击并讲清原因;补齐 topN/costs 两处「产生了却没人显示」的行内错误落点
- 数值字段补 inputMode/step/min/max 与单位、取值范围提示;工具条检索/筛选用
.input--search/.input--filter/.input--picker 类,不再写内联宽度
- /experiments 筛选无结果的空态与「暂无实验」区分开(原文案会让人以为归档丢了)
- 同一页面可能挂两份表单:radio name 与 label/for 加表单实例前缀(useId),
否则两边单选互相取消、label 指错控件
- 新增 scripts/verify_ui_alignment.py:系统 Chrome + 原生 CDP(仅标准库,
独占随机端口与临时 profile),按 7 个页面 × 1500/375px 检查同排等高、
高度取值归一、点击目标、标签与无障碍名、字号圆角一致、尺寸匹配内容、
横向溢出、提示裁切;本次基线 108/32 → 现 140/140
验证:pytest 388 passed、ruff 全绿(顺带清掉 qlib_verify.py 一处死代码)、
tsc 0 错误、图表单测 7 passed、next build 成功、契约自检 59/59、
对齐自检 140/140(含 375px 小屏)。
524 lines
17 KiB
TypeScript
524 lines
17 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* 策略库(/strategies)—— 策略的命名资产中心。
|
||
*
|
||
* 职责:
|
||
* - 列表 + 检索:每个策略显示**一句话说明**与**计算公式**(后端 describe_strategy 推导)。
|
||
* - 新建 / 编辑:复用 StrategyParamsForm(与回测页同一份参数模型与校验)。
|
||
* - 一键回测:`POST /strategies/{id}/expand`(补 period + 资金)→ `POST /jobs` → 轮询 →
|
||
* 就地展示核心指标,并提供「查看实验详情 / 回到回测页看曲线」。
|
||
* - 去回测页:`/backtest?strategy={id}`(回测页载入后可保存为策略、可微调)。
|
||
*
|
||
* 设计取舍:编辑不弹窗而是就地展开表单(页面滚动位置不丢,参数多,弹窗太挤)。
|
||
*/
|
||
import Link from "next/link";
|
||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api";
|
||
import { STAGE_LABEL, submitJob, waitJob, type JobOutcome } from "@/lib/jobs";
|
||
import { recentRange } from "@/lib/dates";
|
||
import type { BacktestResult, FactorMeta, StrategyDefinition } from "@/lib/types";
|
||
import {
|
||
Btn,
|
||
Banner,
|
||
Card,
|
||
Empty,
|
||
Loading,
|
||
PageHeader,
|
||
Pill,
|
||
Progress,
|
||
BacktestMetrics,
|
||
Field,
|
||
} from "@/components/ui";
|
||
import {
|
||
StrategyParamsForm,
|
||
casePreset,
|
||
emptyParams,
|
||
paramsFromStrategy,
|
||
strategyFromParams,
|
||
validateParams,
|
||
type StrategyParams,
|
||
} from "@/components/StrategyParamsForm";
|
||
import { StrategyDocBody, StrategyDocCard } from "@/components/StrategyDocCard";
|
||
import { useStrategyDoc, useStrategyDocById } from "@/lib/strategy";
|
||
|
||
export default function StrategiesPage() {
|
||
const [list, setList] = useState<StrategyDefinition[] | null>(null);
|
||
const [factors, setFactors] = useState<FactorMeta[]>([]);
|
||
const [query, setQuery] = useState("");
|
||
const [err, setErr] = useState("");
|
||
const [msg, setMsg] = useState("");
|
||
const [editing, setEditing] = useState<StrategyParams | null>(null);
|
||
const [editingId, setEditingId] = useState<string | null>(null);
|
||
// 保存被拦下过 → 立即展开全部校验错误(见 StrategyParamsFormProps.revealErrors)
|
||
const [revealErrors, setRevealErrors] = useState(false);
|
||
const [busy, setBusy] = useState(false);
|
||
const [docId, setDocId] = useState<string | null>(null);
|
||
|
||
const range = useMemo(() => recentRange(), []);
|
||
|
||
const load = useCallback(async () => {
|
||
try {
|
||
const rows = await apiGet<StrategyDefinition[]>("/strategies");
|
||
setList(rows);
|
||
setErr("");
|
||
} catch (e) {
|
||
setErr((e as Error).message);
|
||
setList([]);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
void load();
|
||
let alive = true;
|
||
apiGet<FactorMeta[]>("/factors")
|
||
.then((f) => alive && setFactors(f))
|
||
.catch(() => alive && setFactors([]));
|
||
return () => {
|
||
alive = false;
|
||
};
|
||
}, [load]);
|
||
|
||
const filtered = useMemo(() => {
|
||
if (!list) return null;
|
||
const q = query.trim().toLowerCase();
|
||
if (!q) return list;
|
||
return list.filter((s) => {
|
||
const hay = [
|
||
s.name,
|
||
s.description,
|
||
(s.factors ?? []).map((f) => f.name).join(" "),
|
||
(s.conditions ?? []).map((c) => c.field).join(" "),
|
||
]
|
||
.join(" ")
|
||
.toLowerCase();
|
||
return hay.includes(q);
|
||
});
|
||
}, [list, query]);
|
||
|
||
/* ---------- 新建 / 编辑 ---------- */
|
||
function startCreate() {
|
||
setEditingId(null);
|
||
setEditing(emptyParams(range));
|
||
setDocId(null);
|
||
setMsg("");
|
||
setErr("");
|
||
setRevealErrors(false);
|
||
}
|
||
|
||
function startEdit(s: StrategyDefinition) {
|
||
setEditingId(s.id ?? null);
|
||
setEditing(paramsFromStrategy(s, { ...emptyParams(range) }));
|
||
setDocId(s.id ?? null);
|
||
setMsg("");
|
||
setErr("");
|
||
setRevealErrors(false);
|
||
}
|
||
|
||
async function save() {
|
||
if (!editing) return;
|
||
const errors = validateParams(editing, { requireMeta: true });
|
||
if (Object.keys(errors).length) {
|
||
setErr(Object.values(errors)[0]);
|
||
setRevealErrors(true); // 一次把问题全列出来,而不是让用户逐个试
|
||
return;
|
||
}
|
||
setBusy(true);
|
||
setErr("");
|
||
try {
|
||
const body = strategyFromParams(editing, editingId ?? undefined);
|
||
if (editingId) {
|
||
await apiPut<StrategyDefinition>(`/strategies/${encodeURIComponent(editingId)}`, body);
|
||
setMsg(`已更新策略「${body.name}」`);
|
||
} else {
|
||
const saved = await apiPost<StrategyDefinition>("/strategies", body);
|
||
setMsg(`已保存策略「${saved.name}」(${saved.id})`);
|
||
}
|
||
setEditing(null);
|
||
setEditingId(null);
|
||
setDocId(null);
|
||
await load();
|
||
} catch (e) {
|
||
setErr((e as Error).message);
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
async function remove(s: StrategyDefinition) {
|
||
if (!s.id) return;
|
||
if (!window.confirm(`删除策略「${s.name}」?此操作不可撤销(已跑过的实验不受影响)。`)) return;
|
||
setBusy(true);
|
||
try {
|
||
await apiDelete(`/strategies/${encodeURIComponent(s.id)}`);
|
||
setMsg(`已删除策略「${s.name}」`);
|
||
await load();
|
||
} catch (e) {
|
||
setErr((e as Error).message);
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<PageHeader
|
||
title="策略库"
|
||
sub="把调好的参数存成命名策略:每个策略都有「一句话说明 + 计算公式 + 执行步骤」,可一键回测、可复现、可对比。"
|
||
actions={
|
||
<div className="row" style={{ gap: 8 }}>
|
||
<Link href="/experiments" className="btn">
|
||
<span>查看实验</span>
|
||
</Link>
|
||
<Btn variant="primary" icon="plus" onClick={startCreate} disabled={busy}>
|
||
新建策略
|
||
</Btn>
|
||
</div>
|
||
}
|
||
/>
|
||
|
||
{err ? <Banner tone="error">{err}</Banner> : null}
|
||
{msg ? <Banner tone="info">{msg}</Banner> : null}
|
||
|
||
{/* ---------- 编辑器 ---------- */}
|
||
{editing ? (
|
||
<Card
|
||
icon={editingId ? "edit" : "plus"}
|
||
title={editingId ? `编辑策略 · ${editingId}` : "新建策略"}
|
||
tools={
|
||
<div className="row" style={{ gap: 8 }}>
|
||
<Btn onClick={() => { setEditing(null); setEditingId(null); setDocId(null); }} disabled={busy}>
|
||
取消
|
||
</Btn>
|
||
<Btn variant="primary" icon="check" loading={busy} onClick={save} disabled={busy}>
|
||
{editingId ? "保存修改" : "保存到策略库"}
|
||
</Btn>
|
||
</div>
|
||
}
|
||
>
|
||
<StrategyParamsForm
|
||
value={editing}
|
||
onChange={setEditing}
|
||
factorOptions={factors}
|
||
showMeta
|
||
showPeriod={false}
|
||
disabled={busy}
|
||
errors={validateParams(editing, { requireMeta: true })}
|
||
revealErrors={revealErrors}
|
||
/>
|
||
<div className="hint" style={{ marginTop: 10 }}>
|
||
策略只保存「怎么选股/怎么调仓/怎么收费」,**不含回测区间与初始资金** ——
|
||
这两项在运行回测时才指定,因此同一策略可用于不同区间的复现与对比。
|
||
</div>
|
||
<div className="row" style={{ marginTop: 12, gap: 8 }}>
|
||
<Btn
|
||
icon="target"
|
||
disabled={busy}
|
||
onClick={() =>
|
||
setEditing({
|
||
...casePreset(range),
|
||
name: editing.name,
|
||
description: editing.description,
|
||
})
|
||
}
|
||
>
|
||
载入高股息案例默认参数
|
||
</Btn>
|
||
</div>
|
||
</Card>
|
||
) : null}
|
||
|
||
{editing ? (
|
||
<EditingDoc params={editing} savedId={editingId} />
|
||
) : null}
|
||
|
||
{docId && !editing ? <SavedDoc id={docId} onClose={() => setDocId(null)} /> : null}
|
||
|
||
{/* ---------- 列表 ---------- */}
|
||
<Card
|
||
icon="archive"
|
||
title={`已保存策略${list ? ` · ${list.length}` : ""}`}
|
||
tools={
|
||
<input
|
||
className="input input--search"
|
||
placeholder="搜索策略名 / 说明 / 因子 / 条件"
|
||
value={query}
|
||
onChange={(e) => setQuery(e.target.value)}
|
||
aria-label="搜索策略"
|
||
/>
|
||
}
|
||
>
|
||
{filtered === null ? (
|
||
<Loading label="读取策略库…" />
|
||
) : filtered.length === 0 ? (
|
||
<Empty
|
||
icon="archive"
|
||
title={list && list.length ? "没有匹配的策略" : "策略库还是空的"}
|
||
hint={
|
||
list && list.length
|
||
? "换个关键词试试。"
|
||
: "点右上角「新建策略」保存第一个策略;也可以先去回测页调好参数,再点「保存为策略」。"
|
||
}
|
||
/>
|
||
) : (
|
||
<div className="strategy-grid">
|
||
{filtered.map((s) => (
|
||
<StrategyCard
|
||
key={s.id ?? s.name}
|
||
s={s}
|
||
range={range}
|
||
busy={busy}
|
||
onEdit={() => startEdit(s)}
|
||
onDelete={() => remove(s)}
|
||
onDoc={() => setDocId(s.id ?? null)}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</Card>
|
||
</>
|
||
);
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
/** 未保存参数的实时说明预览 */
|
||
function EditingDoc({ params, savedId }: { params: StrategyParams; savedId: string | null }) {
|
||
const live = useStrategyDoc(params, !savedId);
|
||
const saved = useStrategyDocById(savedId);
|
||
const state = savedId ? saved : live;
|
||
return (
|
||
<StrategyDocCard
|
||
doc={state.doc}
|
||
loading={state.loading}
|
||
error={state.error}
|
||
title="策略说明与计算公式(随参数实时更新)"
|
||
/>
|
||
);
|
||
}
|
||
|
||
function SavedDoc({ id, onClose }: { id: string; onClose: () => void }) {
|
||
const state = useStrategyDocById(id);
|
||
return (
|
||
<Card
|
||
icon="book"
|
||
title={`策略说明 · ${id}`}
|
||
tools={<Btn size="sm" onClick={onClose}>收起</Btn>}
|
||
>
|
||
{state.doc ? (
|
||
<StrategyDocBody doc={state.doc} />
|
||
) : (
|
||
<div className="hint">
|
||
{state.loading
|
||
? "正在生成说明与公式…"
|
||
: `说明暂不可用${state.error ? `:${state.error}` : "(后端 /api/strategies/{id}/describe 未就绪)"}。`}
|
||
</div>
|
||
)}
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
/** 单个策略卡片:说明 + 参数摘要 + 一键回测 */
|
||
function StrategyCard({
|
||
s,
|
||
range,
|
||
busy,
|
||
onEdit,
|
||
onDelete,
|
||
onDoc,
|
||
}: {
|
||
s: StrategyDefinition;
|
||
range: { start: string; end: string };
|
||
busy: boolean;
|
||
onEdit: () => void;
|
||
onDelete: () => void;
|
||
onDoc: () => void;
|
||
}) {
|
||
const [start, setStart] = useState("2020-01-01");
|
||
const [end, setEnd] = useState(range.end);
|
||
const [capital, setCapital] = useState(1_000_000);
|
||
const [running, setRunning] = useState(false);
|
||
const [jobId, setJobId] = useState("");
|
||
const [result, setResult] = useState<BacktestResult | null>(null);
|
||
const [stage, setStage] = useState("");
|
||
const [expId, setExpId] = useState("");
|
||
const [error, setError] = useState("");
|
||
const [open, setOpen] = useState(false);
|
||
|
||
const sel = s.selection ?? {};
|
||
const costs = s.costs ?? {};
|
||
|
||
async function run() {
|
||
if (!s.id) return;
|
||
if (start >= end) {
|
||
setError("开始日期必须早于结束日期");
|
||
return;
|
||
}
|
||
setRunning(true);
|
||
setError("");
|
||
setResult(null);
|
||
setExpId("");
|
||
try {
|
||
const spec = await apiPost<Record<string, unknown>>(
|
||
`/strategies/${encodeURIComponent(s.id)}/expand`,
|
||
{ period: [start, end], initial_capital: capital }
|
||
);
|
||
const { job_id } = await submitJob(spec as never);
|
||
setJobId(job_id);
|
||
setStage("queued");
|
||
const out: JobOutcome<BacktestResult> = await waitJob<BacktestResult>(job_id, 900_000, (info) =>
|
||
setStage(info.stage)
|
||
);
|
||
if (out.status === "success" && out.result) {
|
||
setResult(out.result);
|
||
setExpId(out.experimentId ?? "");
|
||
} else {
|
||
setError(`任务${out.status}${out.error ? `:${out.error}` : ""}`);
|
||
}
|
||
} catch (e) {
|
||
setError((e as Error).message);
|
||
} finally {
|
||
setRunning(false);
|
||
setJobId("");
|
||
setStage("");
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="card" style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||
<div className="between">
|
||
<div style={{ minWidth: 0 }}>
|
||
<div className="card__title" style={{ marginBottom: 2 }}>
|
||
{s.name}
|
||
</div>
|
||
<div className="row" style={{ gap: 6, flexWrap: "wrap" }}>
|
||
<span className="mono hint">{s.id}</span>
|
||
{s.created_at ? <span className="hint">创建 {String(s.created_at).slice(0, 10)}</span> : null}
|
||
</div>
|
||
</div>
|
||
<Pill tone="violet" icon="target">
|
||
{s.factors?.map((f) => f.name).join(" + ") || "—"}
|
||
</Pill>
|
||
</div>
|
||
|
||
<div className="strategy-desc">{s.description || "(无说明:建议补一句话说明,便于日后识别)"}</div>
|
||
|
||
<div className="chips">
|
||
<span className="chip">
|
||
候选池 <b>{sel.top_n ?? "—"}</b> → 持仓 <b>{sel.hold_top_x ?? sel.top_n ?? "—"}</b>
|
||
</span>
|
||
<span className="chip">
|
||
择股 <b>{s.selection_interval_months ?? "跟随 y"}</b> 月 / 调仓{" "}
|
||
<b>{s.rebalance_interval_months ?? "跟随 m"}</b> 月
|
||
</span>
|
||
<span className="chip">
|
||
复权 <b>{adjLabel(s.price_adjustment)}</b>
|
||
</span>
|
||
<span className="chip">
|
||
{s.universe?.exclude_st ? "剔除 ST" : "含 ST"} · 费率{" "}
|
||
<b>{((costs.commission_rate ?? 0) * 100).toFixed(3)}%</b> + 印花{" "}
|
||
<b>{((costs.stamp_tax_rate ?? 0) * 100).toFixed(2)}%</b>
|
||
</span>
|
||
{(s.conditions ?? []).length ? (
|
||
<span className="chip">
|
||
条件 <b>{(s.conditions ?? []).map((c) => `${c.field} ${opLabel(c.op)} ${c.value ?? ""}`).join(" 且 ")}</b>
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="strategy-actions">
|
||
<Btn size="sm" variant="primary" icon="play" loading={running} disabled={running || busy} onClick={run}>
|
||
{running ? "后台运行中…" : "一键回测"}
|
||
</Btn>
|
||
<Link href={`/backtest?strategy=${encodeURIComponent(s.id ?? "")}`} className="btn btn--sm">
|
||
<span>载入回测页(可微调)</span>
|
||
</Link>
|
||
<Btn size="sm" icon="book" onClick={onDoc} disabled={busy}>
|
||
看说明/公式
|
||
</Btn>
|
||
<Btn size="sm" icon="edit" onClick={onEdit} disabled={busy}>
|
||
编辑
|
||
</Btn>
|
||
<Btn size="sm" variant="danger" icon="trash" onClick={onDelete} disabled={busy}>
|
||
删除
|
||
</Btn>
|
||
</div>
|
||
|
||
{/* 运行区间(默认 2020-01-01 ~ 最近交易日) */}
|
||
<details open={open} onToggle={(e) => setOpen((e.target as HTMLDetailsElement).open)}>
|
||
<summary className="hint" style={{ cursor: "pointer" }}>
|
||
回测区间与资金(一键回测使用)
|
||
</summary>
|
||
<div className="form-grid" style={{ marginTop: 8 }}>
|
||
<Field label="开始日期">
|
||
<input type="date" className="input" value={start} onChange={(e) => setStart(e.target.value)} disabled={running} />
|
||
</Field>
|
||
<Field label="结束日期">
|
||
<input type="date" className="input" value={end} onChange={(e) => setEnd(e.target.value)} disabled={running} />
|
||
</Field>
|
||
<Field label="初始资金(元)">
|
||
<input
|
||
type="number"
|
||
className="input"
|
||
step={100000}
|
||
min={10000}
|
||
value={capital}
|
||
onChange={(e) => setCapital(Number(e.target.value))}
|
||
disabled={running}
|
||
/>
|
||
</Field>
|
||
</div>
|
||
</details>
|
||
|
||
{running ? (
|
||
<Progress
|
||
value={35}
|
||
label={
|
||
<>
|
||
任务 <span className="mono">{jobId || "排队中…"}</span> 后台执行中(全市场回测通常 1~5 分钟)
|
||
</>
|
||
}
|
||
/>
|
||
) : null}
|
||
{error ? <Banner tone="error">{error}</Banner> : null}
|
||
|
||
{result ? (
|
||
<div>
|
||
<div className="between" style={{ marginBottom: 8 }}>
|
||
<Pill tone="pos" icon="check">
|
||
回测完成
|
||
</Pill>
|
||
<Link
|
||
href={expId ? `/experiments?exp=${encodeURIComponent(expId)}` : "/experiments"}
|
||
className="btn btn--sm"
|
||
>
|
||
<span>在实验中对比</span>
|
||
</Link>
|
||
{expId ? (
|
||
<>
|
||
<Link href={`/experiments/${encodeURIComponent(expId)}`} className="btn btn--sm">
|
||
<span>打开归档(完整快照)</span>
|
||
</Link>
|
||
<Link href={`/backtest?from_experiment=${encodeURIComponent(expId)}`} className="btn btn--sm">
|
||
<span>以此参数再跑</span>
|
||
</Link>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
<BacktestMetrics s={result.summary} />
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function adjLabel(m?: string): string {
|
||
return m === "hfq" ? "后复权" : m === "qfq" ? "前复权" : "不复权";
|
||
}
|
||
|
||
function opLabel(op: string): string {
|
||
return (
|
||
{ gt: ">", gte: "≥", lt: "<", lte: "≤", eq: "=", ne: "≠", in: "属于", not_in: "不属于" }[op] ?? op
|
||
);
|
||
}
|