Files
kyugao ec02a791ee 迭代22+23: 策略tab静态化 + 计算字段(数字/判定型)+ 网格超市信号字段
- 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 通过
2026-09-10 14:05:06 +08:00

251 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 技术实现方案:22-策略计算字段与策略 tab 静态化
> 依据:R-027(重构·第一阶段)+ R-026(计算字段·第二阶段)+ PLAN-019 + `UI交互设计.md`(已定稿)
> 日期:2026-09-10 | 状态:**方案稿(待老师过审后进入实现)**
> 约束依据:技术约束-003(REST 直连)/011(回归独立数据目录)/012(策略定义存储)/015configSchema)、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` |
**关键设计决策(本方案定)**
1. **计算在服务端 API 层附着**`strategy-positions` 返回前),不改 `PositionManager` 的份额语义;
2. **聚合口径只读内存缓存**QuoteHub.quotes / instruments),即使 miss 也**不做读穿透**——`strategy-positions` 是高频读路径,不引入网络往返;行情新鲜度由 QuoteSync 5s 覆盖持仓 code 保证;
3. **无 formula 字段的策略零开销**(不取行情、不进引擎);
4. 存储真相源单一:字段定义迁到 `strategyFields``strategies` 收窄为内置身份表(读取兼容旧定义)。
## 1. 第一阶段:静态化与入口迁移(R-027)
### 1.1 数据模型(src/settings.js
**常量(新增/收窄)**
```js
/** 内置策略(静态,R-027):身份固定,不可增删改名 */
export const BUILTIN_STRATEGIES = [
{ id: 'grid-supermarket', name: '网格超市' },
{ id: 'manual-t', name: '手动做T' },
];
```
- `DEFAULT_STRATEGIES` 退役(由 `BUILTIN_STRATEGIES` 取代,内部先行别名过渡)。
**schema 变更**
```js
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)。
**读取归一化(单一真相源 + 旧数据兼容)**
```js
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 复用既有加载(L236239)。
## 2. 第二阶段:计算字段(R-026)
### 2.1 字段模型扩展(settings.js fieldSchema
```js
{ key, label, type: '…'|'formula', enum: [], def, unit: '',
formula: z.string().default(''), // 仅 type='formula':表达式串(中文变量)
decimals: z.number().default(2) } // 仅 type='formula':显示小数位 0-4
```
- `normalizeFields``decimals` 夹取 04 整数;`formula` 去首尾空白;`type='formula'` 时忽略 `def`
- 值不落库:`strategy_holdings.values` 与 formula 字段无关(读写路径零改动)。
### 2.2 公式引擎(`src/formula/evaluator.js`,纯函数零依赖)
```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`
```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`(新增)**
```js
computeRows({ strategyId, rows, fieldDefs, marketHub }) rows 附加 computed: { [fieldKey]: number|null }
```
1.`type='formula'` 字段 → **直接返回原 rows**(零开销);
2. `codes = rows.map(r => r.code)` → 行情**只读内存缓存**`marketHub.quotes.get(code)`、合约 `marketHub.instruments.get(code)`(与 `api/market.js#projectQuote` 同口径;**不读穿透**,miss → 相关变量 null);
3. 逐行 `buildContext`(含该策略非 formula 字段的值)→ 每个 formula 字段 `evaluateFormulaCompiled` 一次;
4. 结果:`computed[field.key] = number|null`(引擎返回原始数值,格式化交前端)。
**接线**`api/strategies.js``strategy-positions` 分支)
```js
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`(新增)**
```js
// 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/trialstrategy-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**:内置策略恒两项;旧 settingsstrategies[].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. 实施顺序
1. settings 层(常量 / strategyFields / tabs 归一化 / 退役)+ test-r027
2. API 层(端点退役 + schema-update+ 设置页收敛 + 字段配置弹层 + test-r013 更新;
3. 公式引擎 + 变量目录 + FormulaService + test-r026(引擎部分);
4. schema-update 字段校验 + formula/trial + strategy-positions 附着;
5. 前端 formula 分支(公式框/选择器/试算/小数位)+ 只读计算列;
6. typecheck + build + 全量回归 → 老师人工验收 → 复盘。
## 7. 约束落地(实施时执行)
- **修订**:产品约束-002/003/004(标签体系 → 固定两策略 + 字段自定义)、产品约束-009(Tab 统一管理中去掉策略条目动态化)、UI约束-002(设置页子 tab 构成)、UI约束-003(Tab 设置策略行不再随 CRUD 变化)、UI约束-005(字段配置入口迁移 + 更正为「表格列化 + 单元格内联编辑」现状);
- **新增**:UI约束-008(计算字段表单 + 只读计算列:`ƒ` 标记 / 悬停公式 / 小数位 / `—`)、产品功能约束(计算字段语义:不落库、只读、仅非公式变量)、技术方案约束(公式引擎自写零依赖不用 eval、变量目录单一入口、现算只读内存缓存)。