迭代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 通过
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
# 技术实现方案: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` |
|
||||
|
||||
**关键设计决策(本方案定)**
|
||||
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 复用既有加载(L236–239)。
|
||||
|
||||
## 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` 夹取 0–4 整数;`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/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. 实施顺序
|
||||
|
||||
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、变量目录单一入口、现算只读内存缓存)。
|
||||
Reference in New Issue
Block a user