diff --git a/docs/02-计划/计划-策略计算字段与策略tab静态化.md b/docs/02-计划/计划-策略计算字段与策略tab静态化.md new file mode 100644 index 0000000..6208702 --- /dev/null +++ b/docs/02-计划/计划-策略计算字段与策略tab静态化.md @@ -0,0 +1,92 @@ +# 计划:策略计算字段 + 策略 tab 静态化重构(阶段航点) + +> 编号:PLAN-019 | 粒度:阶段航点 | 创建:2026-09-10 | 状态:**进行中(迭代 22 交互设计阶段)** +> 派生自终极目标:目标-002(策略定义能力)、目标-003(按策略监控市场) +> 依据需求:**R-027(已定稿,2026-09-10,重构·第一阶段)+ R-026(已定稿,2026-09-10,计算字段·第二阶段)** —— 均符合入范围门槛 +> 设计约束:沿用 技术约束-012(策略定义存储)、技术约束-015(configSchema)、UI约束-003/004/005/006/007; +> 本次拟修订(实施时执行):产品约束-002/003/004/009、UI约束-002/003/005;新增计算字段约束条目 + +## 目标 + +**一句话**:策略字段能力升级为「可算」,并把字段配置的入口搬到它该在的地方。 + +- **第一阶段(R-027 重构)**:策略 tab **静态化**(插件内置、不可增删改名,保留显隐/排序);设置页「策略分组」移除; + 字段编辑功能迁到**主窗口策略 tab 内、「列设置」旁的「字段配置」弹层**,保存走单策略字段端点; +- **第二阶段(R-026 计算字段)**:在迁移后的入口上新增第五种字段类型「**计算(formula)**」——用户写中文变量公式 + 引用服务端缓存数据集,服务端按持仓行**实时计算**列值(不落库、列只读、缺数据显示 `—`)。 + +**为什么合并成一个迭代**:计算字段的公式表单必须落在字段配置入口上;若先按旧入口(设置页)实现再搬迁,等于白做一遍。 + +## 范围 + +**第一阶段 · 重构(R-027)** + +1. **tab 体系**:`strategies` 常量化为内置两项(网格超市 / 手动做T);`settings.tabs` 中策略条目固定(不可增删); + 保留 Tab 设置的显隐 + 拖拽排序;`addStrategy / removeStrategy / appendStrategyTab / removeStrategyTab / generateStrategyId` 退役; +2. **设置页**:移除「策略分组」子 tab(含策略新增/重命名/删除与确认弹窗)→ 设置页 = Tab 设置 + QMT 连接配置; +3. **字段配置入口**:策略 tab 顶部新增「字段配置」按钮(列设置旁)→ 独立弹层(字段列表 + 添加/编辑/删除 + 保存); + `StrategyFieldsEditor` 改造为弹层内容;保存改走新增端点 `strategies/schema-update { strategyId, configSchema }`; +4. **存储归一化**:settings 只保留字段定义(按 strategyId)+ `strategyColumns` 覆盖;存量 settings 读取时归一化(无迁移脚本)。 + +**第二阶段 · 计算字段(R-026)** + +5. **轻量变量目录**(服务端):数据集 → 中文变量名 → 取数函数(行情+合约 / 持仓账本 / 本行自定义字段值),T-006 数据池的第一块砖; +6. **公式引擎**:自写小型表达式解析器(零依赖、支持中文标识符、四则 + 括号 + round/abs/min/max),含语法校验与求值; +7. **数据/API**:`configSchema` 支持 `type='formula'` + 公式串 + 小数位;`strategy-positions` 逐行现算随行返回; + 字段保存校验(语法 / 变量存在性 / 变量命名规则)+ **试算端点**(取一行真实持仓试算); +8. **UI**:字段配置弹层内公式分支(公式框 + 变量选择器 + 试算 + 单位/小数位);策略持仓表计算列只读渲染(`ƒ` 表头 + 悬停看公式 + 格式化 + 缺数据 `—`); +9. 交互设计 → 技术方案 → 实现 → 回归脚本(独立数据目录,技术约束-011)→ 验收。 + +**不做**(本期边界): +- 策略级聚合(跨行求和)——留二期;QMT 账户域 / 交易记录作为变量来源(用例不明确,后补); +- 公式引用公式(变量目录排除 formula 字段);历史持仓行时点计算(显示 `—`);布尔型公式结果;公式框实时高亮/自动补全; +- 完整 T-006 数据池中间层(本期只落变量目录); +- 策略的重命名 / 增删 / 归档(静态化后不存在);自建策略数据迁移(实测无自建策略)。 + +## 涉及文件(技术方案阶段细化) + +``` +src/ +├── settings.js # strategies 常量 + tabs 固定 + 字段定义/列配置归一化;退役 CRUD 辅助 +├── api/strategies.js # 策略 CRUD 端点退役;新增 strategies/schema-update(单策略字段) +├── formula/ # 新增:公式引擎(第二阶段) +│ ├── variables.js # 轻量变量目录(数据集 → 中文变量名 → 取数函数) +│ └── evaluator.js # 表达式解析 + 校验 + 求值 +├── position/PositionManager.js # strategy-positions 行内公式现算 +├── client/views/SettingsSection.jsx # 移除「策略分组」子 tab;Tab 设置保留 +├── client/views/StrategyTab.jsx # 顶栏「字段配置」+「列设置」;计算列只读渲染 +├── client/views/FieldConfigDialog.jsx # 新增:字段配置弹层(复用/改造 StrategyFieldsEditor) +└── client/views/StrategyFieldsEditor.jsx # 改造为弹层内容 + 单策略保存 + 公式分支 +scripts/ +└── test-r026-formula-fields.mjs # 回归脚本(第二阶段) +``` + +## 实现步骤 + +1. **文档骨架**:PLAN-019 + 迭代 22(迭代目标 / UI交互设计),R-026 + R-027 定稿 —— 本步; +2. **交互设计过审**(老师拍板)→ 出技术实现方案(两阶段规格)+ 验收标准; +3. **第一阶段实现**:tab 静态化 + 设置页收敛 + 字段配置弹层 + 单策略端点 + 存量归一化 → 回归(策略 CRUD 相关用例退役); +4. **第二阶段实现**:变量目录 + 公式引擎 + 现算/校验/试算端点 + UI(公式表单 / 只读列)→ 回归; +5. **验证 + 老师人工验收 + 迭代复盘**。 + +## 验收要点 + +**第一阶段(重构)** +- 设置页只有「Tab 设置 / QMT 连接配置」,无「策略分组」;无新增/重命名/删除策略入口; +- 策略 tab 恒为「网格超市 / 手动做T」两个:不可增删改名;Tab 设置里仍可显隐 + 拖拽排序(现有偏好生效); +- 策略 tab 顶部「字段配置」按钮打开弹层:可添加/编辑/删除字段并保存;保存后表格列同步跟随;重启后定义仍在; +- 服务端只接受内置两个 strategyId;`strategy_holdings` / 归属数据零影响(13 / 3 行不变)。 + +**第二阶段(计算字段)** +- 字段类型可选「计算」:写中文变量公式、选择器插入、试算可算出结果;非法公式/未知变量被拒并提示; +- 策略持仓表计算列只读,按单位/小数位格式化,悬停可见公式;缺数据/除零显示 `—`,不炸表; +- 计算字段不落库(公式存 settings、值每次现算);四式用例(浮动盈亏 / 盈亏比例 / 距涨停 / 网格占用)可算出; +- 既有四类字段与手填值编辑、列设置、历史持仓展示零回归; +- typecheck + build + 回归脚本通过;老师人工验收。 + +## 关联 + +- 需求:**R-027**(重构·第一阶段)、**R-026**(计算字段·第二阶段);上游 R-013(字段模型/列机制)、R-011(Tab 显隐排序)、R-015(行情/合约缓存)、R-010(最后一笔成交价)、R-018(份额账本); +- 取代:R-003、R-011(部分)、R-013(入口条款)——由 R-027 取代; +- 延展:T-006(数据池中间层草稿)——变量目录为其起点; +- 迭代:22-策略计算字段与策略tab静态化(两阶段)。 diff --git a/docs/03-设计约束/UI交互约束.md b/docs/03-设计约束/UI交互约束.md index a2b1589..02e6b75 100644 --- a/docs/03-设计约束/UI交互约束.md +++ b/docs/03-设计约束/UI交互约束.md @@ -10,13 +10,16 @@ | 编号 | 约束说明 | 添加日期 | 生效状态 | 失效日期 | 最后一次变更描述 | |---|---|---|---|---|---| | UI约束-001 | 会话头部 QMT 连接切换 chip:紧凑形态 `QMT: <激活配置名> ▾`,与 PTC 模式标签并排;下拉菜单列出全部配置(当前激活项勾选标记),点选即激活;操作结果用顶部轻提示反馈(成功绿/失败红,约 3s 消失,沿用现有 Toast 体系) | 2026-08-29 | 生效 | - | R-004 定稿(2026-08-29):Q9/Q10 确认的头部控件形态 | -| UI约束-002 | 设置页「QMT 连接配置」子 tab:与「Tab 设置 / 策略分组」并列的第三个子 tab;配置以**卡片形式**展示(自适应网格 minmax(260px,1fr),激活卡片绿色描边):卡片含名称 + 激活/默认徽标 + HTTP 地址 + 行内操作(激活 / 设为默认 / 编辑 / 测试连接 / 删除)+ 新增/编辑表单(纵排两字段);删除沿用现有确认弹窗模式,删除最后一条时给出禁止提示;超时不设输入框(Q5);长文本(地址/错误信息)单行省略 + 悬停看全文,组件设 minWidth:0 防撑行 | 2026-08-29 | 生效 | - | 变更(2026-08-29 老师反馈):列表形式改卡片形式,约束组件宽度、长文本省略不换行;原定稿:设置页子 tab 形态沿用现有设置交互体系;变更(2026-09-02 R-011 定稿):「通用设置」更名为「Tab 设置」 | -| UI约束-003 | 设置页「Tab 设置」子 tab:表格列出**全部会话 tab(系统内置 + 策略分组)混排**,内置行带「内置」徽标、策略行带「策略」徽标;每行 = 拖动手柄(原生 HTML5 Drag & Drop)+ 名称 + 显示/隐藏开关;**任何行均无重命名/删除按钮**;拖动落点确认后**立即持久化**(每次 drop 提交整表,沿用「刷新页面后生效」提示机制);「策略分组」子 tab 只保留 新增/重命名/删除,移除排序箭头与显隐开关,并提示「顺序与显示请在「Tab 设置」中调整」 | 2026-09-02 | 生效 | - | R-011 定稿(2026-09-02 Q1/Q4/Q5 确认):拖动排序 + 显隐开关,禁重命名/删除 | +| UI约束-002 | 设置页「QMT 连接配置」子 tab:与「Tab 设置」并列的子 tab(R-027 后设置页仅此两个子 tab);配置以**卡片形式**展示(自适应网格 minmax(260px,1fr),激活卡片绿色描边):卡片含名称 + 激活/默认徽标 + HTTP 地址 + 行内操作(激活 / 设为默认 / 编辑 / 测试连接 / 删除)+ 新增/编辑表单(纵排两字段);删除沿用现有确认弹窗模式,删除最后一条时给出禁止提示;超时不设输入框(Q5);长文本(地址/错误信息)单行省略 + 悬停看全文,组件设 minWidth:0 防撑行 | 2026-08-29 | 生效 | - | 变更(2026-08-29 老师反馈):列表形式改卡片形式,约束组件宽度、长文本省略不换行;原定稿:设置页子 tab 形态沿用现有设置交互体系;变更(2026-09-02 R-011 定稿):「通用设置」更名为「Tab 设置」;**变更(2026-09-10 R-027)**:移除「策略分组」子 tab,设置页子 tab 由三变二 | +| UI约束-003 | 设置页「Tab 设置」子 tab:表格列出**全部会话 tab(系统内置 + 策略分组)混排**,内置行带「内置」徽标、策略行带「策略」徽标;每行 = 拖动手柄(原生 HTML5 Drag & Drop)+ 名称 + 显示/隐藏开关;**任何行均无重命名/删除按钮**;拖动落点确认后**立即持久化**(每次 drop 提交整表,沿用「刷新页面后生效」提示机制);**落点指示为行间间隙高亮线**(迭代 21,与列设置弹层一致,见 UI约束-007);「策略分组」子 tab 已移除(R-027 策略静态化),设置页不再有任何策略增删改名入口 | 2026-09-02 | 生效 | - | R-011 定稿(2026-09-02 Q1/Q4/Q5 确认):拖动排序 + 显隐开关,禁重命名/删除;变更(2026-09-10 R-025 老师指令):落点从整行高亮改行间间隙高亮线;**变更(2026-09-10 R-027)**:策略 tab 静态化、策略分组子 tab 移除 | -| UI约束-005 | 策略自定义字段配置 UI(R-013,2026-09-02):设置页「策略分组」子 tab 策略行**可展开** → 展开区 = 字段列表(label/key/type/enum/默认值)+ 添加/编辑/删除字段表单(名称/类型/枚举选项/默认值)+ 保存(走 strategies/update 整表);策略持仓 tab 持仓行展开区(R-010 基础上)增加「自定义字段」区块:按该策略 configSchema 渲染输入控件(文本=输入框、数字=数字输入、布尔=开关、枚举=下拉),值来自该行 values,编辑即保存调 holdings/values-update,Toast 反馈;未配置字段定义的策略持仓行不渲染字段区 | 2026-09-02 | 生效 | - | 新增(2026-09-02 R-013 定稿 D6 老师确认:值编辑入口 = 持仓行展开区) | +| UI约束-005 | 策略自定义字段配置 UI(R-013 → **R-027 入口迁移 + 现状更正**):**入口** = 策略 tab 顶部「列设置」**旁**的「字段配置」按钮 → 独立弹层(约 520px 宽、70vh 高、内部滚动,锚定弹层视觉同列设置):字段列表(展示名 / key / 类型 / 公式或默认值 / 单位 / 操作)+ 添加/编辑/删除字段表单(名称/类型/枚举选项/默认值/单位)+ 底部 [关闭] + [保存字段](保存走 strategies/schema-update 单策略写入);有未保存改动时关闭需二次确认;**现状更正**:字段**值**不再是「持仓行展开区编辑」,而是**作为持仓表列展示 + 单元格点击内联编辑**(R-013 §4 演进:文本=输入框、数字=数字输入、布尔=开关、枚举=下拉,编辑即保存调 holdings/values-update + Toast);列显隐/顺序由「列设置」弹层管理(每策略独立);未配置字段定义的策略该列不存在 | 2026-09-02 | 生效 | - | 新增(2026-09-02 R-013 定稿 D6);**变更(2026-09-10 R-027/R-026 实施)**:入口由设置页「策略分组」迁至策略 tab 内「字段配置」弹层;同时更正文案到现状(值编辑 = 表格列内联编辑,展开区仅保留交易明细) | | UI约束-004 | 神之一手 UI 适配 DSH 主题:所有颜色引用宿主 `--dsw-*` token(带 fallback `var(--dsw-alias-xxx, #原色)`),随 DSH 浅色 / 深色 / 跟随系统自动切换,不自行维护主题偏好(跟随宿主);语义映射:表面底→bg-layer-1、次级面→bg-layer-2、hover→interactive-bg-hover、主/次/弱文字→label-primary/secondary/tertiary、边框→border-l1..l4、红(涨/删/错)→state-error-primary、绿(跌/成/激活)→state-success-primary、蓝(信息/业务)→state-business-primary、实心按钮字→button-contrast-fill、遮罩→bg-mask-1、阴影→shadow-lv3;淡底用 color-mix(in srgb, var(--语义色) 10-12%, transparent);禁止硬编码色值(#hex/rgb)进运行代码 | 2026-09-02 | 生效 | - | R-012 定稿(2026-09-02,暂定跟随系统)+ 迭代 10 实施:141 处硬编码色 token 化,宿主机制实证(body[data-ds-dark-theme] + alias token 双值定义) | | UI约束-006 | 展开/收起箭头全站统一:使用共享组件 `ExpandChevron`(src/client/views/ExpandChevron.jsx)——14×14 chevron-right、描边圆角、主题 success 绿、展开时旋转 90°(0.15s 过渡);适用:策略持仓 tab / 交易记录 tab 表格行、设置页「策略分组」行首箭头(原 ▸/▾ 文字箭头废弃);不得再散落内联 SVG 或文字符号实现 | 2026-09-03 | 生效 | - | 新增(2026-09-03 老师反馈:设置页策略分组箭头与策略 tab 表格行展开图标一致;顺带消除两处内联 SVG 重复) | +| UI约束-007 | 列表/弹层排序交互:优先**原生 HTML5 拖拽排序**(draggable 行 + ≡ 拖拽手柄 + drop 重排 + 落点立即持久化,行之间允许 ↑↓ 箭头兜底微调);**落点指示用间隙高亮线**(两行之间的主色线,指示精确插入位置/分清前与后,不做整行高亮)——策略 tab 列设置弹层(R-024)与设置页 Tab 设置(R-025)均已采用;不做表头直拖列排序(兼容成本高,需处理排序点击/固定列冲突) | 2026-09-10 | 生效 | - | 新增(2026-09-10 R-024 定稿:策略 tab 列设置弹层从 ↑↓ 升级为拖拽排序,复用 Tab 设置已验证模式,去掉「保存」按钮改立即持久化);变更(2026-09-10 老师反馈:整行高亮无法判断插入前/后,落点改行间间隙高亮线);变更(2026-09-10 R-025 老师指令:设置页「Tab 设置」落点统一为同款间隙线,整行高亮全站废止) | + +| UI约束-008 | 计算字段 UI(R-026):字段类型下拉含「计算」——选中后**隐藏默认值**,显示公式输入框(等宽字体)+ 变量**标签平铺**(行情 / 合约 / 持仓 / 自定义字段 分组成行,每组 = 灰色组名 + 胶囊形 tag,点选即插入光标处;不自弹下拉)+「试算」按钮(用该策略一行真实持仓数据算出结果行内展示:`试算:平安银行 000001.SZ → 6.67 %`;无持仓提示「暂无可试算的持仓数据」)+ **结果类型下拉(数字 / 判定,R-028)**:选「判定」时**隐藏小数位**、公式框 placeholder 切换为判定示例(如 `涨停价 > 基准值 + 网格大小`),公式框下方常驻运算符提示(`运算:+ - × ÷ 括号;判定:> < >= <= == != ,组合:and / or`)+ 判定型试算显示 `→ ✓ 满足 / — 不满足`;数字型仍为小数位输入(0-4,默认 2);保存失败时公式框**描红**(state-error)+ 显示服务端提示,表单不关闭、改动保留;持仓表中计算列**只读**:表头列名前缀 `ƒ`(tertiary 色,title="计算字段(只读)")、列头与单元格 title 显示公式原文(`= <公式>`)、**判定型(R-028)**:满足 → `✓ <字段展示名>`(success 绿),不满足或数据缺失 → `—`(tertiary 灰,两者同形以免把「缺数据」误读为「不可以」);数字型按小数位 + 单位格式化(如 `6.67 %`、`700.00 元`)、缺数据/计算失败显示 `—`(tertiary 色)、点击不进内联编辑;历史持仓行显示 `—`;列设置弹层中该列名同样带 `ƒ` 前缀 | 2026-09-10 | 生效 | - | 新增(2026-09-10 R-026 定稿 + D-1~D-7 老师拍板:中文变量名 + 选择器插入、表头 ƒ 前缀 + 悬停看公式、本期做试算、已删变量 ⚠ 标记、小数位 0-4 默认 2、不做实时高亮);**变更(2026-09-10 R-028)**:新增「结果类型(数字/判定)」下拉与判定列渲染(`✓ 字段名` / `—`)+ 判定试算;**变更(2026-09-10 老师反馈)**:变量选择从「+ 插入变量 ▾」下拉改为**标签平铺**(分组成行、点选插入),弃用下拉交互;判定型隐藏小数位 | \ No newline at end of file diff --git a/docs/03-设计约束/技术方案约束.md b/docs/03-设计约束/技术方案约束.md index 5508bcf..ce518c2 100644 --- a/docs/03-设计约束/技术方案约束.md +++ b/docs/03-设计约束/技术方案约束.md @@ -23,13 +23,19 @@ | 技术约束-009 | 会话头部快捷切换控件挂载 DSH 开放 slot `conversation.session.header.actions`(多实例挂载点,按 order 排序多插件共存):客户端插件以独立 id 并排注册(DSH 内置 PTC 标签 order=-10,本控件 order=-9),不改动 DSH 宿主;控件经 ConnectionProvider 包装复用现有 RPC 通道与 /odl/api/* 端点 | 2026-08-29 | 生效 | - | R-004 定稿(2026-08-29):Q9 确认;宿主代码审查核实 slot 机制与内置插件注册方式 | | 技术约束-013 | 交易记录本地存储(R-009):QMT 当日交易数据(委托/成交)由服务端 TradeSync 定时同步落 SQLite(启动预热 + 60s 定时 + UPSERT 幂等,只同步当日);trade_orders(委托主行,order_id 主键 + insert_ts 派生时间列 + **strategy_id/holding_id 手动归属列**)+ trade_fills(成交明细,trade_id 主键、order_id 外键)两表;**委托归属由用户在交易记录 tab 手动设置**(候选 = 该 code 当前持仓策略 + 未关联,全手动选、可随时改、以最终为准);**UPSERT 不覆盖归属列**(手动指定为插件逻辑);本地历史查询走 trades/history 端点(策略过滤 = 用户设置的归属);今日实时仍走 QMT Bridge;QMT 委托/成交 code 无后缀、持仓带后缀 —— 数据源映射层统一 normalizeInstrumentCode 归一化;委托交易日 = insertDate(tradeDate 兜底) | 2026-09-01 | 生效 | - | 新增(2026-09-01 R-009 定稿 + 迭代 07 实施):两表 + 定时同步 + 本地历史查询;变更 1(2026-09-01):+code 归一化 + tradeDate 兜底;变更 2(2026-09-01 老师二次定稿):归属改**手动设置**(trade_orders 冗余 strategy_id+holding_id,UPSERT 不覆盖归属列),弃算法推导 | -| 技术约束-015 | 策略自定义字段存储(R-013,2026-09-02):字段定义随策略定义存 settings.strategies 扩展 configSchema([{key,label,type,enum?,def}],type ∈ text|number|boolean|enum,旧项缺省空数组);字段值落 strategy_holdings 新增 values TEXT(JSON 键值对,key 对齐 configSchema.key,允许额外键=可扩展,NULL=未配置);补列用幂等 ALTER(沿用 _ensureTradeAttributionColumns 模式,只读连接容忍);API:strategy-positions 每行附 values,新增 holdings/values-update {holdingId, values} 写回,服务端按 configSchema 校验(number=有限数、enum=在选项内、boolean=布尔),空值/缺省可写入;持仓生命周期操作(openHolding/addShares/reduceShares/closeHolding)不碰 values 列 | 2026-09-02 | 生效 | - | 新增(2026-09-02 R-013 定稿 + PLAN-012):定义 settings + 值 SQLite 列 + 幂等补列 + 类型校验 | -| 技术约束-014 | 会话 tab 注册与顺序显隐(R-011):客户端注册统一读 **settings.tabs 有序数组**(唯一顺序与显隐来源,内置条目 refKey + 策略条目 refId),按 order 排序、过滤 visible 后注册(builtin 走内置 render、strategy 走 StrategyTab),移除硬编码 order 间隔(原内置 10/11/12、策略 13+);settings.tabs 从布尔对象升级为有序数组,读取时对旧格式(布尔对象 + 策略自带 order/visible)静默归一化迁移(旧隐藏策略迁移后显示),写入即落库;策略定义表收窄为 {id,name}(去除 visible/order);tabs/update 语义改为整表更新(顺序 + 显隐),strategies/add 联动追加 tab 条目(末尾),strategies/remove 联动删除对应 tab 条目,废弃 strategies/move | 2026-09-02 | 生效 | - | R-011 定稿(2026-09-02 Q1-Q5 确认):统一 tabs 有序数组 + 自动迁移 + 联动增删 | +| 技术约束-015 | 策略自定义字段存储(R-013 → **R-027 真相源迁移**):字段定义存 **settings.strategyFields**(`{ [strategyId]: [{ key, label, type, enum?, def?, unit?, formula?, decimals? }] }`,type ∈ text|number|boolean|enum|**formula**);`settings.strategies` 收窄为**内置身份表**(仅兼容读取旧 `configSchema`:strategyFields 缺失时按内置 id 回填视图,首次保存即落新键,无迁移脚本、不动库数据);写路径唯一 = `strategies/schema-update { strategyId, configSchema }`(单策略整份覆盖,未知 strategyId 抛 strategy-not-found,校验见 技术约束-024);`normalizeFields` 归一化(类型白名单回退 text、decimals 夹取 0-4、formula 字段不带 def);字段值落 strategy_holdings 新增 values TEXT(JSON 键值对,key 对齐 configSchema.key,允许额外键=可扩展,NULL=未配置);补列用幂等 ALTER(沿用 _ensureTradeAttributionColumns 模式,只读连接容忍);API:strategy-positions 每行附 values,新增 holdings/values-update {holdingId, values} 写回,服务端按 configSchema 校验(number=有限数、enum=在选项内、boolean=布尔),空值/缺省可写入;持仓生命周期操作(openHolding/addShares/reduceShares/closeHolding)不碰 values 列 | 2026-09-02 | 生效 | - | 新增(2026-09-02 R-013 定稿 + PLAN-012):定义 settings + 值 SQLite 列 + 幂等补列 + 类型校验;**变更(2026-09-10 R-027/R-026)**:定义真相源迁 strategyFields(strategies 只读兼容)、类型白名单加计算类型、写路径改单策略端点 | +| 技术约束-014 | 会话 tab 注册与顺序显隐(R-011 → **R-027 静态化**):客户端注册统一读 **settings.tabs**(唯一顺序与显隐来源,内置条目 refKey + 策略条目 refId),按 order 排序、过滤 visible 后注册(builtin 走内置 render、strategy 走 StrategyTab);**R-027 后**:tab 序列由**内置常量派生**(3 内置 tab + 2 内置策略 tab),`normalizeTabs` 只保留常量表内条目的 visible/order 偏好、**丢弃**未知条目(自建策略 tab / 已退役 tab)、补齐缺失条目;`updateTabs` 只接受内置 id(未知条目过滤),不再有联动增删(`appendStrategyTab/removeStrategyTab` 退役);旧布尔对象格式(迭代 02)继续兼容 | 2026-09-02 | 生效 | - | R-011 定稿(2026-09-02 Q1-Q5 确认):统一 tabs 有序数组 + 自动迁移 + 联动增删;**变更(2026-09-10 R-027)**:策略静态化,tabs 由常量派生、联动增删与 strategies CRUD 一并退役 | | 技术约束-016 | src 目录按功能域归类(2026-09-02 结构优化):服务端代码**禁止平铺**,按职责域分目录 —— src/data-source/(QmtBridgeRestDataSource + data-source-types + QmtHealthMonitor,数据源与连接健康)、src/storage/(SqliteStore + DataStore,存储层)、src/position/(PositionManager,分仓逻辑)、src/market/(MarketDataHub + MarketFeed,行情)、src/trades/(TradeSync,交易同步);api/ 按领域拆分子文件(positions/strategies/qmt-connections/market/trades),client/ 仅放 UI(views/ 组件 + market/ provider);文件命名 = 类名(PascalCase)+ .js/.jsx;新增服务端模块必须先落对应域目录,无合适域时先讨论补域,不得回退平铺 | 2026-09-02 | 生效 | - | 新增(2026-09-02 结构审查 + 优化落地):component/ 平铺还原为语义分域,删除死代码 AllocationStorage、DataStore.setDataset/removeDataset | | 技术约束-017 | 持仓内存快照(R-014,2026-09-02;**变更 1:2026-09-08 R-018 数据域分界**):全量实盘持仓由服务端 PositionSync **进程内内存快照**管理(启动预热 + 10s 定时全量拉 /trade/positions → 校验 → 整体替换),**不落库**(账本与对账单分离,holding_id 交易锚点不掺易变快照);PositionManager.getAllPositions 以快照为准(strategy-positions / unallocated / summary 三接口不再请求时穿透 QMT),快照为空读穿透兜底;同步失败保留上次快照;空快照双重确认(/health 可用 + getAsset 账户身份可识别)才接受为真清仓;syncNow 允许手动调用。**幽灵自动清仓**(R-014 原条款:快照连续 3 轮消失 → 本地全部策略当前持仓 closeHolding 转历史)**退役**——同步机制只作用于对账单域,不再写 strategy_holdings 账本(R-018 老师拍板:幽灵清仓本就是一个同步机制,不可以让幽灵把爪子伸太长);账本行转历史唯一途径 = 卖出单关联减至 0 closeHolding(R-018);对账单域「码消失」仅表现为快照无此行 + 漏关联软提示(positions/orphan-hints 只读提示) | 2026-09-02 | 生效 | - | 新增(2026-09-02 R-014 定稿 + 迭代 12 实施);**变更 1(2026-09-08 R-018/迭代 16 拍板)**:幽灵自动清仓退役(不再 closeHolding 本地账本),PositionSync 只同步对账单快照;漏关联由只读软提示承担 | | 技术约束-018 | 盘口内存快照(R-015,2026-09-02):行情数据由 QuoteSync(取数:启动 prime + 5s REST 定时刷 watch 集合 + 涨停跌停经 /data/instrument 按**交易日**内存缓存)与 QuoteHub(存查:内存快照 Map + watchCodes Set + 读穿透走 dataSource.getTicks + getQuote/getByCodes 对外)管理,**替换并删除 MarketFeed/MarketDataHub**(方案 A,不留兼容壳);**WS 数据通路移除**(ingest 入口带 source 标签留回归口子);**market_quotes_cache 表退役 DROP**(幂等),价格单一入口 = QuoteHub,DataStore 行情方法(loadMarket/getMarketQuote(s)/setMarketQuotes)删除;同步失败保留内存旧值;watchCodes 维持只进不出无上限;涨停/跌停/昨收不落库 | 2026-09-02 | 生效 | - | 新增(2026-09-02 R-015 定稿 + 迭代 13 实施):老师五拍板(替换/WS 移除/纯内存 DROP/watch 现状/指示灯);warmup bug 复现(loadMarket return this 残迹)为不落库关键证据 | | 技术约束-019 | 历史持仓查询(R-016,2026-09-07):策略 tab 历史持仓展示走**本地库只读查询**(SqliteStore.getHoldingsHistory:strategy_id + closed_at IS NOT NULL + closed_at ≥ sinceMs,closed_at DESC),经独立端点 strategy-holdings/history 暴露;**当前持仓路径(strategy-positions / PositionSync 快照语义)不掺历史数据**(两份结果前端合并渲染);closeHolding 置 shares=0 语义维持不变(Q2 老师拍板:历史行份额显示 0,重点在追溯该持仓的历史操作而非清仓时份额);范围换算服务端做(week=7d/month=30d/quarter=90d/halfYear=182d/year=365d 自然日近似)——**变更 1(2026-09-07 二轮补充 Q8-Q9 老师拍板)**:range 新增 'today' = **本地自然日 00:00 起**(特判零点,不落回溯毫秒档),供前端「今日已清仓默认层」(恒显示、不受历史持仓开关控制);历史范围层语义收窄为**今天之前**;其余不变 | 2026-09-07 | 生效 | - | 变更 1(2026-09-07 R-016 二轮补充 Q8-Q9 老师拍板):+range=today 自然日边界,历史开关只控今天之前;首轮新增(Q1-Q7):历史行=追溯操作锚点,不动存储写路径 | +| 技术约束-020 | QMT 连接健康自适应探测(R-022,2026-09-09):QmtHealthMonitor 探测节奏由固定 5 分钟改为**结果驱动自适应**——健康 → intervalMs(默认 5 分钟,稳态低开销);异常/未知(含从未探测成功或 baseUrl 解析失败)→ retryMs(默认 10 秒)快速重试,与前端 sync-status 10s 轮询对齐,QMT Bridge 启动/恢复后指示灯 ≤20s 自动回绿(原固定 5 分钟导致恢复感知滞后,老师反馈「自动检查没生效」);间隔经构造参数 { intervalMs, retryMs } 注入(测试用短间隔);setTimeout 自适应链 + _inFlight 防并发,手动探测/配置热切换共用 probe() 并重排下轮节奏;stop() 即停;resolveActiveBaseUrl 失败也落 healthy:false 缓存走快速重试;前端读缓存秒回与 sync-status/qmt-health 端点语义不变 | 2026-09-09 | 生效 | - | 新增(2026-09-09 R-022 讨论 + 迭代 19 实施):健康灯恢复感知修复;稳态间隔维持原 5 分钟设计不变 | +| 技术约束-021 | MCP 状态自适应探测(R-023,2026-09-09):QmtMcpManager 状态缓存增加**结果驱动自适应探测**(原无定时回路,只在挂载/切换/手动探测时刷新)——connected → intervalMs(默认 5 分钟,稳态低开销);非 connected → retryMs(默认 10 秒)快速重试,MCP 服务(激活连接 baseUrl + /mcp)恢复后状态灯 ≤20s 自动回绿;dsh-mcp-client 自带 reconnect 只恢复真实连接、不刷新状态缓存,本类缓存必须自带刷新回路;无激活连接(url 为空)不探测不调度,dispose()/unmount() 停止调度;探测走 SDK 独立只读握手(Client connect + initialize + tools/list),与 dsh-mcp-client 自管连接互不干扰;间隔经构造参数 { intervalMs, retryMs } 注入(测试用短间隔);_probing 防并发与手动/热切换探测共用 probe() 重排节奏;前端 getStatus 读缓存与 mcp-status/sync-status 端点语义不变 | 2026-09-09 | 生效 | - | 新增(2026-09-09 R-023 讨论 + 迭代 19 实施):MCP 状态灯恢复感知修复(与 R-022 同模式);挂载/重连逻辑零改动 | +| 技术约束-022 | 公式引擎与变量目录(R-026,2026-09-10):`src/formula/evaluator.js` **自写小型表达式引擎**(零依赖,**禁止 `eval/Function`**):词法支持 Unicode 标识符(**中文变量名**)与全角归一(()+-×÷),语法 = 四则 + 括号 + 一元负 + 函数 round/abs/min/max(递归下降 + 深度≤32 + 长度≤500 上限);**R-028 扩展**:比较 `> < >= <= == !=` 与逻辑 `and / or`(含 `&& ||`、全角 `≥ ≤ ≠ > < =`),优先级 `or < and < 比较 < 加减 < 乘除`,判定结果缺值短路;新增 `inferResultKind(ast)` 静态推断结果类型(顶层比较/逻辑 → boolean,`neg` 下钻);`validateFormula(expr, allowedVars)` 返回 { ok, vars } 或 { ok:false, error:{ code: empty|syntax|unknown-var|unknown-func|arity, message, position } };求值语义 = **缺值短路**(任一变量 null/undefined/NaN → 整式 null)、除零/非有限 → null、**最终结果浮点归一**(|v|<1e12 时四舍五入 10 位,消除 0.7000000000000002 类噪声,中间步骤不归一再保精度);`src/formula/variables.js` 为**变量目录单一入口**(行情 7 项 / 合约 2 项 / 持仓账本 3 项 + 运行时按策略非 formula 字段展示名展开「自定义字段」组),`allowedVarNames` 排除 formula 字段(零循环依赖) | 2026-09-10 | 生效 | - | 新增(2026-09-10 R-026 定稿 + 技术方案 §2.2/§2.3):中文标识符需自有词法(第三方库均不支持中文变量),该层即 T-006 数据池的表达式层;**变更(2026-09-10 R-028)**:新增比较与逻辑运算 + `inferResultKind`(判定型计算字段);**复核(2026-09-10 老师提问「有没有现成的库」)**:对比 expr-eval(CVE-2025-12735 变量对象注入 → RCE)、mathjs(标识符不含 CJK)、Jexl(ASCII 标识符)、CEL、解析器工具包后**维持自写引擎**,论证见 迭代 23 技术方案 §4.5 | +| 技术约束-023 | 计算字段现算口径(R-026,2026-09-10):`src/formula/FormulaService.js#computeRows` 在 **API 层**为 `strategy-positions` 返回行附加 `computed: { [fieldKey]: number|null }`(不改 PositionManager 份额语义);取数**只读 QuoteHub 内存缓存**(`quotes` / `instruments`,与 api/market.js#projectQuote 同口径),**不做读穿透**(高频读路径零网络;miss → 相关变量 null → 该格 `—`,等 QuoteSync 下轮 ≤5s 覆盖);**无 formula 字段的策略直接返回原数组(零开销)**;值**不落库**(公式存 settings,值每次现算);`strategy-holdings/history` 不计算(历史行前端显示 `—`) | 2026-09-10 | 生效 | - | 新增(2026-09-10 R-026 技术方案 §2.4):计算位置与缓存口径由 AI 定并写入方案,老师验收覆盖缺数据边界 | +| 技术约束-024 | 字段定义 API 与校验(R-027/R-026,2026-09-10):写路径唯一 `strategies/schema-update { strategyId, configSchema }`(返回该策略对象);校验顺序 = 结构(展示名/key 非空、类型白名单、枚举选项非空)→ 唯一性(key 唯一、展示名唯一)→ **内置变量名冲突**(字段展示名不得等于现价/份额/…等内置变量名)→ 公式(`validateFormula`,变量集合 = 内置名 ∪ 本策略非 formula 字段展示名);失败统一抛 `code='field-validation'`(前端 Toast + 公式框描红);`formula/variables { strategyId? }` 提供变量目录、`formula/trial { strategyId, formula }` 试算(取份额>0 首行,返回 { code, name, value, reason: no-holding|no-data|null });**退役端点** `strategies/add|remove|update`(调用返回 not-found) | 2026-09-10 | 生效 | - | 新增(2026-09-10 R-027/R-026 定稿 + 技术方案 §1.2/§2.5):单策略写入避开整表覆盖,校验规则服务端唯一;**变更(2026-09-10 R-028)**:公式字段增加「结果类型一致性」校验——`resultKind`(默认 number)必须等于 `inferResultKind(compileFormula(formula).ast)`,不一致抛 `field-validation`(双向提示)| + \ No newline at end of file diff --git a/docs/04-迭代记录/12-持仓内存快照/迭代复盘.md b/docs/04-迭代记录/12-持仓内存快照/迭代复盘.md index 4a30c7c..abbd44c 100644 --- a/docs/04-迭代记录/12-持仓内存快照/迭代复盘.md +++ b/docs/04-迭代记录/12-持仓内存快照/迭代复盘.md @@ -42,3 +42,5 @@ 1. **部分减持的自动修正**(未分配负数仍靠 UI 暴露,人工「移出」修正):如需「检测账实不符 → 提示一键按实盘修正」可另立需求; 2. **同步时间前端显示**:老师拍板本轮不做;如持仓滞后感知需要,可加「更新于 HH:mm:ss」小标注(后端 syncedAt 已就绪); 3. **既有观察点未动**(另行登记评估):getStrategyPositions 的 N+1 委托查询(可改 IN 一次查);src/api/trades.js orders 端点绕 DataStore 门面直摸 sqlite.db(层级破洞);calcOrderFees 数据源/前端双实现(可收敛);数据存储设计.md §9.1 库文件名(one-divine-lot.db vs 实际 store.db)等历史偏差。 +## 验收状态更新(2026-09-08) +> 老师 2026-09-08 归档指令:确认验收并归档;R-014 已按归档清单移入 05-需求池/已完成/。 diff --git a/docs/04-迭代记录/13-盘口内存快照/迭代复盘.md b/docs/04-迭代记录/13-盘口内存快照/迭代复盘.md index 75f90f7..3bae3fe 100644 --- a/docs/04-迭代记录/13-盘口内存快照/迭代复盘.md +++ b/docs/04-迭代记录/13-盘口内存快照/迭代复盘.md @@ -39,3 +39,5 @@ 2. **WS 回归路径**:ingest 已留 source 标签;T-005 草稿维持起草,需要亚秒级行情时再议订阅契约; 3. **指示灯扩展位**:sync-status 已按域结构化(position/quote/qmt),未来交易域(TradeSync 60s)可加第三盏灯,前端加一行渲染; 4. **前端 getQuote 消费扩展**:涨停/跌停已随 market-snapshot 下发,UI 尚无展示列(如需「接近涨停提示」另立需求)。 +## 验收状态更新(2026-09-08) +> 老师 2026-09-08 归档指令:确认验收并归档;R-015 已按归档清单移入 05-需求池/已完成/。 diff --git a/docs/04-迭代记录/14-策略tab历史持仓展示/迭代复盘.md b/docs/04-迭代记录/14-策略tab历史持仓展示/迭代复盘.md index 7a099e1..649732c 100644 --- a/docs/04-迭代记录/14-策略tab历史持仓展示/迭代复盘.md +++ b/docs/04-迭代记录/14-策略tab历史持仓展示/迭代复盘.md @@ -36,3 +36,5 @@ 2. **历史行成本价/收益推导**:本期不做(边界定稿);若后续要做,数据源 = 关联委托成交记录,属展示层推导,不动存储; 3. **清仓方式标记**(手动 vs 幽灵自动):数据未区分,本期不做;若要做需 closeHolding 增加来源标记(涉及写路径,另立需求); 4. **R-015 / 迭代 13 人工验收**仍待老师确认(与本迭代无依赖)。 +## 验收状态更新(2026-09-08) +> 老师 2026-09-08 归档指令:确认验收并归档;R-016 已按归档清单移入 05-需求池/已完成/。 diff --git a/docs/04-迭代记录/15-归属候选纳入清仓持仓/迭代复盘.md b/docs/04-迭代记录/15-归属候选纳入清仓持仓/迭代复盘.md index f697d41..7f00b0d 100644 --- a/docs/04-迭代记录/15-归属候选纳入清仓持仓/迭代复盘.md +++ b/docs/04-迭代记录/15-归属候选纳入清仓持仓/迭代复盘.md @@ -32,3 +32,5 @@ 1. **候选窗口(7 天)与 R-016 范围档是两套口径**:当前均满足场景;若老师后续要统一(候选也用 5 档范围),另议小改; 2. **自动归属推导**(_resolveStrategyAttribution 时间窗)仍在(技术约束-013),但 Q3 全手动原则下仅为内部能力,未暴露 UI;维持现状; 3. R-015 / 迭代 13、R-016 / 迭代 14 的人工验收仍待老师确认。 +## 验收状态更新(2026-09-08) +> 功能被 R-018(迭代 16)语义取代退役,按取代归档(无需独立验收);R-017 已按归档清单移入 05-需求池/已完成/。 diff --git a/docs/04-迭代记录/22-策略计算字段与策略tab静态化/UI交互设计.md b/docs/04-迭代记录/22-策略计算字段与策略tab静态化/UI交互设计.md new file mode 100644 index 0000000..91aef90 --- /dev/null +++ b/docs/04-迭代记录/22-策略计算字段与策略tab静态化/UI交互设计.md @@ -0,0 +1,164 @@ +# 迭代 22 UI 交互设计:字段配置入口迁移 + 策略计算字段 + +> 依据:**R-027(重构·第一阶段,已定稿)** + **R-026(计算字段·第二阶段,已定稿)** + PLAN-019 | 日期:2026-09-10 +> 状态:**已定稿**(D-1~D-7 老师逐项拍板;2026-09-10 因入口迁移(R-027)同步修订;D-8~D-9 为 AI 默认项,老师可否决) +> 视觉基线:UI约束-004(--dsw-* token)、UI约束-006(ExpandChevron)、UI约束-007(间隙高亮线);UI约束-005 待随本次修订 +> 复用:ColumnSettingsPopover(列设置)、StrategyFieldsEditor(改造为弹层内容)、Toast;FieldCellEditor 不参与(formula 不可编辑) + +## 1. 交互总览(一句话) + +策略 tab 顶部**「字段配置」+「列设置」**两个按钮:字段配置管**字段定义**(含计算字段的公式),列设置管**列的显隐与顺序**; +字段定义保存后表格列自动跟随;计算字段列**只读**展示服务端实时算出的值(缺数据 `—`)。 + +> 本次修订要点(R-027):字段配置入口从「设置页 → 策略分组 → 策略行展开」**迁到策略 tab 内**,与列设置并列;设置页不再有「策略分组」子 tab。 + +## 2. 控件布局(示意) + +### 2.1 策略 tab 顶栏 + +``` +┌─ 策略 tab:网格超市 ─────────────────────────────────────────────┐ +│ 持仓 13 只 · 今日盈亏 … [历史持仓 ▾] [字段配置] [列设置] │ +│ ┌ 持仓表 ─────────────────────────────────────────────────────┐ │ +│ │ | | 代码 | 名称 | 现价 | ƒ 盈亏比例 | … | 操作 | │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +- 两按钮同层级、同视觉族(现有「列设置」按钮样式),**字段配置**在左、**列设置**在右(先定义字段、再排列,顺序符合操作动线)。 + +### 2.2 字段配置弹层(列表 + 表单) + +``` +┌─ 字段配置:网格超市 ─────────────────────────────────────────┐ +│ 自定义字段(5) [+ 添加字段] │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ 展示名 key 类型 公式/默认 单位 操作 │ │ +│ │ 网格上边界 grid_ceiling 数字 默认 11 元 编辑 删除│ +│ │ 基准值 current_base 数字 默认 10 元 编辑 删除│ +│ │ ƒ 盈亏比例 pctProfit 计算 (现价 ÷ 成本价 - 1)×100 % 编辑 删除│ +│ └──────────────────────────────────────────────────────────┘ │ +│ 字段增删后,持仓表列自动跟随(新增列默认追加末尾、删除列自动移除)│ +│ [关闭] [保存字段] │ +└────────────────────────────────────────────────────────────────┘ +``` + +- 弹层形态(D-9):锚定弹层(与列设置同源视觉),宽度约 520px、最大高度约 70vh、内容超出内部滚动; +- **保存字段**按钮在弹层底部(右对齐,主色实心);**关闭**在左(次按钮); +- 有未保存改动时点「关闭」/ 外点/Esc → 二次确认「有未保存的字段改动,确定放弃?」(D-8)。 + +### 2.3 字段表单 · 类型 = 计算(第二阶段) + +``` +┌─ 添加字段 / 编辑字段 ────────────────────────────────────────────┐ +│ 展示名 [盈亏比例 ] 标识 [pctProfit ] 类型 [计算 ▾] │ +│ 单位 [% ] 小数位 [2] │ +│ ┌ 公式 ───────────────────────────────────────────────────────┐ │ +│ │ (现价 ÷ 成本价 - 1) × 100 │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ [试算] 试算:平安银行 000001.SZ → 6.67 % │ +│ 行情 [现价] [昨收] [今开] [最高] [最低] [成交量] [成交额] │ +│ 合约 [涨停价] [跌停价] 持仓 [份额] [成本价] [最后成交价] │ +│ 自定义 [网格大小] [基准值] [网格交易量] ← 点 tag 插入公式 │ +│ 可用变量 行情:现价 昨收 今开 最高 最低 成交量 成交额 │ +│ 合约:涨停价 跌停价 │ +│ 持仓:份额 成本价 最后成交价 │ +│ 自定义:网格大小 基准值 网格交易量 ← 本策略手填字段 │ +│ [取消] [确定] │ +└─────────────────────────────────────────────────────────────────┘ +``` + +> 其余四类(文本/数字/布尔/枚举)表单形态不变,仅「类型」下拉多一项「计算」;选「计算」时隐藏默认值、显示公式区与小数位。 + +### 2.4 策略持仓表(只读计算列) + +``` +| | 代码 | 名称 | 现价 | 成本价 | 份额 | ƒ 盈亏比例 | ƒ 浮动盈亏 | 操作 | +|▸| 000001.SZ | 平安银行 | 11.20 | 10.50 | 1000 | 6.67 % | 700.00 元 | 全部移入/移出 | +``` + +- 表头 `ƒ`(tertiary 色,`title="计算字段(只读)"`)+ 列名;悬停列头或单元格 title 显示公式原文(`= (现价 ÷ 成本价 - 1) × 100`); +- 单元格点击**不进入内联编辑**;手填字段列的行为不变(点击即编辑)。 + +### 2.5 列设置弹层(ColumnSettingsPopover) + +- 计算字段与手填字段同列混排(名称显示 `ƒ 盈亏比例`);显隐勾选 / 拖拽排序 / 间隙高亮线 / 立即持久化**零改动**。 + +## 3. 字段配置弹层状态机 + +| 状态 | 外观 | 交互 | +|---|---|---| +| 初始 | 列表(含公式原文列)+ 底部「保存字段」(无改动时禁用/次色) | 可添加/编辑/删除字段 | +| 有未保存改动 | 「保存字段」主色可点;关闭类动作触发放弃确认 | 保存 → 调 `strategies/schema-update` | +| 保存中 | 「保存字段」disabled(文案「保存中…」) | 防重入 | +| 保存成功 | Toast「字段已保存」+ 弹层关闭(或保持打开并刷新)+ 表格列同步跟随 | — | +| 保存失败 | Toast 错误 + 弹层不关闭 + 改动保留 | 可重试 | +| 公式校验失败(计算字段) | 表单内公式框**描红** + 错误行(`语法错误:第 8 字符处缺少运算符` / `未知变量「网格距」`) | 表单不关闭,光标回公式框 | +| 试算中 | 「试算」disabled(「试算中…」) | 防重入 | +| 试算成功/失败 | 行内 `试算:平安银行 000001.SZ → 6.67 %` / error 色原因(`该策略当前无持仓`) | 不阻塞保存 | + +## 4. 交互细节 + +1. **打开入口**:点策略 tab 顶栏「字段配置」→ 弹层打开并显示**本策略**字段定义(各自 tab 管自己,Q4);数据来源沿用 `strategies` 端点(取当前策略 configSchema)。 +2. **字段列表**:列 = 展示名 / key / 类型 / 公式或默认值 / 单位 / 操作(编辑、删除);计算字段无默认值,公式列等宽、单行省略、悬停全文。 +3. **添加/编辑字段**:沿用现有表单(展示名 → 自动生成 key、类型下拉、枚举选项、默认值、单位)**新增**「计算」分支(见 2.3);`类型` 下拉:文本 / 数字 / 布尔 / 枚举 / **计算**。 +4. **删除字段**:列表内即时移除(弹层内草稿),**保存**后生效;保存后该列自动消失(归一化跟随),行内残留旧值按 R-013 既有防御隐藏。 +5. **保存**:调新增单策略端点 `strategies/schema-update { strategyId, configSchema }`;成功后 Toast + 刷新当前策略数据(列与字段同步);**不写 values 列**、不动份额账本。 +6. **变量插入**(计算字段,2026-09-10 老师反馈改为标签平铺):公式框下方按分组(行情 / 合约 / 持仓 / 自定义字段)**平铺变量 tag**(胶囊形、悬停显示释义),点选即插入到公式框光标处(无光标信息则追加末尾并补空格);不自弹下拉菜单。 +7. **变量命名规则**(D-1 已拍板):内置变量固定中文名(现价/昨收/今开/最高/最低/成交量/成交额/涨停价/跌停价/份额/成本价/最后成交价);自定义字段以其**展示名**作变量名。 +8. **保存校验(服务端)**:① 公式非空且语法合法;② 变量全部存在;③ 同策略字段展示名唯一、且不与内置变量名冲突;④ 仅引用**非公式字段**(目录层排除)。失败 → Toast + 公式框描红 + 定位文案。 +9. **试算**:服务端取该策略一行真实持仓(优先份额 > 0 首行)→ 变量目录取数 → 求值 → 返回 {`code`,`name`,`value`};前端按单位/小数位格式化显示。 +10. **持仓表只读渲染**:按小数位 + 单位格式化为 `6.67 %` / `700.00 元`;悬停显示公式原文。 +11. **刷新语义**(已知取舍):计算列随 `strategy-positions` 轮询刷新(行情服务端 5s 更新,列值刷新粒度 = 接口轮询周期);不与 `PriceCell` 实时高亮联动。 +12. **历史持仓行**:计算列显示 `—`(清仓行无「现价」语义,不做历史时点计算)。 +13. **旧公式引用已删变量**(D-4 已拍板):字段列表给 `⚠` 标记 + 悬停原因;运行期该列显示 `—`。 +14. **设置页**(第一阶段):不再有「策略分组」子 tab;Tab 设置里策略行与内置行同权(显隐 + 拖拽排序 + 间隙高亮线),**不提供**新增/重命名/删除任何入口。 +15. **键盘/可及**:Esc / 外点关闭弹层(有未保存改动则确认);`ƒ` 与 `⚠` 标记均带 title 文本(不单靠颜色表意)。 + +## 5. 空态与边界 + +| 场景 | UI | +|---|---| +| 本策略尚无字段 | 弹层列表空态:「尚未配置字段 —— 点击「+ 添加字段」为本策略添加(该策略下每个持仓按此定义填写值)」 | +| 该策略无手填字段(配计算字段时) | 变量菜单「自定义」分组空态:「本策略暂无手填字段」 | +| 该策略当前无持仓 | 试算行内:「暂无可试算的持仓数据(保存不受影响)」 | +| 行情缺失 | 计算列单元格 `—`(tertiary 色,不报错) | +| 计算失败(除零/非有限) | 单元格 `—` | +| 公式为空 / 未知变量 / 变量名冲突 | 拒绝保存 + 公式框描红 + 具体文案(见 §4.8) | +| 弹层有未保存改动时关闭 | 二次确认「有未保存的字段改动,确定放弃?」(D-8,AI 默认) | +| 未分配持仓 / 无 holding 锚点行 | 计算列照常显示(公式只用行情 + 持仓账本,不依赖 holding 锚点) | +| 设置页 | 仅「Tab 设置 / QMT 连接配置」两个子 tab | + +## 6. 组件落点(技术方案阶段细化) + +- `src/client/views/StrategyTab.jsx`:顶栏新增「字段配置」按钮(列设置旁)+ 弹层开关;计算列只读渲染(`ƒ` 表头 + title + 格式化 + `—`); +- `src/client/views/FieldConfigDialog.jsx`(**新增**):字段配置弹层容器(宽度/滚动/底部按钮/放弃确认),内容复用改造后的 StrategyFieldsEditor; +- `src/client/views/StrategyFieldsEditor.jsx`(**改造**):作为弹层内容;保存回调改单策略端点;类型下拉加「计算」+ 公式分支(公式框 / 变量选择器 / 试算 / 小数位); +- `src/client/views/SettingsSection.jsx`(**改造**):移除「策略分组」子 tab(含 CRUD 与确认弹窗); +- `src/client/views/ColumnSettingsPopover.jsx`:列名 `ƒ` 前缀(仅文案); +- 服务端(技术方案阶段):`strategies` 常量 + tabs 策略条目固定 + 存储归一化;`strategies/schema-update` 单策略字段端点;变量目录 + 表达式引擎 + `strategy-positions` 现算 + 校验/试算端点; +- 零改动面:`FieldCellEditor`(formula 不进入)、列配置持久化与归一化、历史持仓路径、份额账本与交易归属。 + +## 7. 已拍板决策(2026-09-10 老师逐项确认) + +| # | 问题 | 结论 | +|---|---|---| +| D-1 | 变量命名细则 | 内置固定中文名;自定义字段用**展示名**(新增唯一性 + 不与内置名冲突校验) | +| D-2 | 计算列视觉区分 | 表头 `ƒ` 前缀 + 悬停看公式;不加背景色 | +| D-3 | 试算预览 | 本期做 | +| D-4 | 已删变量的提示 | 字段列表 `⚠` 标记 + 悬停原因;运行期 `—` | +| D-5 | 小数位 | 数字输入 0-4,默认 2 | +| D-6 | 公式框能力 | 等宽字体 + 选择器插入;不做实时高亮/自动补全 | +| D-7 | 约束文档更正 | 一并更正 UI约束-005 + 新增计算字段约束条目 | +| D-8 | 弹层未保存改动的关闭行为 | **AI 默认**:二次确认放弃;老师可否决(备选:直接丢弃 / 禁止关闭) | +| D-9 | 弹层形态 | **AI 默认**:锚定弹层(与列设置同源视觉),~520px 宽、70vh 高、内部滚动;老师可否决(备选:右侧抽屉 / 居中模态) | + +## 8. 约束与历史需求联动(实施时执行) + +- **约束修订**(R-027):产品约束-002/003/004(标签体系 / 标签自定义 + 显示开关场景收敛为「固定两策略 + 字段自定义」)、产品约束-009(Tab 统一管理中「策略条目动态化」条款)、UI约束-002(设置页子 tab 构成:移除策略分组)、UI约束-003(Tab 设置:策略行不再随 CRUD 增删)、**UI约束-005**(字段配置 UI 入口迁移 + 更正为「表格列化 + 单元格内联编辑」现状); +- **约束新增**(R-026):拟 UI约束-008(计算字段表单 + 只读计算列)、产品功能约束(计算字段语义:不落库 / 只读 / 仅非公式变量)、技术方案约束(变量目录 + 表达式引擎 + 服务端现算); +- **历史需求取代**:R-003(策略 CRUD)→ 退役;R-011(Tab 统一管理)→ 策略条目动态化部分取代、显隐排序机制保留;R-013 → 字段模型/列机制保留、入口条款取代(均由 R-027 取代,索引标注追溯)。 + +## 9. 待确认/顺带发现 + +- 无(D-8 / D-9 为 AI 默认项,老师如有异议直接改判)。 diff --git a/docs/04-迭代记录/22-策略计算字段与策略tab静态化/技术实现方案.md b/docs/04-迭代记录/22-策略计算字段与策略tab静态化/技术实现方案.md new file mode 100644 index 0000000..a846fb9 --- /dev/null +++ b/docs/04-迭代记录/22-策略计算字段与策略tab静态化/技术实现方案.md @@ -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 } + | { 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、变量目录单一入口、现算只读内存缓存)。 diff --git a/docs/04-迭代记录/22-策略计算字段与策略tab静态化/迭代复盘.md b/docs/04-迭代记录/22-策略计算字段与策略tab静态化/迭代复盘.md new file mode 100644 index 0000000..c8dfdc3 --- /dev/null +++ b/docs/04-迭代记录/22-策略计算字段与策略tab静态化/迭代复盘.md @@ -0,0 +1,105 @@ +# 迭代复盘:22-策略计算字段与策略 tab 静态化 + +## 结论 + +**实现完成(两阶段全范围),待老师人工验收**(尚未宣告验收通过)。事实:策略 tab 静态化 + 字段配置入口迁移(R-027) +与计算字段(R-026)均已落地;`pnpm typecheck` 0 错、`pnpm build` 通过、17 个回归脚本全绿(含新增 2 个)。 + +## 事实记录 + +### 阶段一 · 策略 tab 静态化与入口迁移(R-027) + +- **src/settings.js**:新增 `BUILTIN_STRATEGIES`(内置两项:网格超市 grid-supermarket / 手动做T manual-t)、 + `isBuiltinStrategy / assertBuiltinStrategy / normalizeFields`;字段定义真相源迁到 **`strategyFields`** + (`getStrategies` 恒返回内置两项、缺失时兼容读取旧 `strategies[].configSchema`;`getStrategyFields` / + `updateStrategyFields` 为唯一读写入口);`normalizeTabs` 改为**常量派生**(3 内置 tab + 2 策略 tab, + 只保留存量 visible/order 偏好、丢弃未知条目、补齐缺失条目),`updateTabs` 只接受内置 id; + `normalizeStrategyColumns` 的字段列来源改读 `getStrategyFields`;新增 `scopeValue` 容错读取 + (测试桩无 `get` 不炸);**退役** `addStrategy / removeStrategy / renameStrategy / updateStrategies / + appendStrategyTab / removeStrategyTab / DEFAULT_STRATEGIES`。 +- **src/api/strategies.js**:**退役** `strategies/add | strategies/remove | strategies/update`(调用返回 not-found); + 新增 `strategies/schema-update { strategyId, configSchema }`(唯一写路径)+ `validateConfigSchema` + (结构 / key 唯一 / 展示名唯一 / 内置变量名冲突 / 枚举选项 / 公式校验,失败统一 `code='field-validation'`); + 新增 `formula/variables`(变量目录)与 `formula/trial`(试算)。 +- **设置页**(SettingsSection.jsx):「策略分组」子 tab 整体移除(含 CRUD 与删除确认)→ 设置页仅 + 「Tab 设置 / QMT 连接配置」。 +- **字段配置弹层**(FieldConfigDialog.jsx 新建 + StrategyFieldsEditor.jsx 改造):策略 tab 顶栏 + 「字段配置」(列设置左侧)→ 锚定弹层(520px / 70vh / 内部滚动);字段列表 + 添加/编辑/删除 + 底部 + [关闭]/[保存字段];dirty 时关闭/外点/Esc 二次确认;保存走 `strategies/schema-update`。 + +### 阶段二 · 计算字段(R-026) + +- **src/formula/evaluator.js**(自写零依赖引擎,不用 `eval/Function`):词法支持中文标识符 + 全角归一 + (()+-×÷),递归下降解析(四则 / 括号 / 一元负 / round·abs·min·max;长度 ≤500、深度 ≤32); + `validateFormula` 返回 `{ok,vars}` 或 `{ok:false,error:{code:empty|syntax|unknown-var|unknown-func|arity,message,position}}`; + 求值**缺值短路**(任一变量 null → 整式 null)、除零/非有限 → null、**最终结果浮点归一**(|v|<1e12 四舍五入 10 位, + 消除 `0.7000000000000002` 类噪声;中间步骤不归一再保精度——首版曾因每步归一导致 `6.66666667` 精度劣化,已改)。 +- **src/formula/variables.js**:变量目录单一入口(行情 7 项 / 合约 2 项 / 持仓账本 3 项 + 运行时按策略 + 非 formula 字段展示名展开「自定义字段」组);`allowedVarNames` 排除 formula 字段(零循环依赖); + `buildContext` 取数(昨收兜底合约 preClose、布尔 → 0/1、空值 → null)。 +- **src/formula/FormulaService.js**:`computeRows` 为 `strategy-positions` 行附 `computed`;行情/合约 + **只读 QuoteHub 内存缓存(不读穿透)**;**无 formula 字段直接返回原数组(零开销)**;值不落库。 +- **前端**:字段表单类型「计算」分支(等宽公式框 + 「+ 插入变量」分组菜单 + 试算 + 小数位 0-4 默认 2; + 保存失败公式框描红 + 服务端文案);持仓表计算列**只读**(表头 `ƒ` + title 公式原文、按 `toFixed(decimals)` + 单位 + 格式化如 `6.67 %` / `700.00 元`、缺数据 `—`、点击不进内联编辑);历史持仓行 `—`;列设置弹层列名带 `ƒ`。 + +### 实机数据核实(实施前) + +- `~/.dsh/settings.yaml`:策略仅两个内置(网格超市 5 字段 / 手动做T 1 字段); +- `~/.dsh/one-divine-lot/store.db`:`strategy_holdings` 13 / 3 行,交易与归属数据只涉及这两个策略 + → **无自建策略,静态化零数据迁移**。 + +### 验证 + +- `pnpm typecheck` → 0 错;`pnpm build` → 通过(lib 32 文件 + client bundle 195545 bytes,`scripts/wrap-client.mjs` 正常); +- 回归脚本 **17 / 17 全绿**: + - 新增 `scripts/test-r027-static-strategies.mjs` 35/35(策略常量 / 旧定义兼容 / 单策略写入 / tabs 归一化 / 端点退役); + - 新增 `scripts/test-r026-formula-fields.mjs` 48/48(引擎 / 目录 / 上下文 / 现算 / 保存校验 / 试算); + - 更新 `test-r011-tabs` 22/0、`test-r011-api` 17/0、`test-r013-custom-fields` 23/0、 + `test-r013-columns` 18/0(期望值按 R-015 行情列 + R-027 真相源更新)、`test-r017-candidates` 12/0(策略 id 改用内置); + - 其余(r009 系列 / r016 / r018 / quote-sync / position-sync / health / mcp)零改动全绿。 + +## 偏差、顺带修复与发现 + +1. **`generateStrategyId` / `PINYIN_MAP` 保留**(方案原文列入退役):QMT 连接 id 生成 + `generateQmtConnectionId` 仍依赖,本轮不动(已回写技术方案)。 +2. **真 bug(实施中发现并修复)**:`normalizeStrategyColumns` 仍读旧 `strategies[].configSchema`, + 字段定义迁到 `strategyFields` 后策略列会丢失字段列 → 改为 `getStrategyFields(scope, strategyId)`。 + 教训:真相源迁移必须全量搜「读点」而不只是「写点」。 +3. **兼容修复**:`strategy-positions` 新增 settings 依赖后,测试里无 `get` 的 settings 桩会抛错 + (test-r016/test-r017 暴露)→ 新增 `scopeValue` 容错读取。 +4. **顺带修复迭代 20 复盘遗留第 1 项**:`test-r013-columns` 期望过期(R-015 增加 4 个默认隐藏行情列后应为 + 12 列)——本次随实现更新期望并补「strategyFields 优先」用例。 +5. **`test-r017-candidates` 策略 id 迁移**:`attribution-targets` 经 `getStrategies` 取策略表, + 静态化后仅内置两项 → 测试改用 `manual-t / grid-supermarket`(R-027 的必然结果,非缺陷)。 +6. **交互边界修复**:字段配置弹层的「外点关闭」原以弹层面板为界,点「字段配置」按钮自身会触发放弃确认 + (dirty 时甚至静默卸载)→ 改为以**锚点容器**(含两个按钮)为界。 +7. **工作区背景(非本轮产出)**:仓库工作区另含迭代 19/20/21(R-022/R-023/R-024/R-025)的未提交改动, + 本轮未触碰其逻辑,仅在其上叠加;建议老师确认后一并提交。 + +## 遗留与风险 + +- **行情 miss 显示 `—`**:计算列取数只读内存缓存、不读穿透(技术约束-023);某 code 无快照时该格为空, + 等 QuoteSync 下轮(≤5s)覆盖。若老师更希望「宁可慢也要立刻有价」,可改判为读穿透。 +- **需要重载插件才能人工验收**:`~/.dsh/profiles/web/node_modules/one-divine-lot` 是指向本工作区的软链, + lib 已构建完成;老师侧需重载插件(或重启 dsh web)后在页面验收(未由我启动任何服务)。 +- **旧 `strategies[].configSchema` 仅只读兼容**:本机字段定义在其首次保存后落入 `strategyFields` + (不写迁移脚本、不动库数据)。 +- 自建策略若未来出现:读取忽略、tab 丢弃,其 `strategy_holdings` 数据保留在库(本机无此情况)。 + +## 待老师确认事项(人工验收) + +按 `验收标准.md`:**A1–A11**(重构)+ **B1–B12**(计算字段),重点四条: +1. 设置页无「策略分组」;策略 tab 恒两个、不可增删改名,但 Tab 设置仍可显隐/拖拽排序; +2. 策略 tab 顶栏「字段配置 / 列设置」可用,字段增删保存后表格列跟随、重启后定义仍在; +3. 计算字段可用:中文变量公式 + 变量选择器 + 试算 → 持仓列只读显示结果(`ƒ` 标记 + 悬停看公式); +4. 边界:非法公式/未知变量/名称冲突被拒 + 描红;缺数据与历史行显示 `—`;`strategy_holdings.values` 不含计算字段键。 + +## 经验沉淀(候选) + +- **并行委派的契约必须在开工前冻结**:本轮前端由子代理并行实现,期间服务端 `settings.js` 仍在演进 + (子代理观察到 mtime 变化、并在报告里提出「疑似工作区未冻结」)——契约冻结 + 文件集互斥是并行可行性的前提。 +- **真相源迁移要全量搜读点**:本次 `normalizeStrategyColumns` 差点漏改,靠既有回归脚本的红灯暴露; + 遗留的旧键兼容读取(而非直接删键)让迁移零风险、零脚本。 +- **测试期望也需要跟着设计演进**:`test-r013-columns` 的过期期望在迭代 20 复盘就被记录却未处理, + 本轮顺手清偿,避免「红灯常态化」。 diff --git a/docs/04-迭代记录/22-策略计算字段与策略tab静态化/迭代目标.md b/docs/04-迭代记录/22-策略计算字段与策略tab静态化/迭代目标.md new file mode 100644 index 0000000..66bd5a1 --- /dev/null +++ b/docs/04-迭代记录/22-策略计算字段与策略tab静态化/迭代目标.md @@ -0,0 +1,45 @@ +# 迭代目标:22-策略计算字段与策略 tab 静态化 + +## 目标 + +两条腿,一个迭代走完: + +- **第一阶段 · 重构(R-027)**:策略 tab 静态化为插件内置 tab(不可增删改名,保留显隐/排序);设置页「策略分组」移除; + 字段编辑功能迁到**策略 tab 内、「列设置」旁的「字段配置」弹层**; +- **第二阶段 · 计算字段(R-026)**:在迁移后的入口上新增第五种字段类型「**计算(formula)**」——用户写中文变量公式 + 引用服务端缓存数据集,服务端按持仓行**实时计算**列值(不落库、列只读、缺数据 `—`)。 + +## 目标描述 + +- **第一阶段背景**:2026-09-10 老师提出重构想法——「策略 tab 不想做成动态可增减修改的了,改成静态的,和全部持仓、交易记录这些一样, + 都是插件内置的 tab」「这两个策略 tab 原有的字段编辑功能,切换到主窗口的策略 tab 下面,列设置旁边,作为统一的字段配置管理」。 + 实机核实:仅两个内置策略(网格超市 / 手动做T)、**无自建策略**(store.db 13/3 行),静态化零数据迁移风险;需求定稿 R-027。 +- **第二阶段背景**:2026-09-10 老师提出字段配置优化的下一步——新增**可计算**的字段类型(公式引用服务端缓存数据集得出结果); + 需求定稿 R-026。 +- **合并理由**:公式表单必须落在字段配置入口上;若先按旧入口(设置页策略分组)实现,再随重构搬迁 = 白做一遍(老师 Q6 拍板:一次改到位)。 +- **本迭代节奏(老师指令 2026-09-10)**:先出计划 → 启动迭代 → **本轮只做交互设计**(不做技术方案与编码);交互设计过审后补技术方案 + 验收标准,再进入实现。 +- **验收线**: + - 重构:设置页无「策略分组」;策略 tab 恒两个且不可增删改名;Tab 设置仍可显隐/排序;字段配置弹层可用并落库到正确策略; + - 计算字段:四式用例(浮动盈亏 / 盈亏比例 / 距涨停 / 网格占用)在策略 tab 列上算出;计算字段不落库、列只读、非法公式被拒、缺数据不炸表。 + +## 目标分解 + +1. **交互设计**(本步):① 重构部分——策略 tab 顶栏两个按钮(字段配置 / 列设置)、字段配置弹层(列表 + 表单 + 保存)、 + 设置页收敛;② 计算字段部分——弹层内公式分支(公式框 + 变量选择器 + 试算 + 单位/小数位)、持仓表只读计算列、空态与错误态; +2. **技术实现方案**(交互过审后):两阶段规格——tab/存储归一化、单策略字段端点、变量目录、表达式引擎、现算通路、校验与试算、回归脚本; +3. **验收标准**(同上):两阶段各自的验收方法与判定线; +4. **实现与验收**:第一阶段(重构)→ 回归 → 第二阶段(计算字段)→ 回归 → 老师人工验收 → 复盘。 + +## 目标讨论过程 + +1. 2026-09-10 老师提出计算字段方向 → AI 摸清 R-013 现状 + 服务端缓存数据集清单 → Q1-Q10 问题清单 → 老师「定」+ 三项选择题确认 → 定稿 **R-026**; +2. AI 出迭代 22 `UI交互设计.md`(公式表单落在设置页策略分组)→ 老师拍板 D-1~D-7 → 交互设计定稿; +3. 2026-09-10 老师提出**重构想法**(策略 tab 静态化 + 字段配置入口迁移)→ AI 核实实机数据(无自建策略、零迁移风险)+ 影响面分析 + + 撞位提示(公式表单落在设置页,入口一搬即废)→ 老师拍板 Q1~Q4、Q6(全采建议),Q5/Q7 由 AI 定 → 定稿 **R-027**; +4. 迭代 22 结构调整为「第一阶段=重构、第二阶段=计算字段」,交互设计同步修订(公式表单移入「字段配置」弹层)。 + +## 对老师(项目主理人)的配合需求 + +- **本轮(已完成 2026-09-10)**:拍板 D-1~D-7(计算字段交互)与 Q1~Q6(重构方案)—— 全部采纳 AI 建议; +- **后续**:技术方案过审后,按两阶段做页面人工验收——① 设置页无策略分组、策略 tab 不可增删改名、字段配置弹层可用; + ② 公式配置与试算、持仓表只读计算列、缺数据/非法公式边界。 diff --git a/docs/04-迭代记录/22-策略计算字段与策略tab静态化/验收标准.md b/docs/04-迭代记录/22-策略计算字段与策略tab静态化/验收标准.md new file mode 100644 index 0000000..2b3b293 --- /dev/null +++ b/docs/04-迭代记录/22-策略计算字段与策略tab静态化/验收标准.md @@ -0,0 +1,98 @@ +# 验收标准:22-策略计算字段与策略 tab 静态化 + +> 依据:R-027(第一阶段)+ R-026(第二阶段)+ PLAN-019 + `技术实现方案.md` | 日期:2026-09-10 | 状态:**待实施后验收** +> 验收线在哪里:本文件(分阶段清单 + 判定线);配套证据:回归脚本输出、typecheck/build 输出、老师人工页面确认 + +## 1. 验收目标 + +- **第一阶段**:策略 tab 变成**插件内置静态 tab**(不可增删改名,保留显隐/排序),设置页「策略分组」消失,字段配置入口落到**策略 tab 内、列设置旁**; +- **第二阶段**:「计算(formula)」字段类型可用——用户写中文变量公式(引用行情/合约/持仓账本/本行手填字段),服务端按持仓行**实时算**出列值,**不落库、列只读、缺数据 `—`**; +- 既有能力(四类手填字段、列设置、历史持仓、份额账本、交易归属)**零回归**。 + +## 2. 验收标准线 · 第一阶段(R-027 重构) + +| # | 验收项 | 判定 | +|---|---|---| +| A1 | 设置页子 tab 仅「Tab 设置 / QMT 连接配置」 | 页面无「策略分组」入口 | +| A2 | 无任何策略新增/重命名/删除入口 | 设置页与策略 tab 均无该入口;服务端调用 `strategies/add\|remove\|update` 返回 unknown method | +| A3 | 策略 tab 恒为「网格超市 / 手动做T」 | 重启、改配置、增删数据后仍恒两项 | +| A4 | Tab 设置中策略行与内置行同权 | 可拖拽排序(间隙高亮线)、可显隐开关;现有偏好(策略置顶 / 全部持仓·关注列表隐藏)保留 | +| A5 | 策略 tab 顶栏有「字段配置」与「列设置」两个按钮 | 位置:字段配置在左、列设置在其右 | +| A6 | 字段配置弹层显示**本策略**现有字段 | 网格超市 5 个(网格上边界/下边界/基准值/网格大小/网格交易量);手动做T 1 个(T仓成本价) | +| A7 | 弹层内可添加/编辑/删除字段并保存 | 保存走 `strategies/schema-update`;成功 Toast;另一策略定义不受影响 | +| A8 | 字段增删后表格列自动跟随 | 新增列出现在表格(列设置中可勾选/排序);删除字段后该列消失 | +| A9 | 未保存改动时关闭弹层 | 出现「确定放弃?」二次确认(D-8) | +| A10 | 重启后字段定义仍在 | 存储落 `strategyFields`(旧 `strategies[].configSchema` 首次保存后被取代,无迁移脚本) | +| A11 | 数据零影响 | `strategy_holdings` 行数(13 / 3)、交易归属、份额不变 | + +## 3. 验收标准线 · 第二阶段(R-026 计算字段) + +| # | 验收项 | 判定 | +|---|---|---| +| B1 | 字段类型下拉含「计算」 | 选中后隐藏默认值,出现公式框 + 变量选择器 + 试算 + 小数位(0–4,默认 2) | +| B2 | 变量选择器按分组列出可用变量 | 行情(现价/昨收/今开/最高/最低/成交量/成交额)、合约(涨停价/跌停价)、持仓(份额/成本价/最后成交价)、自定义字段(本策略手填字段的**展示名**) | +| B3 | 公式用**中文变量**且可算出结果(四式用例) | ① `(现价 - 成本价) × 份额` ② `(现价 ÷ 成本价 - 1) × 100` ③ `(涨停价 - 现价) ÷ 现价 × 100` ④ `现价 × 份额`(或 × 网格交易量)——每行值正确(与手工核算一致) | +| B4 | 试算可用 | 点「试算」用该策略一行真实持仓算出并显示(含代码/名称/格式化结果);无持仓 → 「暂无可试算的持仓数据」 | +| B5 | 保存校验生效 | 语法错误(如 `现价 ×`)→ 拒绝 + 公式框描红 + 定位文案;未知变量 → 拒绝;字段展示名与内置变量冲突(如叫「现价」)→ 拒绝;同策略展示名重复 → 拒绝 | +| B6 | 计算列只读且可辨识 | 表头 `ƒ` 前缀;点击单元格**不进入**编辑;悬停列头/单元格显示公式原文 | +| B7 | 格式化正确 | 按小数位 + 单位显示(如 `6.67 %`、`700.00 元`) | +| B8 | 缺数据不炸表 | 无行情 / 除零 / 非有限 / 手填字段值非数字 → 该格 `—`(不显示 NaN、不报错、其余列正常) | +| B9 | 计算字段**不落库** | `strategy_holdings.values` 无 formula 字段键;重启后公式仍在(存 settings)、值仍为实时计算 | +| B10 | 历史持仓行 | 已清仓行的计算列显示 `—`(不做历史时点计算) | +| B11 | 列设置联动 | 计算列与手填列同列混排(名称带 `ƒ` 前缀),显隐/拖拽排序正常 | +| B12 | 零回归 | 四类手填字段编辑、列设置、历史持仓开关与范围、份额操作、交易归属、Tab 设置全部照旧 | + +## 4. 边界与异常用例 + +| 场景 | 期望 | +|---|---| +| 公式引用已删除的字段 | 保存时被拒(未知变量);已存在的旧公式运行时该列 `—`,字段列表 `⚠` 标记 + 悬停原因(D-4) | +| 公式引用另一个计算字段 | 被拒(变量目录不含 formula 字段,零循环依赖) | +| 公式为空 / 只有空格 | 拒绝保存「请填写公式」 | +| 极长公式 / 深层括号 | 不崩溃(解析器深度限制,超出 → 语法错误提示) | +| 手填字段值为文本(非数字) | 参与计算时按 `null` 处理 → `—`(不报错) | +| QMT 断开 | 该策略计算列大面积 `—`;表格其余功能正常 | +| 无 formula 字段的策略 | 行为与重构前完全一致(无额外取数开销) | +| 弹层打开时保存失败(服务端异常) | Toast 错误、弹层不关闭、改动保留可重试 | + +## 5. 验收方法 + +1. **回归脚本**(独立数据目录,技术约束-011) + - `node scripts/test-r027-static-strategies.mjs` → 全绿(内置策略常量 / 旧定义兼容 / 单策略写入 / tabs 归一化 / 端点退役); + - `node scripts/test-r026-formula-fields.mjs` → 全绿(引擎 / 目录 / 校验 / 附着 computed / 试算); + - `node scripts/test-r011-tabs.mjs`、`node scripts/test-r013-custom-fields.mjs`(已更新)→ 全绿; +2. **静态检查**:`pnpm typecheck` + `pnpm build` 通过; +3. **老师人工验收**(按 §6 步骤逐条确认,A1–A11 + B1–B12)。 + +## 6. 老师配合项(人工验收步骤) + +1. 打开设置页 → 确认只有「Tab 设置 / QMT 连接配置」(A1/A2); +2. Tab 设置 → 拖动两个策略 tab、切换显隐(A3/A4); +3. 进入策略 tab(网格超市)→ 顶栏 `[字段配置] [列设置]` → 打开字段配置 → 看到原有 5 个字段(A5/A6); +4. 添加计算字段(例:展示名「网格占用」、类型「计算」、单位「元」、小数位 2、公式 `现价 × 份额`)→ 插入变量 → 试算 → 保存(B1/B2/B4); +5. 表格中该列出现并显示数值;悬停看公式;点击不可编辑(B3/B6/B7); +6. 再配两个计算字段验证其余用例:`(现价 - 成本价) × 份额`、`(现价 ÷ 成本价 - 1) × 100`(单位 %)(B3); +7. 故意写坏公式(`现价 ×`)、引用未知变量、把字段命名为「现价」→ 均应被拒绝并提示(B5); +8. 造缺数据场景(如某 code 无行情)→ 该格 `—`,表格正常(B8); +9. 清仓一只持仓 → 历史行计算列 `—`(B10); +10. 重启插件 → 字段定义仍在、值重新现算(A10/B9); +11. 抽查数据库:`strategy_holdings.values` 不含 formula 键(B9)。 + +## 7. 判定线 + +**通过(全部满足)** +- A1–A11 与 B1–B12 全部通过; +- 回归脚本全绿 + typecheck/build 通过; +- 老师人工验收确认。 + +**任一即不通过** +- 计算字段写入 `values` 列(或落任何库表); +- 公式可执行表达式以外的行为(无 `eval/Function`、不可触达进程/文件能力); +- 无 formula 字段的策略出现额外取数开销或行为变化(回归); +- 既有四类字段编辑、列设置、历史持仓、份额/归属任一回归失败; +- 策略仍可被增删改名(静态化未达成)。 + +## 8. 明确不在本次验收范围 + +- 策略级聚合(跨行求和)、QMT 账户域/交易记录作为变量源、公式引用公式、历史时点计算、布尔型公式结果、公式框实时高亮/自动补全、完整 T-006 数据池; +- 自建策略的历史数据清理(本机无自建策略;如未来出现,另行讨论)。 diff --git a/docs/04-迭代记录/23-计算字段判定型扩展/技术实现方案.md b/docs/04-迭代记录/23-计算字段判定型扩展/技术实现方案.md new file mode 100644 index 0000000..2d8d2a1 --- /dev/null +++ b/docs/04-迭代记录/23-计算字段判定型扩展/技术实现方案.md @@ -0,0 +1,88 @@ +# 技术实现方案:23-计算字段判定型扩展 + +> 依据:R-028(已定稿)+ R-026 既有实现(迭代 22)| 日期:2026-09-10 | 状态:**已实施** + +## 1. 公式引擎(src/formula/evaluator.js) + +- **词法**:新增多字符运算符识别(`>= <= == != && ||` 优先于单字符);全角/符号归一扩展 + `≥→>= ≤→<= ≠→!= >→> <→< =→=`; +- **语法优先级**(低 → 高):`or` → `and` → 比较(`> < >= <= == !=`) → 加减 → 乘除 → 一元负 → 主; + `and`/`or` 以标识符(大小写不敏感)识别,`&&`/`||` 等价; +- **求值**:比较返回布尔;逻辑返回布尔(`!!l && !!r`);两者**缺值短路**(任一操作数为 null → null); + 数字运算维持原语义(除零 → null、最终结果浮点归一);`normalizeResult` 对布尔直通; +- **新增 `inferResultKind(ast)`**:顶层为比较/逻辑 → `'boolean'`(`neg` 递归下钻),否则 `'number'`; +- 校验(`validateFormula`)逻辑不变(变量存在性 / 函数白名单 / arity)。 + +## 2. 存储与校验 + +- **src/settings.js**:`fieldSchema` 增 `resultKind: z.union([z.const('number'), z.const('boolean')]).default('number')`; + `normalizeFields` 对 formula 字段归一 `resultKind`(非 `'boolean'` 一律 `'number'`),`decimals` 维持夹取 0-4; +- **src/api/strategies.js#validateConfigSchema**:公式校验通过后增加**结果类型一致性**校验 —— + `declared = resultKind`(默认 number)必须等于 `inferResultKind(compileFormula(formula).ast)`, + 不一致抛 `field-validation`: + - 声明「判定」但公式无比较 → 「结果类型选了「判定」,但公式没有比较运算(如 涨停价 > 基准值 + 1)」; + - 公式是判定但声明「数字」 → 「公式结果是判定,请把「结果类型」改为「判定」」。 + +## 3. 现算(src/formula/FormulaService.js) + +`computed[key]` 布尔直通: + +```js +computed[c.key] = (typeof v === 'boolean') ? v + : ((typeof v === 'number' && Number.isFinite(v)) ? v : null); +``` + +其余不变(无 formula 字段零开销、只读 QuoteHub 内存缓存、不落库、历史行不计算)。 + +## 4. 前端 + +**StrategyFieldsEditor.jsx** +- 表单草稿增 `resultKind`(默认 `number`,编辑时回填); +- 「类型」行新增「结果」下拉(数字 / 判定); +- 公式框 placeholder 按结果类型切换(判定示例:`涨停价 > 基准值 + 网格大小`); +- 公式框下新增运算符提示行:`运算:+ - × ÷ 括号;判定:> < >= <= == != ,组合:and / or`; +- `resultKind === 'boolean'` 时**隐藏小数位**输入; +- 列表「类型」列显示 `计算(判定)`; +- **判定型试算**:`value` 为布尔时显示 `试算:<名称> <代码> → ✓ 满足 / — 不满足`。 + +**StrategyTab.jsx** +- 计算列渲染:**先判布尔**(`typeof raw === 'boolean'`)—— `true` → `✓ <字段展示名>`(`state-success-primary`), + `false` / 缺值 → `—`(tertiary);否则走原数字格式化(`toFixed(decimals)` + 单位); + 顺序不可颠倒(`Number(false) === 0` 会被误格式化成 `0.00`); +- 表头 `ƒ` 前缀、悬停公式原文、历史行 `—` 维持不变。 + +## 4.5 附:公式引擎选型论证(2026-09-10 老师提问「有没有现成的库」) + +**结论:维持自写引擎**(`src/formula/evaluator.js`,约 250 行 / 零依赖 / 71 条回归断言)。 + +| 候选 | 中文变量名 | 能力覆盖 | 体积/依赖 | 风险与维护 | +|---|---|---|---|---| +| **expr-eval** | ✗ ASCII 标识符 | 四则/比较/and·or/**三元 ?:**/属性访问/数组索引 | 小 | **CVE-2025-12735**:构造 variables 对象可致**任意代码执行**(GHSA-jc85-fpwf-qm7x);上游维护停滞,社区有 fork | +| **mathjs** | ✗ 明确枚举可用字符(拉丁/希腊/字母类/数学字母数字),**不含 CJK** | 极全(矩阵/单位/复数/函数库) | 大(数百 KB 级) | 重;unicode 标识符诉求自 2015 年 issue #265 起未覆盖 CJK | +| **Jexl** | ✗ ASCII 标识符(支持 `a.b` / `a[b]` 取值) | 比较/逻辑/三元/自定义运算符 | 中 | 取值语义与「缺值短路」需自行包装 | +| **CEL(cel-js 等)** | ✗ ASCII 标识符 | 受限表达式语言(工业标准、可静态检查) | 中 | 语义偏严格,接入成本不低 | +| **解析器工具包**(chevrotain / ohm-js) | ✓(自定义词法) | 自己写语法 + 求值 | 中 | **等于仍自己实现**,只是换成语法文件 + 依赖 | +| **自写引擎(现状)** | ✓ 原生支持 | 四则/括号/比较/and·or/round·abs·min·max | 0 | 无 eval/Function、无属性访问、无数组索引、变量来自白名单目录 → **结构上无任意代码执行面** | + +**为什么不换**: + +1. **中文变量名是产品决策**(R-026 Q2 老师拍板)。主流库标识符均限 ASCII → 引库就得加「中文名 → 占位符」映射层 + (按长度降序替换防前缀冲突 + 错误位置与提示回译),**复杂度不降反升**,报错体验还变差; +2. 所需能力集很小,自写约 250 行即覆盖,且**语义完全可控**(缺值短路 → `—`、除零 → `—`、最终值浮点归一); +3. **攻击面**:表达式与变量都来自用户输入,expr-eval 恰恰因变量对象注入出过 RCE;自写引擎不做属性访问/动态函数调用(仅 4 个纯数学函数白名单),无此类面; +4. 项目为 DSH 外部插件,依赖越少越稳(现依赖仅 schemastery + MCP SDK)。 + +**什么时候值得换成库(判据)**: + +- 需要**条件分支/三元**(如三态信号「空/多/观望」)→ 自写加 `?:` 约 30 行即可,仍不必引库; +- 需要**字符串/日期/正则** → 能力超出「数值公式」,届时评估 CEL / Jexl(并重做安全评估); +- 需要**Excel 级函数库**(SUM/IF/VLOOKUP…)→ 考虑 hyperformula / Formula.js,但那是产品形态变化(表格公式),应先讨论需求。 + +> 若后续决定改用第三方库,需同步修订**技术约束-022**(现规定「自写零依赖、禁止 eval/Function」)。 + +- `scripts/test-r026-formula-fields.mjs` 新增两节(第 7 节判定型 15 条 + 第 8 节端到端 8 条): + 比较/and/or/全角符号/短路、`inferResultKind` 三例、结果类型一致性双向拒绝、布尔直通、 + `strategy-positions` 附判定 `computed`、判定试算; +- **端到端夹具 = 真实数据**:以 2026-09-10 实盘涨跌停(积成电子 8.22/6.72 等)+ 真实持仓基准值构造三行, + 断言 `可下空单 / 可下多单` 判定与实算一致; +- `npx tsc --noEmit` 0 错;`npm run build` 通过;全量 **17 个回归脚本全绿**(r026 由 48 → 71 条断言)。 diff --git a/docs/04-迭代记录/23-计算字段判定型扩展/迭代复盘.md b/docs/04-迭代记录/23-计算字段判定型扩展/迭代复盘.md new file mode 100644 index 0000000..157ff73 --- /dev/null +++ b/docs/04-迭代记录/23-计算字段判定型扩展/迭代复盘.md @@ -0,0 +1,43 @@ +# 迭代复盘:23-计算字段判定型扩展 + +## 结论 + +**实现完成,待老师人工验收**。计算字段由「只出数字」扩展为「数字 / 判定」双结果类型; +网格超市两条网格规则(可下空单 / 可下多单)已可用公式表达,行为与实盘数据一致(真实验证 5 只 / 2 只)。 + +## 事实记录 + +- **引擎**(src/formula/evaluator.js):多字符运算符识别 + 全角 `≥ ≤ ≠ > < =` 归一; + 优先级 `or < and < 比较 < 加减 < 乘除`;比较/逻辑求值(缺值短路);`normalizeResult` 布尔直通; + 新增 `inferResultKind(ast)`; +- **存储**(src/settings.js):`fieldSchema.resultKind`(number|boolean,默认 number)+ `normalizeFields` 归一; +- **校验**(src/api/strategies.js):结果类型一致性校验(声明 vs `inferResultKind`),不一致抛 `field-validation`; +- **现算**(src/formula/FormulaService.js):`computed` 布尔直通; +- **前端**:`StrategyFieldsEditor` 结果类型下拉 + 判定时隐藏小数位 + 运算符提示 + 判定试算(✓ 满足 / — 不满足); + `StrategyTab` 判定列渲染 `✓ 字段名`(绿)/ `—`(灰,含缺值与 `false`);列表类型显示 `计算(判定)`; +- **验证**:`test-r026` 48 → **71** 条(新增第 7 节判定型 15 条、第 8 节真实夹具端到端 8 条); + typecheck 0 错;build 通过(client bundle 197794 bytes);全量 17 脚本全绿。 + +## 设计与实现要点(沉淀) + +1. **布尔必须先判**:渲染分支若先做 `Number.isFinite(Number(raw))`,`false` 会被当成 `0` 格式化成 `0.00`—— + 实现中显式把 `typeof raw === 'boolean'` 放在最前(代码注释标注); +2. **判定型「不满足」= `—` 而非「否」**:缺行情与条件不满足在视觉上同形,避免把「数据缺失」误读为「不可以」; +3. **结果类型显式声明 + 静态推断校验**:避免「公式悄悄变成判定」导致列渲染与语义脱节; + 推断只看**顶层**运算(`neg` 下钻),规则简单可预期; +4. **阈值用字段引用而非字面量**:`基准值 + 网格大小` 让规则随字段可调(老师 Q5 拍板)。 + +## 偏差 + +- 无功能偏差;范围按 Q1–Q5 收敛(方案 C 三态信号、`not` 取反未做,已记入「不做」)。 + +## 补记(2026-09-10 老师反馈) + +- 字段配置弹层的变量选择从「+ 插入变量 ▾」下拉改为**标签平铺**(分组成行、点选即插入公式框光标处); + 已随迭代实施并重建 bundle(typecheck/build/17 脚本回归全绿)。 + +## 遗留 + +- 判定「不满足」与「缺数据」同显示为 `—`:若老师希望区分(如缺数据显示 `?` 或 tooltip「数据缺失」), + 可后续加 hover 原因; +- 由老师侧重载插件后手动添加两个字段(或授权我用 `strategies/schema-update` 端点直接写入 settings)。 diff --git a/docs/04-迭代记录/23-计算字段判定型扩展/迭代目标.md b/docs/04-迭代记录/23-计算字段判定型扩展/迭代目标.md new file mode 100644 index 0000000..46dcd1e --- /dev/null +++ b/docs/04-迭代记录/23-计算字段判定型扩展/迭代目标.md @@ -0,0 +1,42 @@ +# 迭代目标:23-计算字段判定型扩展(含网格超市信号字段) + +> 依据:**R-028(已定稿,2026-09-10)** | 类型:小步迭代(R-026 能力延伸,不单列计划文档) +> 日期:2026-09-10 | 状态:**已实现,待老师人工验收** + +## 目标 + +把计算字段从「只出数字」扩展到**判定型**(比较运算 + and/or 组合,结果类型由字段显式声明), +并用它给网格超市落地两条网格规则:**可下空单** = `涨停价 > 基准值 + 网格大小`、 +**可下多单** = `跌停价 < 基准值 - 网格大小`。 + +## 目标描述 + +- **背景**:老师提出「当天涨停值 > 当前基准值 + 1 即可下空单 / 当天跌停值 < 当前基准值 - 1 可下多单」; + R-026 的计算字段只产数字(无比较运算),需先扩能力; +- **范围**:引擎(比较/逻辑/inferResultKind)、存储(`resultKind`)、API 校验(结果类型一致性)、 + 字段表单(结果类型选择 + 判定时隐藏小数位 + 运算符提示 + 判定试算)、持仓表判定列渲染(`✓ 字段名` / `—`); +- **不做**:三态信号字段(空/多/观望,方案 C)、not 一元取反、判定与数字的混合字段、策略级聚合; +- **验收线**(详见 `验收标准.md`):C1–C8。 + +## 目标分解 + +1. 引擎:比较 `> < >= <= == !=`(含全角 `≥ ≤ ≠`)+ 逻辑 `and/or`(含 `&& ||`)+ 优先级 + 缺值短路 + `inferResultKind`; +2. 存储/校验:`resultKind: number|boolean`(归一化 + 保存校验一致性); +3. 现算:`computed` 布尔直通(判定型不参与 toFixed 格式化); +4. UI:结果类型选择、判定占位与运算符提示、判定列 `✓ 字段名`(绿)/ `—`(灰)、判定试算(✓ 满足 / — 不满足); +5. 回归:`test-r026` 增补 R-028 段(23 条)+ 网格超市真实夹具端到端; +6. 文档:R-028 + 本迭代四件套 + 约束变更 3 处。 + +## 目标讨论过程 + +1. 2026-09-10 老师提出网格超市两条判定规则; +2. AI 用**真实数据**验证可行性(13 只持仓 × 今日涨跌停 × 真实基准值 → 空单 5 / 多单 2), + 并指出 R-026「只出数字」的能力边界 → 提出 A/B/C 三方案; +3. 老师拍板 Q1–Q5(全采建议):A 判定型 / ✓ 与 — / 支持 and·or / 结果类型显式 / 阈值引用网格大小; +4. T-012 转正 R-028 → 本轮实施完成(typecheck + build + 17 脚本全绿)。 + +## 对老师(项目主理人)的配合需求 + +- **重载插件后**在页面添加这两个字段(字段配置 → 添加字段 → 类型「计算」→ 结果「判定」→ 公式粘贴 → 试算 → 保存); + 或告知由我用端点写入; +- 按 `验收标准.md` C1–C8 人工确认。 diff --git a/docs/04-迭代记录/23-计算字段判定型扩展/验收标准.md b/docs/04-迭代记录/23-计算字段判定型扩展/验收标准.md new file mode 100644 index 0000000..044c8cf --- /dev/null +++ b/docs/04-迭代记录/23-计算字段判定型扩展/验收标准.md @@ -0,0 +1,48 @@ +# 验收标准:23-计算字段判定型扩展 + +> 依据:R-028 + `技术实现方案.md` | 日期:2026-09-10 | 状态:**待老师人工验收** + +## 1. 验收目标 + +判定型计算字段可用(比较 + and/or + 结果类型显式声明 + 校验一致),并用它给网格超市落地 +「可下空单 / 可下多单」两列;数字型计算字段与既有能力零回归。 + +## 2. 验收标准线 + +| # | 验收项 | 判定 | +|---|---|---| +| C1 | 字段表单出现「结果」下拉(数字 / 判定) | 选「判定」后**小数位输入消失**;公式框 placeholder 变为判定示例 | +| C2 | 判定公式可保存 | 展示名「可下空单」/ 类型「计算」/ 结果「判定」/ 公式 `涨停价 > 基准值 + 网格大小` → 保存成功 | +| C3 | 判定试算 | 点「试算」显示 `试算:<名称> <代码> → ✓ 满足` 或 `— 不满足`(无持仓 → 「暂无可试算的持仓数据」) | +| C4 | 持仓列渲染 | 满足行显示 `✓ 可下空单`(绿);不满足 / 缺行情 → `—`(灰);表头 `ƒ` 前缀、悬停见公式原文 | +| C5 | 两条规则与实盘一致 | 今日上证实况下,「可下空单」应为 5 只(积成电子/天海防务/万顺新材/大连热电/华智数媒)、「可下多单」应 2 只(中国化学/TCL中环),其余两列均为 `—` | +| C6 | 逻辑组合可用 | 公式 `涨停价 > 基准值 + 网格大小 and 现价 < 基准值 + 网格上边界` 可保存并算出(and / or / `&&` / `\|\|` 均可) | +| C7 | 结果类型一致性校验 | 声明「判定」但公式为算式(`1 + 1`)→ 拒绝并提示;公式为判定但声明「数字」→ 拒绝并提示 | +| C8 | 零回归 | 数字型计算字段(如 `(现价 - 成本价) × 份额`)照常;四类手填字段、列设置、历史行 `—`、Tab 设置与静态策略均不受影响 | + +## 3. 边界与异常 + +| 场景 | 期望 | +|---|---| +| 缺行情 / 缺合约信息(涨停跌停取不到) | 判定列 `—`(不显示 false 误导) | +| 手填字段值空(如未填基准值) | 判定列 `—` | +| 全角符号输入(`≥ ≤ ≠ ×÷`) | 正常解析 | +| 公式引用另一个公式字段 | 仍被拒(零循环依赖不变) | +| 历史持仓行 | 判定列 `—` | + +## 4. 验收方法 + +1. `node scripts/test-r026-formula-fields.mjs` → **71/71**(含判定型 15 条 + 真实夹具端到端 8 条); +2. 全量回归 17 个脚本全绿;`npx tsc --noEmit` 0 错;`npm run build` 通过; +3. 老师人工验收:重载插件 → 网格超市 tab → 字段配置 → 添加上述两字段 → 对照 C1–C8(重点 C5 与实盘一致性)。 + +## 5. 判定线 + +**通过**:C1–C8 全部通过 + 回归/构建全绿 + 老师人工确认。 +**任一即不通过**:判定列把 `false` 显示成数字(`0.00`);缺数据显示为 `否`(误导);结果类型声明与公式不一致仍能保存; +数字型计算字段或既有四类字段出现回归。 + +## 6. 不在本次范围 + +三态信号字段(空 / 多 / 观望,方案 C)、`not` 一元取反、判定与数字混用的单字段、策略级聚合(跨行求和)、 +历史持仓行的时点判定。 diff --git a/docs/05-需求池/R-026.md b/docs/05-需求池/R-026.md new file mode 100644 index 0000000..a8b0e66 --- /dev/null +++ b/docs/05-需求池/R-026.md @@ -0,0 +1,127 @@ +# 需求:R-026 策略计算字段(公式字段)· 已定稿 + +> 登记:2026-09-10 | 来源:老师指令(2026-09-10 功能讨论「策略组字段配置优化」)| 状态:**已定稿(2026-09-10 老师确认)** +> 归属:**22-策略计算字段与策略tab静态化 · 第二阶段**(PLAN-019)| 实现状态:**已实现(2026-09-10 完成,typecheck/build/17 个回归脚本全绿),待老师人工验收** +> **入口条款修订(2026-09-10,R-027 取代)**:字段配置入口由「设置页 → 策略分组 → 策略行展开」**迁至「策略 tab 内、列设置旁的『字段配置』弹层」**;本文档 §9 配置 UI 的入口位置随 R-027 变更,字段模型 / 公式语义 / 只读列 / 列机制均不变。 +> 前身:草稿 T-010(已转正,草稿文件已清理) + +## 诉求(老师,2026-09-10) + +> 策略组字段配置优化:添加的字段,目前仅是支持用户指定值;接下来添加新的字段类型,就是**可以计算**, +> 目标是让用户**写一个公式**,公式中支持来自几个**服务端缓存的数据集**,然后通过计算得出结果。 + +## 背景(现状 R-013/迭代 11) + +- 策略字段定义 = `settings.strategies[].configSchema`:`{ key, label, type(text|number|boolean|enum), enum[], def, unit }`; +- 字段值 = `strategy_holdings.values` JSON 列(**用户手工填写**,单元格内联编辑); +- 展示 = 持仓表列化(列显隐/顺序由 `strategyColumns` 每策略独立配置); +- 本次扩展:新增**第五种字段类型「计算(formula)」**——值不是填的,是**算的**。 + +## 定稿结论(2026-09-10 老师逐项确认) + +### 1. 字段类型与存储语义 + +- `configSchema.type` 增加 `'formula'`;新增 `formula` 表达式串字段(与 `unit` 同级,随策略定义存 settings); +- **计算字段不落库**:不读写 `strategy_holdings.values`,每次读 `strategy-positions` 时**逐行实时现算**; +- 表格内**只读**(点击不进内联编辑);列配置兼容零改动(formula 字段自动进列集,新增默认追加末尾)。 + +### 2. 公式可引用的数据集(首批 = 行情/合约 + 持仓账本 + 本行自定义字段) + +| 数据集 | 来源(服务端缓存) | 可引用字段 | 刷新 | +|---|---|---|---| +| 行情盘口 | QuoteHub.quotes(内存) | 现价 / 昨收 / 今开 / 最高 / 最低 / 成交量 / 成交额 | 5s REST | +| 合约信息 | QuoteHub.instruments(内存) | 涨停价 / 跌停价 / 昨收 | 按交易日 | +| 持仓账本 | SQLite strategy_holdings | 策略份额 / 成本价 / 最后一笔成交价 | 交易关联驱动 | +| 本行自定义字段值 | strategy_holdings.values | 本行手填字段(如网格间距、目标价) | 内联编辑 | + +**后补(本期不做)**:QMT 账户域持仓(全账户 volume/可用/市值,跨策略视角)、交易记录聚合(当日已成交量/买卖笔数)——用例尚不明确。 + +### 3. 变量命名:**中文变量名** + +- 公式形如 `(现价 - 成本价) × 份额`,可读性优先; +- 配置时校验**变量名唯一**;配置 UI 提供**变量选择器**(按数据集分组,点击插入); +- 引擎需支持中文标识符(自写解析器,见 §4)。 + +### 4. 公式引擎:自写小型表达式解析器(零依赖) + +- 能力:四则运算 + 括号 + 常见函数(round / abs / min / max); +- 选型理由:中文标识符需自定义词法,第三方库(expr-eval/mathjs/jexl)均不支持中文变量;该层未来即 T-006 数据池的表达式层,值得自有。 + +### 5. 计算位置与粒度:**服务端计算 + 仅行级** + +- 服务端在 `strategy-positions` 返回时逐行现算随行带回(行情单一入口 QuoteHub 在服务端);前端仅渲染; +- 行级上下文 = 本行行情 + 本行持仓账本 + 本行自定义字段值 → 一个结果; +- **策略级聚合(跨行求和,如本策略总盈亏)留二期**。 + +### 6. 循环引用边界:公式**只允许引用非公式字段** + +- 变量目录直接排除 formula 字段 → 不存在循环依赖(不做检测与拓扑排序)。 + +### 7. 结果类型与格式 + +- 首批结果**只做数字**(布尔型公式后续再议); +- 复用现有 `unit`(展示拼在值后);新增**小数位**配置(默认 2 位)。 + +### 8. 缺数据与错误处理 + +- **保存时校验**:语法 + 变量存在性(服务端编译试算一次),非法公式直接拒绝保存并提示; +- **运行期**:缺行情 / 除零 / 无法计算 → 该单元格显示 `—`(不显示 NaN、不报错、不炸整表)。 + +### 9. 配置 UI(设置页「策略分组」字段表单) + +- 类型下拉增加「计算」→ 出现公式输入框 + 变量选择器(分组:行情 / 合约 / 持仓 / 自定义字段)+ 变量速查; +- 加分项:**试算预览**(取一行真实持仓数据现场算出结果)。 + +### 10. 历史持仓行(已清仓) + +- 历史 tab 的公式列统一显示 `—`:清仓行无「现价」语义,**不做历史时点计算**(属另一需求,不混入本期)。 + +### 11. 与服务端数据池(T-006)的关系 + +- 本期先落**轻量变量目录**(服务端声明:数据集 → 字段清单 → 取数函数),公式引擎跑在目录之上; +- 该目录即 T-006「数据池中间层」的第一块砖,**不一次性建完整数据池**(避免过度设计)。 + +## 典型用例(老师视角验收样例) + +| 字段 | 公式 | +|---|---| +| 浮动盈亏 | `(现价 - 成本价) × 份额` | +| 盈亏比例 | `(现价 ÷ 成本价 - 1) × 100` | +| 距涨停 | `(涨停价 - 现价) ÷ 现价 × 100` | +| 网格占用(手填+计算组合) | `现价 × 份额 × 网格间距` | + +## 决策记录 + +| 项 | 结论 | 讨论 | +|---|---|---| +| 字段类型 | configSchema.type 加 `formula` + `formula` 串 | 老师指令(新字段类型) | +| 存储语义 | **不落库**,读接口时逐行现算 | 「计算」的本质;values 列不参与 | +| 数据集范围 | 首批 行情+合约 / 持仓账本 / 本行自定义字段值 | 选择题确认(1+2+3);QMT账户域、交易聚合后补 | +| 变量命名 | **中文变量名** + 选择器插入 | 选择题确认;配置时校验唯一 | +| 计算粒度 | **仅行级**;策略级聚合二期 | 选择题确认 | +| 计算位置 | 服务端(strategy-positions 现算随行返回) | 老师方向「引用服务端缓存数据集」;行情单一入口在服务端 | +| 公式引擎 | 自写小型解析器(零依赖,支持中文标识符) | 第三方库不支持中文变量;未来即数据池表达式层 | +| 循环引用 | 只允许引用非公式字段 | 目录层排除,无循环问题 | +| 结果类型 | 数字 + unit + 小数位(默认 2) | 布尔型后议 | +| 错误处理 | 保存校验拒绝 + 运行期显示 `—` | 不炸整表 | +| 配置 UI | 公式输入 + 变量选择器(+试算预览) | 计算字段只读不内联编辑 | +| 历史行 | 公式列显示 `—` | 不做历史时点计算 | +| 数据池关系 | 轻量变量目录先行(T-006 第一块砖) | 避免一次性建完整中间层 | + +## 关联 + +- 上游:R-013(自定义字段配置,字段类型与列机制)/ 迭代 11(列配置 strategyColumns); +- 数据来源:R-015(QuoteHub 盘口内存快照)、R-014(PositionSync 账户域)、R-010(最后一笔成交价)、R-018(份额账本); +- 延展:T-006(数据池中间层草稿)——本需求的变量目录为其起点; +- 约束条目待实施时补充(拟:产品功能约束 / 技术方案约束 / UI交互约束 各一条,沿用 R-013 惯例)。 + +## 讨论过程 + +- 2026-09-10 老师提出功能方向(策略组字段配置优化 → 可计算公式字段); +- AI 摸清 R-013 现状 + 盘活服务端缓存数据集清单,提出 Q1-Q10 设计问题清单(各附建议); +- 老师「定」:Q4-Q10 按 AI 建议; +- 选择题三项确认:Q1 数据集 1+2+3、Q2 中文变量名、Q3 仅行级计算; +- 转正定稿:T-010 草稿 → R-026(草稿文件已清理,索引同步); +- 2026-09-10 迭代 22 交互设计过审:老师逐项拍板 D-1~D-7(全采纳 AI 建议)→ `docs/04-迭代记录/22-策略计算字段与策略tab静态化/UI交互设计.md` 定稿; +- 2026-09-10 老师提出重构想法(策略 tab 静态化 + 字段配置入口迁移)→ 定稿 **R-027**:本需求的**字段配置入口**随之从设置页迁到「策略 tab 内、列设置旁的『字段配置』弹层」(迭代 22 调整为「第一阶段=重构、第二阶段=本需求」); + 下一步:技术实现方案 + 验收标准(本轮按老师指令未做技术方案与编码)。 diff --git a/docs/05-需求池/R-027.md b/docs/05-需求池/R-027.md new file mode 100644 index 0000000..dff8c71 --- /dev/null +++ b/docs/05-需求池/R-027.md @@ -0,0 +1,85 @@ +# 需求:R-027 策略 tab 静态化 + 字段配置入口迁移 · 已定稿 + +> 登记:2026-09-10 | 来源:老师指令(2026-09-10 功能讨论「重构想法」)| 状态:**已定稿(2026-09-10 老师逐项确认)** +> 归属:**22-策略计算字段与策略tab静态化 · 第一阶段**(PLAN-019)| 实现状态:**已实现(2026-09-10 完成,typecheck/build/17 个回归脚本全绿),待老师人工验收** +> 前身:草稿 T-011(已转正,草稿文件已清理) +> **取代**:R-003(策略 CRUD)、R-011(Tab 统一管理中的「策略条目动态化」部分)、R-013(字段定义入口在设置页部分) + +## 诉求(老师,2026-09-10) + +> 1. 策略 tab 我不想做成动态可增减修改的了,改成静态的,和全部持仓、交易记录这些一样,都是插件内置的 tab; +> 2. 这两个策略 tab 原有的字段编辑功能,切换到主窗口的策略 tab 下面,列设置旁边,作为统一的字段配置管理。 + +## 现状核实(2026-09-10 实机,代码 + settings.yaml + store.db) + +- 策略仅两个内置:`grid-supermarket`(网格超市,5 字段:网格上边界/下边界/基准值/网格大小/网格交易量)、`manual-t`(手动做T,1 字段:T仓成本价); +- `store.db`:`strategy_holdings` 13 / 3 行,`trade_orders`、`trade_order_attributions` 均只涉及这两个策略 → **无自建策略,零数据迁移风险**; +- `settings.tabs` 现状:网格超市(0) → 手动做T(1) → 交易记录(2) → 关注列表(3,隐藏) → 全部持仓(4,隐藏); +- 现状实现:`addStrategy/removeStrategy` 联动 `appendStrategyTab/removeStrategyTab`(R-011);字段编辑 = 设置页「策略分组」子 tab 内 `StrategyFieldsEditor`,保存走 `strategies/update` 整表。 + +## 定稿结论 + +### 1. 策略 tab 静态化(Q1) + +- 策略**集合与名称固定**(插件内置两项,语义等同 `BUILTIN_TABS`):**不可新增、不可删除、不可重命名**; +- **保留**「Tab 设置」里的**显隐开关 + 拖拽排序**(策略 tab 与内置 tab 同权混排)——老师现有偏好(策略 tab 置顶、全部持仓/关注列表隐藏)继续有效; +- 策略 CRUD 能力与 `addStrategy / removeStrategy / appendStrategyTab / removeStrategyTab / generateStrategyId`(拼音 slug 一整套)**整体退役**。 + +### 2. 设置页「策略分组」子 tab 移除(Q2) + +- 设置页收敛为两个子 tab:**Tab 设置 / QMT 连接配置**; +- 移除策略「新增 / 重命名 / 删除」入口与删除确认弹窗。 + +### 3. 字段配置入口迁移(Q3 + Q4) + +- 入口 = 主窗口**策略 tab 顶部、「列设置」旁的「字段配置」按钮**;每个策略 tab 管理**本策略**字段(符合「字段定义随策略」语义); +- 点击打开**独立弹层**:字段列表(展示名 / key / 类型 / 公式或默认值 / 单位 / 操作)+ 添加/编辑/删除字段 + **保存**; +- 与「列设置」职责分离:列设置 = 轻量即时持久化(显隐/顺序);字段配置 = 重表单 + 保存按钮; +- 字段定义增删 → 表格列自动跟随(沿用 R-013 归一化机制,零改动)。 + +### 4. 保存机制(Q5,技术项 AI 定) + +- 新增**单策略字段端点**(拟 `strategies/schema-update { strategyId, configSchema }`):只改本策略字段定义,避开 `strategies/update` 整表覆盖风险; +- 保存成功 → Toast + 刷新该策略数据(列与字段同步跟随)。 + +### 5. 存储结构简化(Q7,技术项 AI 定,技术方案阶段定形) + +- `strategies` **常量化为插件内置两项**(id/name 常量); +- settings 只保留可变部分:**字段定义**(按 strategyId)+ **strategyColumns** 列配置覆盖; +- 存量 settings(strategies 数组 + configSchema)读取时**归一化迁移**(不写独立迁移脚本);本次实机无自建策略,无需数据处理。 + +### 6. 排期(Q6) + +- **并入迭代 22 作第一阶段**(静态化 + 入口迁移),计算字段(R-026)作**第二阶段**——一次改到位,公式表单直接落在新入口,避免「先按设置页做一遍再整体搬迁」的返工; +- 迭代 22 的 `UI交互设计.md` **同步修订**(公式表单移入「字段配置」弹层)。 + +## 影响面 / 约束联动 + +- **约束修订**(实施时执行并记录):产品约束-002/003/004(标签体系 / 标签自定义 + 显示开关的场景收敛)、产品约束-009(Tab 统一管理中「策略条目动态化」条款)、UI约束-002(设置页子 tab 构成:移除策略分组)、UI约束-003(Tab 设置:策略行不再随 CRUD 增删)、UI约束-005(字段配置 UI 入口迁移); +- **历史需求追溯**:R-003(策略 CRUD)→ 被取代退役;R-011(Tab 统一管理)→ 策略条目动态化部分被取代,显隐/排序机制保留;R-013 → 字段模型与列机制保留,**入口条款**被取代。 + +## 决策记录 + +| 项 | 结论 | 讨论 | +|---|---|---| +| 静态化程度 | 固定不可增删改名 + **保留**显隐/排序 | Q1(采纳建议 A)| +| 设置页「策略分组」 | 整体移除 | Q2(A)| +| 入口形态 | 「列设置」旁独立「字段配置」按钮 + 独立弹层 | Q3(A)| +| 「统一」范围 | 每个策略 tab 管自己的字段 | Q4(A)| +| 保存机制 | 单策略字段端点(AI 定,技术项)| Q5 | +| 排期 | 并入迭代 22 第一阶段,一次改到位 | Q6(A)| +| 存储结构 | strategies 常量 + settings 存字段/列配置(AI 定,方案阶段定形)| Q7 | + +## 讨论过程 + +1. 2026-09-10 老师提出重构想法(策略 tab 静态化 + 字段编辑功能迁到策略 tab 内、列设置旁); +2. AI 核实实机现状(settings.yaml 策略与字段、store.db 数据归属、tabs 顺序)→ 结论:仅两个内置策略、无自建策略,**零数据迁移风险**; +3. AI 给出影响面分析(tab 体系 / 设置页 / 字段配置 / 约束与历史需求)+ 与迭代 22 的撞位提示(公式表单落在设置页,入口一搬即废)+ 排期建议; +4. 老师逐项拍板 Q1~Q4、Q6(全采建议);Q5/Q7 为技术项由 AI 定; +5. 转正定稿:T-011 草稿 → R-027(草稿文件已清理,索引同步);迭代 22 结构与交互设计同步修订。 + +## 关联 + +- 上游:T-011 草稿、实机核实(`settings.yaml` + `store.db`); +- 联动:**R-026 / 迭代 22**(入口迁移后,计算字段的公式表单落在「字段配置」弹层内);R-013(字段模型 / 列机制沿用)、R-011(Tab 显隐排序机制沿用); +- 取代:R-003、R-011(部分)、R-013(入口条款)。 diff --git a/docs/05-需求池/R-028.md b/docs/05-需求池/R-028.md new file mode 100644 index 0000000..863d687 --- /dev/null +++ b/docs/05-需求池/R-028.md @@ -0,0 +1,54 @@ +# 需求:R-028 判定型计算字段 + 网格超市「可下空单 / 可下多单」· 已定稿 + +> 登记:2026-09-10 | 来源:老师指令(2026-09-10)| 状态:**已定稿(2026-09-10 老师逐项确认 Q1-Q5)** +> 归属:**23-计算字段判定型扩展** | 实现状态:**已实现(2026-09-10),待老师人工验收** +> 前身:草稿 T-012(已转正,草稿文件已清理)| **能力扩展**:R-026(计算字段,原「首批结果只做数字」)→ 视同修订 产品约束-014 + +## 诉求(老师,2026-09-10) + +> 我想给网格超市添加一个计算字段,即当天涨停值 > 当前基准值 + 1 ,即可以下空单;当天跌停值 < 当前基准值 - 1,可以下多单。 + +## 现状限制(R-026 能力边界) + +计算字段原**只产出数字**(产品约束-014「首批结果只做数字」),公式能力 = 四则 + 括号 + round/abs/min/max, +**没有比较运算** → 上述两条规则无法直接表达为判定列。可用变量已覆盖全部原料 +(`涨停价`/`跌停价` = QuoteHub 合约信息按交易日;`基准值` = 网格超市手填字段)。 + +## 定稿结论(2026-09-10 老师拍板) + +1. **方案 A|判定型计算字段**(Q1):引擎新增比较运算与逻辑组合,字段表单新增「结果类型:数字 / 判定」; +2. **判定列显示**(Q2):满足 → `✓ <字段名>`(success 绿);不满足 / 缺值 → `—`(tertiary 灰); +3. **支持 and / or 组合**(Q3,含 `&&` / `||` 与全角 `≥ ≤ ≠`); +4. **结果类型由字段表单显式声明**(Q4),服务端校验「声明与公式形态一致」(判定=顶层比较/逻辑,数字=其余); +5. **阈值引用「网格大小」字段**(Q5):公式写 `涨停价 > 基准值 + 网格大小`(阈值可配;当前各行网格大小=1,与写死 1 等价)。 + +**网格超市落地字段(两条)** + +| 展示名 | 类型 | 结果 | 公式 | 单位 | +|---|---|---|---|---| +| 可下空单 | 计算 | 判定 | `涨停价 > 基准值 + 网格大小` | — | +| 可下多单 | 计算 | 判定 | `跌停价 < 基准值 - 网格大小` | — | + +## 可行性验证(2026-09-10,真实持仓 + QMT 合约信息) + +13 只网格超市当前持仓实算:**可下空单 5 只**(积成电子 8.22>8、天海防务 7.9>7、万顺新材 7.73>7、 +大连热电 8.51>8、华智数媒 9.28>8);**可下多单 2 只**(中国化学 6.68<7、TCL中环 8.2<9);其余 6 只两条均不满足。 + +## 决策记录 + +| 项 | 结论 | 讨论 | +|---|---|---| +| 结果形态 | 判定型计算字段(方案 A) | Q1(老师选 A) | +| 判定显示 | 满足 `✓ 字段名`(绿)/ 不满足 `—`(灰) | Q2 | +| 组合条件 | 支持 and / or | Q3 | +| 结果类型 | 表单显式声明,服务端校验一致性 | Q4 | +| 阈值 | 引用「网格大小」字段 | Q5 | +| 存储/现算 | 结果可为布尔(`computed[key]` 布尔直通);仍不落库 | 沿用 R-026 语义 | +| 成本 | 引擎 + 校验 + 表单 + 渲染四处小改;无新增数据通路 | — | + +## 关联 + +- 上游:R-026 / 迭代 22(计算字段:引擎、变量目录、现算、只读列); +- 修订:产品约束-014(结果类型扩展为 数字 / 判定)、技术约束-022(引擎运算集)、技术约束-024(校验规则)、UI约束-008(判定 UI); +- 迭代:23-计算字段判定型扩展(含网格超市两字段落地); +- 使用说明(用户手册):`docs/99-其他材料/计算字段公式说明.md`(变量表 / 运算符 / 函数 / 示例 / 报错对照 / 常见问题)。 diff --git a/docs/05-需求池/需求池索引.md b/docs/05-需求池/需求池索引.md index 60edc77..9034766 100644 --- a/docs/05-需求池/需求池索引.md +++ b/docs/05-需求池/需求池索引.md @@ -25,14 +25,22 @@ | R-011 | Tab 设置:统一管理所有 tab(内置 + 策略分组混排,显示/隐藏 + 拖动排序) | 设置页「通用设置」升级为「Tab 设置」,统一管理所有会话 tab(系统内置 + 策略分组)的唯一入口:两类混排、**拖动排序**(原生 HTML5 DnD,落点立即持久化)+ **显隐开关**;**任何 tab 均不支持重命名/删除**(策略命名/删除仍在「策略分组」子 tab);顺序/显隐统一为一份数据源(tabs 有序数组,策略行名 join strategies 自动跟随改名),删除策略联动删除对应条目;新增策略追加末尾;老配置自动迁移(旧隐藏策略迁移后显示)。**2026-09-02 定稿(Q1-Q5 确认)并完成(迭代 09 验收通过),已归档至 已完成/R-011.md** | 老师指令(2026-09-02) | P1 | 已定稿 | 2026-09-02 | 09-Tab设置统一管理 | **已实现(已归档)** | | R-012 | UI 适配 DSH 主题(浅色 / 深色 / 跟随系统) | 神之一手 UI 适配 DSH 浅色/深色/跟随系统主题:141 处硬编码色值替换为宿主 `--dsw-*` token,随主题自动切换;不自行维护主题偏好(**暂定跟随系统**);涨跌红涨绿跌 → 宿主 state-error/success;仅色值 token 化不动布局。**2026-09-02 定稿(暂定跟随系统)并完成(迭代 10),已归档至 已完成/R-012.md** | 老师指令(2026-09-02) | P1 | 已定稿 | 2026-09-02 | 10-UI主题适配 | **已实现(已归档)** | | R-013 | 策略自定义字段配置(定义随策略,值落库) | 策略可自定义、可扩展:每个策略在设置页「策略分组」子 tab 配置自定义字段(字段名/类型文本·数字·布尔·枚举/枚举项/单位 unit/默认值,configSchema 随策略定义存 settings);每个持仓行按所属策略的定义存一份键值对值(strategy_holdings 新增 values TEXT(JSON) 列);自定义字段作为持仓表列展示,列显隐/排序每策略独立配置(strategyColumns),单元格点击内联编辑。**2026-09-02 定稿(Q1-Q4 + D6)并完成(迭代 11 验收通过),已归档至 已完成/R-013.md** | 老师指令(2026-09-02) | P1 | 已定稿 | 2026-09-02 | 11-策略自定义字段配置 | **已实现(已归档)** | -| R-014 | 持仓内存快照(服务端 10s 定时同步,请求不再穿透 QMT) | 服务端建全量持仓**内存快照**(不落库):PositionSync 启动预热 + 每 10s 全量拉 QMT → 校验 → 整体替换;同步失败保留上次快照(QMT 抖动不再白屏);空快照双重确认(/health + getAsset 账户身份)才接受为真清仓;快照为空读穿透兜底(当场拉一次并回填);**幽灵持仓自动清仓**:QMT 连续 3 轮(约 30s)消失的 code,本地全部策略当前持仓自动转历史(账户身份守卫:accountId 未知跳过、切换重置跳过;部分减持不触发);strategy-positions / unallocated / summary 三个接口改读快照,前端零改动。**2026-09-02 定稿(内存不落库 / 读穿透兜底 / 幽灵自动清仓 / 同步时间不显示,老师逐项拍板),2026-09-02 完成(回归 34/34 + r013 回归 21/21 + typecheck + build 通过),迭代 12 验收待老师人工确认** | 架构梳理讨论引出 + 老师指令(2026-09-02) | P1 | 已定稿 | 2026-09-02 | 12-持仓内存快照 | 已实现(待验收) | -| R-015 | 盘口数据内存化(QuoteSync/QuoteHub 统一管理)+ 数据同步指示灯 | QuoteSync(取数:prime + 5s REST 刷新 + 涨停跌停按交易日缓存)/ QuoteHub(存查:内存快照 + watch 集合 + 读穿透 + 价格单一入口)替换 MarketFeed/MarketDataHub(方案 A 替换不包壳);WS 通路移除;market_quotes_cache 表退役 DROP;涨停价/跌停价字段(/data/instrument 按交易日缓存不落库);会话头部新增持仓/盘口同步指示灯(绿/黄/灰 + 悬停详情 + 点击即 syncNow)+ sync-status 端点(吸收临时诊断 market-stats);策略持仓表行情列扩展(涨停/跌停/今开/最高 4 列,默认隐藏)。**2026-09-02 定稿(五项老师逐项拍板),2026-09-02 完成(test-quote-sync 全绿 + 存量回归 34/34、21/21 + typecheck + build),迭代 13 验收待老师人工确认** | 架构演进讨论引出 + 老师指令(2026-09-02) | P1 | 已定稿 | 2026-09-02 | 13-盘口内存快照 | 已实现(待验收) | -| R-016 | 策略 tab 历史持仓展示(显示/隐藏已清仓持仓 + 清仓时间范围筛选) | 两个策略 tab(做T/网格超市)title 旁添加:① 显示/隐藏已清仓历史持仓的开关;② 显示历史时默认展示近一周清仓的持仓,并提供 近1个月/近3个月/近半年/近1年 范围选项。背景:卖出清仓后幽灵清仓(R-014)把持仓行转历史,策略 tab 当场少行(2026-09-07 大连热电做T卖出实际发生);历史数据一直在库(strategy_holdings closed_at 非空),只缺展示入口;历史行可复用 R-010 行展开做交易复盘。**2026-09-07 定稿(Q1-Q7 老师拍板:Q2 改判「份额显示 0 就好,不改 closeHolding 语义」,其余按 AI 建议;核心目标=追溯该持仓相关的历史操作)**;历史行复用 R-010 展开做交易复盘;新增 strategy-holdings/history 端点,当前持仓路径零改动。**2026-09-07 二轮补充(Q8-Q9 老师拍板):当天(本地自然日 00:00 起)已清仓的持仓默认显示(range='today' 恒显示层),「历史持仓」开关只控制今天之前的历史(历史持仓关≠当天清仓被藏起);层间按 holdingId 去重,计数不含已清仓行维持;零写路径变更维持**。详见 R-016.md | 老师指令(2026-09-07 优化功能需求讨论) | P1 | 已定稿 | 2026-09-07 | 14-策略tab历史持仓展示 | 已实现(待验收) | -| R-017 | 委托归属候选纳入近期清仓持仓 | 交易记录 tab 归属候选只回当前持仓——清仓后当日委托失去下拉锚点无法补关联(R-016 关联观察立项,2026-09-07 大连热电实际发生:盘中归属后被清为未关联,下拉已无做T候选)。**2026-09-07 定稿(老师指令确认方向)**:候选 = 当前持仓 + 近 7 天清仓持仓(closed 标记,选项文本「已清仓」后缀),下拉 value 改 holdingId 键控(同策略多轮不撞值),排序当前在前/清仓按 closed_at DESC;当日数据修复经 set-attribution 通道完成(非固化功能)。**2026-09-08 R-018 取代标注:7 天清仓候选退役(R-018 T2 拍板)**——归属候选改策略级 attribution-targets(迭代 16 实施),本需求「清仓后补关联」痛点根因(幽灵清仓抢先归档)已由数据域分界消除;holdingId 键控/占位安全逻辑保留。详见 R-017.md | 老师指令(2026-09-07) | P1 | 已定稿 | 2026-09-07 | 15-归属候选纳入清仓持仓 | 已实现(被 R-018 语义取代) | +| R-014 | 持仓内存快照(服务端 10s 定时同步,请求不再穿透 QMT) | 服务端建全量持仓**内存快照**(不落库):PositionSync 启动预热 + 每 10s 全量拉 QMT → 校验 → 整体替换;同步失败保留上次快照(QMT 抖动不再白屏);空快照双重确认(/health + getAsset 账户身份)才接受为真清仓;快照为空读穿透兜底(当场拉一次并回填);**幽灵持仓自动清仓**:QMT 连续 3 轮(约 30s)消失的 code,本地全部策略当前持仓自动转历史(账户身份守卫:accountId 未知跳过、切换重置跳过;部分减持不触发);strategy-positions / unallocated / summary 三个接口改读快照,前端零改动。**2026-09-02 定稿(内存不落库 / 读穿透兜底 / 幽灵自动清仓 / 同步时间不显示,老师逐项拍板),2026-09-02 完成(回归 34/34 + r013 回归 21/21 + typecheck + build 通过),迭代 12 验收待老师人工确认** | 架构梳理讨论引出 + 老师指令(2026-09-02) | P1 | 已定稿 | 2026-09-02 | 12-持仓内存快照 | **已实现(已归档至 已完成/,2026-09-08 老师归档指令确认验收)** | +| R-015 | 盘口数据内存化(QuoteSync/QuoteHub 统一管理)+ 数据同步指示灯 | QuoteSync(取数:prime + 5s REST 刷新 + 涨停跌停按交易日缓存)/ QuoteHub(存查:内存快照 + watch 集合 + 读穿透 + 价格单一入口)替换 MarketFeed/MarketDataHub(方案 A 替换不包壳);WS 通路移除;market_quotes_cache 表退役 DROP;涨停价/跌停价字段(/data/instrument 按交易日缓存不落库);会话头部新增持仓/盘口同步指示灯(绿/黄/灰 + 悬停详情 + 点击即 syncNow)+ sync-status 端点(吸收临时诊断 market-stats);策略持仓表行情列扩展(涨停/跌停/今开/最高 4 列,默认隐藏)。**2026-09-02 定稿(五项老师逐项拍板),2026-09-02 完成(test-quote-sync 全绿 + 存量回归 34/34、21/21 + typecheck + build),迭代 13 验收待老师人工确认** | 架构演进讨论引出 + 老师指令(2026-09-02) | P1 | 已定稿 | 2026-09-02 | 13-盘口内存快照 | **已实现(已归档至 已完成/,2026-09-08 老师归档指令确认验收)** | +| R-016 | 策略 tab 历史持仓展示(显示/隐藏已清仓持仓 + 清仓时间范围筛选) | 两个策略 tab(做T/网格超市)title 旁添加:① 显示/隐藏已清仓历史持仓的开关;② 显示历史时默认展示近一周清仓的持仓,并提供 近1个月/近3个月/近半年/近1年 范围选项。背景:卖出清仓后幽灵清仓(R-014)把持仓行转历史,策略 tab 当场少行(2026-09-07 大连热电做T卖出实际发生);历史数据一直在库(strategy_holdings closed_at 非空),只缺展示入口;历史行可复用 R-010 行展开做交易复盘。**2026-09-07 定稿(Q1-Q7 老师拍板:Q2 改判「份额显示 0 就好,不改 closeHolding 语义」,其余按 AI 建议;核心目标=追溯该持仓相关的历史操作)**;历史行复用 R-010 展开做交易复盘;新增 strategy-holdings/history 端点,当前持仓路径零改动。**2026-09-07 二轮补充(Q8-Q9 老师拍板):当天(本地自然日 00:00 起)已清仓的持仓默认显示(range='today' 恒显示层),「历史持仓」开关只控制今天之前的历史(历史持仓关≠当天清仓被藏起);层间按 holdingId 去重,计数不含已清仓行维持;零写路径变更维持**。已归档至 已完成/R-016.md | 老师指令(2026-09-07 优化功能需求讨论) | P1 | 已定稿 | 2026-09-07 | 14-策略tab历史持仓展示 | **已实现(已归档至 已完成/,2026-09-08 老师归档指令确认验收)** | +| R-017 | 委托归属候选纳入近期清仓持仓 | 交易记录 tab 归属候选只回当前持仓——清仓后当日委托失去下拉锚点无法补关联(R-016 关联观察立项,2026-09-07 大连热电实际发生:盘中归属后被清为未关联,下拉已无做T候选)。**2026-09-07 定稿(老师指令确认方向)**:候选 = 当前持仓 + 近 7 天清仓持仓(closed 标记,选项文本「已清仓」后缀),下拉 value 改 holdingId 键控(同策略多轮不撞值),排序当前在前/清仓按 closed_at DESC;当日数据修复经 set-attribution 通道完成(非固化功能)。**2026-09-08 R-018 取代标注:7 天清仓候选退役(R-018 T2 拍板)**——归属候选改策略级 attribution-targets(迭代 16 实施),本需求「清仓后补关联」痛点根因(幽灵清仓抢先归档)已由数据域分界消除;holdingId 键控/占位安全逻辑保留。已归档至 已完成/R-017.md(被 R-018 取代退役) | 老师指令(2026-09-07) | P1 | 已定稿 | 2026-09-07 | 15-归属候选纳入清仓持仓 | **已实现(已归档至 已完成/,被 R-018 语义取代退役,2026-09-08)** | | R-018 | 交易关联驱动持仓份额动态调整(归属 = 账本写操作) | 从交易记录出发给委托关联策略/持仓时自动同步调整该策略下持仓份额(关联即账本写操作):策略级候选 + 统一「份额分配器」(一笔委托拆 0..N 段 × 策略×量,余额默认全量可改小,允许部分关联)+ 买入加仓/建新仓(不复活旧行)、卖出减仓/归零清仓、仓不足自动截断续分、无仓关联失败;撤段逆操作(撤建仓买入=持仓作废第三态不产生假清仓历史、撤加仓=减回归零作废、撤卖出=恢复活动仓+份额加回,粒度=段、改归属原子);**数据域分界(老师拍板):幽灵清仓等同步机制只作用于「全部持仓」对账单域,不再写 strategy_holdings 账本**——账本生命周期只由交易关联 + 手动份额操作驱动,账本转历史唯一途径=卖出单关联 closeHolding;R-017 7 天清仓候选退役(holdingId 键控/占位保留);漏关联软提示(账本有活动行+QMT 无此 code)。影响既有定稿:技术约束-017/产品约束-011/R-014/迭代12 幽灵清仓条款修订。详见 R-018.md | 老师指令(2026-09-08 新优化需求讨论) | P1 | 已定稿 | 2026-09-08 | 16-交易关联驱动持仓份额动态调整 | **已实现(已归档至 已完成/R-018.md,2026-09-08 验收通过)** | -| R-019 | 策略会话:会话以策略当前数据为依据做复盘/分析讨论 | 让 AI 会话以策略为 Workspace(老师原话)→ **已定稿(2026-09-08,F1/F2 拍板)**:改造现有 DSH 对话窗口,对话视图首条消息前提供「讨论策略」选择(仿 DSH workspace chip Choose 体验:依据:做T ▾),选策略即把该策略**当前数据摘要**预注入会话上下文开始聊,会话中可换策略/刷新再注入;**不用 DSH 宿主工作区**(R3);注入 = 当前持仓明细 + 全历史关联交易聚合摘要(含已清仓,F2);只读不开放账本写(Q4=A);产物不落盘(R2-Q4,复盘管理/落库归未来 T-002)。宿主注入通道等技术调研列计划阶段待办。详见 R-019.md | 老师指令(2026-09-08) | P1 | 已定稿 | 2026-09-08 | 17-策略会话 | 已立项(迭代 17 设计阶段) | +| R-019 | 策略会话(历史条目,已并入 R-021) | 原“会话以策略为 Workspace(数据依据版)”需求(2026-09-08 定稿)——**2026-09-08 老师指令与 R-021 合并,逻辑整合入合并后的 R-021(策略会话复盘)**;本条目保留为合并前讨论史存档,文件已归档至 已完成/R-019.md | 老师指令(2026-09-08) | P1 | 已定稿 | 2026-09-08 | 17-策略会话 | **已归档(并入 R-021 合并需求,2026-09-08)** | | R-020 | QMT Bridge MCP 能力内建(神之一手自注册 MCP,去三方插件依赖) | 当前 DSH 的 QMT Bridge MCP 由三方插件 dsh-skill-mcp-panel 写入 profile cordis.patch.yml 受管块。**2026-09-08 定稿(Q1-Q6 老师逐项拍板)**:神之一手 apply 内动态挂载 **DSH 官方 dsh-mcp-client**(**只挂当前激活连接**,serverName 固定 QMT_Bridge_MCP,url 自动派生 baseUrl+/mcp;挂载/切换/卸载/状态由神之一手插件内管理),MCP 能力内建;**移除对象 = 三方 dsh-skill-mcp-panel**(2026-09-08 老师澄清:卸载其插件 + 移除其宿主 cordis.patch.yml 受管块,官方 dsh-mcp-client 可依赖);MCP 生命周期随连接 CRUD/激活切换联动;状态出口 = 设置页连接卡片检查 + **会话头部指示灯区新增 MCP 状态灯**;失败重连对齐 failOnStartupError:false+reconnect;**不做通用 MCP 管理**。REST 直连不变(技术约束-003)。详见 已完成/R-020.md | 老师指令(2026-09-08) | P1 | 已定稿 | 2026-09-08 | 18-QMTBridgeMCP内建 | **已实现(已归档,2026-09-08 验收通过)** | +| R-021 | 神之一手数据加入会话(对象目录 + 添加机制;复盘为第一用例;合并原 R-019) | **需求本质(2026-09-08 老师再定调)**:会话(agent)可获取神之一手数据,复盘只是其用法之一;本需求确定 **①数据怎么添加(暂定=A 人挑对象→只读数据上下文快照注入会话,不挂按需工具)②哪些数据可以加(对象目录候选:账户/策略/持仓(当前·历史)/轮次 round/单笔交易/持仓↔交易/行情随行/汇总…)**;首批范围 P1 策略/P2 持仓/P3 持仓↔交易/P4 单笔委托/P5 轮次(待建模)/P6 行情随行(暂定草案,老师可增删);复用为复盘/策略依据等用例;不用宿主工作区、只读、结论不落盘(未来 Phase1-3 预留)。详见 R-021.md | 老师指令(2026-09-08) | P1 | 已定稿 | 2026-09-08 | 17-策略会话 | 已立项(设计阶段) | +| R-022 | QMT 连接健康灯自适应探测(Bridge 启动/恢复后快速回绿) | 老师反馈:启动 QMT Bridge 后持仓/行情/MCP 灯几秒点亮,QMT 灯一直不亮(自动检查没生效)。根因:QmtHealthMonitor 固定每 5 分钟探测一次 /health 并缓存,恢复感知滞后;其余灯有 5-10s 高频自愈回路。**方案(2026-09-09 AI 提议,老师确认):自适应探测节奏——健康 5 分钟稳态(不变)/ 异常未知 10s 快速重试**(与前端 10s 轮询对齐),Bridge 启动后灯 ≤20s 自动回绿;边界:API/前端零改动、不新增配置、健康稳态不空转。详见 R-022.md | 老师反馈(2026-09-09) | P1 | 讨论中(AI 提议定稿,待老师确认) | 2026-09-09 | 19-指示灯状态自适应探测 | 已实现(待验收,验收通过后定稿归档) | +| R-023 | MCP 状态灯自适应探测(MCP 服务恢复后自动回绿) | 老师反馈:R-022 修复后复查发现 MCP 灯同样不自动恢复。根因:QmtMcpManager 状态缓存只在 挂载/切换/手动探测 时刷新、无定时回路;dsh-mcp-client 自带 reconnect 只恢复真实连接(fiber 层),不刷新插件状态缓存。**方案(2026-09-09 AI 提议,与 R-022 同模式):connected → 5 分钟稳态 / 非 connected → 10s 快速重试**,MCP 服务恢复后灯 ≤20s 自动回绿;无激活连接/dispose 不调度;探测本体与挂载/重连逻辑零改动。详见 R-023.md | 老师反馈(2026-09-09) | P1 | 讨论中(AI 提议定稿,待老师确认) | 2026-09-09 | 19-指示灯状态自适应探测 | 已实现(待验收,验收通过后定稿归档) | +| R-024 | 策略 tab 列设置改为拖拽排序(列设计细节优化) | 老师反馈:策略 tab 列设置弹层目前 **↑↓ 箭头排序**,希望改成**鼠标选中后拖动排序**。**2026-09-10 定稿(老师确认方案 A)**:策略 tab「列设置」弹层内实现 **原生 HTML5 拖拽排序**(复用 R-011 Tab 设置已验证模式:≡ 拖拽手柄 + drop 重排);**落点立即持久化**(strategy-columns/update 整表覆盖,弹层去掉「保存」按钮只留「关闭」);**↑↓ 箭头保留作兜底**;固定列不参与排序维持现状。**2026-09-10 验收反馈修订**:落点指示从整行高亮改为**行间间隙高亮线**(按鼠标在目标行内 Y 坐标判定插入前/后)。详见 已完成/R-024.md | 老师反馈(2026-09-10) | P2 | 已定稿 | 2026-09-10 | 20-策略列设置拖拽排序 | **已实现(已归档至 已完成/R-024.md,2026-09-10 老师确认验收)** | +| R-025 | 设置页「Tab 设置」排序落点统一为间隙高亮线 | 老师指令(迭代 20 复盘遗留第 2 项):设置页「Tab 设置」分组 tab 排序目前仍是整行高亮(overId 行淡蓝底),与列设置弹层的**行间间隙高亮线**(R-024/UI约束-007)观感不一致,统一为同款形式。**2026-09-10 定稿(老师指令)**:拖动悬停按目标行内 Y 坐标上下半区判定,间隙线画目标行上缘(插前)/下缘(插后);drop 事件内按坐标重算插入位置;拖回原位置跳过写库;onDragEnd 清理;移除整行高亮;≡ 手柄/显隐开关/立即持久化/徽标/saving 禁用全部维持不变;服务端零改动。详见 R-025.md | 老师指令(2026-09-10) | P2 | 已定稿 | 2026-09-10 | 21-Tab设置排序间隙高亮统一 | 未开始 | +| R-026 | 策略计算字段(公式字段) | R-013 字段类型扩展:新增第五种类型「计算(formula)」——用户写公式(**中文变量名**,如 `(现价 - 成本价) × 份额`),公式引用**服务端缓存数据集**按持仓行实时算出列值。**2026-09-10 定稿(老师逐项确认)**:① 数据集首批=行情+合约信息/持仓账本(份额·成本价·最后一笔成交价)/本行自定义字段值(QMT账户域、交易记录聚合后补);② 计算位置=服务端(strategy-positions 现算随行返回);③ 粒度=仅行级(策略级聚合二期);④ 引擎=自写小型表达式解析器(零依赖、支持中文标识符、四则+括号+round/abs/min/max);⑤ 不落库(不写 values 列),列只读;⑥ 只允许引用非公式字段(零循环依赖);⑦ 结果=数字+unit+小数位(默认2);⑧ 保存时校验语法与变量(拒非法)+ 运行期缺数据显示 —;⑨ 配置UI=公式输入+变量选择器(+试算预览);⑩ 历史持仓行显示 —(不做历史时点计算);⑪ 轻量变量目录先行,作为 T-006 数据池第一块砖。已知用例:浮动盈亏/盈亏比例/距涨停/网格占用。详见 R-026.md | 老师指令(2026-09-10 功能讨论) | P1 | 已定稿 | 2026-09-10 | 22-策略计算字段与策略tab静态化(PLAN-019,第二阶段) | **已实现(2026-09-10),待老师人工验收** | +| R-027 | 策略 tab 静态化 + 字段配置入口迁移(重构) | ① 策略 tab 从「动态增删改」改为**插件内置静态 tab**(与全部持仓/交易记录同权;**不可增删改名**,但保留 Tab 设置里的显隐+拖拽排序);② 设置页「策略分组」子 tab **整体移除**(设置页收敛为 Tab 设置 / QMT 连接配置);③ 字段编辑功能迁到**主窗口策略 tab 顶部、「列设置」旁的「字段配置」按钮 + 独立弹层**(每个策略 tab 管自己的字段),保存走新增单策略字段端点(避开整表覆盖);④ strategies 常量化为内置两项,settings 只存字段定义 + 列配置覆盖(存量归一化,实测无自建策略、零迁移风险);⑤ **并入迭代 22 作第一阶段**(计算字段为第二阶段,公式表单直接落新入口,避免返工)。**取代**:R-003(策略 CRUD)、R-011(策略条目动态化部分)、R-013(字段定义入口条款)。详见 R-027.md | 老师指令(2026-09-10 重构想法) | P1 | 已定稿 | 2026-09-10 | 22-策略计算字段与策略tab静态化(PLAN-019,第一阶段) | **已实现(2026-09-10),待老师人工验收** | +| R-028 | 判定型计算字段 + 网格超市「可下空单 / 可下多单」 | R-026 能力扩展:计算字段新增**结果类型 = 数字 / 判定**(引擎加比较运算 `> < >= <= == !=` 与逻辑 `and / or`,含 `&& ||` 与全角 `≥ ≤ ≠`;字段表单显式声明结果类型,服务端校验「声明与公式形态一致」);判定列满足显示 `✓ <字段名>`(绿)、不满足/缺数据显示 `—`(灰)。**网格超市落地**:可下空单 = `涨停价 > 基准值 + 网格大小`;可下多单 = `跌停价 < 基准值 - 网格大小`(阈值引用字段可调)。**2026-09-10 定稿并实现**(老师拍板 Q1-Q5:方案 A 判定型 / ✓ 与 — / 支持 and·or / 结果类型显式 / 阈值引字段);真实验证:13 只持仓中可下空单 5 只、可下多单 2 只;回归 test-r026 48→71 条全绿。详见 R-028.md | 老师指令(2026-09-10) | P1 | 已定稿 | 2026-09-10 | 23-计算字段判定型扩展 | **已实现(2026-09-10),待老师人工验收** | ## 渐进明细规划素材 @@ -48,4 +56,7 @@ | T-006 | 数据池中间层 + 策略表格动态字段配置 | 服务端数据池(数据集中间层:按 code 聚合宽表、多源字段映射、填充器架构)+ 策略持仓表格动态字段配置(列配置×池行渲染)。源自 R003 讨论(2026-08-28)拆分独立,草稿文件:`草稿/数据池中间层与策略表格动态字段配置.md` | 起草 | | T-007 | RSS 订阅管理 | 管理 RSS 订阅源(增删改查)+ 抓取聚合订阅内容,作为资讯/消息来源;支撑 目标-003 市场监控、目标-004 消息管理,与 T-004 同属消息链路。**2026-08-28 老师确认先放草稿**,草稿文件:`草稿/RSS订阅管理.md` | 起草 | | T-008 | 数据存储管理(JSON → SQLite) | 数据存储从 JSON data store 升级为 SQLite(node:sqlite)数据库 + 数据存储管理能力;**变更既有设计约束**(数据存储设计.md「无需数据库」决策)。**2026-09-01 定稿转正为 R-008 并完成(迭代 06)** | 已转需求 R-008 | -| T-009 | 策略自定义字段配置(策略细节配置) | 每个策略支持自定义字段(键值对),不同策略按自身需要添加各自的细节配置字段(如网格超市配网格间距/单格金额、长线持有配目标价/仓位上限)。**2026-09-02 定稿转正为 R-013**,草稿文件已清理 | 已转需求 R-013 | \ No newline at end of file +| T-009 | 策略自定义字段配置(策略细节配置) | 每个策略支持自定义字段(键值对),不同策略按自身需要添加各自的细节配置字段(如网格超市配网格间距/单格金额、长线持有配目标价/仓位上限)。**2026-09-02 定稿转正为 R-013**,草稿文件已清理 | 已转需求 R-013 | +| T-010 | 策略计算字段(公式字段) | R-013 字段类型扩展:新增「计算(formula)」类型,用户写公式引用服务端缓存数据集(行情快照/合约信息/持仓账本/自定义字段值),按持仓行实时计算列值(不落库)。源自 2026-09-10 功能讨论(老师指令),**2026-09-10 定稿转正为 R-026**,草稿文件已清理 | 已转需求 R-026 | +| T-011 | 策略 tab 静态化 + 字段配置入口迁移 | ① 策略 tab 改为与「全部持仓/交易记录」同类的**插件内置 tab**(不再动态增删改);② 原设置页「策略分组」的字段编辑功能迁到**主窗口策略 tab 内、「列设置」旁**,作为统一的字段配置管理。实机核实:当前仅两个内置策略、无自建策略(零数据迁移风险)。与迭代 22(计算字段)强联动。源自 2026-09-10 老师重构想法,**2026-09-10 定稿转正为 R-027**,草稿文件已清理 | 已转需求 R-027 | +| T-012 | 网格超市「可下空单 / 可下多单」判定字段 | 用计算字段把网格规则翻译成列判定:**可下空单** = 当天涨停价 > 基准值 + 1;**可下多单** = 当天跌停价 < 基准值 - 1。现状限制:R-026 计算字段**只产数字**(无比较运算)→ 需扩展「判定型」结果。已验证数据通路(涨跌停取自合约信息 + 基准值取自持仓行):13 只持仓中 5 只可下空单、2 只可下多单。源自 2026-09-10 老师指令,**2026-09-10 定稿转正为 R-028**,草稿文件已清理 | 已转需求 R-028 | \ No newline at end of file diff --git a/docs/99-其他材料/计算字段公式说明.md b/docs/99-其他材料/计算字段公式说明.md new file mode 100644 index 0000000..3bfd8d8 --- /dev/null +++ b/docs/99-其他材料/计算字段公式说明.md @@ -0,0 +1,151 @@ +# 计算字段公式说明 + +> 适用:策略 tab →「字段配置」→ 添加/编辑字段 → 类型选「**计算**」 +> 版本:2026-09-10(R-026 计算字段 + R-028 判定型扩展)| 本文档与实现行为逐条对齐(已实机核对) + +## 一、一句话 + +计算字段 = **你写一个公式**,服务端按**每个持仓行**实时算出一个值填到该列:公式里可以引用 +**行情 / 合约信息 / 持仓账本 / 本策略手填字段**四类数据。值**不落库**、列**只读**、数据缺失时显示 `—`。 + +## 二、结果类型(先选这个) + +字段表单「**结果**」下拉决定列的呈现方式,且**必须与公式形态一致**(服务端校验,不一致直接拒绝保存): + +| 结果类型 | 公式形态 | 列上显示 | 可配项 | +|---|---|---|---| +| **数字**(默认) | 算式,如 `(现价 - 成本价) × 份额` | `700.00 元`(小数位 + 单位) | 小数位 0–4(默认 2)、单位 | +| **判定** | 顶层是比较,如 `涨停价 > 基准值 + 网格大小` | 满足 → `✓ 可下空单`(绿);不满足/缺数据 → `—`(灰) | 单位、小数位不适用(自动隐藏) | + +- 判定列文字 = 该字段的**展示名**(所以展示名建议直接写成规则名,如「可下空单」); +- 「不满足」与「数据缺失」目前**都显示 `—`**(避免把「没数据」误读成「不满足」)。 + +## 三、可用变量(写中文名即可;点「+ 插入变量」自动插入) + +| 分组 | 变量 | 含义 / 来源 | +|---|---|---| +| 行情 | `现价` `昨收` `今开` `最高` `最低` `成交量` `成交额` | 盘口内存快照(服务端 5s 刷新) | +| 合约 | `涨停价` `跌停价` | 合约信息(按交易日,当天不变) | +| 持仓 | `份额` `成本价` `最后成交价` | 持仓账本:本策略分配份额 / QMT 成本价 / 最后一笔实际成交价 | +| 自定义字段 | 本策略**手填字段的展示名**(如 `基准值` `网格大小` `网格交易量`) | 该持仓行手填的值 | + +规则与注意: + +1. **变量名 = 内置名(上表固定 12 个)或手填字段的展示名**。手填字段**改名后**,引用它的公式会失效 + (该列变 `—`,再保存字段时会提示「未知变量」),把公式里的变量名同步改成新名字即可; +2. 字段展示名**不得与内置变量名冲突**(不能建一个叫「现价」的字段),同策略内展示名不得重复; +3. **计算字段不能被另一个公式引用**(零循环依赖):公式里写另一个计算字段的名字会报「未知变量」; +4. 手填字段的**非数字值**参与运算按「无值」处理(空文本 → 该行 `—`;布尔按 1/0)。 + +## 四、运算符 + +| 类别 | 写法 | 说明 | +|---|---|---| +| 四则 | `+ - * / ` | 全角 `× ÷ + -` 同样可用 | +| 括号 | `( )` | 全角 `( )` 同样可用 | +| 比较 | `> < >= <= == !=` | 全角 `≥ ≤ ≠ > < =` 同样可用;`==` 是**精确相等**(无容差) | +| 逻辑 | `and` / `or`(也可 `&&` / `||`) | 判定型专用,如 `A > 1 and B < 2` | +| 一元负 | `-现价` | — | + +**优先级**(低 → 高):`or` → `and` → 比较 → 加减 → 乘除 → 一元负 → 括号。拿不准就加括号,最稳。 + +## 五、函数(只有这 4 个) + +| 函数 | 参数 | 例子 | 结果 | +|---|---|---|---| +| `round(值, 位数)` | 1–2 个;位数 0–10(负数按 0 处理) | `round(106.666, 2)` | `106.67` | +| `abs(值)` | 恰好 1 个 | `abs(成本价 - 现价)` | `0.7` | +| `min(...)` | ≥1 个 | `min(涨停价, 现价 * 1.05)` | 取小 | +| `max(...)` | ≥1 个 | `max(0, 现价 - 成本价)` | 取大(不小于 0) | + +## 六、计算规则与「缺值」语义 + +- **缺值短路**:公式里**任一变量没有数据**(无行情、无合约信息、手填字段为空)→ 整行结果为空 → 列显示 `—` + (不会显示 0 或 NaN,也不会报错、不会炸表); +- **除零 / 结果非有限数** → `—`; +- **浮点归一**:自动消除浮点噪声(不会出现 `0.7000000000000002` 这种值); +- **限制**:公式 ≤ **500 字符**、嵌套 ≤ **32 层**(超出提示「公式过长 / 过于复杂」); +- **安全**:自写解析引擎,不支持 `eval`,除四则/比较/逻辑/这 4 个函数外没有任何能力(不能读写文件或网络)。 + +## 七、列上怎么显示 + +| 场景 | 显示 | +|---|---| +| 数字型有值 | 按「小数位 + 单位」显示(如 `6.67 %`、`700.00 元`;单位留空则只显示数字) | +| 判定型满足 | `✓ <字段名>`(绿),如 `✓ 可下空单` | +| 判定型不满足 / 数字型无值 | `—`(灰) | +| 已清仓的历史持仓行 | `—`(不做历史时点计算) | +| 表头 | 列名前带 `ƒ` 标记;悬停列头/单元格可见公式原文(`= 公式`) | +| 刷新 | 随策略持仓接口轮询刷新(行情服务端 5s 更新;列值是「计算快照」,不做价格闪烁) | + +## 八、常用公式(可直接抄) + +### 网格超市 + +| 用途 | 结果类型 | 公式 | +|---|---|---| +| 可下空单 | 判定 | `涨停价 > 基准值 + 网格大小` | +| 可下多单 | 判定 | `跌停价 < 基准值 - 网格大小` | +| 触到上边界 | 判定 | `现价 >= 网格上边界` | +| 跌破下边界 | 判定 | `现价 <= 网格下边界` | +| 距上边界 % | 数字(单位 %) | `(网格上边界 - 现价) ÷ 现价 × 100` | +| 每格金额 | 数字(单位 元) | `网格交易量 × 网格大小` | +| 本行网格占用 | 数字(单位 元) | `现价 × 份额` | +| 空单且未触上边界(组合) | 判定 | `涨停价 > 基准值 + 网格大小 and 现价 < 网格上边界` | + +### 手动做T / 通用 + +| 用途 | 结果类型 | 公式 | +|---|---|---| +| 浮动盈亏 | 数字(元) | `(现价 - 成本价) × 份额` | +| 盈亏比例 | 数字(%) | `round((现价 ÷ 成本价 - 1) × 100, 2)` | +| 市值 | 数字(元) | `现价 × 份额` | +| 距涨停 % | 数字(%) | `(涨停价 - 现价) ÷ 现价 × 100` | +| 成本上方空间 | 数字(元) | `max(0, 现价 - 成本价) × 份额` | +| 盈利中 | 判定 | `现价 > 成本价` | +| 成本上方且未涨停 | 判定 | `现价 > 成本价 and 现价 < 涨停价` | +| 相对 T 仓成本盈亏 | 数字(元) | `(现价 - T仓成本价) × 份额` | + +## 九、保存校验与报错对照 + +保存字段时服务端会**先校验语法与变量**,不通过直接拒绝(公式框描红 + 提示,表单不关闭): + +| 提示 | 原因 | 怎么办 | +|---|---|---| +| `公式不能为空` | 公式为空 | 填公式 | +| `公式不完整` / `缺少右括号` / `意外的符号` / `无法识别的字符` | 语法错 | 检查括号与运算符是否写全 | +| `未知变量『xx』——该字段已删除或名称有误` | 变量名不对(或引用了计算字段) | 用「+ 插入变量」插入,或核对字段展示名 | +| `未知函数『xx』(可用:round/abs/min/max)` | 用了未实现的函数 | 只用这 4 个 | +| `函数『xx』参数个数不对` | 参数多/少 | 见第五节 | +| `变量名『现价』与内置变量冲突,请修改字段展示名` | 字段名撞内置变量 | 改展示名 | +| `字段展示名重复: xx` | 同策略两字段同名 | 改其一 | +| `公式过长(上限 500 字符)` / `公式过于复杂(嵌套过深)` | 超限 | 拆成两个字段 | +| `结果类型选了「判定」,但公式没有比较运算(如 涨停价 > 基准值 + 1)` | 结果类型与公式形态不符 | 改结果类型为「数字」,或把公式写成比较 | +| `公式结果是判定,请把「结果类型」改为「判定」` | 同上(反向) | 把结果类型改为「判定」 | + +配置时可点「**试算**」:服务端拿该策略**一行真实持仓**当场算给你看 +(判定型显示 `✓ 满足 / — 不满足`;无持仓提示「暂无可试算的持仓数据」)。 + +## 十、常见问题 + +**Q:为什么整列都是 `—`?** +按序排查:① 该 code 暂无行情/合约快照(等 5s 同步,或点会话头部行情灯立即同步); +② 公式用到的手填字段该行没填值;③ 公式里有除零;④ 公式引用了已删除/改名的字段。 + +**Q:能不能写条件分支(三态:空 / 多 / 观望)?** +目前不支持 `if`/三元表达式;判定型只覆盖「满足 / 不满足」两态。要一列表达三态时, +可先用两个判定字段(`可下空单` + `可下多单`)代替 —— 两个都为 `—` 即观望。 + +**Q:能不能跨行求和(本策略总盈亏)?** +不能——公式是**逐行**计算,只看得到本行数据。 + +**Q:格子能手动改吗?** +不能,计算字段**只读**;手填字段才能在格子里点击编辑。 + +**Q:单位参与计算吗?** +不参与,只影响显示(拼在值后);判定型不需要单位。 + +## 十一、暂不支持(明确边界) + +条件分支(`if`/三元)、`not` 取反、判定与数字混用的单字段、跨行聚合(求和/平均)、历史持仓行时点计算、 +策略以外的数据集(账户域持仓、交易记录聚合——尚未接入为变量)。 diff --git a/scripts/test-r011-api.mjs b/scripts/test-r011-api.mjs index f8841e1..70612bb 100644 --- a/scripts/test-r011-api.mjs +++ b/scripts/test-r011-api.mjs @@ -1,11 +1,13 @@ /** - * R-011 API 层集成测试:handleStrategy tabs 域(联动链路) + * R-011 API 层集成测试:handleStrategy tabs 域(R-027 策略静态化更新) * 独立内存 mock settings scope + mock manager,验证: - * 1. tabs 端点:返回归一化后的统一数组 + * 1. tabs 端点:返回归一化统一数组(恒 5 条:3 内置 + 2 策略) * 2. tabs/update:整表更新(顺序 + 显隐) - * 3. strategies/add:联动追加 tab 条目(末尾) - * 4. strategies/remove:联动删除 tab 条目 + 清份额(manager) - * 5. strategies/update:重命名(tabs 行 name join 跟随) + * 3. strategies/add:已退役(方法表不含 + 调用抛 not-found) + * 4. strategies/remove:已退役(方法表不含 + 调用抛 not-found) + * 5. strategies/update:已退役(方法表不含 + 调用抛 not-found) + * 6. strategies/schema-update:单策略字段定义写入(读回一致) + * 7. 端点表不含 strategies/move(已废弃) * 纯内存,不触真实数据(技术约束-011) */ import { handleStrategy, STRATEGY_METHODS } from '../src/api/strategies.js'; @@ -25,60 +27,79 @@ function makeScope(initial) { }; } +/** 断言某端点已退役:STRATEGY_METHODS 不含 + 调用抛 code=not-found */ +async function assertRetired(method, args, runtime) { + assert(!STRATEGY_METHODS.has(method), "STRATEGY_METHODS 不含 " + method); + try { + await handleStrategy(method, args, runtime); + assert(false, method + " 应抛 not-found(未抛错)"); + } catch (e) { + assert(e.code === "not-found", method + " 抛 code=not-found"); + } +} + const scope = makeScope({ - strategies: [{ id: "grid-supermarket", name: "网格超市" }], + strategies: [ + { id: "grid-supermarket", name: "网格超市" }, + { id: "manual-t", name: "手动做T" }, + ], tabs: [ - { id: "tab-all", kind: "builtin", refKey: "allPositions", name: "全部持仓", visible: true, order: 0 }, - { id: "tab-g", kind: "strategy", refId: "grid-supermarket", name: "网格超市", visible: true, order: 1 }, + { id: "tab-all-positions", kind: "builtin", refKey: "allPositions", name: "全部持仓", visible: true, order: 0 }, + { id: "tab-trade-records", kind: "builtin", refKey: "tradeRecords", name: "交易记录", visible: true, order: 1 }, + { id: "tab-watchlist", kind: "builtin", refKey: "watchlist", name: "关注列表", visible: true, order: 2 }, + { id: "tab-strategy-grid-supermarket", kind: "strategy", refId: "grid-supermarket", name: "网格超市", visible: true, order: 3 }, + { id: "tab-strategy-manual-t", kind: "strategy", refId: "manual-t", name: "手动做T", visible: true, order: 4 }, ], }); -const cleared = []; -const manager = { - async clearStrategyShares(id) { cleared.push(id); }, -}; -const runtime = { manager, settings: scope }; +const runtime = { manager: {}, settings: scope }; // 1. tabs 端点 console.log("\n[1] tabs 端点"); +const strat1 = await handleStrategy("strategies", {}, runtime); +assert(Array.isArray(strat1) && strat1.length === 2, "strategies 端点恒 2 项(内置)"); const tabs1 = await handleStrategy("tabs", {}, runtime); -assert(Array.isArray(tabs1) && tabs1.length === 2, "tabs 返回统一数组"); -assert(tabs1[0].kind === "builtin" && tabs1[1].kind === "strategy", "内置 + 策略混排"); +assert(Array.isArray(tabs1) && tabs1.length === 5, "tabs 恒 5 条(3 内置 + 2 策略)"); +assert(tabs1.filter((t) => t.kind === "builtin").length === 3 && tabs1.filter((t) => t.kind === "strategy").length === 2, "内置 3 + 策略 2 混排"); +assert(tabs1.some((t) => t.refId === "grid-supermarket") && tabs1.some((t) => t.refId === "manual-t"), "策略 tab 恒在(不可增删)"); // 2. tabs/update 整表更新 console.log("\n[2] tabs/update 整表更新"); const afterUpdate = await handleStrategy("tabs/update", { tabs: [ - { id: "tab-g", kind: "strategy", refId: "grid-supermarket", name: "网格超市", visible: false, order: 0 }, - { id: "tab-all", kind: "builtin", refKey: "allPositions", name: "全部持仓", visible: true, order: 1 }, + { id: "tab-strategy-manual-t", kind: "strategy", refId: "manual-t", name: "手动做T", visible: false, order: 0 }, + { id: "tab-all-positions", kind: "builtin", refKey: "allPositions", name: "全部持仓", visible: true, order: 1 }, + { id: "tab-watchlist", kind: "builtin", refKey: "watchlist", name: "关注列表", visible: true, order: 2 }, + { id: "tab-strategy-grid-supermarket", kind: "strategy", refId: "grid-supermarket", name: "网格超市", visible: true, order: 3 }, + { id: "tab-trade-records", kind: "builtin", refKey: "tradeRecords", name: "交易记录", visible: true, order: 4 }, ] }, runtime); -assert(afterUpdate[0].id === "tab-g" && afterUpdate[0].visible === false, "策略 tab 排到前面且隐藏"); -assert(afterUpdate[1].id === "tab-all", "内置 tab 排后"); +assert(afterUpdate[0].id === "tab-strategy-manual-t" && afterUpdate[0].visible === false, "手动做T 排最前且隐藏(顺序+显隐生效)"); +assert(afterUpdate[1].id === "tab-all-positions", "内置 tab 排第二位"); +assert(afterUpdate.length === 5, "整表更新后恒 5 条"); -// 3. strategies/add 联动追加 tab -console.log("\n[3] strategies/add 联动追加 tab"); -const added = await handleStrategy("strategies/add", { name: "长线持有" }, runtime); -const tabsAfterAdd = await handleStrategy("tabs", {}, runtime); -assert(tabsAfterAdd.length === 3, "add 后 tabs 联动新增一条"); -assert(tabsAfterAdd[2].kind === "strategy" && tabsAfterAdd[2].refId === added.id, "新策略 tab 追加末尾"); -assert(tabsAfterAdd[2].name === "长线持有", "新策略 tab name 正确"); +// 3. strategies/add 已退役 +console.log("\n[3] strategies/add 退役"); +await assertRetired("strategies/add", { name: "长线持有" }, runtime); -// 4. strategies/remove 联动删除 tab + 清份额 -console.log("\n[4] strategies/remove 联动删除 tab"); -await handleStrategy("strategies/remove", { strategyId: "grid-supermarket" }, runtime); -const tabsAfterRm = await handleStrategy("tabs", {}, runtime); -assert(!tabsAfterRm.some((t) => t.refId === "grid-supermarket"), "删除策略后对应 tab 条目消失"); -assert(tabsAfterRm.some((t) => t.refId === added.id), "其余策略 tab 保留"); -assert(cleared.includes("grid-supermarket"), "manager.clearStrategyShares 被调用(份额回未分配)"); +// 4. strategies/remove 已退役 +console.log("\n[4] strategies/remove 退役"); +await assertRetired("strategies/remove", { strategyId: "grid-supermarket" }, runtime); -// 5. strategies/update 重命名(tabs 行 join 跟随) -console.log("\n[5] strategies/update 重命名跟随"); -await handleStrategy("strategies/update", { strategies: [ - { id: added.id, name: "长线持有v2" }, -] }, runtime); -const tabsAfterRename = await handleStrategy("tabs", {}, runtime); -assert(tabsAfterRename.find((t) => t.refId === added.id).name === "长线持有v2", "重命名后 tabs 行 name join 跟随(Q4)"); +// 5. strategies/update 已退役 +console.log("\n[5] strategies/update 退役"); +await assertRetired("strategies/update", { strategies: [] }, runtime); -// 6. 端点表不含 strategies/move -console.log("\n[6] strategies/move 已废弃"); +// 6. strategies/schema-update 单策略写入(R-027 唯一写路径) +console.log("\n[6] strategies/schema-update"); +const saved = await handleStrategy("strategies/schema-update", { + strategyId: "grid-supermarket", + configSchema: [{ key: "gridGap", label: "网格间距", type: "text", def: "3%" }], +}, runtime); +assert(saved.configSchema[0].label === "网格间距", "schema-update 返回更新后的策略对象"); +const stratAfter = await handleStrategy("strategies", {}, runtime); +assert(stratAfter.find((s) => s.id === "grid-supermarket").configSchema[0].label === "网格间距", "读回一致(写入 strategyFields)"); +assert(stratAfter.find((s) => s.id === "manual-t").configSchema.length === 0, "只影响目标策略(manual-t 无定义)"); + +// 7. 端点表不含 strategies/move +console.log("\n[7] strategies/move 已废弃"); assert(!STRATEGY_METHODS.has("strategies/move"), "STRATEGY_METHODS 不含 strategies/move"); console.log("\n结果: " + pass + " 通过 / " + fail + " 失败"); diff --git a/scripts/test-r011-tabs.mjs b/scripts/test-r011-tabs.mjs index b8b2af9..8fffb21 100644 --- a/scripts/test-r011-tabs.mjs +++ b/scripts/test-r011-tabs.mjs @@ -1,18 +1,16 @@ /** - * R-011 回归测试:tabs 统一管理数据层 + * R-011 回归测试:tabs 统一管理数据层(R-027 策略 tab 静态化) * 独立内存 mock scope(settings 读写),验证: * 1. 归一化迁移:旧布尔对象 tabs → 有序数组(内置保留原显隐,策略接续追加) * 2. 迁移:旧隐藏策略 → visible=true(Q3) - * 3. getTabs:策略行 name join strategies(Q4 自动跟随改名) + * 3. getTabs:策略行 name 由内置常量派生(改名场景已退役) * 4. updateTabs:整表更新(顺序 + 显隐) - * 5. addStrategy:联动追加 tab 条目(末尾,Q2) - * 6. removeStrategy:联动删除对应 tab 条目(D3) + * 5. 策略 tab 静态化(R-027):getStrategies 恒 2 项;normalizeTabs 恒 5 条; + * 存量自建策略 tab 条目被丢弃;addStrategy/removeStrategy/renameStrategy 等退役导出不存在 * 纯内存,不触真实数据(技术约束-011) */ -import { - getTabs, updateTabs, normalizeTabs, - addStrategy, removeStrategy, renameStrategy, getStrategies, -} from '../src/settings.js'; +import { getTabs, updateTabs, normalizeTabs, getStrategies } from '../src/settings.js'; +import * as settingsMod from '../src/settings.js'; let pass = 0, fail = 0; function assert(cond, msg) { @@ -46,93 +44,71 @@ assert(oldTabs[1].refKey === 'tradeRecords' && oldTabs[1].visible === false, ' assert(oldTabs[2].refKey === 'watchlist' && oldTabs[2].visible === true, '内置 watchlist 保留旧显隐=true'); const manualTab = oldTabs.find((t) => t.kind === 'strategy' && t.refId === 'manual-t'); assert(manualTab && manualTab.visible === true, "旧隐藏策略 manual-t 迁移后 visible=true(Q3)"); -assert(manualTab && manualTab.name === '手动做T', '策略行 name 来自 strategies'); +assert(manualTab && manualTab.name === '手动做T', '策略行 name 来自内置常量'); assert(oldTabs[3].order === 3 && oldTabs[4].order === 4, '策略按 order 接续(order 连续)'); // ---------- 2. 已归一化数组直接返回 ---------- console.log("\n[2] 已归一化数组直接返回"); const arrScope = makeScope({ tabs: [ - { id: 'tab-a', kind: 'builtin', refKey: 'allPositions', name: '全部持仓', visible: true, order: 1 }, - { id: 'tab-b', kind: 'builtin', refKey: 'watchlist', name: '关注列表', visible: true, order: 0 }, + { id: 'tab-all-positions', kind: 'builtin', refKey: 'allPositions', name: '全部持仓', visible: true, order: 1 }, + { id: 'tab-watchlist', kind: 'builtin', refKey: 'watchlist', name: '关注列表', visible: true, order: 0 }, ] }); const arrTabs = normalizeTabs(arrScope); -assert(arrTabs[0].id === 'tab-b' && arrTabs[1].id === 'tab-a', '数组按 order 排序'); +assert(arrTabs[0].id === 'tab-watchlist' && arrTabs[1].id === 'tab-all-positions', '数组按 order 排序'); +assert(arrTabs.length === 5, '缺失条目补齐(恒 5 条)'); -// ---------- 3. 策略行 name join(改名自动跟随,Q4) ---------- -console.log("\n[3] getTabs 策略名 join"); +// ---------- 3. getTabs:策略行 name 由常量派生(改名已退役) ---------- +console.log("\n[3] getTabs 策略行 name"); const joinScope = makeScope({ - strategies: [{ id: 'manual-t', name: '手动做T新名' }], + strategies: [{ id: 'manual-t', name: '手动做T新名' }], // 存量改名残留:不再参与 tabs: [ - { id: 'tab-all', kind: 'builtin', refKey: 'allPositions', name: '全部持仓', visible: true, order: 0 }, - { id: 'tab-s', kind: 'strategy', refId: 'manual-t', name: '旧名', visible: true, order: 1 }, + { id: 'tab-all-positions', kind: 'builtin', refKey: 'allPositions', name: '全部持仓', visible: true, order: 0 }, + { id: 'tab-strategy-manual-t', kind: 'strategy', refId: 'manual-t', name: '旧名', visible: true, order: 1 }, ], }); const joined = getTabs(joinScope); -assert(joined.find((t) => t.id === 'tab-s').name === '手动做T新名', '策略改名后 tabs 行 name 自动跟随(Q4)'); +assert(joined.find((t) => t.id === 'tab-strategy-manual-t').name === '手动做T', '策略行 name 恒为内置常量(存量行 name 被覆盖)'); // ---------- 4. updateTabs 整表更新 ---------- console.log("\n[4] updateTabs 整表更新"); const updScope = makeScope({ tabs: [ - { id: 'tab-a', kind: 'builtin', refKey: 'allPositions', name: '全部持仓', visible: true, order: 0 }, - { id: 'tab-b', kind: 'builtin', refKey: 'watchlist', name: '关注列表', visible: true, order: 1 }, + { id: 'tab-all-positions', kind: 'builtin', refKey: 'allPositions', name: '全部持仓', visible: true, order: 0 }, + { id: 'tab-watchlist', kind: 'builtin', refKey: 'watchlist', name: '关注列表', visible: true, order: 1 }, ] }); await updateTabs(updScope, [ - { id: 'tab-b', kind: 'builtin', refKey: 'watchlist', name: '关注列表', visible: false, order: 0 }, - { id: 'tab-a', kind: 'builtin', refKey: 'allPositions', name: '全部持仓', visible: true, order: 1 }, + { id: 'tab-watchlist', kind: 'builtin', refKey: 'watchlist', name: '关注列表', visible: false, order: 0 }, + { id: 'tab-all-positions', kind: 'builtin', refKey: 'allPositions', name: '全部持仓', visible: true, order: 1 }, ]); const updTabs = getTabs(updScope); -assert(updTabs[0].id === 'tab-b' && updTabs[0].visible === false, '整表更新:顺序与显隐生效'); -assert(updTabs.length === 2, '整表更新后条目数不变'); +assert(updTabs[0].id === 'tab-watchlist' && updTabs[0].visible === false, '整表更新:顺序与显隐生效'); +assert(updTabs.length === 5, '归一化后恒 5 条(缺失策略行补齐)'); -// ---------- 5. addStrategy 联动追加 tab(末尾,Q2) ---------- -console.log("\n[5] addStrategy 联动追加 tab"); -const addScope = makeScope({ - strategies: [{ id: 'grid-supermarket', name: '网格超市' }], - tabs: [ - { id: 'tab-all', kind: 'builtin', refKey: 'allPositions', name: '全部持仓', visible: true, order: 0 }, - { id: 'tab-s', kind: 'strategy', refId: 'grid-supermarket', name: '网格超市', visible: true, order: 1 }, - ], -}); -const added = await addStrategy(addScope, '长线持有'); -assert(getStrategies(addScope).length === 2, '策略定义表新增'); -const addTabs = getTabs(addScope); -assert(addTabs.length === 3, 'tabs 联动新增一条'); -assert(addTabs[2].kind === 'strategy' && addTabs[2].refId === added.id, '新策略 tab 追加到末尾(Q2)'); -assert(addTabs[2].name === '长线持有', '新策略 tab name 正确'); -assert(addTabs[2].visible === true, '新策略 tab 默认显示'); -assert(addTabs[2].order === 2, 'order 连续'); - -// ---------- 6. removeStrategy 联动删除 tab(D3) ---------- -console.log("\n[6] removeStrategy 联动删除 tab"); -const rmScope = makeScope({ +// ---------- 5. 策略 tab 静态化(R-027) ---------- +console.log("\n[5] 策略 tab 静态化(R-027)"); +const statScope = makeScope({ strategies: [ { id: 'grid-supermarket', name: '网格超市' }, { id: 'manual-t', name: '手动做T' }, + { id: 'long-term', name: '长线持有' }, // R-027 前可增:存量自建策略 ], tabs: [ - { id: 'tab-all', kind: 'builtin', refKey: 'allPositions', name: '全部持仓', visible: true, order: 0 }, - { id: 'tab-g', kind: 'strategy', refId: 'grid-supermarket', name: '网格超市', visible: true, order: 1 }, - { id: 'tab-m', kind: 'strategy', refId: 'manual-t', name: '手动做T', visible: true, order: 2 }, + { id: 'tab-all-positions', kind: 'builtin', refKey: 'allPositions', name: '全部持仓', visible: true, order: 1 }, + { id: 'tab-watchlist', kind: 'builtin', refKey: 'watchlist', name: '关注列表', visible: true, order: 2 }, + { id: 'tab-strategy-long-term', kind: 'strategy', refId: 'long-term', name: '长线持有', visible: true, order: 0 }, // 存量自建策略 tab ], }); -await removeStrategy(rmScope, 'grid-supermarket'); -const rmTabs = getTabs(rmScope); -assert(rmTabs.length === 2, '删除策略后 tabs 联动删除对应条目'); -assert(!rmTabs.some((t) => t.refId === 'grid-supermarket'), 'grid-supermarket tab 条目已删除'); -assert(rmTabs.some((t) => t.refId === 'manual-t'), 'manual-t tab 条目保留'); -assert(getStrategies(rmScope).length === 1, '策略定义表删除'); - -// ---------- 7. renameStrategy 只改名(tabs 行 join 跟随,不影响定义) ---------- -console.log("\n[7] renameStrategy"); -const rnScope = makeScope({ - strategies: [{ id: 'manual-t', name: '手动做T' }], - tabs: [ - { id: 'tab-m', kind: 'strategy', refId: 'manual-t', name: '手动做T', visible: true, order: 0 }, - ], -}); -await renameStrategy(rnScope, 'manual-t', '手动T+'); -assert(getStrategies(rnScope)[0].name === '手动T+', '策略定义名更新'); -assert(getTabs(rnScope)[0].name === '手动T+', 'tabs 行 name join 跟随(Q4)'); +const strat = getStrategies(statScope); +assert(strat.length === 2, 'getStrategies 恒 2 项(自建策略被忽略)'); +assert(!strat.some((s) => s.id === 'long-term'), '自建策略不出现在读取结果'); +const statTabs = normalizeTabs(statScope); +assert(statTabs.length === 5, 'normalizeTabs 恒 5 条(3 内置 + 2 策略)'); +assert(!statTabs.some((t) => t.refId === 'long-term'), '存量自建策略 tab 条目被丢弃'); +assert(statTabs.filter((t) => t.kind === 'builtin').length === 3 && statTabs.filter((t) => t.kind === 'strategy').length === 2, '条目构成 = 3 内置 + 2 策略'); +assert(!('addStrategy' in settingsMod), '退役导出 addStrategy 不存在'); +assert(!('removeStrategy' in settingsMod), '退役导出 removeStrategy 不存在'); +assert(!('renameStrategy' in settingsMod), '退役导出 renameStrategy 不存在'); +assert(!('updateStrategies' in settingsMod), '退役导出 updateStrategies 不存在'); +assert(!('appendStrategyTab' in settingsMod) && !('removeStrategyTab' in settingsMod), '退役导出 append/removeStrategyTab 不存在'); // ---------- 汇总 ---------- console.log("\n结果: " + pass + " 通过 / " + fail + " 失败"); diff --git a/scripts/test-r013-columns.mjs b/scripts/test-r013-columns.mjs index 766446a..f5a4474 100644 --- a/scripts/test-r013-columns.mjs +++ b/scripts/test-r013-columns.mjs @@ -1,5 +1,5 @@ /** - * R-013 列显隐配置回归(迭代 11 扩展,2026-09-02) + * R-013 列显隐配置回归(迭代 11 扩展,2026-09-02;期望值随 R-015/迭代13 行情列与 R-027/迭代22 真相源更新) * 覆盖(纯内存 mock scope,技术约束-011): * 1. normalizeStrategyColumns 默认:基础列全显默认序 + configSchema 字段列全显 * 2. 覆盖生效:visible=false 隐藏、排序调整 @@ -41,15 +41,19 @@ const scope = makeScope({ ], }); const gridCols = normalizeStrategyColumns(scope, 'grid-supermarket'); -const baseKeys = ['lastPrice','pctChange','lastClose','avgPrice','lastTradePrice','shares']; -assert(gridCols.length === 8, '默认 8 列(6 基础 + 2 字段)'); +// 基础列目录(COLUMN_META,迭代 13/R-015 扩展:4 个行情列默认隐藏) +const baseKeys = ['lastPrice','pctChange','lastClose','avgPrice','lastTradePrice','shares', + 'upStopPrice','downStopPrice','open','high']; +const hiddenByDefault = ['upStopPrice','downStopPrice','open','high']; +assert(gridCols.length === 12, '默认 12 列(10 基础 + 2 字段)'); assert(baseKeys.every((k) => gridCols.some((c) => c.key === k)), '含全部基础列'); -assert(gridCols[6].key === 'gridGap' && gridCols[7].key === 'riskLevel', '字段列按 configSchema 序追加末尾'); -assert(gridCols.every((c) => c.visible !== false), '默认全部可见'); +assert(gridCols[10].key === 'gridGap' && gridCols[11].key === 'riskLevel', '字段列按 configSchema 序追加末尾'); +assert(gridCols.slice(0, 6).every((c) => c.visible !== false), '前 6 基础列默认可见'); +assert(gridCols.filter((c) => hiddenByDefault.includes(c.key)).every((c) => c.visible === false), '4 个行情列默认隐藏(R-015)'); assert(gridCols[0].key === 'lastPrice', '默认序:现价在前'); const manualCols = normalizeStrategyColumns(scope, 'manual-t'); -assert(manualCols.length === 6, '旧策略(无 configSchema)仅 6 基础列'); +assert(manualCols.length === 10, '无字段定义策略仅 10 基础列'); // ---------- 2. 覆盖生效 ---------- section('2. 覆盖(隐藏 + 排序)'); @@ -64,7 +68,7 @@ await updateStrategyColumns(scope, 'grid-supermarket', [ { key: 'lastTradePrice', visible: true }, ]); const after = getStrategyColumns(scope, 'grid-supermarket'); -assert(after.length === 8, '读回 8 列(含不可见,保位)'); +assert(after.length === 12, '读回 12 列(含不可见,保位)'); assert(after[0].key === 'riskLevel', '排序覆盖:riskLevel 提到最前'); assert(after.find((c) => c.key === 'gridGap').visible === false, 'gridGap 已隐藏'); assert(after.find((c) => c.key === 'lastPrice').visible === false, 'lastPrice 已隐藏'); @@ -88,6 +92,15 @@ const afterDel = normalizeStrategyColumns(scope, 'grid-supermarket'); assert(!afterDel.some((c) => c.key === 'riskLevel'), '删除字段 riskLevel 列自动移除'); assert(afterDel.some((c) => c.key === 'gridGap'), '其余列保留'); +// ---------- 4. R-027:字段定义真相源 strategyFields ---------- +section('4. 字段定义真相源(strategyFields 优先,旧 strategies 兜底)'); +const newScope = makeScope({ + strategies: [{ id: 'grid-supermarket', name: '网格超市', configSchema: [{ key: 'old', label: '旧字段', type: 'text' }] }], + strategyFields: { 'grid-supermarket': gridSchema }, +}); +const newCols = normalizeStrategyColumns(newScope, 'grid-supermarket'); +assert(newCols.some((c) => c.key === 'gridGap') && !newCols.some((c) => c.key === 'old'), 'strategyFields 优先于旧 configSchema'); + console.log(""); console.log('结果: ' + pass + ' 通过, ' + fail + ' 失败'); process.exit(fail > 0 ? 1 : 0); \ No newline at end of file diff --git a/scripts/test-r013-custom-fields.mjs b/scripts/test-r013-custom-fields.mjs index dea7bba..56b3788 100644 --- a/scripts/test-r013-custom-fields.mjs +++ b/scripts/test-r013-custom-fields.mjs @@ -1,7 +1,7 @@ /** * R-013 回归测试:策略自定义字段(定义随策略 configSchema + 值落 strategy_holdings.values) * 覆盖(独立数据目录,技术约束-011): - * 1. settings.configSchema:schema 归一化(旧策略无 configSchema → []);updateStrategies 保留定义 + * 1. settings.configSchema:schema 归一化(旧策略无 configSchema → []);updateStrategyFields 单策略写入 * 2. SqliteStore:_ensureHoldingValuesColumn 幂等补列;openHolding 后 values=null;readValues/writeValues 读写 * 3. PositionManager.updateHoldingValues:定位持仓 → 按所属策略 configSchema 校验(number/enum/boolean) * → 写回;空值放行;非法值拒绝(field-validation);额外键放行(可扩展);null 清除 @@ -14,7 +14,7 @@ import { join } from 'node:path'; import { SqliteStore } from '../src/storage/SqliteStore.js'; import { DataStore } from '../src/storage/DataStore.js'; import { PositionManager } from '../src/position/PositionManager.js'; -import { getStrategies, updateStrategies, addStrategy } from '../src/settings.js'; +import { getStrategies, getStrategyFields, updateStrategyFields } from '../src/settings.js'; let pass = 0, fail = 0; function assert(cond, msg) { @@ -56,9 +56,16 @@ assert(grid && Array.isArray(grid.configSchema) && grid.configSchema.length === const manual = all.find((s) => s.id === 'manual-t'); assert(manual && Array.isArray(manual.configSchema) && manual.configSchema.length === 0, '旧策略 manual-t configSchema 归一化为 [](Q4)'); -// updateStrategies 整表更新保留定义 -await updateStrategies(scope, [{ id: 'manual-t', name: '手动做T' }, { id: 'grid-supermarket', name: '网格超市', configSchema: gridSchema }]); -assert(getStrategies(scope).find((s) => s.id === 'grid-supermarket').configSchema.length === 4, 'updateStrategies 整表保留 configSchema'); +// updateStrategyFields 单策略写入(R-027 唯一写路径) +await updateStrategyFields(scope, 'grid-supermarket', gridSchema); +assert(getStrategyFields(scope, 'grid-supermarket').length === 4, 'updateStrategyFields 写入后读回 4 字段'); +assert(getStrategyFields(scope, 'manual-t').length === 0, '写入只影响目标策略(manual-t 仍无定义)'); +try { + await updateStrategyFields(scope, 'long-term', gridSchema); + assert(false, '未知 strategyId 应抛错'); +} catch (e) { + assert(e.code === 'strategy-not-found', '未知 strategyId 抛 strategy-not-found'); +} // ---------- 2. SqliteStore values 列 ---------- section('2. SqliteStore values 列'); diff --git a/scripts/test-r017-candidates.mjs b/scripts/test-r017-candidates.mjs index d52213a..4cc13f7 100644 --- a/scripts/test-r017-candidates.mjs +++ b/scripts/test-r017-candidates.mjs @@ -1,5 +1,5 @@ /** - * R-017 / 迭代 15 回归 → R-018 / 迭代 16 语义修订:归属候选退役 7 天清仓窗口 + * R-017 / 迭代 15 回归 → R-018 / 迭代 16 语义修订 → R-027 迭代 22:策略 id 改用内置(manual-t / grid-supermarket):归属候选退役 7 天清仓窗口 * 新语义(R-018 T2 老师拍板):候选回归「当前活动持仓 + 未关联」;正式候选为策略级 attribution-targets。 * 覆盖:当前活动持仓候选 / 清仓/作废行不进候选 / 多策略 / 空 code / targets 端点。 */ @@ -20,25 +20,25 @@ const tmp = mkdtempSync(join(tmpdir(), 'odl-r017-')); const storage = new DataStore({ dataDir: tmp }); const runtime = { storage, - settings: { get: () => ({ strategies: [{ id: 's-t', name: '做T' }, { id: 's-g', name: '网格' }] }) }, + settings: { get: () => ({}) }, // R-027:策略恒为内置两项(不再从 settings 读取) }; try { console.log('\n[1] 当前活动持仓候选(closed=false)'); - await storage.openHolding('s-t', '600719.SH', 1000); - await storage.openHolding('s-g', '600719.SH', 800); // 同码第二策略 + await storage.openHolding('manual-t', '600719.SH', 1000); + await storage.openHolding('grid-supermarket', '600719.SH', 800); // 同码第二策略 let cands = await storage.getTradeAttributionCandidates('600719.SH'); assert(cands.length === 2, '同码两策略当前持仓都在候选'); assert(cands.every((c) => c.closed === false && c.closedAt === null), '当前持仓 closed=false / closedAt=null'); assert(cands[0].shares >= cands[1].shares, '当前持仓按 shares DESC'); console.log('\n[2] 清仓/作废行不进候选(R-018 退役 7 天窗口)'); - const hc = await storage.openHolding('s-t', '000001.SZ', 500); - await storage.closeHolding('s-t', '000001.SZ'); // 刚清仓(R-017 时代会入选,R-018 起不再) + const hc = await storage.openHolding('manual-t', '000001.SZ', 500); + await storage.closeHolding('manual-t', '000001.SZ'); // 刚清仓(R-017 时代会入选,R-018 起不再) cands = await storage.getTradeAttributionCandidates('000001.SZ'); assert(cands.length === 0, '刚清仓行不进候选(7 天窗口退役)'); - const hv = await storage.openHolding('s-t', '600000.SH', 300); - storage.sqlite.voidHolding('s-t', '600000.SH'); + const hv = await storage.openHolding('manual-t', '600000.SH', 300); + storage.sqlite.voidHolding('manual-t', '600000.SH'); cands = await storage.getTradeAttributionCandidates('600000.SH'); assert(cands.length === 0, '作废行不进候选'); @@ -47,18 +47,18 @@ try { assert((await storage.getTradeAttributionCandidates('999999.SH')).length === 0, '无持仓 code → []'); console.log('\n[4] R-018 attribution-targets(策略级候选端点)'); - const hc2 = await storage.openHolding('s-t', '000002.SZ', 500); + const hc2 = await storage.openHolding('manual-t', '000002.SZ', 500); const tgt = await handleTrade('orders/attribution-targets', { code: '000002.SZ' }, runtime); assert(Array.isArray(tgt), 'targets 端点返回数组'); assert(tgt.every((x) => x.strategyId && x.activityShares != null), 'targets 每项含 strategyId + activityShares'); - const rowT = tgt.find((x) => x.strategyId === 's-t'); + const rowT = tgt.find((x) => x.strategyId === 'manual-t'); assert(rowT && rowT.activityShares === 500 && rowT.hasActiveHolding === true, '有仓策略 activityShares=500 / hasActiveHolding=true'); - const rowG = tgt.find((x) => x.strategyId === 's-g'); + const rowG = tgt.find((x) => x.strategyId === 'grid-supermarket'); assert(rowG && rowG.activityShares === 0 && rowG.hasActiveHolding === false, '无仓策略 activityShares=0 / hasActiveHolding=false'); // 关闭的持仓不占 hasActiveHolding - await storage.closeHolding('s-t', '000002.SZ'); + await storage.closeHolding('manual-t', '000002.SZ'); const tgt2 = await handleTrade('orders/attribution-targets', { code: '000002.SZ' }, runtime); - assert(tgt2.find((x) => x.strategyId === 's-t').hasActiveHolding === false, '清仓后该策略 hasActiveHolding=false'); + assert(tgt2.find((x) => x.strategyId === 'manual-t').hasActiveHolding === false, '清仓后该策略 hasActiveHolding=false'); } finally { storage.close(); try { rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ } diff --git a/scripts/test-r026-formula-fields.mjs b/scripts/test-r026-formula-fields.mjs new file mode 100644 index 0000000..39025c3 --- /dev/null +++ b/scripts/test-r026-formula-fields.mjs @@ -0,0 +1,218 @@ +/** + * R-026 回归测试:策略计算字段(公式引擎 + 变量目录 + 现算 + 校验 + 试算)(迭代 22 阶段二) + * 覆盖: + * 1. 引擎:中文变量 / 全角归一 / 优先级 / 一元负 / 函数 / 语法错误 / 未知变量与函数 / 缺值短路 / 除零 / 浮点归一 + * 2. 变量目录:内置三组 + 自定义字段组;变量名集合排除 formula 字段(零循环依赖) + * 3. 上下文取数:行情/合约/持仓账本/自定义字段值;昨收兜底;布尔 → 0/1;空值 → null + * 4. 现算:computed 附着 / 缺数据 → null / 无 formula 字段零开销 / 不写 values + * 5. 保存校验:语法、未知变量、变量名冲突、重复展示名、字段名引用公式字段 → 拒绝 + * 6. 试算:正常 / 无持仓 / 缺数据 + * 7. R-028 判定型:比较运算 / and·or / 全角 ≥ ≤ ≠ / inferResultKind / 结果类型一致性校验 / 布尔直通 / 判定试算 + * 8. 端到端:网格超市真实夹具(2026-09-10 实盘涨跌停 + 真实基准值)→ 可下空单 / 可下多单 + * 纯内存 mock(技术约束-011:不触真实数据) + */ +import { validateFormula, evaluateFormula, compileFormula, inferResultKind } from '../src/formula/evaluator.js'; +import { buildContext, catalogFor, allowedVarNames, BUILTIN_VAR_NAMES } from '../src/formula/variables.js'; +import { computeRows } from '../src/formula/FormulaService.js'; +import { handleStrategy, STRATEGY_METHODS } from '../src/api/strategies.js'; + +let pass = 0, fail = 0; +function assert(cond, msg) { + if (cond) { pass++; console.log(' ✅ ' + msg); } else { fail++; console.log(' ❌ ' + msg); } +} +function section(t) { console.log('\n[' + t + ']'); } +const approx = (a, b) => typeof a === 'number' && Math.abs(a - b) < 1e-6; +function makeScope(initial) { + const store = structuredClone(initial ?? {}); + return { get: () => structuredClone(store), update: async (p) => { Object.assign(store, structuredClone(p)); }, _store: store }; +} +async function throwsAsync(fn, code, msg) { + try { await fn(); assert(false, msg + '(未抛错)'); } + catch (e) { assert(e?.code === code, msg + '(code=' + e?.code + ')'); } +} + +const VARS = ['现价', '成本价', '份额', '涨停价']; + +// ---------- 1. 引擎 ---------- +section('1. 公式引擎'); +assert(validateFormula('(现价 - 成本价) * 份额', VARS).ok, '中文变量公式通过校验'); +assert(validateFormula('(现价 ÷ 成本价 - 1)× 100', VARS).ok, '全角括号/乘除归一化'); +assert(validateFormula('现价 × 份额', VARS).ok, '全角乘号'); +assert(!validateFormula('现价 ×', VARS).ok && validateFormula('现价 ×', VARS).error.code === 'syntax', '语法错误被拒'); +assert(validateFormula('现价 + 网格距', VARS).error.code === 'unknown-var', '未知变量被拒'); +assert(validateFormula('sqrt(现价)', VARS).error.code === 'unknown-func', '未知函数被拒'); +assert(validateFormula('round(现价, 1, 2)', VARS).error.code === 'arity', '函数参数个数校验'); +assert(compileFormula('').error.code === 'empty', '空公式'); +const ctx = { 现价: 11.2, 成本价: 10.5, 份额: 1000, 涨停价: 12 }; +assert(evaluateFormula('(现价 - 成本价) * 份额', ctx) === 700, '浮动盈亏 = 700(浮点归一)'); +assert(approx(evaluateFormula('(现价 / 成本价 - 1) * 100', ctx), 6.666667), '盈亏比例'); +assert(approx(evaluateFormula('(涨停价 - 现价) / 现价 * 100', ctx), 7.142857), '距涨停'); +assert(evaluateFormula('现价 * 份额 * 0.5', ctx) === 5600, '网格占用(手填+计算组合)'); +assert(evaluateFormula('现价 / 0', ctx) === null, '除零 → null'); +assert(evaluateFormula('现价 + 缺变量', ctx) === null, '缺值短路 → null'); +assert(evaluateFormula('max(1, 2) + min(4, 5) + abs(-3) - round(2.6)', ctx) === 6, '函数组合(2+4+3-3)'); +assert(evaluateFormula('2 + 3 * 4', ctx) === 14 && evaluateFormula('(2 + 3) * 4', ctx) === 20, '优先级与括号'); +assert(evaluateFormula('-现价 + 10', ctx) === -1.2, '一元负号'); +assert(compileFormula('(现价', VARS).ok === false, '缺右括号被拒'); +const longExpr = '现价' + ' + 现价'.repeat(120); +assert(compileFormula(longExpr).ok === false, '超长公式被拒'); + +// ---------- 2. 变量目录 ---------- +section('2. 变量目录'); +const fieldDefs = [ + { key: 'gridGap', label: '网格间距', type: 'number', def: 0.5, unit: '元' }, + { key: 'pct', label: '盈亏比例', type: 'formula', formula: '(现价 / 成本价 - 1) * 100', decimals: 2 }, +]; +assert(BUILTIN_VAR_NAMES.has('现价') && BUILTIN_VAR_NAMES.has('最后成交价'), '内置变量名集合'); +const names = allowedVarNames(fieldDefs); +assert(names.has('现价') && names.has('网格间距'), '变量名含内置 + 手填字段展示名'); +assert(!names.has('盈亏比例'), 'formula 字段不进变量目录(零循环依赖)'); +const cat = catalogFor(fieldDefs); +assert(cat.groups.length === 4 && cat.groups[3].group === '自定义字段', '目录含自定义字段组'); +assert(cat.groups[0].group === '行情' && cat.groups[0].items[0].name === '现价', '行情组首位为现价'); + +// ---------- 3. 上下文取数 ---------- +section('3. 上下文取数'); +const row = { code: '000001.SZ', name: '平安银行', shares: 1000, avgPrice: 10.5, lastTradePrice: 10.4, values: { gridGap: 0.5 } }; +const c1 = buildContext({ row, quote: { lastPrice: 11.2, lastClose: 11 }, instrument: { upStopPrice: 12, downStopPrice: 9 }, fieldDefs }); +assert(c1['现价'] === 11.2 && c1['昨收'] === 11 && c1['涨停价'] === 12 && c1['份额'] === 1000 && c1['网格间距'] === 0.5, '四类数据集取数'); +const c2 = buildContext({ row, quote: { lastPrice: 11.2 }, instrument: { preClose: 10.9 }, fieldDefs }); +assert(c2['昨收'] === 10.9, '昨收兜底取合约 preClose'); +const c3 = buildContext({ row, quote: null, instrument: null, fieldDefs }); +assert(c3['现价'] === null && c3['涨停价'] === null && c3['份额'] === 1000, '缺行情 → null,持仓账本仍可取'); +const c4 = buildContext({ row: { ...row, values: { gridGap: '' } }, quote: null, instrument: null, + fieldDefs: [{ key: 'gridGap', label: '网格间距', type: 'text' }, { key: 'on', label: '开关', type: 'boolean', def: true }] }); +assert(c4['网格间距'] === null && c4['开关'] === 1, '空串 → null;布尔 → 0/1'); + +// ---------- 4. 现算 ---------- +section('4. computeRows 现算'); +const hub = { + quotes: new Map([['000001.SZ', { lastPrice: 11.2, lastClose: 11 }]]), + instruments: new Map([['000001.SZ', { upStopPrice: 12 }]]), +}; +const rows = computeRows({ rows: [row, { code: 'X.SZ', name: '无行情', shares: 100, avgPrice: 5 }], fieldDefs, hub }); +assert(approx(rows[0].computed.pct, 6.666667), '有行情 → computed 算出'); +assert(rows[1].computed.pct === null, '无行情 → computed 为 null(前端显示 —)'); +assert(row.computed === undefined, '原行对象未被就地修改'); +const noFormula = computeRows({ rows: [row], fieldDefs: [{ key: 'g', label: 'G', type: 'number' }], hub }); +assert(noFormula[0] === row && noFormula[0].computed === undefined, '无 formula 字段 → 原数组零开销'); +const mixed = computeRows({ rows: [row], fieldDefs: [ + { key: 'b', label: 'B', type: 'formula', formula: '现价 ×' }, + { key: 'g', label: 'G', type: 'formula', formula: '现价 * 份额' }], hub }); +assert(mixed[0].computed.b === undefined && approx(mixed[0].computed.g, 11200), '非法公式列无值(前端 —),合法列照常算'); +const allBad = computeRows({ rows: [row], fieldDefs: [{ key: 'b', label: 'B', type: 'formula', formula: '现价 ×' }], hub }); +assert(allBad[0].computed === undefined, '公式全非法 → 不附加 computed(前端统一 —)'); + +// ---------- 5. 保存校验 ---------- +section('5. schema-update 校验'); +assert(STRATEGY_METHODS.has('formula/variables') && STRATEGY_METHODS.has('formula/trial'), '新增端点入方法表'); +const scope = makeScope({ strategyFields: { 'manual-t': [{ key: 't_cost', label: 'T仓成本价', type: 'number', unit: '元' }] } }); +const runtime = { manager: {}, settings: scope, storage: {}, marketHub: hub }; +const okSave = await handleStrategy('strategies/schema-update', { + strategyId: 'manual-t', + configSchema: [ + { key: 't_cost', label: 'T仓成本价', type: 'number', unit: '元' }, + { key: 'pnl', label: '浮动盈亏', type: 'formula', formula: '(现价 - 成本价) × 份额', unit: '元', decimals: 2 }, + { key: 'pnl_pct', label: '盈亏比例', type: 'formula', formula: '(现价 ÷ 成本价 - 1) × 100', unit: '%', decimals: 2 }, + ], +}, runtime); +assert(okSave.configSchema.length === 3 && okSave.configSchema[1].decimals === 2, '合法字段(含公式)保存成功'); +await throwsAsync(() => handleStrategy('strategies/schema-update', { strategyId: 'manual-t', configSchema: [ + { key: 'a', label: 'A', type: 'formula', formula: '现价 ×' }] }, runtime), 'field-validation', '语法错误公式被拒'); +await throwsAsync(() => handleStrategy('strategies/schema-update', { strategyId: 'manual-t', configSchema: [ + { key: 'a', label: 'A', type: 'formula', formula: '现价 * 不存在的字段' }] }, runtime), 'field-validation', '未知变量公式被拒'); +await throwsAsync(() => handleStrategy('strategies/schema-update', { strategyId: 'manual-t', configSchema: [ + { key: 'a', label: '现价', type: 'number' }] }, runtime), 'field-validation', '变量名与内置冲突被拒'); +await throwsAsync(() => handleStrategy('strategies/schema-update', { strategyId: 'manual-t', configSchema: [ + { key: 'a', label: '同名', type: 'number' }, { key: 'b', label: '同名', type: 'number' }] }, runtime), 'field-validation', '重复展示名被拒'); +await throwsAsync(() => handleStrategy('strategies/schema-update', { strategyId: 'manual-t', configSchema: [ + { key: 'a', label: 'A', type: 'formula', formula: '1', }, + { key: 'b', label: 'B', type: 'formula', formula: 'A + 1' }] }, runtime), 'field-validation', '公式引用公式字段被拒(零循环依赖)'); + +// ---------- 6. strategy-positions / trial ---------- +section('6. 端点:持仓现算与试算'); +const manager = { getStrategyPositions: async () => structuredClone([row]) }; +const rt2 = { manager, settings: scope, storage: {}, marketHub: hub }; +const positions = await handleStrategy('strategy-positions', { strategyId: 'manual-t' }, rt2); +assert(positions.length === 1 && approx(positions[0].computed.pnl, 700), 'strategy-positions 附 computed'); +const trial = await handleStrategy('formula/trial', { strategyId: 'manual-t', formula: '(现价 - 成本价) × 份额' }, rt2); +assert(trial.code === '000001.SZ' && trial.name === '平安银行' && approx(trial.value, 700) && trial.reason === null, '试算返回代码/名称/值'); +await throwsAsync(() => handleStrategy('formula/trial', { strategyId: 'manual-t', formula: '现价 ×' }, rt2), + 'field-validation', '试算非法公式被拒'); +const rtEmpty = { manager: { getStrategyPositions: async () => [] }, settings: scope, storage: {}, marketHub: hub }; +const tNoHolding = await handleStrategy('formula/trial', { strategyId: 'grid-supermarket', formula: '现价 × 份额' }, rtEmpty); +assert(tNoHolding.reason === 'no-holding' && tNoHolding.value === null, '无持仓 → reason=no-holding'); +const rtNoData = { manager: { getStrategyPositions: async () => [row] }, settings: scope, storage: {}, marketHub: { quotes: new Map(), instruments: new Map() } }; +const tNoData = await handleStrategy('formula/trial', { strategyId: 'manual-t', formula: '现价 × 份额' }, rtNoData); +assert(tNoData.reason === 'no-data' && tNoData.code === '000001.SZ', '缺行情 → reason=no-data'); +const vars = await handleStrategy('formula/variables', { strategyId: 'manual-t' }, rt2); +assert(Array.isArray(vars.builtin) && vars.groups.some((g) => g.group === '自定义字段'), 'formula/variables 返回目录'); +await throwsAsync(() => handleStrategy('formula/variables', { strategyId: 'nope' }, rt2), 'strategy-not-found', '目录端点守卫未知策略'); + +// ---------- 7. R-028 判定型 ---------- +section('7. 判定型(比较运算 / and·or / 结果类型)'); +const jctx = { 涨停价: 8.22, 跌停价: 6.72, 基准值: 7, 网格大小: 1, 现价: 7.47, 份额: 800 }; +assert(validateFormula('涨停价 > 基准值 + 网格大小', ['涨停价', '基准值', '网格大小']).ok, '判定公式(比较)通过校验'); +assert(evaluateFormula('涨停价 > 基准值 + 网格大小', jctx) === true, '涨停价 > 基准值 + 网格大小 → true(积成电子实况)'); +assert(evaluateFormula('跌停价 < 基准值 - 网格大小', jctx) === false, '跌停价 < 基准值 - 网格大小 → false'); +assert(evaluateFormula('涨停价 >= 8.22 and 现价 < 涨停价', jctx) === true, 'and 组合'); +assert(evaluateFormula('跌停价 > 基准值 or 现价 > 基准值', jctx) === true, 'or 组合'); +assert(evaluateFormula('涨停价 > 基准值 && 现价 > 基准值', jctx) === true, '&& 等价 and'); +assert(evaluateFormula('涨停价 ≥ 8.22 and 跌停价 ≤ 6.72', jctx) === true, '全角 ≥ ≤ 归一'); +assert(evaluateFormula('现价 ≠ 基准值', jctx) === true, '全角 ≠ 归一'); +assert(evaluateFormula('涨停价 > 基准值 and 缺失变量 > 1', jctx) === null, '判定缺值短路 → null'); +assert(inferResultKind(compileFormula('涨停价 > 基准值 + 1').ast) === 'boolean', 'inferResultKind:比较 → boolean'); +assert(inferResultKind(compileFormula('涨停价 - 基准值 - 1').ast) === 'number', 'inferResultKind:算式 → number'); +assert(inferResultKind(compileFormula('涨停价 > 基准值 and 现价 > 1').ast) === 'boolean', 'inferResultKind:and → boolean'); + +const jDefs = [ + { key: 'current_base', label: '基准值', type: 'number' }, + { key: 'grid_break', label: '网格大小', type: 'number' }, + { key: 'sig_up', label: '可下空单', type: 'formula', formula: '涨停价 > 基准值 + 网格大小', resultKind: 'boolean' }, +]; +const jScope = makeScope({ strategyFields: { 'grid-supermarket': jDefs } }); +const jRuntime = { manager: {}, settings: jScope, storage: {}, marketHub: hub }; +const savedSig = await handleStrategy('strategies/schema-update', { strategyId: 'grid-supermarket', configSchema: jDefs }, jRuntime); +assert(savedSig.configSchema[2].resultKind === 'boolean', '判定字段保存后 resultKind=boolean'); +await throwsAsync(() => handleStrategy('strategies/schema-update', { strategyId: 'grid-supermarket', configSchema: [ + { key: 'a', label: 'A', type: 'formula', formula: '1 + 1', resultKind: 'boolean' }] }, jRuntime), + 'field-validation', '声明判定但公式无比较 → 拒绝'); +await throwsAsync(() => handleStrategy('strategies/schema-update', { strategyId: 'grid-supermarket', configSchema: [ + { key: 'a', label: 'A', type: 'formula', formula: '涨停价 > 1', resultKind: 'number' }] }, jRuntime), + 'field-validation', '公式是判定但声明数字 → 拒绝'); + +// ---------- 8. 端到端:网格超市真实夹具 ---------- +section('8. 端到端(2026-09-10 实盘涨跌停 + 真实基准值)'); +const realRows = [ + { code: '002339.SZ', name: '积成电子', shares: 800, base: 7, up: 8.22, down: 6.72, gap: 1, expectUp: true, expectDown: false }, + { code: '601117.SH', name: '中国化学', shares: 600, base: 8, up: 8.16, down: 6.68, gap: 1, expectUp: false, expectDown: true }, + { code: '603028.SH', name: '赛福天', shares: 800, base: 7, up: 7.87, down: 6.44, gap: 1, expectUp: false, expectDown: false }, +]; +const gridDefs = [ + { key: 'current_base', label: '基准值', type: 'number' }, + { key: 'grid_break', label: '网格大小', type: 'number' }, + { key: 'sig_short', label: '可下空单', type: 'formula', formula: '涨停价 > 基准值 + 网格大小', resultKind: 'boolean' }, + { key: 'sig_long', label: '可下多单', type: 'formula', formula: '跌停价 < 基准值 - 网格大小', resultKind: 'boolean' }, +]; +const gRows = realRows.map((r) => ({ + code: r.code, name: r.name, shares: r.shares, avgPrice: 7, lastTradePrice: 7, + values: { current_base: r.base, grid_break: r.gap }, +})); +const gHub = { + quotes: new Map(realRows.map((r) => [r.code, { lastPrice: 7.4 }])), + instruments: new Map(realRows.map((r) => [r.code, { upStopPrice: r.up, downStopPrice: r.down }])), +}; +const gOut = computeRows({ rows: gRows, fieldDefs: gridDefs, hub: gHub }); +realRows.forEach((r, i) => { + assert(gOut[i].computed.sig_short === r.expectUp, r.name + ' 可下空单=' + r.expectUp); + assert(gOut[i].computed.sig_long === r.expectDown, r.name + ' 可下多单=' + r.expectDown); +}); +const gScope = makeScope({ strategyFields: { 'grid-supermarket': gridDefs } }); +const gRt = { manager: { getStrategyPositions: async () => structuredClone(gRows) }, settings: gScope, storage: {}, marketHub: gHub }; +const gPos = await handleStrategy('strategy-positions', { strategyId: 'grid-supermarket' }, gRt); +assert(gPos[0].computed.sig_short === true && gPos[2].computed.sig_short === false, 'strategy-positions 附判定 computed'); +const gTrial = await handleStrategy('formula/trial', { strategyId: 'grid-supermarket', formula: '涨停价 > 基准值 + 网格大小' }, gRt); +assert(gTrial.value === true && gTrial.code === '002339.SZ', '判定型试算返回布尔值'); + +console.log('\n结果: ' + pass + ' 通过 / ' + fail + ' 失败'); +process.exit(fail === 0 ? 0 : 1); diff --git a/scripts/test-r027-static-strategies.mjs b/scripts/test-r027-static-strategies.mjs new file mode 100644 index 0000000..04571d2 --- /dev/null +++ b/scripts/test-r027-static-strategies.mjs @@ -0,0 +1,139 @@ +/** + * R-027 回归测试:策略 tab 静态化 + 字段配置存储归一化(迭代 22 阶段一) + * 覆盖: + * 1. 策略常量:getStrategies 恒为内置两项(存量自建策略被忽略) + * 2. 旧数据兼容:strategies[].configSchema → 读取回填;strategyFields 优先 + * 3. 字段归一化:类型白名单 / decimals 夹取 / formula 字段不带 def + * 4. 单策略写入:updateStrategyFields 只改目标策略;未知 strategyId 抛 strategy-not-found + * 5. tabs 归一化:丢弃自建策略条目、保留 visible/order、缺失补齐、旧布尔对象兼容 + * 6. updateTabs 只接受内置条目 + * 7. 端点:strategies/schema-update 校验 + 落库;已退役端点调用被拒 + * 纯内存 mock(技术约束-011:不触真实数据) + */ +import { + BUILTIN_STRATEGIES, getStrategies, getStrategyFields, updateStrategyFields, normalizeFields, + getTabs, updateTabs, normalizeTabs, strategySchema, +} from '../src/settings.js'; +import { STRATEGY_METHODS, handleStrategy } from '../src/api/strategies.js'; + +let pass = 0, fail = 0; +function assert(cond, msg) { + if (cond) { pass++; console.log(' ✅ ' + msg); } else { fail++; console.log(' ❌ ' + msg); } +} +function section(t) { console.log('\n[' + t + ']'); } +function makeScope(initial) { + const store = structuredClone(initial ?? {}); + return { + get: () => structuredClone(store), + update: async (patch) => { Object.assign(store, structuredClone(patch)); }, + _store: store, + }; +} +async function throwsAsync(fn, code, msg) { + try { await fn(); assert(false, msg + '(未抛错)'); } + catch (e) { assert(e?.code === code, msg + '(code=' + e?.code + ')'); } +} + +// ---------- 1. 策略常量 ---------- +section('1. 策略恒为内置两项'); +const legacyScope = makeScope({ + strategies: [ + { id: 'grid-supermarket', name: '网格超市', configSchema: [{ key: 'g', label: '网格大小', type: 'number', def: 1, unit: '元' }] }, + { id: 'manual-t', name: '手动做T', configSchema: [{ key: 't', label: 'T仓成本价', type: 'number', unit: '元' }] }, + { id: 'long-term', name: '长线持有', configSchema: [{ key: 'x', label: '目标价', type: 'number' }] }, + ], +}); +const list = getStrategies(legacyScope); +assert(list.length === 2, '策略表恒 2 项(自建策略被忽略)'); +assert(list.map((s) => s.id).join(',') === BUILTIN_STRATEGIES.map((s) => s.id).join(','), '顺序与内置常量一致'); +assert(!list.some((s) => s.id === 'long-term'), '自建策略不出现在读取结果'); + +// ---------- 2. 旧数据兼容 ---------- +section('2. 字段定义读取(旧数据回填 / 新键优先)'); +assert(getStrategyFields(legacyScope, 'grid-supermarket').length === 1, '旧 strategies[].configSchema 回填'); +assert(getStrategyFields(legacyScope, 'grid-supermarket')[0].label === '网格大小', '回填内容正确'); +const newScope = makeScope({ + strategies: [{ id: 'grid-supermarket', name: '网格超市', configSchema: [{ key: 'old', label: '旧的', type: 'text' }] }], + strategyFields: { 'grid-supermarket': [{ key: 'new', label: '新的', type: 'number', def: 1 }] }, +}); +assert(getStrategyFields(newScope, 'grid-supermarket')[0].label === '新的', 'strategyFields 优先于旧键'); +assert(getStrategyFields(newScope, 'manual-t').length === 0, '另一策略无定义 → []'); + +// ---------- 3. 字段归一化 ---------- +section('3. normalizeFields'); +const norm = normalizeFields([ + { key: 'a', label: 'A', type: '不合法', def: 1 }, + { key: 'b', label: 'B', type: 'formula', formula: ' 现价 × 份额 ', decimals: 9, def: 123 }, + { key: 'c', label: 'C', type: 'number', def: 5, unit: '元' }, +]); +assert(norm[0].type === 'text', '非法类型回退 text'); +assert(norm[1].formula === '现价 × 份额', '公式去首尾空白'); +assert(norm[1].decimals === 4, 'decimals 夹取上限 4'); +assert(!('def' in norm[1]), 'formula 字段不带 def'); +assert(norm[2].def === 5 && norm[2].unit === '元', '普通字段保留 def/unit'); + +// ---------- 4. 单策略写入 ---------- +section('4. updateStrategyFields'); +const wScope = makeScope({ strategyFields: { 'manual-t': [{ key: 't', label: 'T仓成本价', type: 'number' }] } }); +const updated = await updateStrategyFields(wScope, 'grid-supermarket', [{ key: 'g', label: '网格大小', type: 'number', def: 1 }]); +assert(updated.configSchema.length === 1, '返回更新后的策略对象'); +assert(wScope._store.strategyFields['grid-supermarket'].length === 1, '写入 strategyFields[目标策略]'); +assert(wScope._store.strategyFields['manual-t'].length === 1, '另一策略定义不受影响'); +assert(!wScope._store.strategies, '不写 strategies[] 键(真相源单一)'); +await throwsAsync(() => updateStrategyFields(wScope, 'long-term', []), 'strategy-not-found', '未知策略 id 被拒'); + +// ---------- 5. tabs 归一化 ---------- +section('5. normalizeTabs(静态化)'); +const tScope = makeScope({ + tabs: [ + { id: 'tab-strategy-long-term', kind: 'strategy', refId: 'long-term', name: '长线持有', visible: true, order: 0 }, + { id: 'tab-strategy-manual-t', kind: 'strategy', refId: 'manual-t', name: '手动做T', visible: false, order: 1 }, + { id: 'tab-strategy-grid-supermarket', kind: 'strategy', refId: 'grid-supermarket', name: '网格超市', visible: true, order: 2 }, + { id: 'tab-trade-records', kind: 'builtin', refKey: 'tradeRecords', name: '交易记录', visible: true, order: 3 }, + ], +}); +const tabs = normalizeTabs(tScope); +assert(tabs.length === 5, 'tabs 恒 5 条(3 内置 + 2 策略)'); +assert(!tabs.some((t) => t.refId === 'long-term'), '自建策略 tab 条目被丢弃'); +const mt = tabs.find((t) => t.refId === 'manual-t'); +assert(mt && mt.visible === false, '保留存量 visible 偏好(手动做T 隐藏)'); +assert(tabs.findIndex((t) => t.refId === 'manual-t') < tabs.findIndex((t) => t.refId === 'grid-supermarket'), + '保留存量 order 相对次序(手动做T 在网格超市前)'); +assert(tabs.some((t) => t.refKey === 'allPositions'), '缺失的内置条目被补齐'); +const legacyBool = normalizeTabs(makeScope({ tabs: { allPositions: false, tradeRecords: true, watchlist: false } })); +assert(legacyBool.find((t) => t.refKey === 'allPositions').visible === false, '旧布尔对象格式兼容'); +assert(legacyBool.some((t) => t.kind === 'strategy'), '旧格式下策略行补齐'); +assert(getTabs(tScope).length === 5, 'getTabs 走归一化'); + +// ---------- 6. updateTabs 过滤 ---------- +section('6. updateTabs 只接受内置条目'); +const uScope = makeScope({}); +await updateTabs(uScope, [ + { id: 'tab-strategy-long-term', kind: 'strategy', refId: 'long-term', visible: true, order: 0 }, + { id: 'tab-all-positions', kind: 'builtin', refKey: 'allPositions', visible: true, order: 1 }, +]); +assert(uScope._store.tabs.length === 1 && uScope._store.tabs[0].id === 'tab-all-positions', '未知条目被过滤'); + +// ---------- 7. 端点 ---------- +section('7. 端点:schema-update 与退役端点'); +assert(STRATEGY_METHODS.has('strategies/schema-update'), 'STRATEGY_METHODS 含 schema-update'); +assert(!STRATEGY_METHODS.has('strategies/add') && !STRATEGY_METHODS.has('strategies/remove') && !STRATEGY_METHODS.has('strategies/update'), + '退役端点已从方法表移除'); +const runtime = { manager: {}, settings: makeScope({}), storage: {}, marketHub: null }; +await throwsAsync(() => handleStrategy('strategies/add', { name: 'x' }, runtime), 'not-found', 'strategies/add 被拒'); +await throwsAsync(() => handleStrategy('strategies/update', { strategies: [] }, runtime), 'not-found', 'strategies/update 被拒'); +await throwsAsync(() => handleStrategy('strategies/remove', { strategyId: 'manual-t' }, runtime), 'not-found', 'strategies/remove 被拒'); +const apiScope = makeScope({}); +const apiRuntime = { manager: {}, settings: apiScope, storage: {}, marketHub: null }; +const saved = await handleStrategy('strategies/schema-update', { + strategyId: 'grid-supermarket', + configSchema: [{ key: 'grid_size', label: '网格大小', type: 'number', def: 1, unit: '元' }], +}, apiRuntime); +assert(saved.configSchema.length === 1 && saved.configSchema[0].label === '网格大小', 'schema-update 写入成功'); +assert(apiScope._store.strategyFields['grid-supermarket'].length === 1, '落库到 strategyFields'); +await throwsAsync(() => handleStrategy('strategies/schema-update', { strategyId: 'nope', configSchema: [] }, apiRuntime), + 'strategy-not-found', '未知策略写入被拒'); +assert(strategySchema != null, 'strategySchema 导出可用'); + +console.log('\n结果: ' + pass + ' 通过 / ' + fail + ' 失败'); +process.exit(fail === 0 ? 0 : 1); diff --git a/src/api/index.js b/src/api/index.js index c6b8772..9760f2f 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -12,12 +12,12 @@ * * 端点(POST /odl/api/,body = { args: {...} }): * positions → 全量持仓 - * strategies → 策略列表(含 visible) - * strategies/update → 更新策略列表(整表) - * strategies/add → 新增策略 { name } - * strategies/remove → 删除策略 { strategyId },联动清份额 - * strategies/move → 排序 { strategyId, dir } - * strategy-positions → 某策略持仓 { strategyId } + * strategies → 策略列表(R-027:恒为内置两项 + 各自 configSchema) + * strategies/schema-update → 写某策略字段定义 { strategyId, configSchema }(R-027 唯一写路径) + * formula/variables → 公式变量目录 { strategyId? }(R-026) + * formula/trial → 公式试算 { strategyId, formula }(R-026) + * strategy-positions → 某策略持仓 { strategyId }(含计算字段 computed) + * (R-027 退役:strategies/update、strategies/add、strategies/remove、strategies/move) * strategy-holdings/history → 某策略已清仓历史持仓 { strategyId, range }(R-016) * unallocated → 未分配持仓 * summary → 分仓摘要 diff --git a/src/api/strategies.js b/src/api/strategies.js index f3bca34..e6f5d91 100644 --- a/src/api/strategies.js +++ b/src/api/strategies.js @@ -2,18 +2,74 @@ * 服务端 API:策略 / 份额 / tabs 域(从 api.js 拆分,2026-08-31) * * 端点: - * strategies / strategies/update / strategies/add / strategies/remove + * strategies(读;R-027:恒为内置两项) + * strategies/schema-update(R-027:写某策略字段定义,唯一写路径) * strategy-positions / unallocated / summary * strategy-holdings/history(R-016 迭代 14:已清仓历史持仓,范围筛选) * add-shares / move-all-shares / remove-shares * tabs / tabs/update + * formula/variables(R-026:公式变量目录)/ formula/trial(R-026:试算) + * holdings/values-update / strategy-columns / strategy-columns/update + * + * R-027 退役:strategies/update、strategies/add、strategies/remove(策略静态化,不可增删改名) */ import { - getStrategies, updateStrategies, addStrategy, removeStrategy, + getStrategies, getStrategyFields, updateStrategyFields, assertBuiltinStrategy, getTabs, updateTabs, getStrategyColumns, updateStrategyColumns, } from '../settings.js'; +import { validateFormula, compileFormula, evaluateFormula, inferResultKind } from '../formula/evaluator.js'; +import { allowedVarNames, buildContext, catalogFor, BUILTIN_VAR_NAMES } from '../formula/variables.js'; +import { computeRows } from '../formula/FormulaService.js'; + +/** 字段类型白名单(R-026 含计算类型) */ +const FIELD_TYPE_WHITELIST = new Set(['text', 'number', 'boolean', 'enum', 'formula']); + +/** 字段校验失败(前端 Toast + 公式框描红) */ +function fieldError(message) { + return Object.assign(new Error(message), { code: 'field-validation' }); +} + +/** + * 字段定义校验(R-027/R-026):结构 + 唯一性 + 名称冲突 + 公式(语法/变量/仅非公式字段) + * @param {Array} fieldDefs 客户端提交的 configSchema + */ +function validateConfigSchema(fieldDefs) { + const fields = Array.isArray(fieldDefs) ? fieldDefs : []; + const keys = new Set(); + const labels = new Set(); + fields.forEach((f, i) => { + const label = String(f?.label ?? '').trim(); + const key = String(f?.key ?? '').trim(); + if (!label) throw fieldError(`第 ${i + 1} 个字段:展示名不能为空`); + if (!key) throw fieldError(`字段『${label}』:标识(key)不能为空`); + if (!FIELD_TYPE_WHITELIST.has(f?.type)) throw fieldError(`字段『${label}』:类型无效`); + if (keys.has(key)) throw fieldError(`字段标识重复: ${key}`); + if (labels.has(label)) throw fieldError(`字段展示名重复: ${label}`); + if (BUILTIN_VAR_NAMES.has(label)) throw fieldError(`变量名『${label}』与内置变量冲突,请修改字段展示名`); + if (f.type === 'enum' && !(Array.isArray(f.enum) && f.enum.some((x) => String(x).trim()))) { + throw fieldError(`字段『${label}』:枚举类型需填写选项`); + } + keys.add(key); + labels.add(label); + }); + // 公式校验:变量 = 内置名 ∪ 本策略非 formula 字段展示名(零循环依赖) + const vars = allowedVarNames(fields); + for (const f of fields) { + if (f?.type !== 'formula') continue; + const res = validateFormula(f.formula, vars); + if (!res.ok) throw fieldError(`字段『${f.label}』公式错误:${res.error.message}`); + // R-028:结果类型声明必须与公式形态一致(判定 = 顶层比较/and/or;数字 = 其余) + const declared = f.resultKind === 'boolean' ? 'boolean' : 'number'; + const inferred = inferResultKind(compileFormula(f.formula).ast); + if (declared !== inferred) { + throw fieldError(declared === 'boolean' + ? `字段『${f.label}』:结果类型选了「判定」,但公式没有比较运算(如 涨停价 > 基准值 + 1)` + : `字段『${f.label}』:公式结果是判定,请把「结果类型」改为「判定」`); + } + } +} /** R-016 范围档 → 回溯毫秒(自然日近似,展示用途足够;技术约束-019)。 * range='today' 不走回溯毫秒——二轮补充(2026-09-07 老师定):本地自然日 00:00 起清仓的持仓 @@ -36,9 +92,9 @@ function localDayStartMs() { /** 策略/份额/tabs 端点方法表 */ export const STRATEGY_METHODS = new Set([ 'strategies', - 'strategies/update', - 'strategies/add', - 'strategies/remove', + 'strategies/schema-update', // R-027:写某策略字段定义(唯一写路径) + 'formula/variables', // R-026:公式变量目录 + 'formula/trial', // R-026:公式试算(取一行真实持仓) 'strategy-positions', 'unallocated', 'summary', @@ -59,27 +115,47 @@ export const STRATEGY_METHODS = new Set([ * @param {object} args * @param {object} runtime { manager, settings, storage } */ -export async function handleStrategy(method, args, { manager, settings, storage }) { +export async function handleStrategy(method, args, { manager, settings, storage, marketHub }) { switch (method) { case 'strategies': return getStrategies(settings); - case 'strategies/update': - await updateStrategies(settings, args.strategies); - return getStrategies(settings); - case 'strategies/add': - return await addStrategy(settings, args.name); - case 'strategies/remove': - // 删除策略 + 联动清空该策略下所有份额(份额回未分配) - await removeStrategy(settings, args.strategyId); - await manager.clearStrategyShares(args.strategyId); - return getStrategies(settings); + case 'strategies/schema-update': + // R-027:单策略字段定义写入(结构/唯一性/公式校验 → strategyFields) + assertBuiltinStrategy(args.strategyId); + validateConfigSchema(args.configSchema); + return await updateStrategyFields(settings, args.strategyId, args.configSchema); + case 'formula/variables': + // R-026:公式变量目录(内置分组 + 本策略非 formula 字段) + if (args.strategyId != null) assertBuiltinStrategy(args.strategyId); + return catalogFor(getStrategyFields(settings, args.strategyId)); + case 'formula/trial': { + // R-026:试算(用该策略一行真实持仓数据求值;无持仓返回 reason) + assertBuiltinStrategy(args.strategyId); + const fieldDefs = getStrategyFields(settings, args.strategyId); + const check = validateFormula(args.formula, allowedVarNames(fieldDefs)); + if (!check.ok) throw fieldError(check.error.message); + const rows = await manager.getStrategyPositions(args.strategyId); + const row = rows.find((r) => (r.shares ?? 0) > 0) ?? rows[0] ?? null; + if (!row) return { code: null, name: null, value: null, reason: 'no-holding' }; + const ctx = buildContext({ + row, + quote: marketHub?.quotes?.get(row.code) ?? null, + instrument: marketHub?.instruments?.get(row.code) ?? null, + fieldDefs, + }); + const value = evaluateFormula(args.formula, ctx); + return { code: row.code, name: row.name ?? null, value, reason: value === null ? 'no-data' : null }; + } case 'tabs': return getTabs(settings); case 'tabs/update': await updateTabs(settings, args.tabs); return getTabs(settings); - case 'strategy-positions': - return await manager.getStrategyPositions(args.strategyId); + case 'strategy-positions': { + // R-026:含计算字段的策略,逐行现算(只读内存缓存;无 formula 字段零开销) + const rows = await manager.getStrategyPositions(args.strategyId); + return computeRows({ rows, fieldDefs: getStrategyFields(settings, args.strategyId), hub: marketHub }); + } case 'unallocated': return await manager.getUnallocatedPositions(); case 'summary': diff --git a/src/client/views/ColumnSettingsPopover.jsx b/src/client/views/ColumnSettingsPopover.jsx index 29fd724..fe4c62a 100644 --- a/src/client/views/ColumnSettingsPopover.jsx +++ b/src/client/views/ColumnSettingsPopover.jsx @@ -1,7 +1,10 @@ /** - * 策略持仓表列设置弹层(迭代 11 列显隐扩展,2026-09-02) - * 管理可配置列(基础数据列 + 自定义字段列):显隐勾选 + ↑↓ 排序 + 立即持久化 - * 固定列(代码/名称/操作/展开箭头)不在列表内,恒显示 + * 策略持仓表列设置弹层(迭代 11 列显隐扩展 + 迭代 20 拖拽排序,2026-09-10) + * 管理可配置列(基础数据列 + 自定义字段列):显隐勾选 + 原生 HTML5 拖拽排序(≡ 手柄)+ ↑↓ 兜底微调 + * 所有变更(勾选 / 拖拽 / ↑↓)立即持久化,无「保存」按钮;固定列(代码/名称/操作/展开箭头)不在列表内,恒显示 + * 拖拽模式复用 Tab 设置(R-011 SettingsSection 已验证):draggable 行 + 间隙高亮线 + drop 重排 + * 落点判定(2026-09-10 老师反馈修改):拖动时根据鼠标在目标行内的 Y 坐标(上半/下半)计算插入位置, + * 在目标行上缘/下缘(两行之间的间隙)显示高亮线,明确指示插入该列之前/之后,而非整行高亮 */ import React, { useState } from 'react'; import { useRpc } from './connection.jsx'; @@ -20,41 +23,85 @@ export function ColumnSettingsPopover({ columns, strategyId, onClose, onSaved }) const [list, setList] = useState(() => (Array.isArray(columns) ? columns.map((c) => ({ ...c })) : [])); const [saving, setSaving] = useState(false); const [dragKey, setDragKey] = useState(null); + // 落点:idx = 悬停目标行下标;half = 'top'(插入该行上缘=之前)/ 'bottom'(下缘=之后) + const [overPos, setOverPos] = useState(null); - const toggle = (key) => setList((ls) => ls.map((c) => (c.key === key ? { ...c, visible: !c.visible } : c))); - - /** ↑↓ 排序(同 Tab 设置简化版:用箭头替代 DnD,零依赖稳妥) */ - const move = (index, dir) => { - setList((ls) => { - const next = ls.slice(); - const to = index + dir; - if (to < 0 || to >= next.length) return ls; - const [item] = next.splice(index, 1); - next.splice(to, 0, item); - return next; - }); - }; - - const save = async () => { + /** 立即持久化整份列配置(含不可见列,保位置);失败回滚到服务端最新配置 */ + const persist = async (nextList, msg) => { setSaving(true); try { - // 提交整列配置(含不可见列,保位置) - const payload = list.map((c) => ({ key: c.key, visible: c.visible })); + const payload = nextList.map((c) => ({ key: c.key, visible: c.visible })); const res = await call('one-divine-lot/strategy-columns/update', { args: { strategyId, columns: payload } }); if (res && res.ok) { - toast.success('列设置已保存'); + toast.success(msg); onSaved?.(); - onClose?.(); } else { toast.error((res && res.error && res.error.message) || '保存失败'); + setList(Array.isArray(columns) ? columns.map((c) => ({ ...c })) : []); } } catch (e) { toast.error(e.message); + setList(Array.isArray(columns) ? columns.map((c) => ({ ...c })) : []); } finally { setSaving(false); } }; + /** 勾选显隐:立即持久化 */ + const toggle = (key) => { + const next = list.map((c) => (c.key === key ? { ...c, visible: !c.visible } : c)); + const target = next.find((c) => c.key === key); + setList(next); + persist(next, (target && target.visible ? '已显示「' : '已隐藏「') + (target ? target.label : '') + '」'); + }; + + /** ↑↓ 兜底微调:立即持久化 */ + const move = (index, dir) => { + const next = list.slice(); + const to = index + dir; + if (to < 0 || to >= next.length) return; + const [item] = next.splice(index, 1); + next.splice(to, 0, item); + setList(next); + persist(next, '「' + item.label + '」已移动'); + }; + + /** 悬停:按鼠标在该行内的上下半区决定插入线画在上缘(之前)还是下缘(之后) */ + const handleOver = (e, index) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + const rect = e.currentTarget.getBoundingClientRect(); + const half = e.clientY - rect.top < rect.height / 2 ? 'top' : 'bottom'; + setOverPos({ idx: index, half }); + }; + + /** 落点重排 + 立即持久化:drop 事件内直接按坐标判定,避免 state 闭包滞后 */ + const handleDrop = (e, index) => { + e.preventDefault(); + if (!list || !dragKey) { setDragKey(null); setOverPos(null); return; } + const rect = e.currentTarget.getBoundingClientRect(); + const before = e.clientY - rect.top < rect.height / 2; + let to = before ? index : index + 1; // 目标行之前 / 之后(原始下标) + const from = list.findIndex((c) => c.key === dragKey); + if (from < 0 || to < 0 || to > list.length) { setDragKey(null); setOverPos(null); return; } + // 无操作(原地):插入位置等于原位置或原位置的后一格(等同原槽位),跳过写库 + if (to === from || to === from + 1) { setDragKey(null); setOverPos(null); return; } + const next = list.slice(); + const [moved] = next.splice(from, 1); + const insertAt = to > from ? to - 1 : to; // 移除后目标下标左移 + next.splice(insertAt, 0, moved); + setDragKey(null); + setOverPos(null); + setList(next); + persist(next, '「' + moved.label + '」已移动'); + }; + + /** 拖拽结束清理(拖出弹层 / 取消等场景) */ + const handleDragEnd = () => { + setDragKey(null); + setOverPos(null); + }; + const rowBtn = { padding: '0 5px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 3, background: 'var(--dsw-alias-bg-layer-1, #fff)', fontSize: 11, lineHeight: '16px' }; const kindTag = (kind) => ({ fontSize: 10, borderRadius: 6, padding: '0 4px', marginRight: 4, @@ -62,33 +109,61 @@ export function ColumnSettingsPopover({ columns, strategyId, onClose, onSaved }) border: '1px solid var(--dsw-alias-border-l3, #90caf9)', }); + // 间隙高亮线:悬停行上缘(该行之前)或下缘(该行之后);行自身有 3px 垂直 padding,线落在两行之间的间隙 + const gapLineStyle = (i) => { + if (!overPos || overPos.idx !== i) return null; + return { + position: 'absolute', left: 6, right: 6, height: 3, borderRadius: 2, + background: 'var(--dsw-alias-state-business-primary, #1565c0)', + ...(overPos.half === 'top' ? { top: 0 } : { bottom: 0 }), + }; + }; + return (
列设置
-
勾选显示列,↑↓ 调整顺序(代码/名称/操作固定)
-
+
+ 勾选显示列,按住 ≡ 拖到两行之间的落点线(代码/名称/操作固定) +
+
{list.map((c, i) => ( -
+
{ setDragKey(c.key); setOverPos(null); e.dataTransfer.effectAllowed = 'move'; }} + onDragOver={(e) => handleOver(e, i)} + onDrop={(e) => handleDrop(e, i)} + onDragEnd={handleDragEnd} + style={{ + position: 'relative', + display: 'flex', alignItems: 'center', gap: 6, padding: '3px 0', fontSize: 13, borderRadius: 4, + cursor: saving ? 'default' : 'grab', + }} + > + {overPos && overPos.idx === i &&
} + - - + + - toggle(c.key)} /> + toggle(c.key)} disabled={saving} /> {c.kind === 'base' ? '数据' : '字段'} - {c.label} + + {c.kind === 'field' && c.type === 'formula' ? 'ƒ ' + c.label : c.label} +
))}
- - +
); -} \ No newline at end of file +} diff --git a/src/client/views/FieldConfigDialog.jsx b/src/client/views/FieldConfigDialog.jsx new file mode 100644 index 0000000..3013975 --- /dev/null +++ b/src/client/views/FieldConfigDialog.jsx @@ -0,0 +1,123 @@ +/** + * 字段配置弹层(迭代 22,R-026/R-027) + * 锚定弹层(与列设置同源视觉):width 520 / maxHeight 70vh / 内容内部滚动 + * 底部 [关闭](次)+ [保存字段](主,saving 时 disabled);保存走子组件 ref.save() + * 有未保存改动(dirty = 子组件 onDirtyChange 上报)时:关闭 / 外点 / Esc → 二次确认放弃 + */ +import React, { useState, useRef, useEffect, useCallback } from 'react'; +import { StrategyFieldsEditor } from './StrategyFieldsEditor.jsx'; + +export function FieldConfigDialog({ strategyId, strategyName, configSchema, onSaved, onClose }) { + const [dirty, setDirty] = useState(false); + const [saving, setSaving] = useState(false); + const [confirmDiscard, setConfirmDiscard] = useState(false); + const editorRef = useRef(null); + const panelRef = useRef(null); + const dirtyRef = useRef(dirty); + dirtyRef.current = dirty; + + /** 关闭请求:有未保存改动 → 二次确认;否则直接关 */ + const requestClose = useCallback(() => { + if (dirtyRef.current) setConfirmDiscard(true); + else onClose(); + }, [onClose]); + + // Esc / 外点关闭(D-8:未保存改动时二次确认) + useEffect(() => { + const onKey = (e) => { if (e.key === 'Escape') requestClose(); }; + const onDown = (e) => { + // 以“锚点容器”(含字段配置/列设置按钮)为界:点按钮自身不算外点, + // 否则按钮 onClick 与放弃确认会互相打架(dirty 时弹层被静默卸载) + const anchor = panelRef.current?.parentElement ?? panelRef.current; + if (anchor && !anchor.contains(e.target)) requestClose(); + }; + document.addEventListener('keydown', onKey); + document.addEventListener('mousedown', onDown); + return () => { + document.removeEventListener('keydown', onKey); + document.removeEventListener('mousedown', onDown); + }; + }, [requestClose]); + + /** 保存字段:调子组件 save(strategies/schema-update)→ 成功 onSaved + 关闭 */ + const handleSave = async () => { + if (saving) return; + setSaving(true); + let ok = false; + try { + ok = (await editorRef.current?.save?.()) === true; + } catch { /* save 内部已提示 */ } + setSaving(false); + if (ok) { + onSaved?.(); + onClose(); + } + }; + + return ( +
+
+ 字段配置:{strategyName || strategyId} +
+ +
+ +
+ +
+ + +
+ + {confirmDiscard && ( +
+
+

放弃未保存的改动

+
有未保存的字段改动,确定放弃?
+
+ + +
+
+
+ )} +
+ ); +} diff --git a/src/client/views/SettingsSection.jsx b/src/client/views/SettingsSection.jsx index 5ca2cb5..08dc8e3 100644 --- a/src/client/views/SettingsSection.jsx +++ b/src/client/views/SettingsSection.jsx @@ -1,19 +1,16 @@ /** * 神之一手设置 section * - * 结构(R-011 迭代 09): - * - 三个子 tab:Tab 设置 / 策略分组 / QMT 连接配置 - * - Tab 设置:统一管理所有会话 tab(内置 + 策略分组混排),拖动排序(落点立即持久化)+ 显示/隐藏开关; + * 结构(迭代 22,R-027 后): + * - 两个子 tab:Tab 设置 / QMT 连接配置(「策略分组」已移除,字段配置入口迁到策略 tab 内) + * - Tab 设置:统一管理所有会话 tab(内置 + 策略混排),拖动排序(落点立即持久化)+ 显示/隐藏开关; * 任何 tab 均不支持重命名/删除(产品约束-009、UI约束-003) - * - 策略分组:策略 CRUD(新增/重命名/删除确认)+ 提示「顺序与显示请在 Tab 设置中调整」 * - QMT 连接配置:R-004 卡片形式(迭代 03) */ -import { useState, useEffect, useCallback, Fragment } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { useRpc } from './connection.jsx'; import { ToastProvider, useToast } from './Toast.jsx'; -import { StrategyFieldsEditor } from './StrategyFieldsEditor.jsx'; // R-013:策略自定义字段编辑器 -import { ExpandChevron } from './ExpandChevron.jsx'; // 展开箭头与策略 tab 表格行图标统一 /** 确认弹窗 */ function ConfirmDialog({ title, message, onConfirm, onCancel }) { @@ -58,13 +55,14 @@ function Switch({ checked, onChange, disabled }) { ); } -/** Tab 设置子 tab(R-011:统一管理所有会话 tab —— 内置 + 策略分组混排,拖动排序 + 显隐开关) */ +/** Tab 设置子 tab(R-011:统一管理所有会话 tab —— 内置 + 策略混排,拖动排序 + 显隐开关) */ function TabSettings() { const [tabs, setTabs] = useState(null); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [dragId, setDragId] = useState(null); - const [overId, setOverId] = useState(null); + // 落点:idx = 悬停目标行下标;half = 'top'(该行上缘=插入前)/ 'bottom'(下缘=插入后)——迭代 21 间隙高亮线 + const [overPos, setOverPos] = useState(null); const call = useRpc(); const toast = useToast(); @@ -106,19 +104,34 @@ function TabSettings() { await persist(next, '「' + t.name + '」已' + (next.find((x) => x.id === t.id).visible ? '显示' : '隐藏')); }; - // 原生 HTML5 拖动:落点重排 + 立即持久化(Q1) - const handleDrop = async (targetId) => { - if (!tabs || !dragId || dragId === targetId) { setDragId(null); setOverId(null); return; } + /** 悬停:按鼠标在该行内的上下半区决定间隙线画上缘(插入前)还是下缘(插入后) */ + const handleOver = (e, index) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + const rect = e.currentTarget.getBoundingClientRect(); + const half = e.clientY - rect.top < rect.height / 2 ? 'top' : 'bottom'; + setOverPos({ idx: index, half }); + }; + + /** 原生 HTML5 拖动:落点重排 + 立即持久化(Q1);迭代 21:drop 事件内按坐标判定插入前/后,原地跳过写库 */ + const handleDrop = async (e, index) => { + e.preventDefault(); + if (!tabs || !dragId) { setDragId(null); setOverPos(null); return; } + const rect = e.currentTarget.getBoundingClientRect(); + const before = e.clientY - rect.top < rect.height / 2; + let to = before ? index : index + 1; const from = tabs.findIndex((x) => x.id === dragId); - const to = tabs.findIndex((x) => x.id === targetId); - if (from < 0 || to < 0) { setDragId(null); setOverId(null); return; } + if (from < 0 || to < 0 || to > tabs.length) { setDragId(null); setOverPos(null); return; } + // 原地(插入位置等于原位置或其下一格 = 原槽位)→ 跳过写库 + if (to === from || to === from + 1) { setDragId(null); setOverPos(null); return; } const next = tabs.slice(); const [moved] = next.splice(from, 1); - next.splice(to, 0, moved); + const insertAt = to > from ? to - 1 : to; + next.splice(insertAt, 0, moved); // 重写 order(整表连续) const ordered = next.map((x, i) => ({ ...x, order: i })); setDragId(null); - setOverId(null); + setOverPos(null); await persist(ordered, '「' + moved.name + '」已移动'); }; @@ -128,10 +141,21 @@ function TabSettings() { border: kind === 'builtin' ? '1px solid var(--dsw-alias-border-l3, #90caf9)' : '1px solid var(--dsw-alias-border-l3, #ce93d8)', }); + // 间隙高亮线(迭代 21,与列设置弹层 R-024 全同):悬停行上缘(插入前)或下缘(插入后); + // 表格行 tr 内不能直接嵌 div,故在每个 td 内各画一段线(position:relative 锚点),视觉连续 + const gapLineStyle = (i) => { + if (!overPos || overPos.idx !== i) return null; + return { + position: 'absolute', left: 0, right: 0, height: 3, borderRadius: 2, + background: 'var(--dsw-alias-state-business-primary, #1565c0)', + ...(overPos.half === 'top' ? { top: 0 } : { bottom: 0 }), + }; + }; + return (
- 拖动调整所有标签页顺序,开关控制显示/隐藏;内置与策略标签可任意混排。名称不支持在此修改(策略名称在「策略分组」中修改)。 + 拖动调整所有标签页顺序,开关控制显示/隐藏;内置与策略标签页可任意混排。名称不支持在此修改。
{error &&
{error}
} {!tabs && !error &&
加载中...
} @@ -145,25 +169,28 @@ function TabSettings() { - {tabs.map((t) => ( + {tabs.map((t, i) => ( { setDragId(t.id); e.dataTransfer.effectAllowed = 'move'; }} - onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setOverId(t.id); }} - onDragLeave={() => { if (overId === t.id) setOverId(null); }} - onDrop={(e) => { e.preventDefault(); handleDrop(t.id); }} + onDragStart={(e) => { setDragId(t.id); setOverPos(null); e.dataTransfer.effectAllowed = 'move'; }} + onDragOver={(e) => handleOver(e, i)} + onDrop={(e) => handleDrop(e, i)} + onDragEnd={() => { setDragId(null); setOverPos(null); }} style={{ borderBottom: '1px solid var(--dsw-alias-border-l1, #eee)', cursor: saving ? 'default' : 'grab', - background: overId === t.id ? 'color-mix(in srgb, var(--dsw-alias-state-business-primary, #1565c0) 12%, transparent)' : 'transparent', }} > - ≡ - + + {overPos && overPos.idx === i &&
}≡ + + + {overPos && overPos.idx === i &&
} {t.kind === 'builtin' ? '内置' : '策略'} {t.name} - + + {overPos && overPos.idx === i &&
} toggleVisible(t)} disabled={saving} /> @@ -175,239 +202,9 @@ function TabSettings() { ); } -/** 策略分组子 tab(原策略 CRUD) */ -function StrategyGroupSettings() { - const [strategies, setStrategies] = useState(null); - const [error, setError] = useState(null); - const [saving, setSaving] = useState(false); - const [newName, setNewName] = useState(''); - const [editingId, setEditingId] = useState(null); - const [editingName, setEditingName] = useState(''); - const [confirmDelete, setConfirmDelete] = useState(null); - const [changedMsg, setChangedMsg] = useState(null); - // R-013:当前展开配置字段的策略 id(null=无展开) - const [expandedId, setExpandedId] = useState(null); - const [fieldSaving, setFieldSaving] = useState(false); - const call = useRpc(); - const toast = useToast(); - - const load = useCallback(async () => { - try { - const res = await call('one-divine-lot/strategies', {}); - if (res && res.ok) setStrategies(res.value); - else setError((res && res.error && res.error.message) || '加载失败'); - } catch (e) { - setError(e.message); - } - }, [call]); - - useEffect(() => { load(); }, [load]); - - const handleChanged = useCallback((msg) => { - setChangedMsg(msg); - toast.success(msg + '(刷新页面后生效)'); - load(); - }, [load, toast]); - - const handleAdd = async () => { - const name = newName.trim(); - if (!name) { toast.error('请输入策略名称'); return; } - setSaving(true); - try { - const res = await call('one-divine-lot/strategies/add', { args: { name } }); - if (res && res.ok) { - setNewName(''); - handleChanged('策略「' + name + '」已添加'); - } else { - toast.error((res && res.error && res.error.message) || '添加失败'); - } - } catch (e) { - toast.error(e.message); - } finally { - setSaving(false); - } - }; - - const handleRename = async (id) => { - const name = editingName.trim(); - if (!name) { toast.error('名称不能为空'); return; } - setSaving(true); - try { - const res = await call('one-divine-lot/strategies/update', { - args: { strategies: strategies.map((s) => (s.id === id ? { ...s, name } : s)) }, - }); - if (res && res.ok) { - setEditingId(null); - handleChanged('策略已重命名'); - } else { - toast.error((res && res.error && res.error.message) || '重命名失败'); - } - } catch (e) { - toast.error(e.message); - } finally { - setSaving(false); - } - }; - - /** R-013:保存某策略的自定义字段定义(整表 strategies/update) */ - const handleSaveConfig = async (id, configSchema) => { - setFieldSaving(true); - try { - const res = await call('one-divine-lot/strategies/update', { - args: { strategies: strategies.map((s) => (s.id === id ? { ...s, configSchema } : s)) }, - }); - if (res && res.ok) { - handleChanged('策略自定义字段已保存'); - } else { - toast.error((res && res.error && res.error.message) || '保存失败'); - } - } catch (e) { - toast.error(e.message); - } finally { - setFieldSaving(false); - } - }; - - const handleDelete = async (id) => { - setSaving(true); - try { - const res = await call('one-divine-lot/strategies/remove', { args: { strategyId: id } }); - if (res && res.ok) { - setConfirmDelete(null); - handleChanged('策略已删除,份额已回到未分配'); - } else { - toast.error((res && res.error && res.error.message) || '删除失败'); - setConfirmDelete(null); - } - } catch (e) { - toast.error(e.message); - setConfirmDelete(null); - } finally { - setSaving(false); - } - }; - - return ( -
- {changedMsg && ( -
- {changedMsg} —— 刷新页面后生效 -
- )} - - {error &&
{error}
} - -
- setNewName(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleAdd()} - style={{ flex: 1, padding: '6px 10px', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 4 }} - /> - -
- -
- 顺序与显示请在「Tab 设置」中调整。 -
- - {!strategies && !error &&
加载中...
} - {strategies && ( - - - - - - - - - - {strategies.map((s) => ( - - - - - - - {expandedId === s.id && ( - - - - )} - - ))} - -
策略ID操作
- {editingId === s.id ? ( - - setEditingName(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleRename(s.id)} - style={{ padding: '4px 6px', border: '1px solid #ccc', borderRadius: 3, marginRight: 6 }} - /> - - - - ) : ( - - - {s.name} - - )} - {s.id} - - - -
- handleSaveConfig(s.id, fields)} - /> -
- )} - - {confirmDelete && ( - handleDelete(confirmDelete.id)} - onCancel={() => setConfirmDelete(null)} - /> - )} -
- ); -} - -/** 设置 section 主组件:三个子 tab */ +/** 设置 section 主组件:两个子 tab */ function SettingsSectionInner(props) { - const [activeTab, setActiveTab] = useState('tabs'); // tabs | strategies | qmt + const [activeTab, setActiveTab] = useState('tabs'); // tabs | qmt return (
@@ -417,7 +214,6 @@ function SettingsSectionInner(props) {
{[ { key: 'tabs', label: 'Tab 设置' }, - { key: 'strategies', label: '策略分组' }, { key: 'qmt', label: 'QMT 连接配置' }, ].map((tab) => (
); diff --git a/src/client/views/StrategyFieldsEditor.jsx b/src/client/views/StrategyFieldsEditor.jsx index 85eefbf..de7546f 100644 --- a/src/client/views/StrategyFieldsEditor.jsx +++ b/src/client/views/StrategyFieldsEditor.jsx @@ -1,33 +1,68 @@ /** - * 策略自定义字段编辑器(R-013 迭代 11) - * 管理某策略的 configSchema:字段列表 + 添加/编辑/删除 + 类型选择(文本/数字/布尔/枚举) - * 保存 = 调父组件 onSave(fields)(父组件整表 strategies/update) + * 策略字段编辑器(迭代 22 改造:弹层内容 + 单策略保存,R-026/R-027) + * 管理某策略 configSchema:字段列表 + 添加/编辑/删除 + 类型(文本/数字/布尔/枚举/计算) + * 计算(formula)分支:公式框(等宽)+ 变量**标签平铺**(按 行情/合约/持仓/自定义字段 分组, + * 点选即插入公式框;数据来自 formula/variables,另加「自定义字段」组 = 本策略非 formula 字段展示名) + * + 试算(formula/trial)+ 小数位 0-4 + * 保存 = 父弹层调 ref.save() → strategies/schema-update { strategyId, configSchema: fields } + * dirty 经 onDirtyChange 上报(弹层据此做关闭二次确认) */ -import React, { useState } from 'react'; +import React, { useState, useEffect, useMemo, useRef, forwardRef, useImperativeHandle } from 'react'; +import { useRpc } from './connection.jsx'; import { useToast } from './Toast.jsx'; -const TYPE_LABEL = { text: '文本', number: '数字', boolean: '布尔', enum: '枚举' }; +const TYPE_LABEL = { text: '文本', number: '数字', boolean: '布尔', enum: '枚举', formula: '计算' }; +const MONO = 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace'; -/** 样式常量 */ +/** 样式常量(沿用设置页既有风格) */ const inputStyle = { padding: '4px 6px', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 3, marginRight: 6, fontSize: 12, background: 'var(--dsw-alias-bg-layer-1, #fff)' }; const btnStyle = { padding: '3px 10px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 3, background: 'var(--dsw-alias-bg-layer-1, #fff)', marginRight: 4, fontSize: 12 }; const solidBtn = (bg) => ({ ...btnStyle, border: 'none', background: bg, color: 'var(--dsw-alias-button-contrast-fill, #fff)' }); const dangerBtn = { ...btnStyle, borderColor: 'var(--dsw-alias-state-error-primary, #c62828)', color: 'var(--dsw-alias-state-error-primary, #c62828)' }; +/** 小数位夹取 0-4 整数(默认 2) */ +function clampDecimals(v) { + const n = Number(v); + if (!Number.isFinite(n)) return 2; + return Math.min(4, Math.max(0, Math.round(n))); +} + +/** 计算值/试算值格式化:toFixed(decimals) + 单位(如 6.67 % / 700.00 元) */ +function fmtValue(v, decimals, unit) { + const s = Number(v).toFixed(clampDecimals(decimals)); + return unit ? s + ' ' + unit : s; +} + /** - * 字段编辑器 * @param {object} props - * @param {object} props.strategy 策略(含 configSchema) - * @param {Function} props.onSave 保存回调 (fields) => Promise - * @param {boolean} props.saving 保存中 + * @param {string} props.strategyId + * @param {Array} props.configSchema 字段定义(strategies 端点) + * @param {boolean} props.saving 保存中(弹层「保存字段」驱动) + * @param {Function} props.onDirtyChange dirty 变化上报 (bool) => void */ -export function StrategyFieldsEditor({ strategy, onSave, saving }) { +export const StrategyFieldsEditor = forwardRef(function StrategyFieldsEditor({ strategyId, configSchema, saving, onDirtyChange }, ref) { const toast = useToast(); - const [fields, setFields] = useState(() => (Array.isArray(strategy?.configSchema) ? strategy.configSchema.map((x) => ({ ...x })) : [])); + const call = useRpc(); + const [fields, setFields] = useState(() => (Array.isArray(configSchema) ? configSchema.map((x) => ({ ...x })) : [])); // 表单草稿:null=关闭;{ index:-1=新增 | 编辑下标, ... } const [draft, setDraft] = useState(null); + const [saveError, setSaveError] = useState(null); + const [formulaError, setFormulaError] = useState(false); + const [varGroups, setVarGroups] = useState(null); + const [trial, setTrial] = useState(null); // { kind:'ok'|'empty'|'error', text } + const [trialLoading, setTrialLoading] = useState(false); + const formulaRef = useRef(null); - const emptyDraft = () => ({ index: -1, key: '', label: '', type: 'text', unit: '', enumText: '', defText: '', defBool: false, defEnum: '' }); + // R-026:变量目录(formula/variables;「自定义字段」组本地合成,见 menuGroups) + useEffect(() => { + let alive = true; + call('one-divine-lot/formula/variables', { args: { strategyId } }).then((res) => { + if (alive && res?.ok && res.value) setVarGroups(res.value.groups ?? []); + }).catch(() => { /* 失败静默:变量菜单只剩自定义字段组 */ }); + return () => { alive = false; }; + }, [call, strategyId]); + + const emptyDraft = () => ({ index: -1, key: '', label: '', type: 'text', unit: '', enumText: '', defText: '', defBool: false, defEnum: '', formula: '', resultKind: 'number', decimals: 2 }); /** 由 label 生成 key(转小写 ascii slug;冲突去重) */ const makeKey = (label, usedKeys) => { @@ -42,7 +77,13 @@ export function StrategyFieldsEditor({ strategy, onSave, saving }) { return candidate; }; - const startAdd = () => setDraft(emptyDraft()); + // dirty:与初始 configSchema 的 JSON 快照比较,经 onDirtyChange 上报 + const initialJson = useMemo(() => JSON.stringify(Array.isArray(configSchema) ? configSchema : []), [configSchema]); + const dirty = useMemo(() => JSON.stringify(fields) !== initialJson, [fields, initialJson]); + useEffect(() => { onDirtyChange?.(dirty); }, [dirty, onDirtyChange]); + + const startAdd = () => { setDraft(emptyDraft()); setTrial(null); setFormulaError(false); }; + const startEdit = (index) => { const fld = fields[index]; setDraft({ @@ -55,7 +96,11 @@ export function StrategyFieldsEditor({ strategy, onSave, saving }) { defText: fld.type === 'boolean' ? '' : (fld.def != null ? String(fld.def) : ''), defBool: fld.type === 'boolean' ? !!fld.def : false, defEnum: fld.type === 'enum' ? (fld.def ?? '') : '', + formula: fld.formula ?? '', + resultKind: fld.resultKind === 'boolean' ? 'boolean' : 'number', // R-028 + decimals: typeof fld.decimals === 'number' ? fld.decimals : 2, }); + setTrial(null); }; const removeField = (index) => { @@ -67,45 +112,146 @@ export function StrategyFieldsEditor({ strategy, onSave, saving }) { if (!draft) return; const label = draft.label.trim(); if (!label) { toast.error('字段名称不能为空'); return; } - if (!['text', 'number', 'boolean', 'enum'].includes(draft.type)) { toast.error('字段类型无效'); return; } + if (!['text', 'number', 'boolean', 'enum', 'formula'].includes(draft.type)) { toast.error('字段类型无效'); return; } let enumOptions = []; if (draft.type === 'enum') { enumOptions = draft.enumText.split(/[,,]/).map((s) => s.trim()).filter(Boolean); if (enumOptions.length === 0) { toast.error('枚举类型需填写选项(逗号分隔)'); return; } } + if (draft.type === 'formula' && !String(draft.formula ?? '').trim()) { + toast.error('公式不能为空'); + setFormulaError(true); + return; + } const usedKeys = fields.filter((_, i) => i !== draft.index).map((x) => x.key); const key = draft.key.trim() || makeKey(label, usedKeys); if (usedKeys.includes(key)) { toast.error('字段标识已存在: ' + key); return; } - let defVal; - if (draft.type === 'text') defVal = draft.defText; - else if (draft.type === 'number') defVal = draft.defText.trim() === '' ? null : Number(draft.defText); - else if (draft.type === 'boolean') defVal = draft.defBool; - else defVal = draft.defEnum || null; - const field = { key, label, type: draft.type, unit: String(draft.unit ?? '').trim() }; - if (draft.type === 'enum') field.enum = enumOptions; - if (defVal !== null && defVal !== undefined && defVal !== '') field.def = defVal; + let field; + if (draft.type === 'formula') { + field = { + key, label, type: 'formula', unit: String(draft.unit ?? '').trim(), + formula: String(draft.formula ?? '').trim(), + resultKind: draft.resultKind === 'boolean' ? 'boolean' : 'number', // R-028 + decimals: clampDecimals(draft.decimals), + }; + } else { + let defVal; + if (draft.type === 'text') defVal = draft.defText; + else if (draft.type === 'number') defVal = draft.defText.trim() === '' ? null : Number(draft.defText); + else if (draft.type === 'boolean') defVal = draft.defBool; + else defVal = draft.defEnum || null; + field = { key, label, type: draft.type, unit: String(draft.unit ?? '').trim() }; + if (draft.type === 'enum') field.enum = enumOptions; + if (defVal !== null && defVal !== undefined && defVal !== '') field.def = defVal; + } if (draft.index === -1) setFields([...fields, field]); else setFields(fields.map((f2, i) => (i === draft.index ? field : f2))); setDraft(null); + setFormulaError(false); }; - const saveAll = async () => { - if (draft) { toast.error('请先完成正在编辑的字段'); return; } - await onSave(fields); + /** 插入变量到公式框光标处(无光标信息 → 追加末尾并补空格) */ + const insertVar = (name) => { + setFormulaError(false); + const cur = String(draft?.formula ?? ''); + const el = formulaRef.current; + if (el && typeof el.selectionStart === 'number') { + let before = cur.slice(0, el.selectionStart); + let after = cur.slice(el.selectionEnd ?? el.selectionStart); + if (before && !/\s$/.test(before)) before += ' '; + if (after && !/^\s/.test(after)) after = ' ' + after; + const next = before + name + after; + setDraft({ ...draft, formula: next }); + const pos = (before + name).length; + requestAnimationFrame(() => { el.focus(); el.setSelectionRange(pos, pos); }); + } else { + setDraft({ ...draft, formula: cur ? cur + ' ' + name : name }); + } }; + /** 试算:formula/trial(服务端取该策略真实持仓 + 缓存行情求值) */ + const runTrial = async () => { + if (trialLoading || !draft) return; + const formula = String(draft.formula ?? '').trim(); + if (!formula) { toast.error('公式不能为空'); return; } + const decimals = clampDecimals(draft.decimals); + const unit = String(draft.unit ?? '').trim(); + setTrialLoading(true); + setTrial(null); + try { + const res = await call('one-divine-lot/formula/trial', { args: { strategyId, formula, decimals, unit } }); + if (res?.ok) { + const v = res.value || {}; + if (typeof v.value === 'boolean') { + // R-028:判定型试算(满足 = ✓ 字段名) + setTrial({ kind: 'ok', text: '试算:' + (v.name ?? '—') + ' ' + (v.code ?? '—') + ' → ' + (v.value ? '✓ 满足' : '— 不满足') }); + } else if (v.value != null && Number.isFinite(Number(v.value))) { + setTrial({ kind: 'ok', text: '试算:' + (v.name ?? '—') + ' ' + (v.code ?? '—') + ' → ' + fmtValue(Number(v.value), decimals, unit) }); + } else if (v.reason === 'no-holding') { + setTrial({ kind: 'empty', text: '暂无可试算的持仓数据' }); + } else { + setTrial({ kind: 'empty', text: '行情数据缺失,暂无法试算' }); + } + } else { + const msg = (res?.error?.message) || '试算失败'; + toast.error(msg); + setTrial({ kind: 'error', text: msg }); + setFormulaError(true); + } + } catch (e) { + toast.error(e.message); + setTrial({ kind: 'error', text: e.message }); + } finally { + setTrialLoading(false); + } + }; + + /** 保存(弹层「保存字段」触发):strategies/schema-update → 成功 true(弹层负责 onSaved + 关闭) */ + useImperativeHandle(ref, () => ({ + save: async () => { + if (draft) { toast.error('请先完成正在编辑的字段'); return false; } + setSaveError(null); + try { + const res = await call('one-divine-lot/strategies/schema-update', { args: { strategyId, configSchema: fields } }); + if (res?.ok) { toast.success('字段已保存'); return true; } + const msg = (res?.error?.message) || '保存失败'; + toast.error(msg); + setSaveError(msg); + // 服务端字段校验失败:公式框描红(表单不关闭,可重试) + if (/公式|变量|语法|表达式|『/.test(msg)) setFormulaError(true); + return false; + } catch (e) { + toast.error(e.message); + setSaveError(e.message); + return false; + } + }, + }), [fields, draft, strategyId, call, toast]); + + /** 变量菜单分组:服务端 行情/合约/持仓 + 「自定义字段」(本策略非 formula 字段展示名) */ + const menuGroups = useMemo(() => { + const server = (Array.isArray(varGroups) ? varGroups : []) + .filter((g) => g && g.group && g.group !== '自定义字段') + .map((g) => ({ group: g.group, items: (Array.isArray(g.items) ? g.items : []).map((it) => ({ name: it.name, desc: it.desc })) })); + const custom = fields + .filter((f, i) => f.type !== 'formula' && i !== draft?.index) + .map((f) => ({ name: String(f.label || f.key), desc: '本策略手填字段' })); + return [...server, { group: '自定义字段', items: custom }]; + }, [varGroups, fields, draft?.index]); + return ( -
+
自定义字段({fields.length}) - - - - +
+ {saveError && ( +
{saveError}
+ )} + {fields.length === 0 && !draft && ( -
尚未配置字段 —— 点击「+ 添加字段」为本策略添加自定义字段(该策略下每个持仓将按此定义填写值)。
+
尚未配置字段 —— 点击「+ 添加字段」为本策略添加(该策略下每个持仓按此定义填写值)。
)} {fields.length > 0 && ( @@ -115,7 +261,7 @@ export function StrategyFieldsEditor({ strategy, onSave, saving }) { 展示名 key 类型 - 选项/默认 + 公式/默认 操作 @@ -124,9 +270,19 @@ export function StrategyFieldsEditor({ strategy, onSave, saving }) { {fld.label || fld.key} {fld.key} - {TYPE_LABEL[fld.type] || fld.type} + + {TYPE_LABEL[fld.type] || fld.type} + {fld.type === 'formula' && fld.resultKind === 'boolean' ? '(判定)' : ''} + - {fld.type === 'enum' ? (Array.isArray(fld.enum) ? fld.enum.join(' / ') : '') + {fld.type === 'formula' ? ( + + {fld.formula || '—'} + + ) : fld.type === 'enum' ? (Array.isArray(fld.enum) ? fld.enum.join(' / ') : '') : fld.type === 'boolean' ? (fld.def ? '默认开' : '默认关') : ((fld.def != null ? '默认 ' + fld.def + (fld.unit ? ' ' + fld.unit : '') : '') + (fld.unit && fld.def == null ? '单位 ' + fld.unit : ''))} @@ -145,14 +301,108 @@ export function StrategyFieldsEditor({ strategy, onSave, saving }) {
setDraft({ ...draft, label: e.target.value })} /> setDraft({ ...draft, key: e.target.value })} /> - { setDraft({ ...draft, type: e.target.value }); setTrial(null); setFormulaError(false); }} + > + - setDraft({ ...draft, unit: e.target.value })} /> + { setDraft({ ...draft, unit: e.target.value }); setTrial(null); }} /> + {draft.type === 'formula' && ( + + )}
+ + {draft.type === 'formula' && ( +
+