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:
Simon
2026-09-20 07:31:04 +08:00
parent 7e15b7251e
commit 23972e7063
112 changed files with 17908 additions and 3893 deletions
+49
View File
@@ -14,6 +14,33 @@ export async function apiGet<T>(path: string): Promise<T> {
return (await resp.json()) as T;
}
/**
* GET 并**同时拿到响应头**。
*
* 为什么需要:列表接口用 `X-Total-Count` 暴露「过滤后总数」,而 body 必须保持
* `list[...]` 形状(既有页面依赖)。只读 body 的 `apiGet` 拿不到这个信息,
* 会让前端无法区分「这就是全部」和「只是最近 N 条」——那正是 §7 要避免的静默截断。
*/
export async function apiGetWithHeaders<T>(
path: string
): Promise<{ data: T; headers: Headers }> {
const resp = await fetch(`${BASE}${path}`);
if (!resp.ok) throw new Error(`GET ${path} → ${resp.status}: ${await resp.text()}`);
return { data: (await resp.json()) as T, headers: resp.headers };
}
/**
* GET 原始文本。
*
* 导出归档时用:直接落盘**后端存储的那份 JSON**,而不是前端再 `JSON.stringify`
* 一遍(后者会因字段顺序/缩进差异而与归档原文不同,且多一次内存拷贝)。
*/
export async function apiGetText(path: string): Promise<string> {
const resp = await fetch(`${BASE}${path}`);
if (!resp.ok) throw new Error(`GET ${path} → ${resp.status}: ${await resp.text()}`);
return await resp.text();
}
export async function apiPost<T>(path: string, body: unknown): Promise<T> {
const resp = await fetch(`${BASE}${path}`, {
method: "POST",
@@ -27,6 +54,28 @@ export async function apiPost<T>(path: string, body: unknown): Promise<T> {
return (await resp.json()) as T;
}
export async function apiPut<T>(path: string, body: unknown): Promise<T> {
const resp = await fetch(`${BASE}${path}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!resp.ok) {
const text = await resp.text();
throw new Error(`PUT ${path} → ${resp.status}: ${text.slice(0, 300)}`);
}
return (await resp.json()) as T;
}
export async function apiDelete<T = { deleted?: string }>(path: string): Promise<T> {
const resp = await fetch(`${BASE}${path}`, { method: "DELETE" });
if (!resp.ok) {
const text = await resp.text();
throw new Error(`DELETE ${path} → ${resp.status}: ${text.slice(0, 300)}`);
}
return (await resp.json().catch(() => ({}))) as T;
}
export interface ApiError {
status: number;
message: string;
+62
View File
@@ -0,0 +1,62 @@
/**
* 归档结果的**纯派生逻辑**(无 React、无 "use client")。
*
* 为什么单独放 `lib/`:归档详情页是 Server Component(只读内容必须能 SSR 直出),
* 而 Server Component **不能调用** `"use client"` 模块里导出的普通函数
* (`Attempted to call X() from the server but X is on the client` → 整页 500,
* 实测踩过一次)。所以「按 kind 派生展示口径」这类纯函数必须待在非客户端模块里,
* 客户端组件与服务端组件都从 `lib/` 引用。
*/
import type {
BacktestResult,
ExperimentDetail,
FactorTestReport,
SelectionResult,
} from "@/lib/types";
/** 归档结果的联合类型(结构与 `kind` 一一对应)。 */
export type AnyArchiveResult =
| BacktestResult
| FactorTestReport
| SelectionResult;
/** 「带回测结果」的归档详情:对比视图与指标表只接受这一种。 */
export type BacktestDetail = ExperimentDetail & { result: BacktestResult };
/** 判断某条归档详情是否带回测结果(对比/指标只能用在回测归档上)。 */
export function isBacktestDetail(d: ExperimentDetail): d is BacktestDetail {
return d.kind === "backtest" && !!d.result && "equity_curve" in d.result;
}
/**
* 归档结果的**关键字段计数**,按 `kind` 给不同口径。
*
* 用回测字段(净值/曲线/成交)去数因子测试或选股结果会得到 0 或 undefined,
* 显示成「净值 0 点」属于误导;这里按类型分别统计,未知类型返回 null(不编造)。
*/
export function archivedFieldSummary(
kind: string,
result: AnyArchiveResult | null
): string | null {
if (!result) return null;
if (kind === "backtest") {
const r = result as BacktestResult;
return `结果字段:净值 ${r.equity_curve?.length ?? 0} 点 · 个股曲线 ${
r.symbol_curves?.length ?? 0
} 条 · 成交 ${r.trades?.length ?? 0} 笔`;
}
if (kind === "factor_test") {
const r = result as FactorTestReport;
return `结果字段:因子 ${r.factor_name} · 样本 ${r.sample_days} 日 · 分层 ${
r.quantile_returns?.length ?? 0
} 层`;
}
if (kind === "selection") {
const r = result as SelectionResult;
return `结果字段:as_of ${r.as_of_date} · 候选 ${
r.candidates?.length ?? 0
} 只 · 评估 ${r.statistics?.evaluated ?? 0} 只`;
}
return null;
}
+35 -2
View File
@@ -14,25 +14,48 @@ export interface JobSubmit {
export interface JobStatusResp {
job_id: string;
status: string;
/** 执行阶段(data_loading / backtesting / analysis…)—— 用于给用户真实进度而非假进度条 */
stage?: string | null;
/** 归档后的实验 id(后端在 Job 完成时写入) */
experiment_id?: string | null;
result?: unknown;
error?: string | null;
spec?: Record<string, unknown>;
started_at?: string | null;
finished_at?: string | null;
}
export function submitJob(spec: ResearchSpec): Promise<JobSubmit> {
return apiPost<JobSubmit>("/jobs", spec);
}
/** 任务终态(success / failed / cancelled / timeout)与结果 */
export interface JobOutcome<T> {
status: string;
result: T | null;
error?: string;
/** 归档实验 id:结果出来后前端可直接给出「去对比」入口 */
experimentId?: string | null;
}
/** 轮询直到 success / failed / cancelled,或超时(默认 10 分钟)。 */
export async function waitJob<T>(
jobId: string,
timeoutMs = 600_000,
): Promise<{ status: string; result: T | null; error?: string }> {
onStage?: (info: { stage: string; elapsedMs: number }) => void,
): Promise<JobOutcome<T>> {
const deadline = Date.now() + timeoutMs;
const t0 = Date.now();
while (Date.now() < deadline) {
const job = await apiGet<JobStatusResp>(`/jobs/${jobId}`);
// 阶段来自后端真实执行状态,避免前端用假进度条假装在跑
if (onStage && job.stage) onStage({ stage: job.stage, elapsedMs: Date.now() - t0 });
if (job.status === "success") {
return { status: job.status, result: (job.result as T) ?? null };
return {
status: job.status,
result: (job.result as T) ?? null,
experimentId: job.experiment_id ?? null,
};
}
if (job.status === "failed" || job.status === "cancelled") {
return { status: job.status, result: null, error: job.error ?? undefined };
@@ -41,3 +64,13 @@ export async function waitJob<T>(
}
return { status: "timeout", result: null, error: "等待结果超时,请稍后在「实验」页查看归档" };
}
/** 阶段中文名(后端 stage 枚举 → 用户可读) */
export const STAGE_LABEL: Record<string, string> = {
queued: "排队中",
data_loading: "加载行情与因子数据",
selection: "逐择股日选股",
backtesting: "撮合与净值结算",
analysis: "汇总指标与曲线",
done: "完成",
};
+38
View File
@@ -0,0 +1,38 @@
/**
* 展示用文案映射(跨页面共用的纯函数)。
*
* 放在独立模块的理由:同一个口径(复权模式、补位策略…)在回测页、归档页、策略库
* 都要显示一致的措辞,各写一份必然出现「后复权 / hfq / 后复权口径」三种叫法。
*/
/** 复权模式 → 中文口径名(未知值**原样返回**,不猜测) */
export function adjustLabel(mode?: string | null): string {
if (!mode) return "未记录";
if (mode === "hfq") return "后复权";
if (mode === "qfq") return "前复权";
if (mode === "none") return "不复权";
return mode;
}
/** 调仓频率(`rebalance` 字段)→ 中文 */
export function rebalanceLabel(v?: string | null): string {
if (!v) return "未记录";
if (v === "monthly") return "月度";
if (v === "weekly") return "周度";
if (v === "daily") return "每日";
return v;
}
/** 归档 kind → 中文 */
export function experimentKindLabel(kind?: string | null): string {
switch (kind) {
case "backtest":
return "回测";
case "factor_test":
return "因子测试";
case "selection":
return "选股";
default:
return kind ?? "未知";
}
}
+94
View File
@@ -0,0 +1,94 @@
"use client";
/**
* 策略说明的获取钩子。
*
* 说明/公式由后端 `describe_strategy` 从 spec **真实推导**(不是前端拼字符串):
* 这样「页面显示的公式」与「引擎实际执行的规则」只有一个来源,
* 不会出现文案与实现漂移(本平台最怕的问题)。
*
* 两个入口:
* - `useStrategyDoc(params)`:未保存的参数也能实时预览(POST /strategies/describe),
* 带去抖,避免每次按键都请求。
* - `useStrategyDocById(id)`:已保存策略(GET /strategies/{id}/describe)。
*/
import { useEffect, useMemo, useRef, useState } from "react";
import { apiGet, apiPost } from "@/lib/api";
import type { StrategyDoc } from "@/lib/types";
import { paramsToSpec, type StrategyParams } from "@/components/StrategyParamsForm";
export interface DocState {
doc: StrategyDoc | null;
loading: boolean;
error: string;
}
const EMPTY: DocState = { doc: null, loading: false, error: "" };
/** 未保存参数 → 说明(去抖 500ms) */
export function useStrategyDoc(params: StrategyParams | null, enabled = true): DocState {
const [state, setState] = useState<DocState>(EMPTY);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
// 只依赖会改变说明的字段,避免改「初始资金」也重新请求
const key = useMemo(() => {
if (!params) return "";
return JSON.stringify({
f: params.factors,
a: params.priceAdjustment,
n: params.topN,
x: params.holdX,
m: params.mMonths,
y: params.yMonths,
r: params.rebalance,
fp: params.fillPolicy,
c: params.conditions,
s: params.excludeSt,
l: params.minListingDays,
cost: [params.commission, params.stamp, params.slippage, params.minCommission],
});
}, [params]);
useEffect(() => {
if (!params || !enabled || !key) {
setState(EMPTY);
return;
}
let alive = true;
if (timer.current) clearTimeout(timer.current);
setState((s) => ({ ...s, loading: true }));
timer.current = setTimeout(() => {
apiPost<StrategyDoc>("/strategies/describe", paramsToSpec(params))
.then((doc) => alive && setState({ doc, loading: false, error: "" }))
.catch((e: Error) => alive && setState({ doc: null, loading: false, error: e.message }));
}, 500);
return () => {
alive = false;
if (timer.current) clearTimeout(timer.current);
};
// key 已覆盖所有影响说明的字段
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key, enabled]);
return state;
}
/** 已保存策略 → 说明 */
export function useStrategyDocById(id: string | null): DocState {
const [state, setState] = useState<DocState>(EMPTY);
useEffect(() => {
if (!id) {
setState(EMPTY);
return;
}
let alive = true;
setState({ doc: null, loading: true, error: "" });
apiGet<StrategyDoc>(`/strategies/${encodeURIComponent(id)}/describe`)
.then((doc) => alive && setState({ doc, loading: false, error: "" }))
.catch((e: Error) => alive && setState({ doc: null, loading: false, error: e.message }));
return () => {
alive = false;
};
}, [id]);
return state;
}
+204
View File
@@ -0,0 +1,204 @@
"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>
);
}
+165 -4
View File
@@ -22,6 +22,13 @@ export interface FactorMeta {
direction: "higher_is_better" | "lower_is_better";
}
export interface ResearchCondition {
field: string;
op: "gt" | "gte" | "lt" | "lte" | "eq" | "ne" | "in" | "not_in";
value?: number | string | (number | string)[] | null;
ref?: string | null;
}
export interface ResearchSpec {
type: "factor_test" | "backtest";
universe: {
@@ -29,14 +36,32 @@ export interface ResearchSpec {
min_listing_days?: number;
symbols?: string[];
};
price_adjustment?: "none" | "qfq";
/** 行情口径:none 不复权 / qfq 前复权 / hfq 后复权(按 adjust_factor 折算) */
price_adjustment?: "none" | "qfq" | "hfq";
factors: { name: string; weight: number }[];
selection: { top_n: number };
/** 选股过滤条件(AND,可选):universe 之后、因子排序之前执行 */
conditions?: ResearchCondition[];
selection: {
/** n:候选池大小(择股条件选出来的股数) */
top_n: number;
/** x:实际持仓数,必须 ≤ top_n;省略 = top_n */
hold_top_x?: number | null;
/** true:从 n 名之外替补补足;false:不引入计划外标的 */
allow_substitute?: boolean;
/** true:买不进(涨停/停牌)时顺延到之后首个可成交日的收盘买入 */
defer_buy?: boolean;
};
rebalance: "weekly" | "monthly";
/** m:择股间隔(月);省略 = 每次调仓都择股 */
selection_interval_months?: number | null;
/** y:调仓间隔(月);省略 = m */
rebalance_interval_months?: number | null;
costs?: {
commission_rate?: number;
stamp_tax_rate?: number;
slippage_rate?: number;
/** 单笔最低佣金(元);0 = 不启用 */
min_commission?: number;
benchmark?: string;
};
portfolio?: { weighting?: string; max_position_pct?: number | null; max_industry_weight_pct?: number | null };
@@ -64,17 +89,65 @@ export interface BacktestSummary {
avg_turnover_pct: number;
}
/** 买卖点标注:value 由调用方按曲线口径换算到同一坐标 */
export interface MarkPoint {
date: string;
value: number;
kind: "BUY" | "SELL";
label?: string;
}
/** 交易意图与成交记录(Signal ↔ Fill,v3 §20.3)。signal 为空字符串表示组合级提示。 */
export interface ActionRecord {
date: string;
symbol: string;
/** 股票名称(后端填充,展示用;可能为 null —— 不要编造) */
name?: string | null;
signal: "BUY" | "SELL";
filled: boolean;
reject_reason?: string | null;
price?: number | null;
}
/** 个股收益率曲线 + 该股买卖点标注 */
export interface SymbolCurve {
symbol: string;
/** 股票名称(后端填充,展示用;可能为 null) */
name?: string | null;
points: CurvePoint[];
marks: ActionRecord[];
final_return_pct: number;
}
export interface BacktestResult {
summary: BacktestSummary;
equity_curve: CurvePoint[];
drawdown: CurvePoint[];
monthly_returns: { year: number; month: number; return_pct: number }[];
yearly_returns: { year: number; return_pct: number }[];
positions: { date: string; symbol: string; weight: number }[];
trades: { entry_date: string; exit_date: string; symbol: string; entry_price?: number; exit_price?: number; return_pct: number }[];
positions: { date: string; symbol: string; name?: string | null; weight: number }[];
trades: { entry_date: string; exit_date: string; symbol: string; name?: string | null; entry_price?: number; exit_price?: number; return_pct: number }[];
selection_history?: { date: string; symbol: string; name?: string | null; rank: number; score: number }[];
signal_history?: ActionRecord[];
fills?: ActionRecord[];
symbol_curves?: SymbolCurve[];
turnover_pct: number;
unimplemented: string[];
config_snapshot: Record<string, unknown>;
/** 归档元数据:曲线存了多少/共多少/是否因体积预算被裁剪(后端归档时写入) */
archive_meta?: {
curves_stored?: number;
curves_total?: number;
truncated?: boolean;
/** 预算(字符数口径,历史沿用键名) */
budget_chars?: number;
/** 预算实际执行口径:UTF-8 字节(MEDIUMTEXT 按字节计) */
budget_bytes?: number;
/** 裁剪后仍超预算(极端情况:连曲线都放不下) */
over_budget?: boolean;
result_chars?: number;
result_bytes?: number;
} | null;
}
export interface FactorTestReport {
@@ -115,6 +188,8 @@ export interface SelectionQuery {
export interface SelectionCandidate {
symbol: string;
/** 股票名称(后端填充,展示用;可能为 null) */
name?: string | null;
rank: number;
score: number;
factor_values: Record<string, number>;
@@ -228,3 +303,89 @@ export interface ChartResult {
fills: ChartMarker[];
factor_values?: Record<string, ChartSeriesPoint[]>;
}
/* ---- 策略库(M8.3,与 domain/entities/strategy.py 对应) ---- */
/**
* 命名策略:完整策略定义(universe + factor + selection + rebalance + costs + portfolio),
* 不含回测区间 period 与初始资金 —— 回测时补全后展开为 ResearchSpec。
* 后端以 JSON 整体持久化,因此新增字段无需迁移即可保存。
*/
export interface StrategyDefinition {
id?: string;
name: string;
/** 一句话说明(必填):说清这个策略做什么。为空时后端会用 describe_strategy 自动填充 */
description: string;
spec_type?: "backtest" | "factor_test";
universe?: { exclude_st?: boolean; min_listing_days?: number; symbols?: string[] };
price_adjustment?: "none" | "qfq" | "hfq";
factors: { name: string; weight: number }[];
selection?: {
top_n?: number;
hold_top_x?: number | null;
allow_substitute?: boolean;
defer_buy?: boolean;
};
rebalance?: "weekly" | "monthly";
conditions?: ResearchCondition[];
selection_interval_months?: number | null;
rebalance_interval_months?: number | null;
costs?: {
commission_rate?: number;
stamp_tax_rate?: number;
slippage_rate?: number;
min_commission?: number;
};
portfolio?: Record<string, unknown>;
version?: string;
created_at?: string | null;
}
/**
* 策略说明(后端 `describe_strategy` 依 spec 真实推导,前端只渲染不重算):
* summary=一句话功能说明,formula=计算公式,steps=执行步骤,warnings=如实标注的注意事项。
*/
export interface StrategyDoc {
summary: string;
formula: string;
steps: string[];
warnings: string[];
}
/* ------------------------------------------------------------------ */
/* Experiment 归档 */
/* ------------------------------------------------------------------ */
/** 归档列表项(`GET /api/experiments`);详情在此基础上多 `spec` 与 `result`。 */
export interface ExperimentMeta {
id: string;
kind: string;
factors: string[];
period?: [string, string] | null;
rebalance?: string | null;
top_n?: number | null;
summary_text?: string | null;
code_version?: string | null;
/** 数据快照指纹(归档时写入;2026-09 之前的老归档可能为空) */
data_version?: string | null;
/** 归档体积(result_json 字符数),特征可用于判断是否被裁剪 */
result_bytes?: number | null;
/** 产生该归档的作业 id(同步接口归档时为 null) */
job_id?: string | null;
created_at?: string;
}
export interface ExperimentDetail extends ExperimentMeta {
/** 归档时的完整 ResearchSpec(复现依据) */
spec?: Record<string, unknown> | null;
/**
* 完整结果,**结构随 `kind` 变化**(此前写死为 BacktestResult 等于谎报结构,
* 导致非回测归档在详情页按回测字段取用而整页 500):
* backtest → BacktestResult;factor_test → FactorTestReport;selection → SelectionResult。
* 也可能是 null(历史版本归档 / 结果为空)。
*/
result: BacktestResult | FactorTestReport | SelectionResult | null;
/** 归档元数据(仅回测结果带);非回测归档为 undefined */
archive_meta?: BacktestResult["archive_meta"];
}