feat(web): 字段库页 + 界面单位换算 + 因子目录参数化 + 选股策略条件表单

字段库与单位:
- `/fields`:字段库管理页(中文名/说明可改、可停用;kind 是引擎事实不可改);
  单位只在字段自己的阶梯里选(总市值 = 万元/亿元),越界 422 原样展示。
- `lib/units.ts`:界面单位 ⇄ 基准单位换算集中一处,条件输入按界面单位回显、
  提交前换回基准单位(引擎只认基准单位,库里存的也永远是基准单位)。
- `SelectionStrategyForm` 取代 `StrategyParamsForm`:一个策略只定义「怎么选」
  (股票池 + 因子 + 过滤条件),条件字段来自字段库接口而不是前端硬编码枚举。

因子参数化(名字即身份,界面不许藏):
- `/factors` 新增「参数」列与展开行:精确引擎键、每个参数的允许范围、来源、依赖列
  (依赖列标明「引擎事实,不可改」);说明文案从「不可修改」改为
  「改参数 = 新建参数化因子 = 新身份,旧因子/既有策略不变义」。
- 「新建参数化因子」卡片:模板下拉 + 受控窗口(min/max)+ 方向枚举下拉,
  实时预览规范键/中文名/渲染公式;后端 422 的原文原样展示,不静默截断。
- 因子下拉显示中文名(含参数)与短键,**value 一律是完整引擎键**;
  停用的因子从各页候选里消失(/strategies、/selection、/signals、/factors/compose),
  既有策略/归档仍按名字解析;`/factors/compose` 顺手修了勾选框点击目标过小。
- `/fields` 加提示:字段库只列内置因子名,参数化因子在 /factors 管理,
  选股策略条件下拉里会一并出现。

