Files
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

204 lines
6.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
/**
* 股票代码 ↔ 名称 的统一层。
*
* 需求(本轮):**任何出现股票代码的地方都必须同时显示股票名称,且可点击**
* 跳到个股页(基本信息 + 股价走势图)。
*
* 实现选择:不做 N+1 请求,而是启动时一次性拉全市场 `symbol → name` 映射
* (`GET /api/stocks/names`,约 6 千条),并用全局 Provider 缓存;
* 页面渲染只做 Map 查询。这样每个表格单元格都是纯查表,零额外网络开销。
*
* 降级策略(AGENT §24:不许假装):接口不可用时静默留空名称,但**不隐藏代码**,
* 也不阻塞页面;名称缺失时渲染「—」并在 title 里说明「名称未取到」,
* 避免出现「看起来有名字但其实是代码」的假象。
*/
import Link from "next/link";
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { apiGet } from "@/lib/api";
import type { Stock } from "@/lib/types";
type NameMap = Record<string, string>;
interface Ctx {
names: NameMap;
ready: boolean;
failed: boolean;
reload: () => void;
}
const SymbolCtx = createContext<Ctx>({ names: {}, ready: false, failed: false, reload: () => {} });
/** 模块级缓存:Provider 重挂载(如整页刷新)时不必重新拉取 */
let cache: NameMap | null = null;
export function SymbolNamesProvider({ children }: { children: React.ReactNode }) {
const [names, setNames] = useState<NameMap>(cache ?? {});
const [ready, setReady] = useState<boolean>(cache !== null);
const [failed, setFailed] = useState(false);
const [nonce, setNonce] = useState(0);
useEffect(() => {
let alive = true;
const run = async () => {
try {
const m = await apiGet<NameMap>("/stocks/names");
if (!alive) return;
cache = m;
setNames(m);
setReady(true);
setFailed(false);
} catch {
// 兜底:老接口(返回股票列表)。注意 `/stocks` 单页上限 500,
// 若只取首页会**静默漏掉 500 名之后的股票**(出现大量「—」而不报错),
// 因此这里翻页取全量,翻页失败就如实置 failed 而不是给出残缺映射。
try {
const m: NameMap = {};
for (let offset = 0; offset < 20_000; offset += 500) {
const rows = await apiGet<Stock[]>(`/stocks?limit=500&offset=${offset}`);
if (!alive) return;
for (const r of rows) if (r.name) m[r.symbol] = r.name;
if (rows.length < 500) break;
}
if (!Object.keys(m).length) throw new Error("股票列表为空");
cache = m;
setNames(m);
setReady(true);
setFailed(false);
} catch {
if (alive) {
setReady(true);
setFailed(true);
}
}
}
};
void run();
return () => {
alive = false;
};
}, [nonce]);
const reload = useCallback(() => {
cache = null;
setNonce((n) => n + 1);
}, []);
const value = useMemo(() => ({ names, ready, failed, reload }), [names, ready, failed, reload]);
return <SymbolCtx.Provider value={value}>{children}</SymbolCtx.Provider>;
}
export function useSymbolNames(): Ctx {
return useContext(SymbolCtx);
}
/** 取名称;未就绪或查不到都返回 null(调用方据此渲染占位) */
export function useSymbolName(symbol?: string | null): string | null {
const { names } = useContext(SymbolCtx);
if (!symbol) return null;
return names[symbol] ?? null;
}
export interface SymbolLinkProps {
symbol: string;
/** 优先使用调用方已有的名称(后端 payload 里带的更权威) */
name?: string | null;
/** 名称在前(个股页标题风格)还是代码在前(表格列风格) */
order?: "code-first" | "name-first";
/** 只显示名称(表格里已有独立代码列时用) */
nameOnly?: boolean;
/** 只显示代码(极少用:明确不需要名称时请说明理由) */
codeOnly?: boolean;
className?: string;
/** 不跳转(例如已经在个股页里) */
plain?: boolean;
}
/**
* 股票代码 + 名称,点击进入个股页。
*
* 用 `<Link>` 而非 `<a>`:保留 Next 客户端路由(不整页刷新)。
*/
export function SymbolLink({
symbol,
name,
order = "code-first",
nameOnly = false,
codeOnly = false,
className,
plain = false,
}: SymbolLinkProps) {
const resolved = useSymbolName(symbol);
const real = name ?? resolved;
const label = real || null;
const body = (
<>
{!nameOnly && <span className="sym-code">{symbol}</span>}
{!codeOnly &&
(label ? (
<span className="sym-name">{label}</span>
) : (
<span className="sym-name sym-name--missing" title="名称未取到(股票名称接口不可用或该代码不在股票池)">
—
</span>
))}
</>
);
const cls = ["symlink", className].filter(Boolean).join(" ");
const title = label ? `${symbol} ${label} · 查看基本信息与股价走势图` : `${symbol} · 查看基本信息与股价走势图`;
if (plain) {
return (
<span className={cls} title={label ? `${symbol} ${label}` : symbol}>
{body}
</span>
);
}
return (
<Link
href={`/stocks/${encodeURIComponent(symbol)}`}
className={cls}
title={title}
aria-label={label ? `${symbol} ${label},查看基本信息与走势图` : `${symbol},查看基本信息与走势图`}
style={{ flexDirection: order === "name-first" ? "row-reverse" : undefined }}
>
{body}
</Link>
);
}
/** 纯文本「代码 名称」(不可点击场景,如打印/图例) */
export function SymbolText({
symbol,
name,
order = "code-first",
}: {
symbol: string;
name?: string | null;
order?: "code-first" | "name-first";
}) {
const resolved = useSymbolName(symbol);
const label = name ?? resolved;
const code = <span className="sym-code">{symbol}</span>;
const nm = label ? <span className="sym-name">{label}</span> : null;
return (
<span className="symlink" title={label ? `${symbol} ${label}` : symbol}>
{order === "name-first" ? (
<>
{nm}
{code}
</>
) : (
<>
{code}
{nm}
</>
)}
</span>
);
}