汇总三轮未提交的开发(每轮均在本机 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 逐类验证归档页)。
456 lines
12 KiB
TypeScript
456 lines
12 KiB
TypeScript
/**
|
||
* 设计系统 UI 基元(纯展示,无副作用):页头 / 卡片 / 指标卡 / 徽标 /
|
||
* 按钮 / 表单字段 / 提示条 / 空态 / 骨架 / 进度 / 回测结果区块。
|
||
*/
|
||
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";
|
||
|
||
export function cx(...parts: Array<string | false | null | undefined>): string {
|
||
return parts.filter(Boolean).join(" ");
|
||
}
|
||
|
||
/* ---------- 页头 ---------- */
|
||
|
||
export function PageHeader({
|
||
title,
|
||
sub,
|
||
actions,
|
||
}: {
|
||
title: string;
|
||
sub?: ReactNode;
|
||
actions?: ReactNode;
|
||
}) {
|
||
return (
|
||
<header className="page-head">
|
||
<div>
|
||
<h1 className="page-title">{title}</h1>
|
||
{sub ? <p className="page-sub">{sub}</p> : null}
|
||
</div>
|
||
{actions ? <div className="page-actions">{actions}</div> : null}
|
||
</header>
|
||
);
|
||
}
|
||
|
||
/* ---------- 卡片 ---------- */
|
||
|
||
export function Card({
|
||
title,
|
||
sub,
|
||
icon,
|
||
tools,
|
||
children,
|
||
flush = false,
|
||
className,
|
||
id,
|
||
}: {
|
||
title?: ReactNode;
|
||
sub?: ReactNode;
|
||
icon?: IconName;
|
||
tools?: ReactNode;
|
||
children: ReactNode;
|
||
flush?: boolean;
|
||
className?: string;
|
||
/** 锚点 id:长结果页的分区导航(#sec-equity 等)依赖它 */
|
||
id?: string;
|
||
}) {
|
||
const hasHead = title != null || tools != null;
|
||
return (
|
||
<section className={cx("card", className)} id={id} style={id ? { scrollMarginTop: 76 } : undefined}>
|
||
{hasHead ? (
|
||
<div className="card__head">
|
||
<div className="card__title">
|
||
{icon ? <Icon name={icon} size={17} /> : null}
|
||
<span>
|
||
{title}
|
||
{sub ? <span className="card__sub"> {sub}</span> : null}
|
||
</span>
|
||
</div>
|
||
{tools ? <div className="card__tools">{tools}</div> : null}
|
||
</div>
|
||
) : null}
|
||
<div className={flush ? "card__body--flush" : "card__body"}>{children}</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
/* ---------- 指标卡 ---------- */
|
||
|
||
export function Metric({
|
||
label,
|
||
icon,
|
||
value,
|
||
sub,
|
||
tone = "plain",
|
||
small = false,
|
||
}: {
|
||
label: ReactNode;
|
||
icon?: IconName;
|
||
value: ReactNode;
|
||
sub?: ReactNode;
|
||
tone?: "plain" | "pos" | "neg" | "warn" | "accent";
|
||
small?: boolean;
|
||
}) {
|
||
return (
|
||
<div className="metric">
|
||
<div className="metric__label">
|
||
{icon ? <Icon name={icon} size={14} /> : null}
|
||
{label}
|
||
</div>
|
||
<div
|
||
className={cx(
|
||
"metric__value",
|
||
small && "metric__value--sm",
|
||
tone !== "plain" && `tone-${tone}`,
|
||
)}
|
||
>
|
||
{value}
|
||
</div>
|
||
{sub ? <div className="metric__sub">{sub}</div> : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ---------- 徽标 ---------- */
|
||
|
||
export function Pill({
|
||
tone = "default",
|
||
children,
|
||
icon,
|
||
}: {
|
||
tone?: "default" | "accent" | "pos" | "neg" | "warn" | "violet";
|
||
children: ReactNode;
|
||
icon?: IconName;
|
||
}) {
|
||
return (
|
||
<span className={cx("pill", tone !== "default" && `pill--${tone}`)}>
|
||
{icon ? <Icon name={icon} size={12} /> : null}
|
||
{children}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
/* ---------- 按钮 ---------- */
|
||
|
||
interface BtnProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||
variant?: "primary" | "ghost" | "danger";
|
||
size?: "sm" | "md";
|
||
icon?: IconName;
|
||
loading?: boolean;
|
||
children?: ReactNode;
|
||
}
|
||
|
||
export function Btn({
|
||
variant = "ghost",
|
||
size = "md",
|
||
icon,
|
||
loading = false,
|
||
children,
|
||
className,
|
||
...rest
|
||
}: BtnProps) {
|
||
return (
|
||
<button
|
||
className={cx(
|
||
"btn",
|
||
variant === "primary" && "btn--primary",
|
||
variant === "danger" && "btn--danger",
|
||
size === "sm" && "btn--sm",
|
||
className,
|
||
)}
|
||
disabled={rest.disabled || loading}
|
||
{...rest}
|
||
>
|
||
{loading ? <Icon name="spinner" size={14} className="spin" /> : icon ? <Icon name={icon} size={15} /> : null}
|
||
{children}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
/* ---------- 表单 ---------- */
|
||
|
||
export function Field({
|
||
label,
|
||
hint,
|
||
htmlFor,
|
||
children,
|
||
className,
|
||
}: {
|
||
label: ReactNode;
|
||
hint?: ReactNode;
|
||
htmlFor?: string;
|
||
children: ReactNode;
|
||
className?: string;
|
||
}) {
|
||
return (
|
||
<div className={cx("field", className)}>
|
||
<label className="field__label" htmlFor={htmlFor}>
|
||
{label}
|
||
</label>
|
||
{children}
|
||
{hint ? <span className="field__hint">{hint}</span> : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
interface TextInputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||
mono?: boolean;
|
||
}
|
||
|
||
/** 统一样式的 input(含原生控件),mono 用于代码/数值输入。 */
|
||
export function TextInput({ className, mono, ...rest }: TextInputProps) {
|
||
return <input className={cx("input", mono && "mono", className)} {...rest} />;
|
||
}
|
||
|
||
export function SearchBox({
|
||
value,
|
||
onChange,
|
||
onClear,
|
||
placeholder,
|
||
label,
|
||
}: {
|
||
value: string;
|
||
onChange: (v: string) => void;
|
||
onClear?: () => void;
|
||
placeholder?: string;
|
||
label?: string;
|
||
}) {
|
||
const inputId = useId();
|
||
return (
|
||
<div className="searchbox" role="search">
|
||
<label className="sr-only" htmlFor={inputId}>
|
||
{label ?? "搜索"}
|
||
</label>
|
||
<Icon name="search" size={16} />
|
||
<TextInput
|
||
id={inputId}
|
||
type="search"
|
||
className="input"
|
||
placeholder={placeholder}
|
||
value={value}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
/>
|
||
{value && onClear ? (
|
||
<button type="button" className="icon-btn clear" aria-label="清空搜索" onClick={onClear}>
|
||
<Icon name="x" size={13} />
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ---------- 提示条 / 空态 / 加载 ---------- */
|
||
|
||
export function Banner({
|
||
tone = "info",
|
||
children,
|
||
icon,
|
||
}: {
|
||
tone?: "error" | "warn" | "info" | "muted";
|
||
children: ReactNode;
|
||
icon?: IconName;
|
||
}) {
|
||
const ic: IconName =
|
||
icon ?? (tone === "error" || tone === "warn" ? "alert" : tone === "muted" ? "info" : "info");
|
||
return (
|
||
<div className={cx("banner", `banner--${tone}`)} role={tone === "error" ? "alert" : undefined}>
|
||
<Icon name={ic} size={16} />
|
||
<div>{children}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function Empty({
|
||
icon = "archive",
|
||
title,
|
||
hint,
|
||
children,
|
||
}: {
|
||
icon?: IconName;
|
||
title: ReactNode;
|
||
hint?: ReactNode;
|
||
children?: ReactNode;
|
||
}) {
|
||
return (
|
||
<div className="empty">
|
||
<Icon name={icon} size={30} />
|
||
<strong>{title}</strong>
|
||
{hint ? <p className="hint" style={{ maxWidth: 460, margin: "4px auto 0" }}>{hint}</p> : null}
|
||
{children ? <div style={{ marginTop: 14 }}>{children}</div> : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function SkeletonLines({ n = 3, height = 14 }: { n?: number; height?: number }) {
|
||
return (
|
||
<div className="stack" aria-hidden="true">
|
||
{Array.from({ length: n }).map((_, i) => (
|
||
<div
|
||
key={i}
|
||
className="skeleton"
|
||
style={{ height, width: i === n - 1 ? "62%" : "100%" }}
|
||
/>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function Loading({ label = "加载中…" }: { label?: string }) {
|
||
return (
|
||
<div className="row" style={{ color: "var(--text-2)", padding: "10px 0" }} role="status">
|
||
<Icon name="spinner" size={16} className="spin" />
|
||
<span className="hint">{label}</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function Progress({
|
||
value,
|
||
label,
|
||
}: {
|
||
/** 0-100 */
|
||
value: number;
|
||
label?: ReactNode;
|
||
}) {
|
||
return (
|
||
<div>
|
||
{label ? (
|
||
<div className="hint" style={{ marginBottom: 2 }}>
|
||
{label}
|
||
</div>
|
||
) : null}
|
||
<div
|
||
className="progress"
|
||
role="progressbar"
|
||
aria-valuenow={Math.round(value)}
|
||
aria-valuemin={0}
|
||
aria-valuemax={100}
|
||
>
|
||
<div className="progress__bar" style={{ width: `${Math.max(0, Math.min(100, value))}%` }} />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ---------- 数值 ---------- */
|
||
|
||
export function Signed({
|
||
value,
|
||
suffix = "",
|
||
digits = 2,
|
||
arrow = true,
|
||
}: {
|
||
value: number;
|
||
suffix?: string;
|
||
digits?: number;
|
||
arrow?: boolean;
|
||
}) {
|
||
const cls = value > 0 ? "tone-pos" : value < 0 ? "tone-neg" : "";
|
||
return (
|
||
<span className={cx("num delta", cls)}>
|
||
{arrow ? (value > 0 ? <Icon name="arrowUp" size={12} /> : value < 0 ? <Icon name="arrowDown" size={12} /> : null) : null}
|
||
{value > 0 ? "+" : ""}
|
||
{value.toFixed(digits)}
|
||
{suffix}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
/* ---------- 回测 / 因子结果共用区块 ---------- */
|
||
|
||
export function BacktestMetrics({ s }: { s: BacktestSummary }) {
|
||
return (
|
||
<div className="metric-grid">
|
||
<Metric label="期末净值" value={s.final_equity.toLocaleString()} sub={`起始 ${s.initial_capital.toLocaleString()}`} />
|
||
<Metric label="区间收益" tone={s.total_return_pct >= 0 ? "pos" : "neg"} value={<Signed value={s.total_return_pct} suffix="%" />} sub={`${s.start} ~ ${s.end}`} />
|
||
<Metric label="年化收益" tone={s.annual_return_pct >= 0 ? "pos" : "neg"} value={<Signed value={s.annual_return_pct} suffix="%" />} />
|
||
<Metric
|
||
label="Sharpe"
|
||
tone={s.sharpe >= 1 ? "pos" : s.sharpe < 0 ? "neg" : "plain"}
|
||
value={s.sharpe.toFixed(2)}
|
||
sub={`波动 ${s.volatility_pct.toFixed(2)}%`}
|
||
/>
|
||
<Metric label="最大回撤" tone="neg" value={<span className="num">{s.max_drawdown_pct.toFixed(2)}%</span>} />
|
||
<Metric label="胜率 / 换手" value={`${s.win_rate_pct.toFixed(1)}%`} sub={`交易 ${s.total_trades} 笔 · 平均换手 ${s.avg_turnover_pct.toFixed(0)}%`} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ---------- 月度收益(热力着色) ---------- */
|
||
|
||
export function MonthlyReturnsTable({
|
||
rows,
|
||
}: {
|
||
rows: { year: number; month: number; return_pct: number }[];
|
||
}) {
|
||
const byYear = new Map<number, Map<number, number>>();
|
||
for (const r of rows) {
|
||
if (!byYear.has(r.year)) byYear.set(r.year, new Map());
|
||
byYear.get(r.year)!.set(r.month, r.return_pct);
|
||
}
|
||
const months = Array.from({ length: 12 }, (_, i) => i + 1);
|
||
const years = [...byYear.keys()].sort();
|
||
if (years.length === 0) return <Empty title="无月度收益数据" />;
|
||
return (
|
||
<div className="table-wrap">
|
||
<table className="tbl">
|
||
<thead>
|
||
<tr>
|
||
<th>年份</th>
|
||
{months.map((m) => (
|
||
<th key={m} style={{ textAlign: "right", minWidth: 62 }}>
|
||
{m}月
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{years.map((y) => {
|
||
const row = byYear.get(y)!;
|
||
return (
|
||
<tr key={y}>
|
||
<td className="cell-strong">{y}</td>
|
||
{months.map((m) => {
|
||
const v = row.get(m);
|
||
if (v == null)
|
||
return (
|
||
<td key={m} style={{ textAlign: "right" }}>
|
||
<span className="cell-heat flat">—</span>
|
||
</td>
|
||
);
|
||
const cls = v > 0 ? "pos" : v < 0 ? "neg" : "flat";
|
||
return (
|
||
<td key={m} style={{ textAlign: "right" }}>
|
||
<span className={`cell-heat ${cls}`}>
|
||
{v > 0 ? "+" : ""}
|
||
{v.toFixed(2)}%
|
||
</span>
|
||
</td>
|
||
);
|
||
})}
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function UnimplementedNote({ items }: { items: string[] }) {
|
||
if (!items || items.length === 0) return null;
|
||
return (
|
||
<Card title="未建模约束(如实标注)" icon="info">
|
||
<ul className="hint" style={{ margin: 0, paddingLeft: 18 }}>
|
||
{items.map((u, i) => (
|
||
<li key={i}>
|
||
<RichText text={u} />
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</Card>
|
||
);
|
||
}
|