tsc --noEmit 0 error;verify_ui_alignment 8 页 160 项全过(另跑 3 个选因子页 51/54)。
This commit is contained in:
Simon
2026-10-01 16:38:29 +08:00
parent 2e90f3eeac
commit f13b34c59e
19 changed files with 2982 additions and 1521 deletions
+19
View File
@@ -67,6 +67,25 @@ export async function apiPut<T>(path: string, body: unknown): Promise<T> {
return (await resp.json()) as T;
}
/**
* PATCH:局部更新(如「启用/停用因子」只改 enabled,不该把整行 PUT 回去)。
*
* 为什么单列:因子参数化新增了 `PATCH /api/factors {name, enabled}` ——
* 名字里有括号/等号/逗号,放路径会被代理折腾,所以放在 body 里用 PATCH。
*/
export async function apiPatch<T>(path: string, body: unknown): Promise<T> {
const resp = await fetch(`${BASE}${path}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!resp.ok) {
const text = await resp.text();
throw new Error(`PATCH ${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) {
+51
View File
@@ -0,0 +1,51 @@
/**
* 因子目录在界面上的共用小工具(参数化之后才需要)。
*
* 为什么单独一个文件:因子的「名字」在参数化之后变成了带参数的引擎键
* (`momentum(window=90,direction=lower_is_better)`)。它**不能**直接当界面文字用 ——
* 太长、挤爆布局、也没人想读 `direction=higher_is_better`。而「哪些因子能被选」的规则
* (停用的不出现、算不出来的不出现)必须各处一致,否则「停用」在某个页面就成了假开关。
*/
import type { FactorMeta } from "@/lib/types";
/**
* 可以被**选中/引用**的因子。
*
* - `enabled === false`:在目录里停了用 —— 只是不出现在选择列表里,既有策略/归档仍按名字解析。
* - `resolvable === false`:引擎算不出来(历史手工登记行),选了也只会报错,不该摆给人点。
*
* 注意:这**不是**「能不能解析」的判断(那是引擎的事),只是界面候选集的过滤。
*/
export function pickableFactors(list: FactorMeta[]): FactorMeta[] {
return list.filter((f) => f.enabled !== false && f.resolvable !== false);
}
/** 界面显示名:优先后端给的中文名(含参数),没有才退回引擎键。 */
export function factorLabel(f: FactorMeta | undefined, fallback = ""): string {
return f?.label || f?.name || fallback;
}
/**
* 参数化因子的「短键」:只用于展示。
*
* 参数化因子的名字把参数写全了,直接放进下拉会很长;而 direction 在别处(提示里)已经
* 说成「越高越好 / 越低越好」,展示层去掉它不丢信息。**下拉的 value 始终是完整键**
* (引擎身份),只有显示文字被缩短。非参数化名(`momentum_60`)原样返回。
*/
export function shortFactorKey(name: string): string {
const m = /^([A-Za-z_][A-Za-z0-9_]*)\(([^()]*)\)$/.exec(name);
if (!m) return name;
const kept = m[2]
.split(",")
.filter((part) => !part.trim().startsWith("direction="))
.join(",");
return kept ? `${m[1]}(${kept})` : m[1];
}
/** 下拉/列表里的一行文字:中文名(含参数)· 引擎键。藏着名字就等于藏着身份。 */
export function factorOptionLabel(f: FactorMeta): string {
const d = (f.label || f.description).trim();
const head = d.length > 30 ? `${d.slice(0, 30)}…` : d || f.name;
return `${head} · ${shortFactorKey(f.name)}`;
}
+16
View File
@@ -74,3 +74,19 @@ export const STAGE_LABEL: Record<string, string> = {
analysis: "汇总指标与曲线",
done: "完成",
};
/** 提交一个回测组合为异步 Job(POST /api/combos/run,不保存组合)。 */
export async function submitComboJob(combo: unknown): Promise<JobSubmit> {
return apiPost<JobSubmit>("/combos/run", combo);
}
/**
* 运行**已保存**的回测组合(POST /api/combos/{id}/run)。
*
* 与 submitComboJob 的区别:这里用库里的那份参数,页面上未保存的改动不参与 ——
* 「从组合库直接运行」必须跑库里存的那套,否则用户改了一半的表单会污染既有组合的结果。
*/
export async function runSavedCombo(comboId: string): Promise<JobSubmit> {
return apiPost<JobSubmit>(`/combos/${encodeURIComponent(comboId)}/run`, {});
}
+8 -59
View File
@@ -1,22 +1,18 @@
"use client";
/**
* 策略说明的获取钩子。
* 策略说明的获取钩子(2026-09 重构后简化)。
*
* 说明/公式由后端 `describe_strategy` 从 spec **真实推导**(不是前端拼字符串):
* 这样「页面显示的公式」与「引擎实际执行的规则」只有一个来源,
* 不会出现文案与实现漂移(本平台最怕的问题)。
* 「页面显示的公式」与「引擎实际执行的规则」只有一个来源,不会文案与实现漂移。
*
* 两个入口:
* - `useStrategyDoc(params)`:未保存的参数也能实时预览(POST /strategies/describe),
* 带去抖,避免每次按键都请求。
* - `useStrategyDocById(id)`:已保存策略(GET /strategies/{id}/describe)。
* 重构后选股策略只含选股条件,说明走 `GET /strategies/{id}/describe`(需要已保存的 id);
* 未保存参数的实时预览不再有意义(选股策略没有可即时预览的回测公式),故移除。
*/
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useState } from "react";
import { apiGet, apiPost } from "@/lib/api";
import { apiGet } from "@/lib/api";
import type { StrategyDoc } from "@/lib/types";
import { paramsToSpec, type StrategyParams } from "@/components/StrategyParamsForm";
export interface DocState {
doc: StrategyDoc | null;
@@ -26,54 +22,7 @@ export interface DocState {
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(() => {
@@ -91,4 +40,4 @@ export function useStrategyDocById(id: string | null): DocState {
};
}, [id]);
return state;
}
}
+139 -25
View File
@@ -12,6 +12,26 @@ export interface Stock {
status: string;
}
/** 因子的一个**可编辑参数**的约束(/api/factors 的 param_specs;与后端 ParamSpec 对齐)。 */
export interface FactorParam {
name: string;
label: string;
/** "int":整数,按 minimum/maximum 受控;"enum":只能取 choices 之一。 */
kind: "int" | "enum";
default: number | string;
minimum?: number | null;
maximum?: number | null;
choices?: string[];
note?: string;
}
/**
* 因子目录条目(/api/factors)。
*
* 参数化的关键:因子实例的名字里带着全部参数
* (如 `momentum(window=90,direction=higher_is_better)`),所以**名字就是身份** ——
* 策略/归档存下名字就冻结了参数,改参数只会产生新名字,历史不会变义。
*/
export interface FactorMeta {
name: string;
description: string;
@@ -20,6 +40,38 @@ export interface FactorMeta {
frequency: string;
lookback: number;
direction: "higher_is_better" | "lower_is_better";
/** 该因子消费的数据列(引擎口径):策略表单据此提示「需要 dv_ratio」这类依赖。 */
requires?: string[];
/** 中文显示名(含参数),如「动量(窗口 90,越高越好)」;界面优先用它。 */
label?: string;
/** 模板名("momentum" / "volatility"…);老式手登记因子为空串。 */
template?: string;
/** 该实例冻结的参数取值,如 `{window: 90, direction: "higher_is_better"}`。 */
params?: Record<string, number | string>;
/** 可编辑参数与允许范围(来自模板):界面据此渲染受控表单。 */
param_specs?: FactorParam[];
/** builtin = 代码注册表实例;custom = 目录里创建的参数化实例。 */
source?: "builtin" | "custom";
/** 是否出现在因子的选择列表里(停用只影响「能否被选中」)。 */
enabled?: boolean;
/** 引擎是否算得出来;false = 历史手登记行,引用时会报错。 */
resolvable?: boolean;
}
/** 因子模板(/api/factors/templates):新建参数化因子时的可编辑参数与默认值。 */
export interface FactorTemplate {
name: string;
label: string;
description: string;
formula: string;
brief: string;
requires: string[];
frequency: string;
direction_default: "higher_is_better" | "lower_is_better";
param_specs: FactorParam[];
defaults: Record<string, number | string>;
/** 该模板已有的内置实例名(如 momentum_60),供「目录里已有哪些」提示。 */
instances: string[];
}
export interface ResearchCondition {
@@ -29,6 +81,47 @@ export interface ResearchCondition {
ref?: string | null;
}
/**
* 字段库条目(/api/condition-fields)。name 是引擎字段名(写进 condition.field),
* label/description 是给人看的;ops 由后端按 kind 给出,避免前端自己猜比较符。
*/
/** 一个可选的**界面单位**及它到**基准单位**的换算系数(提交前 ×factor,回显时 ÷factor)。 */
export interface UnitOption {
unit: string;
factor: number;
}
export interface ConditionField {
name: string;
label: string;
description: string;
kind: "num" | "str";
group_name: string;
/** 当前**界面单位**(输入/显示用,可从 units 里选)。 */
unit: string;
/** **基准单位**:引擎存储与比较用的单位,不可改(注册表口径)。 */
base_unit?: string;
/** 可选界面单位(首项 = 基准单位、factor=1;只有一个时界面不给选择)。 */
units?: UnitOption[];
source: "builtin" | "custom";
enabled: boolean;
sort_order: number;
ops: ("gt" | "gte" | "lt" | "lte" | "eq" | "ne" | "in" | "not_in")[];
created_at?: string;
updated_at?: string;
}
/** 「新增字段」的可选项(引擎支持但尚未进库)。 */
export interface ConditionFieldOption {
name: string;
label: string;
description: string;
kind: "num" | "str";
group_name: string;
unit: string;
units?: UnitOption[];
}
export interface ResearchSpec {
type: "factor_test" | "backtest";
universe: {
@@ -305,39 +398,60 @@ export interface ChartResult {
}
/* ---- 策略库(M8.3,与 domain/entities/strategy.py 对应) ---- */
/* ---- 选股策略 / 公共配置 / 回测组合(2026-09 重构,与 domain/entities/{strategy,combo}.py 对应) ---- */
/**
* 命名策略:完整策略定义(universe + factor + selection + rebalance + costs + portfolio),
* 不含回测区间 period 与初始资金 —— 回测时补全后展开为 ResearchSpec。
* 后端以 JSON 整体持久化,因此新增字段无需迁移即可保存。
* 选股策略:策略库现在**只存选股条件组合**(股票池 + 因子 + 过滤条件)。
* 资金 / 持仓数 / 持仓时间 / 调仓时机 / 费率 / 复权 / 区间一律移到「回测组合」与「公共配置」。
* (旧名 StrategyDefinition 仍作为别名导出,便于过渡期引用。)
*/
export interface StrategyDefinition {
export interface SelectionStrategy {
id?: string;
name: string;
/** 一句话说明(必填):说清这个策略做什么。为空时后端会用 describe_strategy 自动填充 */
/** 一句话说明(必填):说清怎么选。为空时后端用 describe_strategy 自动填充 */
description: string;
spec_type?: "backtest" | "factor_test";
universe?: { exclude_st?: boolean; min_listing_days?: number; symbols?: string[] };
price_adjustment?: "none" | "qfq" | "hfq";
spec_type?: "selection" | "backtest";
universe?: {
market?: string;
exclude_st?: boolean;
exclude_suspended?: boolean;
min_listing_days?: number;
index_code?: string | null;
symbols?: string[];
};
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;
}
/** 兼容别名:重构前的名字。新代码请用 SelectionStrategy。 */
export type StrategyDefinition = SelectionStrategy;
/** 公共配置(全局唯一一份):费率 / 滑点 / 最低佣金 / 复权口径 / 基准。 */
export interface GlobalConfig {
id?: string;
commission_rate: number; // 小数,如 0.0003 = 万三
stamp_tax_rate: number;
slippage_rate: number;
min_commission: number; // 元/笔
price_adjustment: "none" | "qfq" | "hfq";
benchmark: string;
updated_at?: string | null;
}
/** 回测组合:引用若干选股策略 + 回测参数(费率/复权来自公共配置,运行时快照进归档)。 */
export interface BacktestCombo {
id?: string;
name: string;
description?: string;
strategy_ids: string[];
initial_capital: number;
hold_count: number; // 目标持仓只数 N
hold_min_days: number; // Tmin
hold_max_days: number | null; // Tmax;null = 不强制了结
rebalance_freq: "daily" | "weekly" | "monthly";
period: [string, string];
version?: string;
created_at?: string | null;
}
+53
View File
@@ -0,0 +1,53 @@
/**
* 单位换算(界面层)——「字段库单位」这件事唯一的一处实现。
*
* 背景(2026-10):字段库里的单位分两层,写错任何一层都会让策略静默算错:
*
* - **基准单位**(`ConditionField.base_unit`):引擎存储与比较用的单位,由数据源落库口径
* 决定(总市值=万元、成交额=元、成交量=股),**不可改**。策略 JSON、归档里的
* ConditionSpec、引擎求值全部只用基准单位 —— 所以归档永远复现得出来。
* - **界面单位**(`ConditionField.unit`):只在输入/显示这一层用的单位,用户可以在字段库里
* 从 `units` 给定的阶梯里选(万元 ⇄ 亿元)。提交前 ×factor,回显时 ÷factor。
*
* 这样「把总市值改成亿元」会立刻生效(输入 5 就是 5 亿元),但不会改动任何已存策略的
* 含义 —— 库里存的仍是 50000 万元。
*/
import type { ConditionField, UnitOption } from "@/lib/types";
/** 该字段当前界面单位 → 基准单位的换算系数(没有备选单位 → 1)。 */
export function unitScale(meta: ConditionField | undefined | null): number {
if (!meta?.units?.length || meta.units.length < 2) return 1;
return meta.units.find((u) => u.unit === meta.unit)?.factor ?? 1;
}
/** 基准单位值 → 界面单位值(除以系数)。 */
export function fromBase(v: number, scale: number): number {
return !scale || scale === 1 ? v : Number((v / scale).toPrecision(12));
}
/** 界面单位值 → 基准单位值(乘以系数)。 */
export function toBase(v: number, scale: number): number {
return !scale || scale === 1 ? v : Number((v * scale).toPrecision(12));
}
/** 基准单位(引擎口径);字段不在库里时为空。 */
export function baseUnitOf(meta: ConditionField | undefined | null): string {
return meta?.base_unit || meta?.unit || "";
}
/** 界面上该显示的单位后缀:只有真的能换算(有备选且当前不是基准)时才显示。 */
export function displayUnitOf(meta: ConditionField | undefined | null): string {
return unitScale(meta) === 1 ? "" : (meta?.unit ?? "");
}
/** 单位阶梯的说明文字,如「1 亿元 = 10000 万元」。 */
export function unitNote(meta: ConditionField | undefined | null): string {
const scale = unitScale(meta);
if (scale === 1) return "";
return `1 ${meta?.unit} = ${scale} ${baseUnitOf(meta)}(引擎按基准单位比较)`;
}
/** 某个单位在阶梯里的系数(找不到 → 1)。 */
export function factorOf(units: UnitOption[] | undefined, unit: string): number {
return units?.find((u) => u.unit === unit)?.factor ?? 1;
}