汇总三轮未提交的开发(每轮均在本机 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 逐类验证归档页)。
207 lines
5.6 KiB
TypeScript
207 lines
5.6 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useRef, useState } from "react";
|
||
import { apiGet } from "@/lib/api";
|
||
import { SymbolLink } from "@/lib/symbols";
|
||
import type { Stock } from "@/lib/types";
|
||
import {
|
||
PageHeader,
|
||
Card,
|
||
Banner,
|
||
Empty,
|
||
Pill,
|
||
SkeletonLines,
|
||
SearchBox,
|
||
} from "@/components/ui";
|
||
import { Icon } from "@/components/icons";
|
||
|
||
const LIMIT = 300;
|
||
|
||
function marketOf(symbol: string): string {
|
||
return symbol.slice(-2);
|
||
}
|
||
|
||
function statusView(s: Stock) {
|
||
switch (s.status) {
|
||
case "L":
|
||
return (
|
||
<Pill tone="pos">
|
||
上市
|
||
</Pill>
|
||
);
|
||
case "D":
|
||
return <Pill tone="neg">退市</Pill>;
|
||
case "P":
|
||
return <Pill tone="warn">暂停</Pill>;
|
||
default:
|
||
return <Pill>{s.status}</Pill>;
|
||
}
|
||
}
|
||
|
||
export default function StocksPage() {
|
||
const [stocks, setStocks] = useState<Stock[]>([]);
|
||
const [q, setQ] = useState("");
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState("");
|
||
const [reloadKey, setReloadKey] = useState(0);
|
||
const first = useRef(true);
|
||
|
||
const load = useCallback(async () => {
|
||
setError("");
|
||
try {
|
||
const list = await apiGet<Stock[]>(
|
||
`/stocks?limit=${LIMIT}&q=${encodeURIComponent(q)}`,
|
||
);
|
||
setStocks(list);
|
||
} catch (e) {
|
||
setError((e as Error).message);
|
||
setStocks([]);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [q]);
|
||
|
||
// 输入防抖 250ms 后再请求
|
||
useEffect(() => {
|
||
if (first.current) {
|
||
first.current = false;
|
||
void load();
|
||
return;
|
||
}
|
||
setLoading(true);
|
||
const t = window.setTimeout(() => void load(), 250);
|
||
return () => window.clearTimeout(t);
|
||
}, [q, reloadKey, load]);
|
||
|
||
return (
|
||
<>
|
||
<PageHeader
|
||
title="股票池"
|
||
sub="本地股票基础信息(Tushare 同步)。支持按代码 / 名称模糊搜索,最多展示 300 条。"
|
||
actions={
|
||
<>
|
||
<Pill tone="accent" icon="candles">
|
||
共 {stocks.length} 条
|
||
</Pill>
|
||
<button
|
||
type="button"
|
||
className="btn btn--sm"
|
||
aria-label="刷新列表"
|
||
onClick={() => {
|
||
setLoading(true);
|
||
setReloadKey((k) => k + 1);
|
||
}}
|
||
>
|
||
<Icon name="refresh" size={14} />
|
||
刷新
|
||
</button>
|
||
</>
|
||
}
|
||
/>
|
||
|
||
{error ? (
|
||
<Banner tone="error">加载失败:{error}</Banner>
|
||
) : null}
|
||
|
||
<div className="row" style={{ marginBottom: 14 }}>
|
||
<SearchBox
|
||
value={q}
|
||
onChange={setQ}
|
||
onClear={() => setQ("")}
|
||
placeholder="按代码 / 名称搜索,如 600519 或 茅台"
|
||
label="搜索股票"
|
||
/>
|
||
</div>
|
||
|
||
<Card flush>
|
||
<div className="table-wrap">
|
||
<table className="tbl">
|
||
<thead>
|
||
<tr>
|
||
<th>代码 / 名称</th>
|
||
<th>市场</th>
|
||
<th>行业</th>
|
||
<th>地区</th>
|
||
<th>上市日</th>
|
||
<th>状态</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{stocks.map((s) => (
|
||
<tr key={s.symbol}>
|
||
{/* 代码列直接合并名称(后端 payload 的 name 更权威),避免与独立名称列重复展示 */}
|
||
<td><SymbolLink symbol={s.symbol} name={s.name} /></td>
|
||
<td>
|
||
<span className="tag tag--mono">{marketOf(s.symbol)}</span>
|
||
</td>
|
||
<td>{s.industry ? <span className="tag">{s.industry}</span> : <span className="hint">—</span>}</td>
|
||
<td className="hint">{s.area ?? "—"}</td>
|
||
<td className="cell-mono">{s.list_date}</td>
|
||
<td>{statusView(s)}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{loading && !error && stocks.length === 0 ? (
|
||
<div style={{ padding: 18 }}>
|
||
<SkeletonLines n={6} />
|
||
</div>
|
||
) : null}
|
||
{!loading && !error && stocks.length === 0 ? (
|
||
<Empty
|
||
icon="search"
|
||
title={q ? "没有匹配的股票" : "股票池为空"}
|
||
hint={
|
||
q
|
||
? `「${q}」没有命中。试试更短的代码或名称关键词。`
|
||
: "请先通过后端同步基础信息:uv run python -m app.cli.sync basic"
|
||
}
|
||
/>
|
||
) : null}
|
||
{stocks.length > 0 ? (
|
||
<div
|
||
className="hint"
|
||
style={{
|
||
padding: "8px 16px",
|
||
borderTop: "1px solid var(--line)",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 14,
|
||
}}
|
||
>
|
||
<span>显示前 {stocks.length} 条(接口上限 {LIMIT})</span>
|
||
<span className="row" style={{ gap: 4 }}>
|
||
<Dot color="var(--pos)" />
|
||
上市
|
||
</span>
|
||
<span className="row" style={{ gap: 4 }}>
|
||
<Dot color="var(--warn)" />
|
||
暂停
|
||
</span>
|
||
<span className="row" style={{ gap: 4 }}>
|
||
<Dot color="var(--neg)" />
|
||
退市
|
||
</span>
|
||
</div>
|
||
) : null}
|
||
</Card>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function Dot({ color }: { color: string }) {
|
||
return (
|
||
<span
|
||
aria-hidden="true"
|
||
style={{
|
||
display: "inline-block",
|
||
width: 8,
|
||
height: 8,
|
||
borderRadius: "50%",
|
||
background: color,
|
||
}}
|
||
/>
|
||
);
|
||
}
|