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 逐类验证归档页)。
This commit is contained in:
@@ -4,8 +4,10 @@
|
||||
* 评分模式(多因子加权 TopN)与条件模式(结构化条件 AND);
|
||||
* 支持当前/历史 as_of;结果落库可查(selection_id),右侧展示最近选股历史。
|
||||
*/
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { apiGet, apiPost } from "@/lib/api";
|
||||
import { SymbolLink } from "@/lib/symbols";
|
||||
import { waitJob } from "@/lib/jobs";
|
||||
import type {
|
||||
FactorMeta,
|
||||
@@ -39,6 +41,22 @@ const FIELD_OPTIONS: { value: string; label: string; kind: "num" | "str" | "ref"
|
||||
];
|
||||
const OPS = ["gt", "gte", "lt", "lte", "eq", "ne", "in"];
|
||||
|
||||
/**
|
||||
* 读取后端 payload 里的 name(若存在)。
|
||||
*
|
||||
* 为什么用宽松读取而非直接 `c.name`:`lib/types.ts` 的 SelectionCandidate 目前没有
|
||||
* 声明 name 字段(该文件由其他改动维护),直接访问会类型报错;但后端可能已经返回更权威的
|
||||
* 中文名。这里只做「有且非空则用」的读取,取不到就交给 SymbolLink 渲染占位「—」,
|
||||
* 绝不编造名称(AGENT §24)。
|
||||
*/
|
||||
function payloadName(row: unknown): string | undefined {
|
||||
if (row && typeof row === "object" && "name" in row) {
|
||||
const n = (row as { name?: unknown }).name;
|
||||
if (typeof n === "string" && n.trim()) return n;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function emptyCondition(): SelectionCondition {
|
||||
return { field: "static.industry", op: "eq", value: "白酒" };
|
||||
}
|
||||
@@ -53,6 +71,14 @@ export default function SelectionPage() {
|
||||
const [conds, setConds] = useState<SelectionCondition[]>([emptyCondition()]);
|
||||
const [result, setResult] = useState<SelectionResult | null>(null);
|
||||
const [selectionId, setSelectionId] = useState("");
|
||||
/**
|
||||
* 已落库的 selection_id —— 只有「同步选股(SelectionRun.selection_id)」和「从历史列表打开(id)」两条路径才有。
|
||||
*
|
||||
* 为什么不能直接用 selectionId:异步路径(POST /selections/jobs)拿到的是 JOB id,
|
||||
* 后端不会为它写 selection 记录(backend/app/api/selections.py 只在同步接口里 selection_repo.save),
|
||||
* 拿它拼 /backtest?from_selection=<JOB id> 会让接收端 GET /api/selections/{id} 404 → 坏链接。
|
||||
*/
|
||||
const [savedSelectionId, setSavedSelectionId] = useState("");
|
||||
const [running, setRunning] = useState(false);
|
||||
const [asyncBusy, setAsyncBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
@@ -101,9 +127,12 @@ export default function SelectionPage() {
|
||||
setRunning(true);
|
||||
setError("");
|
||||
setResult(null);
|
||||
// 新一轮结果尚未落库/尚未返回:先清掉旧的 selection_id,避免「结果 A + 直通到记录 B」的错配链接
|
||||
setSavedSelectionId("");
|
||||
try {
|
||||
const run = await apiPost<SelectionRun>("/selections", buildQuery());
|
||||
setSelectionId(run.selection_id);
|
||||
setSavedSelectionId(run.selection_id);
|
||||
setResult(run.result);
|
||||
apiGet<SelectionMeta[]>("/selections?limit=8").then(setHistory).catch(() => undefined);
|
||||
} catch (e) {
|
||||
@@ -118,6 +147,8 @@ export default function SelectionPage() {
|
||||
setAsyncBusy(true);
|
||||
setError("");
|
||||
setResult(null);
|
||||
// 同上:异步任务不落库选股记录,直通回测必须隐藏按钮(不给坏链接)
|
||||
setSavedSelectionId("");
|
||||
try {
|
||||
const { job_id } = await apiPost<{ job_id: string }>("/selections/jobs", buildQuery());
|
||||
const out = await waitJob<SelectionResult>(job_id);
|
||||
@@ -140,6 +171,7 @@ export default function SelectionPage() {
|
||||
try {
|
||||
const detail = await apiGet<SelectionResult>(`/selections/${id}`);
|
||||
setSelectionId(id);
|
||||
setSavedSelectionId(id);
|
||||
setResult(detail);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
@@ -319,7 +351,7 @@ export default function SelectionPage() {
|
||||
|
||||
{!result && !running && history === null ? <SkeletonLines n={4} /> : null}
|
||||
|
||||
{result ? <ResultView result={result} /> : null}
|
||||
{result ? <ResultView result={result} selectionId={savedSelectionId} /> : null}
|
||||
|
||||
{history && history.length > 0 ? (
|
||||
<Card title="最近选股记录" icon="archive">
|
||||
@@ -343,7 +375,7 @@ export default function SelectionPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function ResultView({ result }: { result: SelectionResult }) {
|
||||
function ResultView({ result, selectionId }: { result: SelectionResult; selectionId: string }) {
|
||||
const s = result.statistics;
|
||||
if (result.candidates.length === 0) {
|
||||
return (
|
||||
@@ -365,15 +397,30 @@ function ResultView({ result }: { result: SelectionResult }) {
|
||||
<Pill>范围 {s.universe_size}</Pill>
|
||||
<Pill>评分 {s.evaluated}</Pill>
|
||||
<Pill tone="pos">选出 {s.selected}</Pill>
|
||||
{/* 选股 → 回测直通:只带「已落库」的 selection_id;没有它(如异步任务未落库)就不渲染按钮,避免坏链接 */}
|
||||
{selectionId ? (
|
||||
<Link
|
||||
href={`/backtest?from_selection=${encodeURIComponent(selectionId)}`}
|
||||
className="btn btn--sm"
|
||||
title="把这次的规则(条件/因子/TopN/口径)预填到回测页;回测会在每个择股日按同一规则重新选股,不是固定持有这批股票"
|
||||
>
|
||||
<span>按此条件回测</span>
|
||||
</Link>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{selectionId ? (
|
||||
<div className="hint" style={{ marginBottom: 10 }}>
|
||||
按此条件回测:把这次的规则(条件 / 因子 / TopN / 口径)预填到回测页;回测会在每个择股日按同一规则重新选股,<b>不是固定持有这批股票</b>。
|
||||
</div>
|
||||
) : null}
|
||||
<div className="table-wrap">
|
||||
<table className="tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>代码</th>
|
||||
<th>代码 / 名称</th>
|
||||
<th>得分</th>
|
||||
<th>因子值</th>
|
||||
<th>入选理由</th>
|
||||
@@ -383,8 +430,9 @@ function ResultView({ result }: { result: SelectionResult }) {
|
||||
{result.candidates.map((c) => (
|
||||
<tr key={c.symbol}>
|
||||
<td className="mono dim">{c.rank}</td>
|
||||
<td className="mono">
|
||||
<b>{c.symbol}</b>
|
||||
<td>
|
||||
{/* 后端 payload 若已带 name 就用它(更权威),否则回落到全局名称表 */}
|
||||
<SymbolLink symbol={c.symbol} name={payloadName(c)} />
|
||||
</td>
|
||||
<td className="mono">{c.score.toFixed(4)}</td>
|
||||
<td style={{ fontSize: 12 }}>
|
||||
|
||||
Reference in New Issue
Block a user