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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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: "完成",
|
||||
};
|
||||
|
||||
@@ -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 ?? "未知";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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"];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user