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:
@@ -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) {
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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`, {});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user