Files
qlib/frontend/web/components/ui.tsx
T
Simon 23972e7063 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 逐类验证归档页)。
2026-09-20 07:31:04 +08:00

456 lines
12 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 设计系统 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>
);
}