ec02a791ee
- R-027 静态化:策略恒为内置两项(不可增删改名,保留显隐/排序);设置页移除「策略分组」; 字段配置入口迁到策略 tab 内「列设置」旁(FieldConfigDialog + schema-update 单策略写入) - R-026 计算字段:自写公式引擎(中文变量、四则/括号/round·abs·min·max、缺值短路→—)+ 变量目录(行情/合约/持仓/自定义字段)+ strategy-positions 逐行现算(只读内存缓存,不落库、列只读) - R-028 判定型:比较运算 + and/or + inferResultKind + 结果类型一致性校验 + 判定列 ✓/— 渲染; 网格超市落地 可下空单=涨停价>基准值+网格大小、可下多单=跌停价<基准值-网格大小 - 变量选择改标签平铺(老师反馈);公式手册 docs/99-其他材料/计算字段公式说明.md - 约束同步:产品约束-002/003/004/009/013/014、技术约束-014/015/022/023/024、UI约束-002/003/005/008 - 回归:新增 test-r026/test-r027,更新 r011/r013/r017,17 个脚本全绿;typecheck/build 通过
17 KiB
17 KiB
技术实现方案:22-策略计算字段与策略 tab 静态化
依据:R-027(重构·第一阶段)+ R-026(计算字段·第二阶段)+ PLAN-019 +
UI交互设计.md(已定稿) 日期:2026-09-10 | 状态:方案稿(待老师过审后进入实现) 约束依据:技术约束-003(REST 直连)/011(回归独立数据目录)/012(策略定义存储)/015(configSchema)、UI约束-004/005/006/007;本次修订与新增见 §7
0. 总览
| 阶段 | 需求 | 一句话 | 主要落点 |
|---|---|---|---|
| 一 | R-027 | 策略 tab 静态化 + 字段配置入口迁到策略 tab 内 | settings.js / api/strategies.js / SettingsSection.jsx / StrategyTab.jsx / StrategyFieldsEditor.jsx |
| 二 | R-026 | 新增「计算(formula)」字段类型:中文变量公式引用服务端缓存数据集现算 | formula/(新)/ PositionManager 或 API 层附着 / StrategyTab.jsx / StrategyFieldsEditor.jsx |
关键设计决策(本方案定)
- 计算在服务端 API 层附着(
strategy-positions返回前),不改PositionManager的份额语义; - 聚合口径只读内存缓存(QuoteHub.quotes / instruments),即使 miss 也不做读穿透——
strategy-positions是高频读路径,不引入网络往返;行情新鲜度由 QuoteSync 5s 覆盖持仓 code 保证; - 无 formula 字段的策略零开销(不取行情、不进引擎);
- 存储真相源单一:字段定义迁到
strategyFields,strategies收窄为内置身份表(读取兼容旧定义)。
1. 第一阶段:静态化与入口迁移(R-027)
1.1 数据模型(src/settings.js)
常量(新增/收窄)
/** 内置策略(静态,R-027):身份固定,不可增删改名 */
export const BUILTIN_STRATEGIES = [
{ id: 'grid-supermarket', name: '网格超市' },
{ id: 'manual-t', name: '手动做T' },
];
DEFAULT_STRATEGIES退役(由BUILTIN_STRATEGIES取代,内部先行别名过渡)。
schema 变更
strategies: z.array(z.object({ id, name, configSchema })).default([]), // 收窄:仅身份表,改为内置两项(兼容读取)
strategyFields: z.dict(z.array(fieldSchema)).default({}), // 新增:策略字段定义真相源 { [strategyId]: configSchema }
strategyColumns: … // 不变
fieldSchema:{ key, label, type(text|number|boolean|enum|formula), enum[], def, unit, formula, decimals }(formula/decimals为第二阶段新增,见 §2.1)。
读取归一化(单一真相源 + 旧数据兼容)
export function getStrategies(scope) {
const v = scope?.get() ?? {};
const legacy = new Map((v.strategies ?? []).map(s => [s.id, s.configSchema])); // 旧:定义挂 strategies[]
return BUILTIN_STRATEGIES.map(s => ({
...s,
configSchema: Array.isArray(v.strategyFields?.[s.id])
? v.strategyFields[s.id]
: (Array.isArray(legacy.get(s.id)) ? legacy.get(s.id) : []), // 旧值回填视图(首次保存即落到 strategyFields)
}));
}
export function getStrategyFields(scope, strategyId) { /* 同上单策略版,未知 id → [] */ }
export async function updateStrategyFields(scope, strategyId, configSchema) {
assertBuiltinStrategy(strategyId); // 非内置 → 抛 strategy-not-found
const cur = scope.get() ?? {};
await scope.update({ ...cur, strategyFields: { ...(cur.strategyFields ?? {}), [strategyId]: normalizeFields(configSchema) } });
}
- 兼容:存量
strategies[].configSchema(本机 = 网格超市 5 字段 / 手动做T 1 字段)读取时回填,前端表单提交全量 → 首次保存即写入strategyFields,无需迁移脚本; - 自建策略(本机实测无):读取时忽略,其
strategy_holdings行保留在库(不删数据)。
tabs 归一化(normalizeTabs 收窄)
- 默认序列 = 内置 tab(3)+ 内置策略 tab(2);
- 读取:按存量
tabs的visible/order应用偏好,丢弃kind='strategy'且refId不在BUILTIN_STRATEGIES中的条目(自建策略 tab 退役); - 新增策略 tab 条目若存量缺失(旧数据无策略 tab)→ 按默认序补齐;
- 删除:
appendStrategyTab/removeStrategyTab(不再有增删场景)。
退役清单(实施确认):addStrategy / removeStrategy / renameStrategy / updateStrategies / appendStrategyTab / removeStrategyTab / DEFAULT_STRATEGIES 已删除;generateStrategyId / PINYIN_MAP 保留(QMT 连接 id 生成 generateQmtConnectionId 仍依赖,本轮不动)。
1.2 API 变更(src/api/strategies.js)
| 端点 | 处理 |
|---|---|
strategies |
保留(返回内置两项 + 各自 configSchema) |
strategies/add |
退役 |
strategies/remove |
退役 |
strategies/update |
退役(整表写入不再需要;重命名随静态化取消) |
strategies/schema-update |
新增:{ strategyId, configSchema } → 服务端校验(§2.5)→ updateStrategyFields → 返回该策略 { id, name, configSchema } |
tabs / tabs/update |
保留(显隐 + 排序为唯一可变项) |
strategy-positions |
保留 + 第二阶段附 computed(§2.4) |
formula/trial |
新增(第二阶段,§2.5) |
STRATEGY_METHODS集合同步增删;api/index.js头部注释同步。handleStrategy解构补marketHub, dataSource(计算字段取数用)。
1.3 设置页收敛(SettingsSection.jsx)
- 删除子 tab 项
{ key:'strategies', label:'策略分组' }与渲染分支、StrategyGroupSettings组件、StrategyFieldsEditor引入、新增/重命名/删除逻辑与删除确认弹窗(L208–436 区块); - 子 tab 仅剩
tabs/qmt;activeTab初值维持'tabs'。
1.4 字段配置弹层(前端)
src/client/views/FieldConfigDialog.jsx(新增)
- props:
{ strategyId, strategyName, configSchema, onSaved, onClose }; - 形态:锚定弹层(
width 520、maxHeight 70vh、内部滚动),视觉沿用ColumnSettingsPopover; - 底部动作:
[关闭](次) +[保存字段](主,saving 时 disabled); - 未保存改动(
dirty)时 关闭/外点/Esc → 二次确认「有未保存的字段改动,确定放弃?」(D-8)。
src/client/views/StrategyFieldsEditor.jsx(改造)
- 由「设置页整表保存」改为「弹层内容 + 单策略保存」:
onSave(fields)→ 调one-divine-lot/strategies/schema-update { strategyId, configSchema: fields }; - 类型下拉增
计算(第二阶段);dirty状态上报父弹层(用于放弃确认)。
src/client/views/StrategyTab.jsx(改造)
- 顶栏「列设置」左侧新增「字段配置」按钮 → 打开
FieldConfigDialog;onSaved→load()(列与数据同步刷新); - configSchema 复用既有加载(L236–239)。
2. 第二阶段:计算字段(R-026)
2.1 字段模型扩展(settings.js fieldSchema)
{ key, label, type: '…'|'formula', enum: [], def, unit: '',
formula: z.string().default(''), // 仅 type='formula':表达式串(中文变量)
decimals: z.number().default(2) } // 仅 type='formula':显示小数位 0-4
normalizeFields:decimals夹取 0–4 整数;formula去首尾空白;type='formula'时忽略def;- 值不落库:
strategy_holdings.values与 formula 字段无关(读写路径零改动)。
2.2 公式引擎(src/formula/evaluator.js,纯函数零依赖)
validateFormula(expr, allowedVars) → { ok: true, vars: Set<string> }
| { ok: false, error: { code: 'empty'|'syntax'|'unknown-var'|'unknown-func'|'arity', message, position } }
evaluateFormula(expr, ctx) → number | null // ctx: { [中文变量名]: number|null }
evaluateFormulaCompiled(ast, ctx) → number | null // 编译一次多行复用(性能)
- 词法:数字(整数/小数)、标识符(
\p{L}起头,含中文,可含数字/下划线)、运算符+ - * / × ÷ ( ) ,(×→*、÷→/归一化); - 语法优先级:括号 > 一元负号 >
* / / ÷>+ -;函数调用name(arg, …); - 内置函数:
round(x[, n=0])、abs(x)、min(a, b, …)、max(a, b, …); - 求值语义:引用的任一变量为
null/undefined/NaN→ 整式返回null(缺值短路,不产出 NaN);除零、非法运算、结果非有限 →null; - 安全:自写解析,不使用
eval/Function(技术约束新增条目,§7)。
2.3 变量目录(src/formula/variables.js)
export const VARIABLE_GROUPS = [
{ group: '行情', source: 'quote', items: [
{ name: '现价', field: 'lastPrice', desc: '最新成交价' },
{ name: '昨收', field: 'lastClose' }, { name: '今开', field: 'open' },
{ name: '最高', field: 'high' }, { name: '最低', field: 'low' },
{ name: '成交量', field: 'volume' }, { name: '成交额', field: 'amount' } ] },
{ group: '合约', source: 'instrument', items: [
{ name: '涨停价', field: 'upStopPrice' }, { name: '跌停价', field: 'downStopPrice' } ] },
{ group: '持仓', source: 'row', items: [
{ name: '份额', field: 'shares' }, { name: '成本价', field: 'avgPrice' }, { name: '最后成交价', field: 'lastTradePrice' } ] },
{ group: '自定义字段', source: 'values', items: [] }, // 运行时按策略非 formula 字段的 label 动态展开
];
export const BUILTIN_VAR_NAMES = new Set([...行情/合约/持仓 items 的 name]);
export function buildContext({ row, quote, instrument, fieldDefs }) → { [中文名]: number|null }
- 自定义字段变量名 = 字段
label;值取row.values?.[key](缺省回退def);非数字值(text/enum/boolean)→Number(...)失败即null; - 不收录 formula 字段(零循环依赖)。
2.4 计算服务与数据通路
src/formula/FormulaService.js(新增)
computeRows({ strategyId, rows, fieldDefs, marketHub }) → rows 附加 computed: { [fieldKey]: number|null }
- 无
type='formula'字段 → 直接返回原 rows(零开销); codes = rows.map(r => r.code)→ 行情只读内存缓存:marketHub.quotes.get(code)、合约marketHub.instruments.get(code)(与api/market.js#projectQuote同口径;不读穿透,miss → 相关变量 null);- 逐行
buildContext(含该策略非 formula 字段的值)→ 每个 formula 字段evaluateFormulaCompiled一次; - 结果:
computed[field.key] = number|null(引擎返回原始数值,格式化交前端)。
接线(api/strategies.js 的 strategy-positions 分支)
const rows = await manager.getStrategyPositions(args.strategyId);
return await formulaService.computeRows({ strategyId: args.strategyId, rows,
fieldDefs: getStrategyFields(settings, args.strategyId), marketHub });
- 历史持仓端点(
strategy-holdings/history)不计算:前端对历史行显示—。
2.5 API(校验与试算)
strategies/schema-update(含字段校验)
- 结构校验:
key非空且唯一(同策略内)、label非空且唯一(D-1)、type合法; - formula 字段:
validateFormula(formula, allowedVars),allowedVars= 内置变量名 ∪ 本策略非 formula 字段的 label; - 名称冲突:
label ∈ BUILTIN_VAR_NAMES→ 拒绝(variable-name-conflict); - 失败统一抛
{ code:'field-validation', message }(前端 Toast + 公式框描红 + 文案)。
formula/trial(新增)
// args: { strategyId, formula, decimals?, unit? }
// 1) 校验公式;2) 取该策略首行份额 > 0 的持仓;3) 取缓存行情/合约 → buildContext → 求值
// → { code, name, value: number|null, reason?: 'no-holding'|'no-data'|null }
2.6 前端渲染(StrategyTab.jsx / ColumnSettingsPopover.jsx)
- 列取值:
computed?.[key];null→—(tertiary);数值 →toFixed(decimals)去尾零 +unit拼接; - 表头:formula 字段列名前缀
ƒ(tertiary,title="计算字段(只读)"),列头/单元格title== <公式原文>; - 只读:formula 列不挂
FieldCellEditor、不绑编辑 onClick(fieldDef.type === 'formula'分支直接渲染文本); - 列设置弹层:列名前缀
ƒ(normalizeStrategyColumns的字段列附type供 UI 判断;排序/显隐零改动); - 历史行:无
computed→—。
3. 涉及文件与改动清单
src/settings.js # BUILTIN_STRATEGIES / strategyFields / normalizeTabs 收窄 / 退役 CRUD 辅助
src/api/strategies.js # 退役 add|remove|update;新增 schema-update、formula/trial;strategy-positions 附着 computed
src/api/index.js # 头部端点注释同步
src/formula/evaluator.js # 新增:词法/语法/求值/校验
src/formula/variables.js # 新增:变量目录 + buildContext
src/formula/FormulaService.js # 新增:批量现算(缓存只读)
src/client/views/SettingsSection.jsx # 移除「策略分组」子 tab
src/client/views/FieldConfigDialog.jsx # 新增:字段配置弹层
src/client/views/StrategyFieldsEditor.jsx# 改造:弹层内容 + 单策略保存 + formula 分支(公式框/变量选择器/试算/小数位)
src/client/views/StrategyTab.jsx # 顶栏「字段配置」按钮 + 计算列只读渲染
src/client/views/ColumnSettingsPopover.jsx # 列名 ƒ 前缀(仅文案)
scripts/test-r027-static-strategies.mjs # 新增回归(第一阶段)
scripts/test-r026-formula-fields.mjs # 新增回归(第二阶段:引擎 + 目录 + 校验 + 附着 + 试算)
scripts/test-r011-tabs.mjs # 更新(策略 tab 静态化后断言调整)
scripts/test-r013-custom-fields.mjs # 更新(改用 updateStrategyFields)
4. 回归与验证
- 回归脚本均用独立数据目录(
ODL_TEST_DATA_DIR,技术约束-011),内存 mock settings scope(沿用 test-r013 惯例); - test-r027:内置策略恒两项;旧 settings(strategies[].configSchema)读取回填;
updateStrategyFields单策略写入且不污染另一策略;非内置 strategyId → 拒绝;normalizeTabs丢弃自建策略条目且保留 visible/order;strategies/add|remove|update端点已退役(unknown method); - test-r026:引擎(中文变量 / 优先级 / 括号 / 一元负 / 函数 arity / 语法错误位置 / 缺值短路 / 除零 → null);变量目录(三组内置名、自定义 label 动态、formula 字段不入目录);
schema-update校验(未知变量 / 名称冲突 / 重复 key / 重复 label);strategy-positions附着computed(有/无 formula 字段两条路径);formula/trial(正常 / 无持仓 / 缺数据); pnpm typecheck+pnpm build通过;- 老师人工验收(见
验收标准.md)。
5. 风险与取舍
| 项 | 取舍 / 缓解 |
|---|---|
| 现算开销 | 仅含 formula 字段的策略付出;行情只读内存(无网络);行数为数十级 → 毫秒内;未直接引入缓存 |
| 行情 miss | 不读穿透 → 该行算式 —(等 QuoteSync 下轮 5s 刷新);换取 strategy-positions 路径零网络 |
| 中文标识符 | 自写词法支持 \p{L};不用第三方库(均不支持中文变量) |
| 旧数据 | 读取回填 + 首次保存落 strategyFields;不写迁移脚本、不删库数据 |
| 自建策略 | 读取忽略 + tab 丢弃;其持仓数据保留在库(不自动清份额),需要时人工处理 |
| 双份真相源风险 | strategies[].configSchema 只读兼容、写路径唯一走 strategyFields(避免双写打架) |
6. 实施顺序
- settings 层(常量 / strategyFields / tabs 归一化 / 退役)+ test-r027;
- API 层(端点退役 + schema-update)+ 设置页收敛 + 字段配置弹层 + test-r013 更新;
- 公式引擎 + 变量目录 + FormulaService + test-r026(引擎部分);
- schema-update 字段校验 + formula/trial + strategy-positions 附着;
- 前端 formula 分支(公式框/选择器/试算/小数位)+ 只读计算列;
- typecheck + build + 全量回归 → 老师人工验收 → 复盘。
7. 约束落地(实施时执行)
- 修订:产品约束-002/003/004(标签体系 → 固定两策略 + 字段自定义)、产品约束-009(Tab 统一管理中去掉策略条目动态化)、UI约束-002(设置页子 tab 构成)、UI约束-003(Tab 设置策略行不再随 CRUD 变化)、UI约束-005(字段配置入口迁移 + 更正为「表格列化 + 单元格内联编辑」现状);
- 新增:UI约束-008(计算字段表单 + 只读计算列:
ƒ标记 / 悬停公式 / 小数位 /—)、产品功能约束(计算字段语义:不落库、只读、仅非公式变量)、技术方案约束(公式引擎自写零依赖不用 eval、变量目录单一入口、现算只读内存缓存)。