Compare commits

...

9 Commits

Author SHA1 Message Date
kyugao ec02a791ee 迭代22+23: 策略tab静态化 + 计算字段(数字/判定型)+ 网格超市信号字段
- R-027 静态化:策略恒为内置两项(不可增删改名,保留显隐/排序);设置页移除「策略分组」;
  字段配置入口迁到策略 tab 内「列设置」旁(FieldConfigDialog + schema-update 单策略写入)
- R-026 计算字段:自写公式引擎(中文变量、四则/括号/round·abs·min·max、缺值短路→—)+
  变量目录(行情/合约/持仓/自定义字段)+ strategy-positions 逐行现算(只读内存缓存,不落库、列只读)
- R-028 判定型:比较运算 + and/or + inferResultKind + 结果类型一致性校验 + 判定列 ✓/— 渲染;
  网格超市落地 可下空单=涨停价>基准值+网格大小、可下多单=跌停价<基准值-网格大小
- 变量选择改标签平铺(老师反馈);公式手册 docs/99-其他材料/计算字段公式说明.md
- 约束同步:产品约束-002/003/004/009/013/014、技术约束-014/015/022/023/024、UI约束-002/003/005/008
- 回归:新增 test-r026/test-r027,更新 r011/r013/r017,17 个脚本全绿;typecheck/build 通过
2026-09-10 14:05:06 +08:00
kyugao 3dfa39b8bf 迭代19-21收尾: 指示灯自适应探测(R-022/R-023)+ 列设置拖拽排序(R-024)+ Tab设置间隙线统一(R-025)+ 需求池归档(R-014/15/16/17/19 → 已完成) 2026-09-10 14:04:51 +08:00
kyugao 8782aa9c50 迭代18归档: 需求 R-020 移入已完成(验收通过)+ 迭代复盘 + 索引更新 2026-09-09 09:03:48 +08:00
kyugao c700f2f760 迭代18: QMT Bridge MCP 能力内建(R-020)—— 动态挂载 dsh-mcp-client + 状态出口(设置页/会话头部灯) 2026-09-09 08:58:49 +08:00
kyugao 9382ace3a0 docs: 迭代17 策略会话(R-019)设计文档 + 开盘啦 API 参考材料 2026-09-09 08:58:49 +08:00
kyugao 6a578cc598 feat(UI): 交易记录归属分配改为 Modal 弹窗,行内只留精简态 2026-09-08 11:13:55 +08:00
kyugao 1223e2e74b 迭代16: 交易关联驱动持仓份额动态调整(R-018) 2026-09-08 11:09:05 +08:00
kyugao 22ec732607 迭代15: 委托归属候选纳入近期清仓持仓(R-017)
- 交易记录 tab 归属候选只回当前持仓 → 清仓后当日委托失去下拉锚点无法补关联
  (2026-09-07 大连热电实际发生: 盘中归属后被清为未关联,下拉已无做T候选)
- SqliteStore.getAttributionCandidates: 候选 = 该 code 当前持仓 + 近 7 天清仓的持仓
  (closed 标记 + closedAt,排序 当前持仓在前 → 清仓行 closed_at DESC)
- AttributionSelect: 下拉 value 改 holdingId 键控(同策略多轮持仓不撞值)、
  清仓选项「(已清仓 MM-DD)」后缀、已归属但候选缺失显示「当前归属」占位
- 回归: test-r017-candidates 12/12 + 存量回归全绿
2026-09-07 18:15:20 +08:00
kyugao 51c70c48fb 迭代14: 策略tab历史持仓展示(R-016)+ 今日已清仓默认层
- R-016 Q1-Q7: 两个策略 tab title 旁「历史持仓」开关 + 清仓范围筛选(近一周默认/1月/3月/半年/1年)
  - SqliteStore.getHoldingsHistory(strategy_id + closed_at 非空 + sinceMs,closed_at DESC,只读)
  - DataStore 门面透传 + strategy-holdings/history 端点(name 兜底 + 参数校验)
  - 前端: 开关/范围下拉/历史行灰显+「已清仓」徽标/展开复用 R-010(rowKey 唯一化)
- R-016 二轮补充(Q8-Q9 老师拍板): 今日已清仓默认层
  - range=today = 本地自然日 00:00 起(特判零点,不落回溯毫秒档)
  - 当天已清仓的持仓默认恒显示、不受「历史持仓」开关控制(开关只控今天之前)
  - rowsToRender 分层合成 + holdingId 去重;标题计数不含已清仓行;零写路径变更
- 回归: test-r016-history 24/24 + 存量回归全绿
2026-09-07 18:15:07 +08:00
109 changed files with 11964 additions and 1037 deletions
@@ -0,0 +1,92 @@
# 计划:QMT Bridge MCP 能力内建(神之一手自注册 MCP,去三方插件依赖)(阶段航点)
> 编号:PLAN-018 | 粒度:阶段航点 创建:2026-09-08 | 状态:待实施(拟迭代 18)
> 派生自终极目标:目标-001(DSH 插件形态·复用宿主 MCP 能力)、目标-008(真实交易系统接入)
> 依据需求:**R-020(已定稿,2026-09-08Q1-Q6 老师拍板)** —— 符合入范围门槛
> 设计约束:技术约束-003(REST 直连不变)、技术约束-008(连接配置存储/热切换)、产品约束-012(会话头部指示灯体系)、UI约束-002(连接卡片);沿用技术约束-004/005/006/007
## 目标
让神之一手插件自带 QMT Bridge 的 MCP 能力:插件在自己的 apply 内按 QMT 连接配置动态挂载 **DSH 官方 @deepseek-ai/dsh-mcp-client**(url 自动派生自激活连接 baseUrl + /mcpserverName 固定 QMT_Bridge_MCP,工具以 mcp__QMT_Bridge_MCP__* 注册给模型;挂载/切换/卸载/状态均由神之一手在插件内管理);连接 CRUD/激活切换与 MCP 实例全生命周期联动;**卸载三方 dsh-skill-mcp-panel 并移除其宿主 cordis.patch.yml 受管块**,不再依赖三方面板加入 MCP 能力。REST 直连架构不变(技术约束-003)。
## 范围
**做**(对应 R-020 定稿边界,决策编号 Q1-Q6):
1. **依赖与可行性前置(R-020 计划阶段待办 #1,已实证 2026-09-08**@deepseek-ai/dsh-mcp-client@0.1.1-rc.2 已加为 one-divine-lot dependencies,从插件 lib 可 import(),模块导出 {name:'mcp-client', inject:['tools'], apply, Config},可用 ctx.plugin() 动态挂载(实证通过);
2. **服务端 MCP 管理器(新增 src/mcp/QmtMcpManager.js**
- 唯一 dsh-mcp-client 实例,serverName 固定 `QMT_Bridge_MCP`url = 激活连接 baseUrl + `/mcp`(自动派生,Q3);
- 启动:解析启动连接(默认→上次激活→第一条,空表回退 cordis qmtBaseUrl)后挂载(对齐 R-004 resolveStartupConnection);
- 生命周期编排:activate/update(地址变)/remove(激活被删切默认)→ 先卸后挂(对齐 qmt-connections API 现有热切换点,Q1 仅激活连接挂载);
- 失败语义(Q5):failOnStartupError:false + reconnect(对齐宿主现有受管块配置:initial 500ms / max 30s / maxAttempts 10);
- 状态缓存:{ state: connected|connecting|disconnected|disabled, serverName, url, toolCount, error, checkedAt },供状态端点与指示灯读取;
- 释放:ctx.effect dispose 时卸载实例;
3. **服务端状态出口(api/qmt-connections.js 或 market.js 扩展)**
- mcp-status 端点(含 refresh 即时重查)→ 设置页连接卡片 MCP 状态行 + 会话头部指示灯读取;
- sync-status 增加 mcp 域(Q4:头部指示灯数据源合并一处);
4. **设置页「QMT 连接配置」卡片 MCP 状态(UI约束-002 形态内扩展)**:卡片显示 MCP 状态(绿=已连接 + N 工具 / 红=失败原因 / 灰=未启用),「检查 MCP」按钮(手动触发,Q4);
5. **会话头部指示灯新增 MCP 状态灯(产品约束-012 体系扩展)**SyncIndicators 三灯 → 四灯(QMT连接|持仓数据|行情数据|MCP),MCP 灯三态对齐(绿=已连接 / 黄=重连中 / 红=断开 / 灰=未启用),点击=refreshQ4);
6. **移除三方 dsh-skill-mcp-panelQ22026-09-08 老师澄清)**dsh plugin remove dsh-skill-mcp-panel(连带其「技能 / MCP」设置菜单);编辑 ~/.dsh/profiles/web/cordis.patch.yml 删除其受管块(QMT_Bridge_MCP 条目,块外内容逐字节保留)——属宿主组成变更,按 editing-cordis-compositions 技能流程执行 + 老师确认;
7. **构建安装与验收**
**不做**(本期,R-020 边界):通用 MCP 服务器管理面板(Q6,含自建任意 MCP 管理 UI);插件 UI/tab 数据源改走 MCP(技术约束-003);多 serverName 并存;自研 MCP 协议客户端(老师定:官方 dsh-mcp-client 可依赖,不重造);认证/鉴权增强(QMT Bridge 无鉴权)。
## 程序结构(新增/改动)
```
src/
├── index.js # 服务端入口(改):注入 mcpManager,启动挂载 + 释放卸载;把 mcpManager 传入 registerApi
├── mcp/ # (新增域目录,技术约束-016 语义:无合适域先讨论,此处讨论定 = 新增 mcp 域)
│ └── QmtMcpManager.js # MCP 实例管理(官方 dsh-mcp-client 动态挂载:唯一实例 + 生命周期编排 + 状态缓存)
├── settings.js # (小改):暴露当前激活连接解析辅助(已有 getActiveQmtConnection 可复用,必要时补暴露)
├── api/
│ ├── index.js # (改):runtime 增 mcpManager,注册 mcp-status 方法分发
│ ├── qmt-connections.js # (改):热切换点(activate/update/remove)联动 mcpManager 重挂;新增 mcp-status 处理
│ └── market.js # (改):sync-status 增加 mcp 域
└── client/views/
├── QmtConnectionChip.jsx # (改):内置 SyncIndicators 传 mcp 状态读取
├── SyncIndicators.jsx # (改):三灯 → 四灯(+MCP),轮询 sync-status 读 mcp 域,点击 refresh
└── SettingsSection.jsx # (改):QmtConnectionCard 增 MCP 状态行 + 「检查 MCP」按钮
package.json # (改,已完成):dependencies + @deepseek-ai/dsh-mcp-client@0.1.1-rc.2 + @modelcontextprotocol/sdk@1.30.0
~/.dsh/profiles/web/cordis.patch.yml # (改):移除 dsh-skill-mcp-panel 受管块(Q2,宿主配置,独立步骤;插件本体 dsh plugin remove
```
## 实现步骤(建议顺序)
1. **依赖可行性实证**(任务 1 决策门):声明 peer + 构建后在宿主实测 import 解析;不可解析则停在这里与老师讨论替代通道;
2. **服务端 mcpManager**QmtMcpManager 骨架 + 启动挂载/卸载 + 状态缓存(先不接 API,日志验证挂载与工具注册);
3. **热切换联动**qmt-connections activate/update/remove 编排点追加 mcpManager.resync()(复用现有 dataSource.setBaseUrl 同点);
4. **状态端点**mcp-status + sync-status.mcp 域;本地 curl/HTTP 验证;
5. **设置页卡片**QmtConnectionCard 增 MCP 状态 + 检查按钮(复用测试连接交互模式与 Toast);
6. **会话头部 MCP 灯**SyncIndicators 四灯改造;验证与三灯并存样式一致(对齐 IndicatorDot/sep 布局与主题 token);
7. **宿主受管块移除**:编辑 cordis.patch.ymlediting-cordis-compositions 流程)→ 重载宿主 → 验证 mcp__QMT_Bridge_MCP__* 工具仍可用(这次来自神之一手);
8. **构建安装验收**:服务端+客户端构建,dsh plugin add / 重载;逐条核对验收标准。
## 验收标准
1. 移除宿主 cordis.patch.yml QMT_Bridge_MCP 受管块后,模型仍能使用 mcp__QMT_Bridge_MCP__qmt_* 工具(来源=神之一手动态挂载实例),工具集与移除前一致(同一 serverName/url);
2. 激活连接切换 → MCP 实例 url 跟随切换(工具可用目标随激活配置变化,观察工具调用返回目标数据源变化);
3. 编辑激活连接地址、删除激活连接(自动切默认)→ MCP 实例同步重挂,无残留旧连接工具;
4. 连接列表为空(无激活)→ 不挂 MCP 实例(mcp-status=disabled),插件其它功能不受影响;
5. QMT Bridge 不可达 → MCP 灯=红(断开),插件不崩、其余灯正常;恢复可达后自动重连至绿灯(Q5 语义);
6. 设置页连接卡片显示 MCP 状态(已连接 + N 工具 / 失败原因 / 未启用),「检查 MCP」可手动触发并刷新;
7. 会话头部出现第四个指示灯「MCP」:绿=已连接 / 黄=重连中 / 红=断开 / 灰=未启用,点击触发即时检查(Q4);
8. 现有功能(分仓/策略/交易/设置子 tab/三灯)无回归;typecheck + build 通过。
## 验收方法
- 移除受管块前先完成 1-6 步并在旧块存在时验证(同名冲突预期:两实例同 serverName 后加载报错——因此**验证顺序必须先移除受管块再加载新实例**,步骤 7 与 1-6 的顺序需在实施时按 HMR 重载节奏小心编排,见实施时核查点);
- 用 qmt 工具的 listTools/实际调用观察目标切换;断开 QMT 观察灯态与自动恢复;
- settings 卡片与头部灯截图/人工核验。
> **实施注意(同名冲突时序)**:宿主现有受管块与神之一手新实例同用 serverName QMT_Bridge_MCP,存活实例重复会报错。安全顺序:① 插件侧完整实现并构建 → ② 移除宿主受管块并重载(此窗口工具短暂消失可接受)→ ③ 插件实例接管(工具恢复,来源变为神之一手)。禁止两实例并存重载。
## 关联
- 需求:R-020(已定稿,2026-09-08)| 参考:mcp_router 动态挂载实证、dsh-skill-mcp-panel 受管块现状
- 设计约束新增(定稿时补充):技术约束:「插件内建 MCP 客户端注册规范」(唯一实例 serverName 固定 + 随激活连接 url 派生 + 生命周期联动);产品约束:「MCP 状态出口(设置卡片 + 会话头部 MCP 灯)」
## 记录
| 日期 | 变更 |
|---|---|
| 2026-09-08 | 依据 R-020 定稿(Q1-Q6)创建 PLAN-018 |
@@ -0,0 +1,36 @@
# 计划:交易关联驱动持仓份额动态调整(阶段航点)
> 编号:PLAN-017 | 粒度:阶段航点 创建:2026-09-08 | 状态:**已完成(迭代 16 验收通过 2026-09-08**
> 派生自终极目标:目标-006(交易复盘)/ 目标-007(人机合一)
> 依据需求:**R-018(已定稿,2026-09-08,老师逐项拍板)** —— 符合入范围门槛
> 设计约束:技术约束-017(变更:幽灵清仓条款)、产品约束-011(变更)、数据存储设计.md §11(变更);新增归属分段存储设计
## 目标
从交易记录出发的关联操作升级为**账本写操作**:策略级候选 + 统一份额分配器(一笔委托拆 0..N 段 × 策略)、买卖联动调整 strategy_holdings 份额(买入加仓/建新仓、卖出减仓/归零清仓、仓不足自动截断续分)、撤段逆操作回滚(作废第三态 / 恢复活动仓)、**数据域分界**(幽灵清仓等同步机制不再写账本,账本只由交易关联 + 手动份额操作驱动)。
## 范围
**做**
1. 数据域分界落地:PositionSync 删除幽灵清仓账本写逻辑(同步机制只维护对账单快照)+ 漏关联软提示检测(账本有活动行 + QMT 快照无此 code → 提示,不动账本);
2. 存储地基:strategy_holdings 增 `void_at` 作废第三态(活动 = closed_at IS NULL AND void_at IS NULL,唯一索引收窄)+ 幂等补列;新增归属分段表 `trade_order_attributions`order_id → strategy_id/holding_id/volume/direction/created_at+ 存量归属迁移(trade_orders 两列 → 段表第一段);
3. 归属服务(核心):份额分配器 apply/revoke——判定动作表(买/卖 × 活动仓状态 → open/add/reduce/close/截断/失败)+ 撤段逆操作(撤建仓买入=作废、撤加仓=减回归零作废、撤卖出=恢复活动+份额加回)+ 原子性(改归属 = 撤旧段 + 加新段);
4. 策略级候选 + 分配器 API:候选回归「当前持仓 + 未关联」(R-017 7 天清仓候选退役,holdingId 键控/占位安全逻辑保留);
5. UITradeRecordsTab 归属入口改造为份额分配器(策略下拉 + 段量输入 + 余额提示 + 部分关联);
6. 回归:新脚本(分配器动作/撤段/迁移/软提示)+ 存量回归 + typecheck + build。
**不做**:自动归属推导暴露;全部持仓/未分配 tab 份额联动 UI;未分配余额自动兜底;账本写自动化兜底(软提示不改账本)。
## 实施步骤(阶段划分,供迭代跟踪)
1. 阶段APositionSync 幽灵清仓退役 + 软提示检测基础;
2. 阶段B:存储地基(void_at + 段表 + 迁移);
3. 阶段C:归属服务核心(apply/revoke 动作表 + 原子性);
4. 阶段D:策略级候选 + API(含 R-017 退役);
5. 阶段EUI 份额分配器;
6. 阶段F:回归 + typecheck + build + 存量回归;
7. 迭代复盘 + R-018 索引状态更新 + 需求归档。
## 验收要点
`docs/04-迭代记录/16-交易关联驱动持仓份额动态调整/验收标准.md`
@@ -0,0 +1,27 @@
# 计划:委托归属候选纳入近期清仓持仓(阶段航点)
> 编号:PLAN-016 | 粒度:阶段航点 创建:2026-09-07 | 状态:**已实施(待验收)**
> 派生自终极目标:目标-008(真实交易系统接入)
> 依据需求:**R-017(已定稿,2026-09-07,老师指令确认方向)** —— 符合入范围门槛
> 设计约束:技术约束-013 语义延伸(候选窗口 + holdingId 键控);不新增约束条目
## 目标
清仓后当日委托可补关联:归属候选 = 该 code 当前持仓 + 近 7 天清仓持仓(closed 标记),下拉锚点改 holdingId 键控。
## 范围
**做**getAttributionCandidates 扩展(closed_at IS NULL OR ≥ 7 天前;closed/closedAt 字段;当前在前/清仓 closed_at DESC+ AttributionSelect holdingId 键控 + 「(已清仓 MM-DD)」后缀 + 「当前归属」占位选项 + 回归 test-r017-candidates.mjs。
**不做**:候选窗口可配置;自动归属;其他候选消费方调整;历史数据修复工具(一次性修复走 set-attribution)。
## 涉及文件
src/storage/SqliteStore.jsgetAttributionCandidates)、src/client/views/TradeRecordsTab.jsxAttributionSelect)、scripts/test-r017-candidates.mjs(新增)。
## 实现步骤
1. R-017 + 本计划 + 三件套;2. 存储扩展;3. UI 重构;4. 回归 + typecheck + build + 存量回归;5. 复盘。
## 验收要点
`docs/04-迭代记录/15-归属候选纳入清仓持仓/验收标准.md`
@@ -0,0 +1,49 @@
# 计划:策略 tab 历史持仓展示(显示/隐藏已清仓持仓 + 清仓时间范围筛选)(阶段航点)
> 编号:PLAN-015 | 粒度:阶段航点 创建:2026-09-07 | 状态:**已实施(待验收)**
> 派生自终极目标:目标-003(按策略监控市场)、目标-008(真实交易系统接入)
> 依据需求:**R-016(已定稿,2026-09-07Q1-Q7 老师拍板)** —— 符合入范围门槛
> 设计约束:新增 技术约束-019(历史持仓查询走本地库,当前持仓快照语义不掺历史)
## 目标
两个策略 tab(做T / 网格超市)title 旁提供「历史持仓」开关 + 清仓时间范围筛选(默认近一周,另有近 1 个月 / 近 3 个月 / 近半年 / 近 1 年);历史持仓行可展开查看关联交易记录(复用 R-010),补上「清仓即失联」的操作追溯缺口。
## 范围
**做**
1. **存储层**SqliteStore):新增 getHoldingsHistory(strategyId, { sinceMs }) —— closed_at 非空 + closed_at ≥ sinceMs 过滤 + 按 closed_at DESC 排序(读只读,零写路径变更,不改 closeHolding 语义);
2. **门面层**DataStore):getHoldingsHistory 透传(await _ensure 后委托);
3. **API**:新增 strategy-holdings/history 端点(args: strategyId 必填 + range ∈ week|month|quarter|halfYear|year,服务端换算 sinceMs;缺失/非法参数报参数错误);strategy-positions 等现有端点零改动;
4. **前端 StrategyTab**
- 头部按钮区(+ 添加持仓 / 列设置 旁)新增「历史持仓」开关按钮 + 范围下拉(仅开关开启时显示;默认 week);
- 开启时懒加载历史数据(切换范围重新拉取;关闭即隐藏不销毁缓存);进 tab 默认关(会话级,不持久化);
- 历史行渲染在当前持仓行之后:代码 / 名称(关联委托 name 兜底,无则 —)/ 份额(显示 DB 原值 0,Q2)/ 清仓时间 / 持有天数;实时行情列(现价/涨幅/涨停跌停/今开/最高)显示 —;
- 历史行灰显 + 「已清仓」徽标(含清仓日期);标题计数(N 只)只数当前持仓;
- 历史行行展开复用 R-010trades/by-holding,按 holdingId 懒加载);
- 自定义字段列历史行只读展示(values 随行返回,无编辑入口);
5. **回归脚本** scripts/test-r016-history.mjs(独立临时数据目录,技术约束-011)。
**不做**:全部持仓 tab;盈亏统计;清仓方式标记;历史行实时行情;成本价/收益推导;closeHolding 语义与任何存储写入路径变更;范围偏好持久化;「全部历史」档位。
## 涉及文件
- 修改:src/storage/SqliteStore.js、src/storage/DataStore.js、src/api/strategies.js、src/client/views/StrategyTab.jsx
- 新增:scripts/test-r016-history.mjs
## 实现步骤
1. 本文档链(PLAN + 迭代三件套 + 技术约束-019);2. SqliteStore.getHoldingsHistory + DataStore 门面;3. API 端点;4. StrategyTab 开关/范围/历史行渲染/展开复用;5. 回归脚本 + typecheck + build + 存量回归(test-position-sync / test-r013 / test-quote-sync);6. 复盘。
## 验收要点
`docs/04-迭代记录/14-策略tab历史持仓展示/验收标准.md`
## 二轮补充(2026-09-07 Q8-Q9 老师拍板:今日已清仓默认层)
- **补充范围**:已清仓行分两层——L1「今日已清仓」(range='today',本地自然日 00:00 起)默认恒显示、不受「历史持仓」开关控制;L2「历史范围层」(今天之前的 week/month/quarter/halfYear/year)仍由开关控制(默认关)。前端按 holdingId 去重两层(开启范围不重复出两行);标题计数不含已清仓行维持;零写路径变更维持。
- **不做(维持首轮)**:不做统计 / 不改 closeHolding / 范围偏好不持久化 / 全部持仓 tab 不加 / 不新增「全部历史」档。
## 验收要点(二轮)
见验收标准「二轮补充验收线 7-10」;自动化已复跑:test-r016 24/24 + 存量回归全绿。
@@ -0,0 +1,23 @@
# 计划:策略会话——会话以策略当前数据为依据做讨论(阶段航点)
> 来源:R-021(合并原 R-019+R-0212026-09-08 已定稿)| 状态:执行中(设计阶段先行)| 所属迭代:17-策略会话
> 粒度:阶段航点(覆盖设计 → 宿主调研 → 实现 → 验收全过程,随进展拆分小任务)
## 目标
让 AI 会话可以"以策略为 Workspace"(数据依据版):**对话开始前选「讨论策略」→ 服务端生成该策略当前数据摘要 → 预注入会话上下文 → 以该策略当前数据为依据进行复盘/分析讨论**;只读、会话中可换/刷新、产物不落盘。
## 范围与分阶段
1. **设计阶段(当前,老师指令:先做产品逻辑 + UI 交互逻辑)**:产出《产品逻辑设计》《UI交互设计》,D 系列问题老师拍板后定稿;
2. **宿主注入通道调研**:确认把外部摘要注入会话上下文的可行通道/形态(决定"折叠数据依据条"还是"文本消息形态"呈现;影响客户端渲染方案与注入动作实现);
3. **技术实现方案**(技术约束核对、服务端摘要服务 + 客户端选择 UI + 注入动作 + 回归);
4. **实现 + 验收**
## 验收指向
`docs/04-迭代记录/17-策略会话/验收标准.md`(实现阶段补充细化)。
## 边界
见 R-021 定稿边界(合并原 R-019+R-021):不用宿主工作区;不开放账本写;不做按需取数工具;复盘产物不落盘;本期不做通用视图(全部持仓/交易记录)作依据。
@@ -0,0 +1,92 @@
# 计划:策略计算字段 + 策略 tab 静态化重构(阶段航点)
> 编号:PLAN-019 | 粒度:阶段航点 创建:2026-09-10 | 状态:**进行中(迭代 22 交互设计阶段)**
> 派生自终极目标:目标-002(策略定义能力)、目标-003(按策略监控市场)
> 依据需求:**R-027(已定稿,2026-09-10,重构·第一阶段)+ R-026(已定稿,2026-09-10,计算字段·第二阶段)** —— 均符合入范围门槛
> 设计约束:沿用 技术约束-012(策略定义存储)、技术约束-015configSchema)、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静态化(两阶段)。
+6 -3
View File
@@ -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 | 策略自定义字段配置 UIR-013,2026-09-02):设置页「策略分组」子 tab 策略行**可展开** → 展开区 = 字段列表(label/key/type/enum/默认值)+ 添加/编辑/删除字段表单(名称/类型/枚举选项/默认值)+ 保存(走 strategies/update 整表);策略持仓 tab 持仓行展开区(R-010 基础上)增加「自定义字段」区块:按该策略 configSchema 渲染输入控件(文本=输入框、数字=数字输入、布尔=开关、枚举=下拉),值来自该行 values,编辑即保存调 holdings/values-updateToast 反馈;未配置字段定义的策略持仓行不渲染字段区 | 2026-09-02 | 生效 | - | 新增(2026-09-02 R-013 定稿 D6 老师确认:值编辑入口 = 持仓行展开区 |
| UI约束-005 | 策略自定义字段配置 UIR-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 老师反馈)**:变量选择从「+ 插入变量 ▾」下拉改为**标签平铺**(分组成行、点选插入),弃用下拉交互;判定型隐藏小数位 |
<!-- 示例条目(确认格式后删除):
| UI约束-001 | 示例:求签页面必须保持单屏完整,不出现滚动 | 2026-08-26 | 生效 | - | 讨论确认:移动端优先,避免滚动打断仪式感 |
+8 -5
View File
@@ -11,17 +11,20 @@
|---|---|---|---|---|---|
| 产品约束-001 | 分仓管理以「全量持仓」为数据基础:系统必须完整展示账户全部持仓信息(代码/名称/数量/可用/均价/现价/市值/盈亏),不遗漏、不裁剪 | 2026-08-27 | 生效 | - | 讨论确认(R-002):老师要求「首先肯定支持全部持仓信息」 |
| 产品约束-002 | 分仓通过「持仓标签」体系实现:在全量持仓之上,用不同标签对持仓进行逻辑分组,每个标签展示其下的仓位汇总(数量/市值/盈亏/占比) | 2026-08-27 | 生效 | - | 讨论确认(R-002):老师要求「分不同的持仓标签,各自有多少仓位」 |
| 产品约束-004 | 删除持仓策略时,该策略下已分配的份额自动回到「未分配」,数据不丢失(删除前需弹窗确认) | 2026-08-28 | 生效 | - | R-003 O3 定稿(2026-08-28:删除策略 = 份额回未分配 + 弹窗确认 |
| 产品约束-003 | 持仓标签可自定义、可增删改;标签维度候选包括策略/用途/风险等级等(具体维度待讨论确认) | 2026-08-27 | 生效 | - | 讨论确认(R-002):标签体系需支持自定义,维度待细化 |
| 产品约束-004 | 删除持仓策略时,该策略下已分配的份额自动回到「未分配」,数据不丢失(删除前需弹窗确认) | 2026-08-28 | **失效** | 2026-09-10 | **失效(2026-09-10 R-027 策略静态化)**:策略不可删除,本条款无适用场景,随之退役(原定稿:删除策略 = 份额回未分配 + 弹窗确认 |
| 产品约束-003 | 持仓标签(策略)**集合与名称固定**为插件内置两项(网格超市 / 手动做T):不可新增、不可删除、不可重命名(R-027 策略静态化);「可自定义」收窄为**标签下的字段定义可自定义**(见 产品约束-010 / 产品约束-014);策略 tab 仍支持显隐与拖拽排序(见 产品约束-009) | 2026-08-27 | 生效 | - | **变更(2026-09-10 R-027**:原「标签可自定义、可增删改」(维度候选策略/用途/风险等级)收窄为「固定两标签 + 字段自定义」;老师重构指令「策略 tab 做成静态的,和全部持仓、交易记录一样是插件内置 tab」 |
| 产品约束-005 | QMT 连接配置多配置管理:设置页「QMT 连接配置」子 tab 维护多个连接配置(名称 + HTTP 地址,增/删/改/查);一次只能激活一个,激活配置为当前生效连接;默认 = 插件启动时自动激活的配置(运行中手动激活其他配置立即生效,重启回到默认);测试连接为手动操作(请求 /health 返回可达性与延迟,激活不强制先测);删除边界:删除激活配置自动切换到默认,删除默认配置则默认标记转移到列表第一条并激活之,禁止删除最后一条 | 2026-08-29 | 生效 | - | R-004 定稿(2026-08-29):Q1-Q6/Q8 逐条确认(不迁移宿主配置、超时不纳入表单) |
| 产品约束-007 | 3 个监控表格(全部持仓 / 手动做T / 网格超市)展示**盘中现价**:现价列显示 lastPrice,红涨绿跌着色(对比昨收),价格变化轻微高亮;本期仅现价一项实时数据 | 2026-08-31 | 生效 | - | R-005 定稿(2026-08-31):老师确认现价列 + 涨跌色 + 变化高亮 |
| 产品约束-006 | QMT 连接会话头部快捷切换:会话窗口顶栏(PTC 模式标签旁)常驻下拉控件(chip 显示 `QMT: <激活配置名>`),点开列出全部配置(激活项勾选),点选即激活并轻提示反馈;头部控件仅做切换,配置管理(增删改/测试连接/默认标记)仍在设置页子 tab | 2026-08-29 | 生效 | - | R-004 定稿(2026-08-29):Q9/Q10 确认(入口=会话头部 PTC 旁,菜单仅切换激活) |
| 产品约束-008 | 交易记录支持**按策略过滤**:交易记录 tab 提供策略过滤下拉(全部 / 各策略 / 未关联),对今日(QMT 实时)与历史(本地 SQLite)均生效;策略归属 = 委托时间 join 持仓生命周期窗口推导(一码多策略取份额最大,未命中=未关联);历史范围展示本地积累数据(不再「接口开发中」占位) | 2026-09-01 | 生效 | - | 新增(2026-09-01 R-009 定稿 + 迭代 07 实施):策略过滤 + 历史本地展示 |
| 产品约束-010 | 策略自定义字段配置:每个策略可在设置页「策略分组」子 tab 配置自定义字段定义(字段名 / 类型文本·数字·布尔·枚举 / 枚举选项 / 默认值),定义随策略存 settings(不落库);该策略下每个持仓(strategy_holdings 行)按所属策略的定义存取一份字段值(values JSON 列,持仓级键值对,key 对齐定义、允许扩展额外键);旧策略无定义时行为与现状一致(不渲染字段区、不迁移历史值) | 2026-09-02 | 生效 | - | 新增2026-09-02 R-013 定稿 Q1-Q4 + D6):定义随策略走、值随持仓行,类型化(含枚举),旧策略兼容 |
| 产品约束-009 | 会话 tab 统一由「Tab 设置」管理:设置页「Tab 设置」子 tab 是**所有会话 tab(系统内置 + 策略分组)的唯一顺序与显隐入口**,两类 tab 混排;每行 = 拖动排序 + 显示/隐藏开关;**任何 tab 均不支持重命名与删除**(内置 tab 名称只读,策略命名/删除仍在「策略分组」子 tab);策略改名后 Tab 设置中的名称自动跟随(只存引用);新增策略默认追加到列表末尾;删除策略联动删除 Tab 设置中对应条目;顺序与显隐唯一数据源 = settings.tabs 有序数组 | 2026-09-02 | 生效 | - | R-011 定稿2026-09-02 Q1-Q5 确认):Tab 设置 = 显示/隐藏 + 拖动排序(落点立即持久化),全 tab 禁重命名/删除 |
| 产品约束-011 | 持仓页数据(策略持仓 / 全部持仓 / 未分配)以服务端 10s 内存快照为准,**接受最多 10s 滞后**(同步时间前端不显示,老师拍板);QMT 抖动/掉线时持仓页面显示**最后一次快照**而非空白;QMT 已卖光的票(连续 3 轮同步确认消失)本地持仓自动转历史(幽灵清仓,不物理删除);部分减持仅表现为「未分配为负」,不做自动修正 | 2026-09-02 | 生效 | - | 新增(2026-09-02 R-014 定稿):老师四问拍板(内存不落库 / 读穿透兜底 / 幽灵自动清仓 / 不显示同步时间) |
| 产品约束-010 | 策略自定义字段配置:每个策略在**策略 tab 顶部「列设置」旁的「字段配置」按钮(独立弹层)**中配置本策略的字段定义(字段名 / 类型文本·数字·布尔·枚举·**计算** / 枚举选项 / 默认值 / 单位 / 计算字段的公式与小数位),定义存 settings(不落库);该策略下每个持仓(strategy_holdings 行)按所属策略的定义存取一份字段值(values JSON 列,持仓级键值对,key 对齐定义、允许扩展额外键);旧策略无定义时行为与现状一致(不渲染字段区、不迁移历史值) | 2026-09-02 | 生效 | - | **变更2026-09-10 R-027/R-026**:入口由设置页「策略分组」迁到策略 tab 内「字段配置」弹层;类型新增「计算」(见 产品约束-014);定义真相源迁 settings.strategyFields(原定稿:设置页策略分组配置、四类型、定义随策略存 settings) |
| 产品约束-009 | 会话 tab 统一由「Tab 设置」管理:设置页「Tab 设置」子 tab 是**所有会话 tab(系统内置 + 策略)的唯一顺序与显隐入口**,两类 tab 同权混排;每行 = 拖动排序(间隙高亮线)+ 显示/隐藏开关;**任何 tab 均不支持重命名与删除**;R-027 静态化后策略 tab 恒存在(不随任何 CRUD 增删),故「策略改名跟随 / 新增追加末尾 / 删除联动」等条款失效;顺序与显隐唯一数据源 = settings.tabs(由内置常量序列 + 存量偏好归一化得出) | 2026-09-02 | 生效 | - | **变更2026-09-10 R-027 策略静态化)**:策略 tab 与内置 tab 完全同权且恒存在;原「策略命名/删除在策略分组子 tab」及联动增删条款失效(设置页「策略分组」子 tab 已移除) |
| 产品约束-011 | 持仓页数据(策略持仓 / 全部持仓 / 未分配)以服务端 10s 内存快照为准,**接受最多 10s 滞后**(同步时间前端不显示,老师拍板);QMT 抖动/掉线时持仓页面显示**最后一次快照**而非空白;**幽灵自动清仓退役(变更 12026-09-08 R-018 数据域分界)**:同步机制不再把本地策略持仓自动转历史——策略持仓份额只由「交易关联(归属=账本写操作)」与「手动份额操作」驱动,账本转历史唯一途径 = 卖出单关联份额减至 0;对账单码消失仅表现为快照无此行 + **漏关联软提示**(只读提示去关联/移出,不自动写账本);部分减持仅表现为「未分配为负」,不做自动修正 | 2026-09-02 | 生效 | - | 新增(2026-09-02 R-014 定稿);**变更 12026-09-08 R-018/迭代 16 拍板)**:幽灵自动清仓退役(同步机制不写账本),策略持仓由交易关联 + 手动份额操作驱动;漏关联软提示替代自动归档 |
| 产品约束-012 | 行情数据服务形态(R-015):现价/昨收/涨停/跌停统一由盘口内存快照提供(≤5s 更新),价格单一入口;QMT 抖动时页面价格保持旧值不空白;会话头部(QMT 健康灯旁)提供**持仓/盘口同步指示灯**——绿=同步正常(持仓 30s/盘口 15s 内)、黄=同步失败中快照陈旧、灰=从未同步;悬停显示同步时间/快照量/失败数/错误摘要;点击灯 = 立即触发该域同步;不做个股停牌标识(另议) | 2026-09-02 | 生效 | - | 新增(2026-09-02 R-015 定稿):老师提出指示灯,采纳 AI 推荐三态/点击即同步/取消 PriceCell 灰点 |
| 产品约束-013 | 策略 tab 静态化(R-027):会话 tab 中的策略 tab 恒为**插件内置两项**(网格超市 / 手动做T),与「全部持仓 / 交易记录」同类的内置 tab —— **不可新增、不可删除、不可重命名**;仍支持 Tab 设置中的显隐与拖拽排序;设置页不再有「策略分组」子 tab(设置页 = Tab 设置 / QMT 连接配置);策略字段配置入口 = 策略 tab 顶部「列设置」**旁**的「字段配置」按钮(独立弹层),每个策略 tab 各管本策略字段 | 2026-09-10 | 生效 | - | 新增(2026-09-10 R-027 定稿 Q1-Q4/Q6 老师拍板):静态化程度=固定不可增删改名但保留显隐/排序;入口形态=列设置旁独立按钮;范围=每策略管自己的字段 |
| 产品约束-014 | 策略计算字段(R-026):字段类型新增「计算(formula)」——用户在字段配置中写**公式**(**中文变量名**,如 `(现价 - 成本价) × 份额`),公式引用**服务端缓存数据集**(行情盘口 / 合约信息(涨停跌停)/ 持仓账本(份额·成本价·最后成交价)/ 本行手填字段值),由服务端按**持仓行实时计算**得出列值;计算字段**不落库**(不写 values 列)、在持仓表中**只读**(不可内联编辑);变量**只能引用非计算字段**(零循环依赖);缺数据 / 除零 / 无法计算 → 该格显示 `—`(不显示 NaN、不报错、不炸表);历史持仓行显示 `—`(不做历史时点计算);**结果类型两种(R-028 扩展)**:`resultKind = number | boolean` —— **数字**(默认,按小数位+单位格式化)与**判定**(比较运算结果:满足显示 `✓ <字段名>`(success 绿)、不满足或数据缺失显示 `—`),结果类型在字段表单**显式声明**且必须与公式形态一致(服务端校验);公式能力边界 = 四则运算 + 括号 + 比较(`> < >= <= == !=`+ 逻辑组合(`and / or`,含 `&& ||` 与全角 `≥ ≤ ≠`+ round/abs/min/max,禁止任意代码执行 | 2026-09-10 | 生效 | - | 新增(2026-09-10 R-026 定稿 Q1-Q3 + Q4-Q10 + D-1~D-7 老师拍板):数据集首批三组、中文变量名、仅行级、服务端现算、只读列、缺值 —;**变更(2026-09-10 R-028)**:原「首批结果只做数字」扩展为「数字 / 判定」双类型 + 逻辑组合,首个落地用例 = 网格超市「可下空单 / 可下多单」 |
<!-- 示例条目(确认格式后删除):
| 产品约束-001 | 示例:求签功能必须保证抽取结果的不可预测性 | 2026-08-26 | 生效 | - | 讨论确认:为保证公平性,抽取必须不可预测 |
-->
+11 -3
View File
@@ -23,11 +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 归一化;委托交易日 = insertDatetradeDate 兜底) | 2026-09-01 | 生效 | - | 新增(2026-09-01 R-009 定稿 + 迭代 07 实施):两表 + 定时同步 + 本地历史查询;变更 12026-09-01):+code 归一化 + tradeDate 兜底;变更 22026-09-01 老师二次定稿):归属改**手动设置**trade_orders 冗余 strategy_id+holding_idUPSERT 不覆盖归属列),弃算法推导 |
| 技术约束-015 | 策略自定义字段存储(R-013,2026-09-02):字段定义随策略定义存 settings.strategies 扩展 configSchema[{key,label,type,enum?,def}]type ∈ text|number|boolean|enum,旧项缺省空数组);字段值落 strategy_holdings 新增 values TEXTJSON 键值对,key 对齐 configSchema.key,允许额外键=可扩展,NULL=未配置);补列用幂等 ALTER(沿用 _ensureTradeAttributionColumns 模式,只读连接容忍);APIstrategy-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 TEXTJSON 键值对,key 对齐 configSchema.key,允许额外键=可扩展,NULL=未配置);补列用幂等 ALTER(沿用 _ensureTradeAttributionColumns 模式,只读连接容忍);APIstrategy-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**:定义真相源迁 strategyFieldsstrategies 只读兼容)、类型白名单加计算类型、写路径改单策略端点 |
| 技术约束-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/ 仅放 UIviews/ 组件 + market/ provider);文件命名 = 类名(PascalCase+ .js/.jsx;新增服务端模块必须先落对应域目录,无合适域时先讨论补域,不得回退平铺 | 2026-09-02 | 生效 | - | 新增(2026-09-02 结构审查 + 优化落地):component/ 平铺还原为语义分域,删除死代码 AllocationStorage、DataStore.setDataset/removeDataset |
| 技术约束-017 | 持仓内存快照(R-0142026-09-02):全量实盘持仓由服务端 PositionSync **进程内内存快照**管理(启动预热 + 10s 定时全量拉 /trade/positions → 校验 → 整体替换),**不落库**无缓存表、不复用 strategy_holdings——账本与对账单分离,holding_id 交易锚点不掺易变快照;判断标准:外部可一次调用重取全的实时投影不落库);PositionManager.getAllPositions 以快照为准(strategy-positions / unallocated / summary 三接口不再请求时穿透 QMT),快照为空读穿透兜底(当场拉一次并回填);同步失败保留上次快照(不清空不报错);空快照双重确认(/health 可用 + getAsset 账户身份可识别)才接受为真清仓;幽灵清仓:快照连续 3 轮(约 30s)消失的 code 该码全部策略当前持仓自动 closeHolding 转历史(不物理删除),账户身份守卫防误清(accountId 未知当轮跳过、切换当轮重置跳过);syncNow 允许手动调用(mounted 只管定时循环,不拦手动同步) | 2026-09-02 | 生效 | - | 新增(2026-09-02 R-014 定稿 + 迭代 12 实施):AI 初版建缓存表方案经老师质疑反转为内存方案(落库三问:不可再生?重启首屏依赖?读放大/复杂查询?全否 → 内存) |
| 技术约束-017 | 持仓内存快照(R-0142026-09-02**变更 12026-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 实施);**变更 12026-09-08 R-018/迭代 16 拍板)**:幽灵自动清仓退役(不再 closeHolding 本地账本),PositionSync 只同步对账单快照;漏关联由只读软提示承担 |
| 技术约束-018 | 盘口内存快照(R-0152026-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**(幂等),价格单一入口 = QuoteHubDataStore 行情方法(loadMarket/getMarketQuote(s)/setMarketQuotes)删除;同步失败保留内存旧值;watchCodes 维持只进不出无上限;涨停/跌停/昨收不落库 | 2026-09-02 | 生效 | - | 新增(2026-09-02 R-015 定稿 + 迭代 13 实施):老师五拍板(替换/WS 移除/纯内存 DROP/watch 现状/指示灯);warmup bug 复现(loadMarket return this 残迹)为不落库关键证据 |
| 技术约束-019 | 历史持仓查询(R-0162026-09-07):策略 tab 历史持仓展示走**本地库只读查询**SqliteStore.getHoldingsHistorystrategy_id + closed_at IS NOT NULL + closed_at ≥ sinceMsclosed_at DESC),经独立端点 strategy-holdings/history 暴露;**当前持仓路径(strategy-positions / PositionSync 快照语义)不掺历史数据**(两份结果前端合并渲染);closeHolding 置 shares=0 语义维持不变(Q2 老师拍板:历史行份额显示 0,重点在追溯该持仓的历史操作而非清仓时份额);范围换算服务端做(week=7d/month=30d/quarter=90d/halfYear=182d/year=365d 自然日近似)——**变更 12026-09-07 二轮补充 Q8-Q9 老师拍板)**range 新增 'today' = **本地自然日 00:00 起**(特判零点,不落回溯毫秒档),供前端「今日已清仓默认层」(恒显示、不受历史持仓开关控制);历史范围层语义收窄为**今天之前**;其余不变 | 2026-09-07 | 生效 | - | 变更 12026-09-07 R-016 二轮补充 Q8-Q9 老师拍板):+range=today 自然日边界,历史开关只控今天之前;首轮新增(Q1-Q7):历史行=追溯操作锚点,不动存储写路径 |
| 技术约束-020 | QMT 连接健康自适应探测(R-0222026-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-0232026-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-0262026-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-evalCVE-2025-12735 变量对象注入 → RCE)、mathjs(标识符不含 CJK)、Jexl(ASCII 标识符)、CEL、解析器工具包后**维持自写引擎**,论证见 迭代 23 技术方案 §4.5 |
| 技术约束-023 | 计算字段现算口径(R-0262026-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-0262026-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`(双向提示)|
<!-- 示例条目(确认格式后删除):
| 技术约束-001 | 示例:技术栈以 Node.js / TypeScript 为准,不引入未讨论的新框架 | 2026-08-26 | 生效 | - | 讨论确认:优先复用 DSH 既有能力,新框架需论证 |
-->
+66 -3
View File
@@ -364,7 +364,7 @@ CREATE INDEX IF NOT EXISTS idx_trade_fills_date ON trade_fills (trade_date);
| 失败策略 | 同步失败**保留上次快照**,不清空不报错 | 读方继续消费旧快照,QMT 抖动不再白屏(强于旧的穿透行为) |
| 空快照 | 双重确认(/health 可用 + getAsset 账户身份可识别)才接受为「真清仓」,否则视为异常保留旧快照 | 防一次异常响应清空缓存 |
| 读路径 | `getAllPositions()` 读内存快照;快照为空 → 读穿透当场拉一次并回填;QMT 也挂 → 抛错(前端 LoadState 重试) | 首启兜底;未注入 positionSync 时保持旧行为(兼容) |
| 幽灵持仓自动清仓 | QMT 快照连续 3 轮(约 30s)消失的 code,本地全部策略当前持仓 closeHolding 转历史;**账户身份守卫**:accountId 未知当轮跳过、账户切换当轮重置跳过(防误清);部分减持不触发(负数未分配由 UI 暴露) | 老师选定;清仓转历史不物理删除,历史保留语义不变 |
| ~~幽灵持仓自动清仓~~**退役,2026-09-08 R-018 数据域分界** | ~~QMT 快照连续 3 轮消失 → 本地全部策略当前持仓 closeHolding 转历史~~**不再写 strategy_holdings**:同步机制只作用于对账单域(老师拍板:幽灵清仓本就是一个同步机制,不可以让幽灵把爪子伸太长);账本转历史唯一途径 = 卖出单关联减至 0(R-018);对账单码消失 → 快照无此行 + 漏关联软提示(positions/orphan-hints,只读) | R-014 选定时防本地账本残留;R-018 改由交易关联驱动账本生命周期,幽灵自动归档与「账本只由关联交易改」冲突 → 退役(迭代 16 实施,见 §12) |
| 前端 | 零改动 | 三个接口数据源自动切换 |
### 11.2 数据流(持仓部分,替代原「读取时组装」描述)
@@ -383,8 +383,71 @@ CREATE INDEX IF NOT EXISTS idx_trade_fills_date ON trade_fills (trade_date);
| PositionManager | getAllPositions 改读快照 + 读穿透兜底(未注入 sync 时兼容旧穿透) | src/position/PositionManager.js |
| 回归测试 | 纯内存 mock 34 项(同步/失败保留/空快照/幽灵防抖/账户守卫/读穿透/组装回归) | scripts/test-position-sync.mjs |
## 12. 约束条目(引用
## 12. 归属分段存储设计(R-018 落实,2026-09-08 迭代 16 实施
- 技术约束-012:数据存储遵循本文件(SQLite 存储设计,含交易记录表 §10);
> **变更理由**(R-018 老师拍板):交易归属从「纯标签」(trade_orders 单组 strategy_id/holding_id,不改数量)升级为**账本写操作**——关联即自动调整 strategy_holdings 份额;支持一笔委托拆多段分配多策略;撤段做逆操作。归属真相源从 trade_orders 两列迁移到**分段表**。
### 12.1 strategy_holdings 状态扩展(作废第三态 + 清仓份额快照)
```sql
-- 幂等 ALTER(沿用 _ensureXxxColumn 模式)
ALTER TABLE strategy_holdings ADD COLUMN void_at INTEGER; -- 作废时间(NULL=有效;非 NULL=建仓被撤=从未成立;不进历史层)
ALTER TABLE strategy_holdings ADD COLUMN closed_shares REAL; -- 清仓前份额快照(closeHolding 写入,供撤卖出段恢复活动用)
-- 活动唯一索引收窄(R-018:活动 = closed_at IS NULL AND void_at IS NULL
DROP INDEX IF EXISTS idx_active_holding;
CREATE UNIQUE INDEX IF NOT EXISTS idx_active_holding
ON strategy_holdings (strategy_id, code) WHERE closed_at IS NULL AND void_at IS NULL;
```
- 生命周期操作语义:closeHolding = shares 置 0 + closed_at + **closed_shares=清仓前份额**shares 展示仍 0R-016 Q2 语义不变);voidHolding(新增)= shares 置 0 + void_at(非清仓:建仓被撤 = 从未成立);
- 作废行不进 R-016 历史「已清仓」层(getHoldingsHistory 过滤 void_at IS NULL),保留数据可追溯。
### 12.2 归属分段表 trade_order_attributions
```sql
CREATE TABLE IF NOT EXISTS trade_order_attributions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id TEXT NOT NULL, -- → trade_orders.order_id
strategy_id TEXT NOT NULL, -- 段归属策略
holding_id INTEGER NOT NULL, -- 段锚点持仓(R-010 展开用)
code TEXT NOT NULL, -- 冗余
direction TEXT NOT NULL, -- buy/sell(冗余,撤段判向)
volume REAL NOT NULL, -- 该段已成交量(>0;总额 ≤ order.traded_volume
created_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_attr_order_strategy ON trade_order_attributions (order_id, strategy_id);
CREATE INDEX IF NOT EXISTS idx_attr_holding ON trade_order_attributions (holding_id);
CREATE INDEX IF NOT EXISTS idx_attr_strategy ON trade_order_attributions (strategy_id);
```
- **真相源 = 段表**trade_orders.strategy_id/holding_id 退役为冗余(保留列不删,防外部脚本 break;不再作为查询/过滤源);
- 存量迁移:段表空且 trade_orders 有单组归属 → 迁为第一段(volume = 该单 traded_volume;幂等);
- 查询适配:orders 附加归属、by-holdingR-010)、历史策略过滤(R-009)全部改经段表。
### 12.3 关键设计决策(R-018 迭代 16)
| 决策 | 结论 | 理由 |
|---|---|---|
| 归属真相源 | **分段表 trade_order_attributions**order → 多段 strategyId/holdingId/volume | 一笔委托可拆 N 段关联不同策略(R2/R4);单列两字段无法表达 |
| 数据域分界 | PositionSync/幽灵清仓**只写对账单快照**,不写 strategy_holdings | R-018 老师拍板:同步机制不伸爪子到账本;账本只由交易关联 + 手动份额操作驱动 |
| 关联驱动量 | 段 volume = 该段**已成交量** | Q4:撤单/废单不动账 |
| 撤段逆操作 | 撤 buy 段 → 减回 / 归零作废(void);撤 sell 段 → 加回 / 恢复活动仓(closed_shares | R3/R6/R7:作废 ≠ 清仓(不产生假清仓历史);恢复用 closed_shares 快照 |
| 撤段约束 | 同 holding 内**逆序撤销**(乱序返回 segment-order-conflict);跨 holding 段独立可任意撤 | 账本正确性 > 操作便利(宁可拒绝不写错账) |
| 事务 | setSegments 全量替换在**单事务**内(撤旧段 + 加新段) | 改归属原子性(R3 边界) |
### 12.4 对应实现(追加)
| 模块 | 职责 | 文件 |
|---|---|---|
| AttributionService | 归属服务:apply/revoke/setSegments(动作表判定 + 逆操作 + 事务) | src/trades/AttributionService.js(新增) |
| SqliteStore | 增列/索引重建/voidHolding/close 快照/段表 CRUD/runInTransaction/查询改段表/存量迁移 | src/storage/SqliteStore.js |
| api/trades.js | attribution-targets / attribution-set / attribution-segmentsorders 附加段 | src/api/trades.js |
| PositionSync | 删除幽灵清仓逻辑(只同步对账单快照) | src/position/PositionSync.js |
| 回归测试 | 分界/动作表/撤段/迁移/软提示 | scripts/test-r018-attribution.mjs(新增) |
## 13. 约束条目(引用)
- 技术约束-012:数据存储遵循本文件(SQLite 存储设计,含交易记录表 §10、归属分段存储 §12);
- 技术约束-011:测试/回归脚本禁止在真实数据上执行写操作(独立数据目录);
- 技术约束-017(变更 1,2026-09-08):幽灵自动清仓退役,PositionSync 只同步对账单快照;
- 本文件为新领域设计约束,后续数据存储变更以本文件为最终依据。
@@ -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-需求池/已完成/。
@@ -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-需求池/已完成/。
@@ -0,0 +1,79 @@
# 技术实现方案:14-策略tab历史持仓展示
> 迭代编号:14 依据:PLAN-015 + R-016(老师拍板:Q2 显示 0 / 其余按建议)
## 1. 存储层(SqliteStore.getHoldingsHistory
```js
/** 某策略已清仓持仓(closed_at 非空),closed_at ≥ sinceMs,按 closed_at DESC */
getHoldingsHistory(strategyId, { sinceMs } = {}) {
// SELECT holding_id, strategy_id, code, shares, created_at, closed_at, "values"
// FROM strategy_holdings
// WHERE strategy_id=? AND closed_at IS NOT NULL [AND closed_at >= ?]
// ORDER BY closed_at DESC
// 返回 _mapHolding 同构(holdingId/strategyId/code/shares/createdAt/closedAt/values
}
```
- 只读新增;**零写路径变更**closeHolding 维持 shares=0 + closed_atQ2 定稿);
- sinceMs 缺省 = 不过滤(内部能力预留;API 层始终传值);
- 与 getHoldingHistory(code) 并存:后者是全表按 code 查(R-008 遗留),不满足本需求维度,不复用不改动。
## 2. 门面层(DataStore
```js
async getHoldingsHistory(strategyId, opts) { await this._ensure(); return this.sqlite.getHoldingsHistory(strategyId, opts); }
```
## 3. APIsrc/api/strategies.js 新 method
- method`strategy-holdings/history`
- args`{ strategyId: string, range: 'week'|'month'|'quarter'|'halfYear'|'year' }`
- range → sinceMs 换算(服务端):week=7d / month=30d / quarter=90d / halfYear=182d / year=365d(自然日近似,回溯窗口展示用途足够,精确日历边界无业务含义);
- 缺 strategyId 或 range 非法 → 参数错误(code: 'bad-request');
- 返回行附加 name 兜底:按 holdingId 查 trade_orders 取最新一笔的 nameSQL 单查,无则 null)——前端直接展示,免二次请求。
## 4. 前端(StrategyTab.jsx
- 状态:`showHistory`(默认 false)、`historyRange`(默认 'week')、`historyRows``historyLoading`
- 头部按钮区:「历史持仓」toggle 按钮 + 开启时显示范围下拉(近一周/近1个月/近3个月/近半年/近1年);
- 数据流:开启或切范围 → call('one-divine-lot/strategy-holdings/history', { strategyId, range });缓存按 range 键保留,关闭仅隐藏;
- 渲染:rowsForRender = 当前持仓行 +showHistory ? 历史行 : []);历史行标识 `isHistory: true`
- 行样式:opacity/灰色调(--dsw-alias-label-tertiary+ 徽标「已清仓 YYYY-MM-DD」;
- 单元格:代码/名称/清仓时间(YYYY-MM-DD HH:mm/持有天数(ceil((closedAt-createdAt)/86400000),同日=1);shares 列显示 DB 原值(=0Q2);行情类列(lastPrice/pctChange/lastClose/avgPrice/lastTradePrice/upStop/downStop/open/high)一律 —(不注册行情、不取价);自定义字段列:p.values 只读展示,禁点击编辑(isHistory 不进入 FieldCellEditor 分支);
- 展开:复用 toggleExpand(code, holdingId) → trades/by-holding(历史行同样有效);
- 排序:当前行维持现状(涨幅排序仅作用于当前行集合);历史行固定清仓时间 DESC 追加在后;
- 计数:标题(N 只)= 当前持仓数,不含历史行。
## 5. 回归脚本(scripts/test-r016-history.mjs
- ODL_TEST_DATA_DIR 临时目录(技术约束-011,不碰真实数据);
- 覆盖:建仓→清仓→history 可查 / 范围过滤边界(7d/30d…)/ 排序 / 策略隔离 / 未清仓行不出现 / API 参数校验 / name 兜底。
## 6. 验证链
typecheck → build → test-r016 → 存量回归(test-position-sync 34 / test-r013 21 / test-quote-sync)→ 人工验收清单。
## 7. 二轮补充实现:今日已清仓默认层(2026-09-07 老师拍板 Q8-Q9
> 在首轮(§1-6)基础上追加;首轮实现保持可追溯,新增改动如下。
### 7.1 服务端(src/api/strategies.js
- range 新增 today**特判本地自然日零点**localDayStartMssetHours(0,0,0,0)),sinceMs = 本地今天 00:00;不落入 HISTORY_RANGE_MS 回溯毫秒档(那 5 档语义 = now − 范围,today 语义 = 自然日边界,两类不可混);
- 参数校验错误信息扩为 today|week|month|quarter|halfYear|year;其余逻辑(strategy_id 过滤 + name 兜底 + DESC 排序)与 5 档完全复用——**SqliteStore/DataStore 零改动**sinceMs 本就通用)。
### 7.2 前端(src/client/views/StrategyTab.jsx
- 状态:historyRows 缓存键新增 today(与 5 档并存互不干扰);
- **数据流**loadHistory 定义提前到 load 之前;load() 的 finally 里调 loadHistory('today')——进 tab 即拉 + 每次持仓重载(移出/加仓/编辑后 load())都刷新今日已清仓层,当天清仓转历史后 tab 内不因此少行;
- **渲染合成(rowsToRender**分两层:
- L1 = histRowsFor('today') —— **恒显示**showHistory 关也显示);当前持仓行后追加,清仓时间 DESC;
- L2 = showHistory 开时追加 histRowsFor(historyRange)**按 holdingId 去重**(剔除已含于 L1 的行,避免今天清仓 + 开启近一周出现两行);
- 交互文案:开关 title 更新为「显示/隐藏今天之前已清仓的历史持仓(当天已清仓的持仓始终显示)」;文件头注释同步;
- 标题计数(N 只)仍只算当前持仓,两层已清仓行不计入。
### 7.3 回归(scripts/test-r016-history.mjs
- 新增 [4b] range=today:昨天 23:59:59 清仓不出现 / 今天 00:00:01 清仓出现 / 不含 40 天前行 / today 行 name 兜底同样生效 / range 非法校验不受 today 引入影响;
- 验证链复跑:test-r016 24/24 + 存量回归全绿(见迭代复盘)。
@@ -0,0 +1,40 @@
# 迭代复盘:14-策略tab历史持仓展示
> 复盘日期:2026-09-07 | 迭代状态:**已实施,待老师人工验收**
> 关联需求:R-016 关联计划:PLAN-015
## 结果
迭代 14 达成:两个策略 tab(做T / 网格超市)title 旁新增「历史持仓」开关 + 清仓时间范围筛选(近一周默认 / 近 1 个月 / 近 3 个月 / 近半年 / 近 1 年);已清仓持仓行灰显 + 「已清仓」徽标(含清仓日期)追加在当前持仓行后(closed_at DESC),展开复用 R-010 查看关联交易——补上「清仓即失联」的操作追溯缺口。当前持仓快照路径(R-014 语义)与所有写路径(closeHolding 置 shares=0Q2 拍板)零改动。
## 过程事实
1. **讨论驱动定稿**2026-09-07Q1-Q7 老师逐项拍板):Q2 为本轮唯一改判——AI 建议改 closeHolding 保留清仓时份额,老师否决:「显示 0 就好了,即使显示了清仓时的 shares 也没多少意义,主要目标是追溯这次持仓相关的历史操作」——需求核心从「数据完整性」校正为「操作追溯锚点」,方案随之收敛为零写路径变更;其余六问均按 AI 建议定稿;
2. **实现**(严格按 PLAN-015 步骤):SqliteStore.getHoldingsHistorystrategy_id + closed_at IS NOT NULL + sinceMs 过滤 + DESC,只读新增)→ DataStore 门面透传 → strategy-holdings/history 端点(range 五档服务端换算自然日近似 + name 关联委托兜底 + 参数校验 bad-request)→ StrategyTab 开关/范围下拉/历史行渲染;
3. **实现中发现并修复一个真实隐患**:R-010 展开状态与交易缓存原以 code 为键——同码「当前行 + 历史行」并存(清仓后重新买入)时两行会同时展开、缓存串行;本轮把展开状态/缓存/行键统一收敛为唯一 rowKey(当前行 = code,历史行 = 'h-' + holdingId),toggleExpand 签名同步变更(当前调用方仅 tbody 一处);
4. **验证**test-r016-history.mjs 19/19(清仓转历史可查 / 5 档范围边界 / 排序 / 策略隔离 / 未清仓不出现 / 参数校验 / name 兜底 / 端点分发零破坏);typecheck 通过;存量回归 test-position-sync 35/35、test-r013 21/21、test-quote-sync 27/27build 通过(client bundle 164KB wrapped);
5. **文档链**R-016 定稿 + PLAN-015 + 迭代三件套 + 技术约束-019 + 需求池索引(R-016 登记;顺带补登遗漏的 R-015 行);
6. **部署态排查(老师首验无数据,2026-09-07)**:实锤定位 = **运行中的 DSH 服务端进程仍是旧代码**——同进程 /odl/api/summary 正常应答、新端点返回 unknown methodcurl 实测);客户端 bundle 因插件 symlink 直连源码 lib/ 而被刷新(按钮已出现),服务端模块注册表却停留在进程启动时;处置 = ① loadHistory 失败从静默吞空改为 Toast 可见化(静默兜底=排障盲区,本轮教训)并重新构建(test-r016 19/19 复跑通过);② 服务端生效依赖 DSH web 进程重启(插件 symlink 方式安装,无需重装,重启即取新 lib)。
## 经验教训(复盘沉淀)
### 1. 需求的「核心目标」要听老师的定调,不要替老师拔高
- AI 在 Q2 提议改 closeHolding 语义以「保留清仓时份额」,出发点是账本完整性;老师一句话把需求核心定调为「追溯历史操作」——份额数字本身没有追溯价值,holding_id 锚点 + 关联委托才有;
- 沉淀:讨论中 AI 的方案建议要标明「我认定的价值假设」,让老师有机会否定假设本身,而不只是选项。
### 2. 复用既有交互时,键的唯一性要先审后用
- 展开状态按 code 键控在「一码一行」时代是隐含正确的;历史行引入打破了「一码一行」不变量——同类隐含假设(缓存 Map、React key、loading 标记)都要在数据形态变化时重新过一遍;
- 沉淀:向既有列表引入第二类行时,先列出所有「按 X 键控」的状态,逐个判断是否需要升级为行唯一键。
### 3. 只读需求的实现应当「零写路径」可断言
- 本轮全程未触碰任何 INSERT/UPDATE 语句与语义(closeHolding 分毫未动),回归脚本专门加了「strategy-positions 分发不变」断言——历史查询与当前查询的隔离在端点层就已成立(独立 method),不需要靠约定;
- 沉淀:「只读功能」的范围声明落成验收断言(分发不变 + 写路径 diff 为空),比口头承诺可靠。
## 遗留/后续
1. **归属候选不纳入清仓持仓**R-016 关联观察,2026-09-07 大连热电实际遇到):清仓后当日委托无法再通过下拉关联到对应 holding——是否立独立需求(候选纳入近期清仓持仓)待老师定;
2. **历史行成本价/收益推导**:本期不做(边界定稿);若后续要做,数据源 = 关联委托成交记录,属展示层推导,不动存储;
3. **清仓方式标记**(手动 vs 幽灵自动):数据未区分,本期不做;若要做需 closeHolding 增加来源标记(涉及写路径,另立需求);
4. **R-015 / 迭代 13 人工验收**仍待老师确认(与本迭代无依赖)。
## 验收状态更新(2026-09-08
> 老师 2026-09-08 归档指令:确认验收并归档;R-016 已按归档清单移入 05-需求池/已完成/。
@@ -0,0 +1,29 @@
# 迭代目标:14-策略tab历史持仓展示
> 迭代编号:14 | 创建:2026-09-07 | 状态:已实施,待验收
> 依据计划:PLAN-015 需求:R-016(已定稿,2026-09-07Q1-Q7 老师拍板)
## 目标描述
两个策略 tab(做T / 网格超市)title 旁添加「历史持仓」功能:① 显示/隐藏已清仓历史持仓的开关;② 显示历史时默认展示近一周清仓的持仓,并提供 近 1 个月 / 近 3 个月 / 近半年 / 近 1 年 范围选项。历史持仓行复用 R-010 行展开查看关联交易记录——补上「清仓即失联」的操作追溯缺口(核心目标:追溯该持仓相关的历史操作,老师 2026-09-07 定调)。
## 目标分解
1. **存储查询**SqliteStore.getHoldingsHistory):closed_at 非空 + closed_at ≥ sinceMs + strategy_id 过滤,按 closed_at DESC;只读新增,不动任何写路径(closeHolding 置 shares=0 语义维持,Q2 老师拍板「显示 0 就好」);
2. **门面透传**DataStore.getHoldingsHistory);
3. **API 端点**strategy-holdings/history):strategyId 必填 + range ∈ week|month|quarter|halfYear|year(服务端换算 sinceMs;week 为前端默认但服务端不设隐式默认,参数缺失报错);
4. **前端**StrategyTab):头部按钮区加「历史持仓」开关 + 范围下拉(开启时显示,默认 week);懒加载 + 范围切换重拉 + 关闭隐藏(缓存保留);历史行追加在当前持仓行后(清仓时间降序)、灰显 + 「已清仓」徽标、标题计数不含历史行;历史行字段 = 代码/名称(关联委托 name 兜底)/份额(0)/清仓时间/持有天数,行情列 —,自定义字段列只读;行展开复用 R-010;
5. **回归**scripts/test-r016-history.mjsODL_TEST_DATA_DIR 临时目录)+ typecheck + build + 存量回归三脚本。
## 二轮补充(2026-09-07 验收前追加:今日已清仓默认层,Q8-Q9 老师拍板)
- **补充诉求**:当天(本地自然日 00:00 起)已清仓的持仓**默认显示**;「历史持仓」开关只控制**今天之前**的历史——历史持仓关 ≠ 当天已清仓也被藏起(首轮「进 tab 默认只看当前」使当天清仓行当场不可见,补上该缺口);
- **实现增量**:① 服务端 range='today' 特判本地自然日零点(不落回溯毫秒档);② 前端 rowsToRender 分两层——L1 今日已清仓恒显示(缓存键 'today',随每次持仓重载刷新)+ L2 历史范围层受开关控制,层间按 holdingId 去重;③ 视觉/计数维持首轮(灰显+徽标、计数不含已清仓行);
- 影响面:只动只读查询 + 前端渲染合成;**零写路径变更**(closeHolding 语义仍不动);
- 回归:test-r016 追加 range=today 自然日边界用例(24/24)。
## 对老师/主理人的配合需求
- 实现完成后需老师真实环境人工验收(开关/范围切换/历史行展示/展开复盘/与真实清仓数据对照);
- 二轮补充后重点验收:**「历史持仓」关时当天清仓的持仓仍显示**、「历史持仓」开时今天之前的行按范围显示、层间无重复行;
- 验收通过后本迭代标记「验收通过」,R-016 进入归档流程。
@@ -0,0 +1,38 @@
# 验收标准:14-策略tab历史持仓展示
> 迭代编号:14 | 依据:PLAN-015 验收要点 + R-016
## 验收标准线
1. **自动化**typecheck + build 通过;test-r016-history.mjs 全绿(清仓转历史可查 / 5 档范围过滤与边界 / closed_at DESC 排序 / 策略隔离 / 未清仓行不出现 / 参数校验 / name 兜底);存量回归 test-position-sync、test-r013、test-quote-sync 全绿;
2. **入口与默认态**:做T / 网格超市两个 tab title 旁出现「历史持仓」开关;默认关闭 = 与现状完全一致(进 tab 无历史行);开启后默认范围 = 近一周;**全部持仓 tab 无此功能**;
3. **范围筛选**:5 档(近一周/近1个月/近3个月/近半年/近1年)切换即重拉;用 2026-09-07 大连热电真实数据验证——holding_id=15(做T,当日 09:32:35 幽灵清仓)在近一周内可见、切近 1 个月仍可见;
4. **历史行展示**:灰显 + 「已清仓 YYYY-MM-DD」徽标;清仓时间/持有天数正确;份额显示 0(Q2);行情列(现价/涨幅/涨停跌停/今开/最高/成本/最后成交价)显示 —;名称有则显示、无则 —;标题计数(N 只)不含历史行;当前持仓在前、历史行按清仓时间降序在后;
5. **展开复盘**:历史行展开可见该 holding 关联委托(大连热电 09:31:26 卖出 1000 股 @7.42 应出现);自定义字段历史行只读(无编辑入口);
6. **零破坏**strategy-positions / unallocated / summary 等现有端点行为不变;当前持仓行展示与现状一致;无任何写路径变更(closeHolding 语义不变)。
## 验收方法
- 自动化项 AI 执行出具结果;
- 2~5 老师真实环境人工验收:重载插件 → 两个策略 tab 开关历史持仓 → 切换范围 → 对照大连热电当日清仓数据 → 展开历史行核对关联委托;
- 全部通过后:迭代 14 标记「验收通过」,R-016 归档 已完成/。
## 验收目标
- 6 条验收线通过,迭代 14 标记「验收通过」,R-016 更新实现状态(已实现)并归档。
## 二轮补充验收线(2026-09-07 Q8-Q9 追加:今日已清仓默认层)
7. **今日已清仓默认显示(核心)**:「历史持仓」**关**时,策略 tab 仍显示 当前持仓 + 今天(本地自然日 00:00 起)已清仓的持仓行;当天卖出/幽灵清仓的持仓行不因开关关闭而消失;
8. **开关只控更早历史**:「历史持仓」开时,追加显示今天之前各范围(近一周/近1个月/近3个月/近半年/近1年)已清仓行——近一周范围**不含**今天已显示的行(层间按 holdingId 去重,无重复行);
9. **边界与展示**:昨天 23:59:59 清仓的行在开关关时不可见(属更早历史);今日行沿用灰显 + 已清仓徽标;标题计数(N 只)仍不含已清仓行;行情列 — / 份额 0 / 只读字段 / 行展开复用 R-010 均维持首轮;
10. **自动化**test-r016 新增 range=today 用例全绿(24/24);typecheck + build + 存量回归(test-position-sync / test-r013 / test-quote-sync)全绿。
## 验收方法(二轮补充)
- 自动化项 AI 执行出具结果(已执行:test-r016 24/24、position-sync 35、r013 21、quote-sync 27、typecheck 0、r017 12);
- 老师真实环境人工验收:刷新页面 → 在不开启「历史持仓」的情况下核对当天已清仓持仓仍显示 → 开启开关核对今天之前的行按范围出现且与今日行无重复 → 对照大连热电当日清仓数据。
## 验收目标
- 首轮 6 条 + 二轮 4 条验收线通过后,迭代 14 标记「验收通过」,R-016 归档 已完成/。
@@ -0,0 +1,29 @@
# 技术实现方案:15-归属候选纳入清仓持仓
> 迭代编号:15 依据:PLAN-016 + R-017
## 1. 存储层(getAttributionCandidates 扩展,门面透传不变)
```sql
SELECT holding_id, strategy_id, shares, closed_at FROM strategy_holdings
WHERE code=? AND (closed_at IS NULL OR closed_at >= ?) -- ? = Date.now() - 7d
ORDER BY (closed_at IS NULL) DESC, shares DESC, closed_at DESC
```
- 返回行新增 closed: boolean / closedAt: number|null(消费方:api strategies.js 同方法直接受益——注意 attribution-candidates 端点在 trades.js,调 DataStore.getTradeAttributionCandidates 透传,零改动);
- 窗口常量 7 * 86400000(与 R-016 默认范围一致)。
## 2. UITradeRecordsTab.jsx AttributionSelect 重构)
- 锚点键:currentKey = order.holdingId != null ? String(order.holdingId) : ''holdingId 是 R-009 定稿的归属锚点;strategyId 在同策略多轮持仓时会撞值);
- handleSelect:按 String(holdingId) 找候选 → setAttribution(orderId, code, cand.strategyId, cand.holdingId)__unassigned__ → 双 null
- 占位选项:已归属但候选缺失(清仓超 7 天)→ 「当前归属({strategyId})」,value=currentKey,防 select 悬空显示第一项误导;
- 清仓候选文本后缀「(已清仓 MM-DD)」(fmtClosed 本地 helper);select maxWidth 120→150。
## 3. 数据修复记录(一次性,非功能)
2026-09-07 大连热电卖出委托 645001623 经 live set-attribution 通道回填 strategy_id=manual-t / holding_id=15curl 实测 ok:trueorders/by-holding 回读确认)。当日归属曾被清为未关联的原因:候选下拉无做T项时老师误触「未关联」入口——本迭代修复后此路径消除。
## 4. 验证链
test-r017-candidates.mjs 12 断言 + typecheck + build + 存量回归(r016 19 / position-sync 35 / r013 21 / quote-sync 27)。
@@ -0,0 +1,36 @@
# 迭代复盘:15-归属候选纳入清仓持仓
> 复盘日期:2026-09-07 | 迭代状态:**已实施,待老师人工验收**
> 关联需求:R-017 关联计划:PLAN-016
## 结果
迭代 15 达成:归属候选 = 当前持仓 + 近 7 天清仓持仓(closed 标记 + 「已清仓 MM-DD」后缀),下拉锚点改 holdingId 键控(同策略多轮持仓不撞值)+ 「当前归属」占位防悬空;清仓后当日委托可补关联。另:当日大连热电卖出委托(645001623)的归属已经 set-attribution 通道修复回 manual-t/holding 15(一次性数据修复,非功能)。
## 过程事实
1. **数据取证先行**:老师报告「无法关联」时,DB 真相 = 委托归属已被清为 null/null(非从未设置)+ 候选接口只剩网格超市——「归属丢失」与「候选缺失」两个问题一次取证分清;
2. **当日修复走既有通道**set-attribution 本就无候选限制(Q4 可随时改语义),curl 修复 + 回读验证,不新造工具;
3. **实现**SqliteStore.getAttributionCandidates WHERE/ORDER 扩展 + closed/closedAt 字段(门面/api 零改动透传);AttributionSelect 从 strategyId 键控重构为 holdingId 键控 + 占位 + 后缀;
4. **验证**test-r017 12/12typecheck 通过;存量回归 r016 19/19、position-sync 35/35、r013 21/21、quote-sync 27/27build 通过。
## 经验教训(复盘沉淀)
### 1. 「关联」的锚点键要在多轮持仓出现时重新审视
- R-009 时代下拉 value 用 strategyId(一码一策略一行时无碍);R-016 历史行引入后同码可同时存在「当前行 + 历史行」、同策略可有多轮清仓——strategyId 键撞值成为真实 bug 面,holdingId 才是归属锚点(R-009 表结构早已如此,UI 层滞后);
- 沉淀:外键语义(表里存什么)和 UI 键(下拉传什么)要对齐审查,表结构升级时 UI 不自动跟上。
### 2. 「数据问题」与「能力缺失」要分层处置
- 老师当下要的是「这笔委托关联回去」(数据问题)——走既有 API 通道立即修复,不等迭代;「以后不再发生」(能力缺失)——立项 R-017 走流程。两层都做但互不阻塞。
### 3. 候选缺失时的 UI 应该显示「当前归属」而不是悬空
- select value 不在任何 option 里时浏览器表现不可控(显示空白或第一项),第一项恰是「未关联」时极易诱导误操作(本次归属被清的可能路径);
- 沉淀:受控 select 的 value 必须保证总有对应 option——数据缺口用占位 option 补,不让浏览器自作主张。
## 遗留/后续
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-需求池/已完成/。
@@ -0,0 +1,19 @@
# 迭代目标:15-归属候选纳入清仓持仓
> 迭代编号:15 | 创建:2026-09-07 | 状态:已实施,待验收
> 依据计划:PLAN-016 需求:R-017(已定稿,2026-09-07
## 目标描述
交易记录 tab 的归属候选(orders/attribution-candidates)纳入近 7 天清仓的持仓(closed 标记),下拉锚点改 holdingId 键控——修复「清仓后当日委托无法补关联」(2026-09-07 大连热电做T实际发生:盘中归属被清后下拉已无手动做T候选)。
## 目标分解
1. **存储**SqliteStore.getAttributionCandidates):WHERE 扩展为 closed_at IS NULL OR closed_at ≥ 7 天前;返回行附 closed/closedAt;排序 当前持仓(shares DESC) 在前 → 清仓行 closed_at DESC
2. **UI**AttributionSelect):value 改 String(holdingId);点选按 holdingId 找候选取 strategyId+holdingId 写入;清仓选项加「(已清仓 MM-DD)」后缀;已归属但候选缺失时显示「当前归属」占位(防 select 值悬空);title 提示更新;
3. **回归**scripts/test-r017-candidates.mjs12 断言:候选构成/标记/窗口边界/排序/隔离/端点透传)。
## 对老师/主理人的配合需求
- 重启 DSH web 进程(服务端代码更新)+ 刷新页面后人工验收;
- 验收通过后迭代 15 标记「验收通过」,R-017 归档。
@@ -0,0 +1,20 @@
# 验收标准:15-归属候选纳入清仓持仓
> 迭代编号:15 | 依据:PLAN-016 验收要点 + R-017
## 验收标准线
1. **自动化**typecheck + build 通过;test-r017-candidates.mjs 12/12;存量回归 r016 / position-sync / r013 / quote-sync 全绿;
2. **候选构成**(真实环境):交易记录 tab 大连热电今日卖出委托的归属下拉 = 网格超市(当前持仓)+ 手动做T(已清仓 09-07)两项;
3. **补关联**:点选「手动做T(已清仓 09-07)」→ 保存成功提示 → 行内归属显示手动做T且下拉保持选中;做T策略 tab 历史持仓行展开可见该笔卖出(holding 15 关联);
4. **可逆**:切回「未关联」→ 双 null 持久化;再切回做T → 恢复;
5. **占位**:归属存在但候选缺失的场景显示「当前归属(…)」占位(可用超 7 天清仓行验证,或观察现有数据)。
## 验收方法
- 自动化项 AI 执行出具结果;2~5 老师重启 DSH web + 刷新页面人工验收;
- 全部通过后迭代 15 标记「验收通过」,R-017 归档 已完成/。
## 验收目标
- 5 条验收线通过,迭代 15 标记「验收通过」,R-017 更新实现状态并归档。
@@ -0,0 +1,141 @@
# 技术实现方案:16-交易关联驱动持仓份额动态调整
> 迭代编号:16 依据:PLAN-017 + R-018(已定稿)
## 0. 核心不变量(数据域分界,R-018 §〇)
- PositionSync(含幽灵清仓)**只写对账单域**(进程内快照),永不写 strategy_holdings
- strategy_holdings 写操作只来自:**归属服务(交易关联)** + **手动份额操作**add-shares/remove-shares/move-all-shares/策略删除清空,现状保留);
- 账本行转历史(closed_at)唯一途径:卖出段关联把份额减到 0(closeHolding);**幽灵清仓代码删除**。
## 1. 存储地基(阶段B
### 1.1 strategy_holdings 增列(幂等 ALTER,沿用 _ensureXxxColumn 模式)
```sql
ALTER TABLE strategy_holdings ADD COLUMN void_at INTEGER; -- 作废时间(NULL=有效;非 NULL=建仓被撤=从未成立)
ALTER TABLE strategy_holdings ADD COLUMN closed_shares REAL; -- 清仓前份额快照(closeHolding 写入;供撤卖出段恢复活动用)
```
- 活动判定收敛:`closed_at IS NULL AND void_at IS NULL`
- 唯一索引重建(活动唯一收窄至两态皆空):
```sql
DROP INDEX IF EXISTS idx_active_holding;
CREATE UNIQUE INDEX IF NOT EXISTS idx_active_holding
ON strategy_holdings (strategy_id, code) WHERE closed_at IS NULL AND void_at IS NULL;
```
- closeHolding 变更:置 shares=0 + closed_at=now + **closed_shares=清仓前份额**shares 列仍置 0,R-016 Q2 展示语义不变);
- 新增 voidHolding(strategyId, code):置 shares=0 + void_at=now(不改 closed_at);作废行不进 R-016 历史层(getHoldingsHistory 过滤 void_at IS NULL);
- _mapHolding 增 voidAt / closedShares 字段(读方法 select 补列)。
### 1.2 归属分段表 trade_order_attributions
```sql
CREATE TABLE IF NOT EXISTS trade_order_attributions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id TEXT NOT NULL, -- → trade_orders.order_id
strategy_id TEXT NOT NULL, -- 段归属策略(冗余,过滤快)
holding_id INTEGER NOT NULL, -- 段锚点持仓(R-010 展开用)
code TEXT NOT NULL, -- 冗余(by-holding 反查/防呆)
direction TEXT NOT NULL, -- buy/sell 冗余(撤段判向)
volume REAL NOT NULL, -- 该段已成交量(>0;总额 ≤ order.traded_volume
created_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_attr_order_strategy ON trade_order_attributions (order_id, strategy_id);
CREATE INDEX IF NOT EXISTS idx_attr_holding ON trade_order_attributions (holding_id);
CREATE INDEX IF NOT EXISTS idx_attr_strategy ON trade_order_attributions (strategy_id);
```
- **真相源 = 段表**trade_orders.strategy_id/holding_id 两列**退役为冗余**(不再作为查询/过滤源;保留列不删,防外部脚本 break;写入不再维护);既有单组归属迁移为第一段(volume=该单 traded_volume,幂等:段表空且 order 有归属才迁);
- 撤销语义支持 R-010 by-holding、R-009 history 策略过滤全部改经段表 join。
## 2. 归属服务(阶段C,新增 src/trades/AttributionService.js
> 命名遵守技术约束-016src/trades/ 交易域)。接口同步(node:sqlite DatabaseSync);归属服务持 `storage.sqlite` + DataStore 门面方法。
### 2.1 段级 apply(一段关联)
输入:{ orderId, code, direction, volume(已成交量) } + 目标 strategyId。判定动作表(R-018 §3.3):
| direction | strategy 活动仓(closed/void 均空) | 动作 |
|---|---|---|
| buy | 有 | addShares(strategyId, code, volume) → holding 取活动仓 |
| buy | 无 | openHolding(strategyId, code, volume) → 新仓(不复活历史/作废行) |
| sell | 有 且 shares ≥ volume | reduceShares;减后 0 → closeHolding |
| sell | 有 且 shares < volume | **拒绝**(报错 code='segment-exceeds',由 UI 自动截断后再提交;服务端不做隐式截断——避免「半生效」状态与部分关联提示脱节) |
| sell | 无 | 拒绝(code='no-active-holding',关联失败提示,R1 |
成功 → 写段表(order_id,strategy_id,holding_id,code,direction,volume,now)。买入建仓时 holding_id=openHolding 返回;买入加仓/卖出取活动仓 holding_id。
### 2.2 段级 revoke(撤一段)
输入:{ order_id, strategy_id }(唯一键)。逆操作(R3,粒度=段):
- **撤 buy 段**:查段 → holding。
- holding 活动(closed/void 空):
- shares ≥ 段量 → reduceShares(段量)**减后 shares=0** → voidHolding(作废,非 closeHoldingR3-1/R6 语义:建仓被撤=从未成立,无真实卖出→作废,不产生假清仓历史);
- shares < 段量(说明该段之后已有卖出段作用于同一 holding)→ **拒绝**code='segment-order-conflict',提示先撤更晚的段);
- holding 已 closed/void → 该 buy 段本就不应撤销成功(段应随原操作撤)→ 拒绝;
- **撤 sell 段**:查段 → holding。
- holding 仍活动(该 sell 只是减仓)→ addShares(段量) 加回;
- holding 已 closed 且 **closed_shares == 段量**(该段就是清仓段,且其后无新仓)→ **恢复活动**closed_at=NULL、shares=closed_shares、closed_shares=NULL
- holding 已 closed 且 closed_shares > 段量(部分减仓后另段清仓)→ 拒绝(先撤更晚段);已 closed 且同 code 新活动仓已存在(close 后又建仓)→ 拒绝(无法恢复,避免撞唯一索引;R-018 §3.4 边界);
- 成功后删段表行。
> 实现约束(写入 R-018 边界):**段撤销按「holding 内逆序」支持**;跨 holding 的段(拆单分给多策略)互相独立可任意撤(老师 R7 场景)。乱序撤同 holding 的段返回 segment-order-conflict,提示先撤更晚段——宁可拒绝不写错账(账本正确性 > 操作便利)。
### 2.3 全量替换 setSegments(改归属 = 撤旧段 + 加新段,原子)
输入:{ orderId, segments: [{ strategyId, volume }] }volume>0;总额 ≤ order.traded_volume,允许 < = 部分关联)。
流程(单事务):
1. 读 ordertrade_orderscode/direction/traded_volume);读段表现状;
2. diff:将被删除段逐个 revoke(同 2.2 冲突规则)→ 冲突则整体回滚并报错;
3. 新增/变更段逐个 apply(同 2.1)→ 任一失败整体回滚;
4. COMMIT 后返回 { segments: 当前全部段 }order 附加)。
> 事务:SqliteStore 暴露 `runInTransaction(fn)`BEGIN/COMMIT/ROLLBACK 包裹;同步 API 直接 exec)。
### 2.4 查询
- getOrderAttributions(orderId):段列表(含 strategyName 由 settings 附名);
- orders 列表附加归属:批量查段表按 order 聚合;
- getOrdersByHolding(holdingId):改经段表 join trade_ordersR-010 展示);
- 历史策略过滤(getOrderHistory/getFillHistory strategyId):改经段表(fill→order→段表 strategy_id 集合;`__unassigned__` = 无任何段);
- getHoldingsHistory:过滤 void_at IS NULL(作废不进历史层)+ closed 逻辑不变。
## 3. 候选与 API(阶段D
- **orders/attribution-candidates 退役 R-017 7 天语义** → 改 `orders/attribution-targets`{ code } → 全部策略(settings strategies+ 每策略该 code 当前活动份额 activityShares0=无仓)+ 该 order 已有关联段数提示(供 UI「指向后判定」的前置展示;**不预筛**,指向后由服务端判定)。策略级(Q1),holdingId 不再作下拉键;
- **orders/attribution-set**{ orderId, segments } → 归属服务 setSegments(替代 set-attributionset-attribution 端点删除或保留为单段包装——删,避免双写源混乱;既有数据迁移已覆盖);
- **orders/attribution-segments**{ orderId } → 读当前段(UI 展开/编辑用);
- 今日 orders 端点:order 行附 `segments: [{strategyId,strategyName,holdingId,volume}]`(取代原单组 strategyId/holdingId 附加;兼容字段保留首段值)。
## 4. UI 份额分配器(阶段ETradeRecordsTab
- AttributionSelect 退役 → **AttributionAllocator**
- 头部:委托已成交量 / 已关联 Σ / 待分配余额(醒目色);
- 一段 = 策略下拉(attribution-targets+ 份额输入(默认=余额全量,可改小;sell 时 max=min(余额, activityShares) 自动钳制)+ 「添加段」;
- 段列表(每段:策略名 + 量 + 「撤」按钮 → 调 attribution-set 去掉该段,即全量重交剩余段;或独立 revoke 端点——为原子性走 attribution-set 全量重交);
- 已关联/部分关联/未关联三态展示 + 保存即生效(每步操作即时 setSegments);
- 余额为 0 全关联提示;余额 >0 显「未分配剩余」提示(允许部分关联);
- 撤销冲突(segment-order-conflict)→ toast 展示后端提示。
- TradeRecordsTab order 行「归属」列 = 段 chips(点开进分配器)。
## 5. 分界 + 软提示(阶段A)
- PositionSync:删除 `_autoCloseGhosts`/ghostRounds/_ghostMiss/_fetchAccountId 调用链、stats.closedGhosts;类注释更新(只同步对账单快照);保留空快照双重确认与读穿透(对账单域行为不变)。test-position-sync.mjs 相应改断言(幽灵清仓→不再写账本:mock 断言 storage.closeHolding 未被调用);
- **漏关联软提示(T3)**:新端点 `positions/orphan-hints`:账本活动行(closed/void 均空)∩ QMT 快照无此 code → 返回 [{strategyId,code,name,shares,holdingId}]**只读**,不动账本);交易记录 tab 顶部横幅提示(N 个持仓对账单已无此票,请去关联卖出单或移出)。阈值:全部孤儿都列(不设 7 天——账本无快照即提示,软提示不自动写)。
## 6. 涉及文件
- src/position/PositionSync.js(删幽灵清仓)、scripts/test-position-sync.mjs(改断言)
- src/storage/SqliteStore.js(增列/索引重建/voidHolding/close 快照/段表 CRUD/runInTransaction/查询改段表/迁移)、src/storage/DataStore.js(门面透传)
- src/trades/AttributionService.js(新增:apply/revoke/setSegments
- src/api/trades.jsattribution-targets/set/segmentsorders 附加段)、src/api/strategies.jsstrategy-holdings/history 过滤 void
- src/client/views/TradeRecordsTab.jsxAttributionAllocator 替换 AttributionSelect
- scripts/test-r018-attribution.mjs(新增回归)
- 设计约束:技术约束-017/产品约束-011/数据存储设计.md §11 变更记录 + §12 追加
## 7. 验证链
test-r018-attribution.mjs(分界/动作表/撤段逆序/迁移/软提示/API+ test-position-sync 更新 + 存量回归(r017 改/ r016 / r013 / quote-sync+ typecheck + build。
@@ -0,0 +1,41 @@
# 迭代复盘:16-交易关联驱动持仓份额动态调整
> 复盘日期:2026-09-08 迭代状态:**验收通过(2026-09-08)** —— 口径:自动化为主体 + 只读走查 + 真实使用顺带确认(老师拍板,见验收标准.md)
> 关联需求:R-018(已定稿)| 关联计划:PLAN-017
## 结果
迭代 16 达成(R-018 全范围):**数据域分界落地**——幽灵清仓退役(PositionSync 不再写 strategy_holdings 账本);**归属升级为账本写操作**——策略级候选 + 统一份额分配器(委托拆 0..N 段 × 策略 × 量,余额默认全量可改小、允许部分关联)+ 买卖联动账本(买入加仓/建新仓不复活旧行、卖出减仓/归零清仓、仓不足拒绝、无仓拒绝)+ 撤段逆操作(撤建仓买入=作废第三态不产生假清仓历史、撤加仓=减回归零作废、撤卖出=恢复活动仓+份额加回,粒度=段、改归属原子)+ 归属分段存储(trade_order_attributions,真相源)+ R-017 7 天候选退役(holdingId 键控/占位安全逻辑由策略级 targets 承担)+ 漏关联软提示(只读)。存量归属(trade_orders 单列)自动迁移为段表第一段。
验证:新回归 test-r018-attribution 29/29test-position-sync 29/29(幽灵退役断言重写);存量回归 r016 24 / r017 12(语义修订)/ r013 21 / r009 14 / r009-api 13(端点更新)/ r009-sync 8 / r009-normalize 11 / r011-api 12 / r011-tabs 23 / quote-sync 27 全绿;typecheck + build 通过。
## 过程事实
1. **文档链先行**:讨论收敛 → R-018 定稿 → PLAN-017 → 迭代 16 三件套 → 设计约束变更(技术约束-017 / 产品约束-011 / 数据存储设计 §11-13);
2. **阶段A 分界**PositionSync 删 `_autoCloseGhosts`/ghostRounds/账户守卫链/stats.closedGhosts,只保留对账单快照同步(含空快照双重确认与读穿透);软提示新端点 positions/orphan-hints(账本活动行 ∩ 快照缺失 code,只读);
3. **阶段B 存储**strategy_holdings 增 void_at/closed_shares(幂等 ALTER + 活动唯一索引重建收窄至两态皆空——索引创建移出 SCHEMA_SQL 到补列之后,防旧库无列建索引崩溃,quote-sync 回归暴露);closeHolding 记 closed_sharesshares 仍置 0R-016 Q2 展示语义不变);voidHolding/reopenHolding/getHoldingById;新建 trade_order_attributions 段表;存量归属迁移段表第一段(幂等);
4. **阶段C 归属服务**src/trades/AttributionService.js 新增):apply(动作表判定)/ revoke(撤段逆操作,同 holding 逆序、跨 holding 独立)/ setSegments(全量替换、单事务原子);setOrderAttribution 兼容封装 = 替换语义(清旧段设新段);
5. **阶段D API**attribution-targets(策略级 + activityShares/ attribution-set / attribution-segmentsorders 与 trades/history 返回附加 segmentsR-017 attribution-candidates/set-attribution 端点退役;strategy-holdings/history name 兜底改段表 join
6. **阶段E UI**AttributionSelect → AttributionCell + AttributionEditor(余额头 + 段 chips 可撤 + 策略下拉/量输入/截断提示 + 全部撤除/完成);交易记录 tab 顶部漏关联软提示横幅;
7. **阶段F 回归**:新脚本 test-r018-attribution;更新 position-sync(幽灵退役断言)、r0177 天候选退役语义)、r009-api(新端点)、r016-history(段表造归属)。
## 经验教训(复盘沉淀)
### 1. 语义迁移要逐调用点核对 + 用旧回归脚本当哨兵
R-018 把「归属真相源」从 trade_orders 单列迁到段表,牵动 r009/r016/r017/verify-real 一堆脚本与 strategies handler。靠跑存量回归暴露了两类坑:① quote-sync 的旧结构库建库路径在 SCHEMA_SQL 含新列索引时崩溃(索引引用尚不存在的列)——列迁移与索引重建必须同序;② 测试里「UPDATE trade_orders SET holding_id」直改冗余列的方式在真相源切换后不再生效(r016 name 兜底失败)——测试自身要改经段表造数据。沉淀:**存储层语义变更时,先跑全部存量回归锁定断裂点,再逐点核对(skill §5 语义迁移核对)**。
### 2. 「作废」与「清仓」是两个生命周期终态,不能用同一标记
撤建仓买入段若走 closeHolding 会造出假的「已清仓」历史行(R-016 历史层会显示、统计会含)——老师 R3-1 拍板新增 void 第三态。沉淀:**「从未成立」与「曾经成立后卖出」在账本语义上必须区分**,UI 历史层只认 close。
### 3. 服务端不做隐式截断,让 UI 截断并提示余额
卖出段 > 活动仓时服务端直接拒绝(segment-exceeds),由 UI 自动截为可容纳量并醒目提示「余额保留」——避免服务端悄悄改用户意图造成「半生效」状态与提示脱节。沉淀:**涉及钱/仓位的写接口要显式,宁可报错回滚也不要隐式修正**。
### 4. 数据域分界用一句话可执行规则落地
「幽灵清仓是一个同步机制,爪子不许伸到账本」落到代码 = PositionSync 构造里不再持有 storage 写能力路径 + 回归断言 closeHolding 零调用。沉淀:**架构性拍板要落到「哪个模块能写哪张表」的显式约束,并用测试钉死**。
## 遗留/后续
1. **r013-columns 测试漂移(既有,非本迭代引入)**:断言「默认 8 列」但 COLUMN_META 自 R-015 加 4 行情列后实际 14 列(4 默认隐藏)——R-015 后未同步,建议另立小修(列显隐功能未破坏,纯测试预期过期);
2. **R-016/R-017 文档同步**:R-017 迭代 15 复盘所述「候选窗口」语义已被 R-018 取代(7 天候选退役),R-017 需求文档待标注退役(R-018 需求文档已含 T2 决策,归档时统一);
3. **幽灵清仓退役的旧数据影响**:存量库中过去被幽灵清仓转历史的行保持 closed 不变(不回溯);新语义下账本只随交易关联/手动操作走;
4. **UI 待老师人工验收项**:多段拆单交互、撤段恢复活动、作废行不显历史层、软提示横幅、策略持仓 tab 不因 QMT 卖光自动消失(须关联卖出单才 close)。
@@ -0,0 +1,29 @@
# 迭代目标:16-交易关联驱动持仓份额动态调整
> 迭代编号:16 | 创建:2026-09-08 状态:实施中
> 依据计划:PLAN-017 需求:R-018(已定稿,2026-09-08 老师逐项拍板)
## 目标描述
把「交易归属」从纯标签升级为**账本写操作**,落实 R-018 的**数据域分界**:幽灵清仓等同步机制只作用于「全部持仓」对账单域,不再写 strategy_holdings 账本;策略持仓份额只由**交易关联分配器 + 手动份额操作**驱动。
范围:策略级候选 + 统一份额分配器(委托已成交量拆 0..N 段 × 策略)+ 买卖联动账本(买入加仓/建新仓、卖出减仓/归零清仓、仓不足截断续分、无仓失败提示)+ 撤段逆操作(撤建仓买入=作废第三态、撤加仓=减回归零作废、撤卖出=恢复活动+份额加回,粒度=段)+ 归属分段存储(trade_order_attributions+ R-017 7 天候选退役 + 漏关联软提示 + PositionSync 删幽灵清仓逻辑。
## 目标分解(阶段)
1. **阶段A 数据域分界**PositionSync 删除幽灵清仓账本写(_autoCloseGhosts 不再 closeHolding),只保留对账单快照同步;漏关联软提示检测基础;
2. **阶段B 存储地基**strategy_holdings 增 `void_at` + `closed_shares`(幂等补列,close 时记录清仓前份额供撤段恢复);新建 `trade_order_attributions` 段表;trade_orders 原两列退役为冗余(读取改经段表);存量单组归属迁移为第一段;
3. **阶段C 归属服务**apply/revoke 判定动作表(买/卖 × 活动仓状态)+ 撤段逆操作 + 原子性(同事务撤旧段+加新段);
4. **阶段D 候选与 API**orders/attribution-candidates 回归「策略级」(R-017 7 天候选退役;holdingId 键控/占位安全逻辑保留);orders/attribution-set(全量替换式)+ attribution-segments(读);
5. **阶段E UI 分配器**TradeRecordsTab 归属入口改策略下拉 + 段量 + 段列表(每段可撤);
6. **阶段F 回归**:新回归脚本(分界/分配器动作/撤段/迁移/软提示)+ 存量回归 + typecheck + build。
## 讨论过程(摘要)
2026-09-08 讨论收敛全部决策:Q1 策略级候选 / Q2 拆段 / Q3 保留建仓逻辑(份额基于 QMT 持仓创建)/ Q4 已成交量 / Q5 新买入=新仓不复活;R1 无仓卖出关联失败 / R2 分段存储 / R3 撤段逆操作(作废第三态+恢复活动)/ R4 统一分配器(不分子买卖、余额默认全量可改小、允许部分关联)/ R5 买入可拆段 / R6 撤加仓归零作废 / R7 段级取消;数据域分界(幽灵清仓不写账本);T1 关闭(无 30s 竞态);T2 R-017 退役;T3 漏关联软提示。
## 对老师/主理人的配合需求
- 重启 DSH web 进程(服务端代码更新)+ 刷新页面人工验收;
- 验收通过后迭代 16 标记「验收通过」,R-018 归档 + 需求池索引更新;
- 迭代 12(R-014 幽灵清仓条款)、迭代 15(R-017 退役)为既有功能变更,人工验收时一并确认新行为符合预期。
@@ -0,0 +1,24 @@
# 验收标准:16-交易关联驱动持仓份额动态调整
> 迭代编号:16 | 依据:PLAN-017 验收要点 + R-018
> 验收状态:**已通过(2026-09-08)** —— 口径修订:自动化为主体 + 只读走查;实盘操作类验证项降为「真实使用顺带确认」(老师拍板:实盘不可为验收而操作,边用边发现问题再修)
## 验收标准线
1. **数据域分界(阶段A**PositionSync 不再写 strategy_holdings——幽灵清仓逻辑删除,同步只维护对账单内存快照;回归脚本断言 storage.closeHolding 不被 PositionSync 调用;
2. **存储地基(阶段B**strategy_holdings 增 void_at/closed_shares 列(幂等);活动唯一索引收窄至两态皆空;closeHolding 记录 closed_sharesshares 仍置 0);voidHolding 置 void_at;段表 trade_order_attributions 建表 + 存量单组归属迁移为第一段(幂等);
3. **份额分配器动作表(阶段C**:买入段→加仓/建新仓(不复活旧行);卖出段→减仓/归零清仓、仓不足拒绝(segment-exceeds)、无仓拒绝(no-active-holding);撤买入段→减回/归零作废(void,不产生假清仓历史);撤卖出段→加回/恢复活动仓(closed_shares 恢复);跨 holding 段可任意撤、同 holding 乱序撤拒绝(segment-order-conflict)——全量替换 setSegments 原子(失败整体回滚);
4. **API(阶段D**attribution-targets(策略级 + activityShares);attribution-setsegments 全量替换);attribution-segments(读);今日 orders / trades/history 附加 segmentsR-017 7 天候选退役(attribution-candidates/set-attribution 端点退役,候选改由 targets 承担);
5. **软提示(T3**positions/orphan-hints 返回账本活动行 ∩ QMT 快照缺失的 code(只读不写);
6. **自动化**test-r018-attribution.mjs 29/29 全绿 + test-position-sync 更新后 29/29 + 存量回归(r016 24 / r017 12 / r013 21 / r009 14 / r009-api 13 / r009-sync 8 / r009-normalize 11 / r011-api 12 / r011-tabs 23 / quote-sync 27)全绿 + typecheck + build 通过。
## 验收方法(2026-09-08 修订,老师拍板)
- **主体 = 自动化回归**:上述标准线 1-6 由回归脚本 + typecheck + build 机器验证(AI 已执行并出具结果);实盘为真实资金,不可为验收做买卖操作;
- **人工只读走查**(老师 2026-09-08 已重启并做一次关联操作确认 OK):分配器界面(余额头/策略下拉带仓数/段 chips)显示正常;仅只读观察,不做实盘写操作;
- **顺带确认项(不主动验收,真实使用自然发生时观察)**:卖出全清后策略持仓行不再 30s 自动消失(幽灵退役);顶部漏关联软提示出现;撤段恢复/作废行为——边用边发现问题再修。
## 验收目标
- 标准线 1-6(自动化)通过 → 迭代 16 标记「验收通过」;R-018 归档 已完成/ + 需求池索引实现状态更新;
- 实盘行为类验证项以「真实使用顺带确认」持续跟进(老师口径:先标记成功,后面边用边发现问题再修)。
@@ -0,0 +1,76 @@
# 迭代 17 UI 交互设计:策略会话(讨论依据控件)
> 依据:R-021(合并原 R-019+R-021,已定稿)+ 产品逻辑设计(同迭代)| 日期:2026-09-08 | 状态:**设计稿(D 问题待拍板)**
> 视觉基线:UI约束-004--dsw-* token/ UI约束-001(头部 chip 形态)/ 现有 QmtConnectionChip 交互模式;复用 Toast、外点关闭、RPC(useRpc) 机制。
## 1. 交互总览(一句话)
对话视图输入区上方有一枚**「讨论依据」chip**(仿 DSH workspace chip:未选=「讨论依据:请选择 ▾」;已选=「依据:做T ▾」+刷新);选择策略 → 服务端生成数据摘要 → 注入并在对话流顶部出现一条**数据依据条目**,之后即可基于该策略数据讨论。
## 2. 控件布局(示意)
```
┌─ 对话视图 ──────────────────────────────┐
│ …对话消息流… │
│ [数据依据条 ▾ 做T · 快照 14:32 · 3仓/42笔] ← 选后注入显示(可展开全文)
│ ─────────────────────────────────────── │
│ [依据:做T ▾] [↻] │ ← accessory 行(输入框上方左侧)
│ ┌ 输入框(composer)────────────────┐ │
│ └────────────────────────────────────┘ │
└─────────────────────────────────────────┘
```
## 3. chip 状态机
| 状态 | 外观 | 交互 |
|---|---|---|
| 未加载 | 不显示(静默,与 QMT chip 一致) | — |
| 未选择 | `讨论依据:请选择 ▾`tertiary 色) | 点开 → 策略下拉(空态见 §5) |
| 生成中 | `依据:做T …`opacity .6,右侧小 spinner/省略) | 不可再点(防抖) |
| 已选择 | `依据:做T ▾` + 右侧「↻ 刷新」小钮 | 点 chip 换策略;点 ↻ 刷新注入 |
| 策略已删除 | `依据:做T(已删除)▾`warning 色) | 下拉选其它/清除 |
| 错误 | toast 错误 + chip 保留原态 | 可重试 |
## 4. 交互细节
1. **选择策略(F1 主流程)**
- 点击 chip → 下拉菜单(与 QmtConnectionChip 同一套 Menu 视觉):
- 标题区「以哪个策略讨论?」;
- 策略项:每项 = 策略名 + 副行「当前 N 只持仓 · 最近交易 …」;
- 底部「清除依据」(仅已选时出现);当前依据项前打 ✓;
- 点击某策略 → chip 进入生成中 → 服务端取摘要 → 注入会话 + 对话流顶部出现依据条目 → toast.success(`已载入「做T」数据依据:3 持仓 / 42 笔交易(快照 14:32:05`)。
2. **注入条目(D-1 建议形态)**
- 视觉:淡色卡片/引用样式(bg-layer 弱化 + 左侧竖条 accent),标题行「⛭ 神之一手数据依据」+ `做T · 快照 2026-09-08 14:32:05`,行 2 摘要「当前持仓 3 只 / 历史交易 42 笔(含已清仓)」;
- 默认折叠,点击展开全文(markdown 摘要内容);
- 多依据/多刷新并存时按时间堆叠,最近一次在消息流最新处;不伪装用户/助手消息(若通道仅支持文本消息,则呈现为最顶部一段普通消息文本——实现以调研为准)。
3. **换策略**:chip 下拉选另一策略 → 同"生成中→注入"流程;chip 文案与已注入条目更新。
4. **刷新依据**:点 chip 右侧 ↻ → 生成新快照并注入新条目(含新快照时间);toast「已更新依据(快照 14:35:10)」。不会删除旧条目。
5. **清除依据**:下拉「清除依据」→ chip 复位「请选择」;不清除历史注入条目。
6. **键盘/可及**:Esc 或外点关闭下拉;chip 提供 title 提示「以某个策略的数据为依据开始讨论(只读)」。
7. **只读表达**:选择器下拉仅含策略与其状态信息,无任何写/操作入口;文案提示"会话只读,不执行交易操作"(依赖尾注与依据条目样式即可,不打断输入)。
## 5. 空态与边界 UI
| 场景 | UI |
|---|---|
| 无策略可配置 | 下拉空态:「暂无策略,请到 设置 → 神之一手 → 策略分组 添加」+「去设置」快捷键位 |
| 策略持仓为空 | 摘要条目持仓区显示「当前无持仓」;交易摘要照常 |
| 行情缺失 | 摘要中现价列显示 —,条目副行提示「部分行情缺失(--)」,不阻塞 |
| 生成失败/服务不可用 | toast.error(原因)chip 回原态可重试 |
| 已在数据 tab.odl-root)| 不显示该控件(现状 CSS 隐藏 composer 的区域一致,仅聊天视图展示) |
## 6. 组件落点(初拟,技术方案细化)
- 客户端新组件 `StrategyBasisChip.jsx`(chip + 下拉 + 刷新),复刻 QmtConnectionChip 的结构(rootRef/外点关闭/useRpc/useToast);
- 注入条目组件 `DataBasisCard.jsx`(折叠/展开 markdown 展示);
- 服务端新只读端点(`one-divine-lot/strategies/basis-summary`)生成摘要(产品逻辑 §4 规格);
- 挂载位置:D-4(对话 accessory vs 会话头部)拍板后确定 slot 与可见性策略。
## 7. D 问题(AI 建议项,与产品逻辑设计共享,待老师拍板)
| # | 问题 | AI 建议 |
|---|---|---|
| D-1 | 注入条目呈现形态 | 折叠「数据依据」卡片,点开全文(见 §4.2) |
| D-2 | 交易摘要聚合粒度 | 按持仓聚合 + 已清仓标注;如需近期逐笔再加近 N 笔 |
| D-3 | 依据跨刷新持久化 | 会话级不持久化(重开重选) |
| D-4 | chip 位置 | 对话视图输入框上方 accessory 行左侧(仿 workspace chip 位置);备选=会话头部与 QMT chip 并排 |
@@ -0,0 +1,131 @@
# 迭代 17 产品逻辑设计:策略会话
> 依据:R-021(合并原 R-019+R-021,已定稿)| 日期:2026-09-08 | 状态:**设计稿(D 问题待老师拍板后定稿)**
## 0. 一句话逻辑
> 会话 = 用户选定「讨论策略」→ 系统把该策略**当前数据**(当前持仓 + 全历史关联交易摘要)作为会话依据注入 → 之后人机围绕该数据复盘/分析;依据可随时更换/刷新;只读。
## 1. 概念与数据域
| 概念 | 定义 | 数据来源 |
|---|---|---|
| 策略 | settings 中的策略定义(id/name,做T、网格超市…) | settings.strategies(现有) |
| 策略当前持仓(活动行) | 该策略账本中未清仓的 holdingshares>0 活动) | SqliteStore strategy_holdingsR-018 数据域:账本为唯一权威) |
| 行情现价/涨跌 | holding code 的实时价格 | QuoteHub 内存快照(读穿透;不可得=—) |
| 关联交易记录 | 归属该策略/持仓的委托(含已清仓历史,trade_orders 归属列 + 历史库) | SqliteStore trade_orders/trade_fillsR-009/R-018 |
| 数据依据(basis) | 一次注入到会话的"策略当前数据摘要快照" | 服务端按需生成(本设计) |
**关键语义(沿用 R-018 数据域分界)**:持仓展示 = 账本(活动行);交易 = 归属账本(历史全量);行情 = 内存快照。摘要生成**只读**这些域,不写账本。
## 2. 会话级状态
策略会话不需要跨会话持久存储(设计建议,见 D-3);会话内 UI 状态:
- `basis: none | { strategyId, strategyName, injectedAt }`
- `basisState: idle | generating | done | error`
- 注入后的摘要内容随会话消息流存在(可折叠"数据依据"条目),不单独存储。
## 3. 核心流程
### 3.1 选择策略 → 注入依据(主流程)
1. 对话视图(聊天)中,用户在「讨论依据」控件里选一个策略;
2. 客户端调服务端 `strategies/:id/basis-summary`(只读)生成摘要文本(规格见 §4);
3. 摘要通过**宿主注入通道**进入当前会话上下文(技术通道调研待定,见 §6);同屏在对话流顶部呈现「数据依据」条目;
4. toast 反馈:`已载入「做T」数据依据(3 持仓 / 42 笔交易,快照 14:32:05`
5. 用户开始提问复盘/分析;模型以摘要(及会话中后续补充)为依据回答。
### 3.2 换策略
- 重开选择器选另一策略 → 再次执行 3.1(新摘要作为新的依据条目追加;chip 切换为新策略)。旧依据条目保留在历史中(模型自然以最近/明确引用的为准)。
### 3.3 刷新依据
- 已选状态下 chip 提供「刷新」:重新生成**当前时刻**摘要并注入(盘中数据变化后想拿最新)。
- 语义:每次刷新=新快照依据条目(含新快照时间),不覆盖旧条目。
### 3.4 清除依据
- 选择器提供「清除依据」:回到无依据普通会话(chip 复位为「讨论依据:未选择」);已注入的历史条目仍在消息流中,后续对话不再视为策略依据会话。
### 3.5 边界
| 场景 | 行为 |
|---|---|
| 无可选策略(未配置) | 选择器显示空态「暂无策略,请到 设置→神之一手→策略分组 添加」 |
| 会话进行中该策略被删除/改名 | chip 下次加载策略列表时按 id 对齐:找不到=显示「策略已删除」+ 可清除;改名后按 id 正常(显示新名);历史依据条目保留 |
| 策略当前持仓为空 / 无行情 | 摘要照常生成:持仓区显示"当前无持仓";现价列显示 —;交易摘要照常(历史) |
| 账本只读 | 摘要生成与注入路径无任何写操作;UI 无操作按钮(区别于数据 tab 的手动操作) |
| 未选策略的普通聊天 | 与现状完全一致,不受影响(策略会话是可选增强,不锁定会话) |
## 4. 摘要规格(服务端 `basis-summary` 输出,markdown 文本)
结构(自上而下):
1. **快照头**`【神之一手数据依据】策略:做T | 快照:2026-09-08 14:32:05 数据为只读快照,非实时`
2. **当前持仓**(活动行,逐行):code 名称|份额|可用|成本价|现价(— 缺失)|市值|浮动盈亏|持仓天数;尾部合计(N 只 / 总市值 / 总盈亏)。现价/市值缺行情时给—,不阻塞。
3. **交易摘要(该策略全历史关联委托,聚合)**:总笔数/买入累计/卖出累计(按归属 holding 聚合):
- 每 holding 一段:code 名称|状态(持有中/已清仓 清仓日)|买卖笔数|累计买入(量/额)|累计卖出(量/额)|当前份额;
- 已清仓 holding 标注清仓日期与最后动作摘要;
- 排序:持有中在前(按成本日/名称),已清仓在后(按清仓日倒序);
- **规模上限**holding 超过 20 行时按最新清仓倒序截断并注明「更多历史见 交易记录 tab」。
4. **尾注**:只读提示「以上为策略数据快照;本会话只读,不执行任何交易/账本操作」。
字段精确取数与空值规则在技术实现方案细化。
## 5. 非目标(本期明确不做)
- 宿主工作区/目录注册(原 R-019 R3,已并入 R-021);
- 账本写/交易执行、任何操作按钮;
- 按需取数工具(AI 自行调用业务接口);
- 复盘产物落盘 / 复盘管理(含 DB)——未来 T-002 方向;
- 通用视图(全部持仓/交易记录)作依据;
- 关注列表作为依据源。
## 6. 技术通道(设计假设,待调研确认)
注入的可行通道候选:① 宿主 injected-context/上下文节点(可折叠条目、非用户消息)→ 首选;② 会话 send(文本消息形态进会话)→ 兜底(呈现为一条前置依据消息)。**渲染与动作方案以调研结果为准**(本期设计保留两种形态的表达,UI 交互设计按"数据依据条目"统一描述)。
## 0.5 产品逻辑总图(2026-09-08 老师确认:画像 / 模型 / 主线 / 分期草案)
### 目标交易者画像(Q-画像,2026-09-08 确认)
- **混合交易模式的多策略分仓管理**:同一账户下并存多种交易风格的策略分组(当前:手动做T + 程序化网格超市;未来:情绪流交易风格的管理逻辑);
- 即"策略 = 分仓 + 交易风格/规则的容器";系统须能承载 手动主观 + 自动化规则 + 未来情绪流 三类玩法(彼此数据/规则/复盘口径不同)。
### 交易员工作模型(知识库归纳,作为产品逻辑基准)
- **日循环**:盘前计划 → 盘中执行/监控 → 盘后记账核对 → 复盘总结 → 明日计划;
- **策略循环**:规则制定 → 按规则执行 → 规则校验(复盘)→ 规则修订;
- **复盘闭环**:重建事实 → 归因 → 对照规则 → 提炼改进;**复盘原料 = 决策时的依据记录**(知识库共识:没有决策留痕的复盘只是猜)。
### AI 分工定位(人机合一,只读)
- **盘中 = AI 参谋台**(状态聚合 / 规则触发提醒 / 轻问答 / 决策留痕 / 人扣扳机执行),快、准、不打断;
- **盘后 = AI 复盘主持人**(自动重建事实线 → 引导人过结论 → 结论留痕),人不被 AI 下"对错"裁决;
- 决策依据记录(盘中一键留痕:理由 + 当时快照)是产品逻辑主线之一(2026-09-08 老师确认)——复盘有据的前提。
### 能力地图(现状 → 缺口)
- 已具备"事实记录层":对账单快照 / 策略账本(唯一权威)/ 交易记录+归属 / 行情快照 / 策略 configSchema(可承载规则参数);
- 缺口(按承接排序):①复盘事实线入口+可讨论会话(R-021 合并需求,本迭代)②决策依据留痕(盘中)③规则触发提醒 ④盘后自动日结/统计 ⑤复盘管理库(决策日志+复盘记录结构化存储检索)。
### 分期草案(2026-09-08 老师认可分步实现;**各 Phase 具体内容待讨论细化**)
| 阶段 | 主题 | 交付设想(草案) |
|---|---|---|
| Phase 0(迭代 17 | 复盘会话闭环 | R-021 策略会话复盘(原 R-019+R-021 合并;策略数据依据 + 粒度复盘上下文,事实线 → 会话讨论 → 结论在会话) |
| Phase 1 | 盘中辅助(雏形) | 决策依据一键留痕(理由+快照落库)+ 规则触发/偏离提醒雏形(configSchema × 行情)+ 会话内盘中快查 |
| Phase 2 | 盘后日结与统计 | 自动日结(当日各策略事实线+盈亏+异常)→ 一键入会话复盘;周期统计(轮次收益/胜率/策略健康) |
| Phase 3 | 复盘管理库 | 决策日志+复盘结论结构化沉淀/检索/关联(DB),对接目标-006;情绪流风格管理逻辑另立项 |
## 0.6 Phase 0 范围确认(2026-09-08 老师确认:就这样)
- **Phase 0 = 复盘会话闭环,范围 = R-021 单一合并需求(原 R-019 + R-0212026-09-08 老师指令合并、逻辑整合入 R-021)**;
- **复盘单元建模(A 拍板)**:引入"轮次 round"概念(holding 之下的交易周期聚合层):
- 网格超市:一次买入触发 + 对应卖出配对 = 一轮(每格触发记一轮);
- 手动做T:一次开仓 → 清仓 = 一轮;
- 支持人工合并/拆分修正(先人工可调);
- round 承接"复盘对象"的最小粒度,委托/段为 round 内明细;
- **行情口径(C 拍板)**:本期事实线 = 交易记录 + 持仓史 + 现价对照(无历史 K 线/分时);历史行情背景 Phase 3 复盘库时再评估;
- **决策日志预留(拍板)**:Phase 0 设计为"决策依据记录"留数据模型位置(时刻/理由/类别/当时快照/关联 round 或 holding),Phase 1 实现,不返工;
- **风格差异**:复盘口径按策略类型可扩展(手动/网格,未来情绪流预留策略类型维度)。
## 7. D 问题(AI 建议项,待老师拍板)
| # | 问题 | AI 建议 |
|---|---|---|
| D-1 | 注入条目的呈现形态 | 对话流顶部「数据依据」条目:默认折叠为一行(图标+策略+快照时间+行数摘要),点开看全文;不伪装成用户消息 |
| D-2 | 交易摘要聚合粒度 | 按持仓聚合(每持仓一段汇总 + 已清仓标注),不逐笔铺开(避免注入过长);如老师要"近期逐笔"可加近 N 笔小表 |
| D-3 | 依据选择是否跨刷新持久化 | 本期**会话级不持久化**(刷新需重选;历史注入条目仍在消息流),与 R-016 会话级偏好一致、不加存储;复盘管理未来再考虑 |
| D-4 | 选择控件挂载位置 | 对话视图输入框上方 accessory 行左侧(仿 DSH workspace chip 位置),仅聊天视图可见;替代方案=会话头部(与 QMT chip 并排)待老师定 |
@@ -0,0 +1,39 @@
# 迭代 17:策略会话(以策略当前数据为依据的会话讨论)
## 目标(一句话)
让 AI 会话可"以策略为 Workspace":对话开始前选「讨论策略」,预注入该策略**当前数据摘要**作为会话依据,AI 与老师围绕该策略持仓/交易做复盘与分析讨论;只读、会话中可换/刷新依据。
## 目标描述
- 老师原始诉求「新需求,让 AI 会话可以以策略为 Workspace」经四轮讨论定稿(R-0192026-09-08;同日老师指令 R-019 并入 R-021 合并为单一需求):
- **改造现有 DSH 对话窗口**,不引入宿主工作区/目录;
- 会话的数据 = ①开始前/过程中注入的策略当前数据摘要 + ②会话过程本身产生的对话;复盘管理(可能带 DB 沉淀)为神之一手未来模块,本期不做;
- 注入口径:当前持仓明细 + 该策略全历史关联交易聚合摘要(含已清仓);
- 只读(Q4=A);预注入不挂工具(R2-Q1=A);产物不落盘(R2-Q4)。
- **本迭代分阶段推进**:先设计(产品逻辑 + UI 交互逻辑,老师 2026-09-08 指令)→ 宿主注入通道调研 → 技术实现方案 → 实现与验收。
## 讨论过程
| 日期 | 轮次 | 要点 |
|---|---|---|
| 2026-09-08 | 需求讨论 Q1-Q4 | 语义收敛:非物理目录;数据为依据;会话内只读 |
| 2026-09-08 | 需求讨论 R2 | 预注入;范围 R2-Q3=A;产物暂不落 |
| 2026-09-08 | 需求讨论 R3 | 参考 DSH 工作区模式的"开始前选策略"体验,但**不用宿主工作区** |
| 2026-09-08 | 需求定稿 F1/F2 | 首条消息前「讨论策略」选择 + 会话中可换/刷新;注入口径维持 R2-Q3=A |
| 2026-09-08 | 老师指令 | 立项迭代:先做产品逻辑、UI 交互逻辑设计 |
## 本期(设计阶段)交付
- `产品逻辑设计.md`(数据与行为逻辑、摘要规格、状态与边界)
- `UI交互设计.md`(交互流程、控件、视觉与文案、状态机)
- D 系列设计问题(AI 建议项)→ 老师拍板 → 设计定稿
## 后续阶段(设计定稿后)
- 宿主注入通道技术调研 → 技术实现方案.md → 实现 → 验收标准.md + 验收(是否拆分独立迭代由老师定)。
## 对老师配合的请求
1. 拍板 D1-D4(产品/UI 设计中的 AI 建议项,见设计文档末);
2. 实现验收需真实会话环境人工目视。
@@ -0,0 +1,68 @@
# 迭代 18 技术实现方案:QMT Bridge MCP 能力内建
> 依据:R-0202026-09-08 定稿,Q1-Q6 + 老师澄清:移除三方 dsh-skill-mcp-panel、官方 dsh-mcp-client 可依赖)| PLAN-018
> 日期:2026-09-08
## 目标架构
神之一手在 apply 内**动态挂载 DSH 官方 @deepseek-ai/dsh-mcp-client**(依赖官方库、管理在插件内),把 QMT Bridge 的 MCP 工具注册给模型;MCP 实例生命周期(挂/切/卸)随 QMT 连接配置的增删改与激活切换联动;状态出口 = 设置页 + 会话头部指示灯。REST 直连架构不变(技术约束-003)。
```
神之一手 apply(ctx)
├─ settingsQMT 连接配置:list + activeId + defaultId [已有 R-004]
├─ dataSourceREST 直连,baseUrl 随激活热切换) [已有]
└─ QmtMcpManager(新增 src/mcp/QmtMcpManager.js
├─ 动态 import(@deepseek-ai/dsh-mcp-client) → ctx.plugin(module, config)
│ config = { serverName: QMT_Bridge_MCP, transport: streamable-http,
│ url: 激活 baseUrl + /mcp, failOnStartupError:false,
│ reconnect: {enabled, 500ms→30s, 10次} } ← 与宿主现受管块同参(Q5)
├─ 生命周期:start(启动挂载激活连接)/ resync(url)(激活·编辑地址·删除联动)
├─ 状态:probe()SDK listTools 握手:connected + 工具数 + 延迟 / error
│ getStatus() → { state, serverName, url, mounted, toolCount, error, latencyMs, checkedAt }
└─ dispose:卸载 fiberctx.effect 释放)
```
## 关键事实(已实证)
- `@deepseek-ai/dsh-mcp-client@0.1.1-rc.2` 已加入 dependencies;从插件 lib 可 import,模块导出 {name:mcp-client, inject:[tools], apply, Config},可 ctx.plugin() 动态挂载(mcp_router 同款实证);
- serverName 存活实例内必须唯一 → 移三方受管块前插件实例与宿主实例不可并存(实施时序见 PLAN-018 注意事项);
- dsh-mcp-client 不暴露连接状态查询 API → 状态用**独立 SDK 探测**(@modelcontextprotocol/sdk Clientinitialize + tools/list → 工具数/延迟),与 dsh-mcp-client 自管连接互不干扰;
- 宿主现 QMT_Bridge_MCP 受管块 = 三方 dsh-skill-mcp-panel 写入,连接与注册实为官方 dsh-mcp-client。
## 改动清单
| 文件 | 改动 |
|---|---|
| package.json | dependencies + @deepseek-ai/dsh-mcp-client@0.1.1-rc.2 + @modelcontextprotocol/sdk@1.30.0(已加) |
| src/mcp/QmtMcpManager.js(新增) | MCP 实例管理:动态挂载/卸载 + resync + probe/getStatus + dispose |
| src/index.js | 实例化 QmtMcpManager、start、ctx.effect 释放、传入 registerApi runtime |
| src/api/index.js | runtime 增 mcpManager(分发 handleQmt/handleMarket |
| src/api/qmt-connections.js | METHODS + mcp-statusactivate/update(地址变)/remove 热切换点 + mcpManager.resync(url) |
| src/api/market.js | sync-status 增加 mcp 域(读 mcpManager.getStatus |
| src/client/views/SyncIndicators.jsx | 三灯 → 四灯:+「MCP」(绿=已连接 / 黄=重连中 / 红=断开 / 灰=未启用;点击刷新) |
| src/client/views/SettingsSection.jsx | QMT 连接配置子 tab 顶部 MCP 状态条 + 「检查 MCP」按钮(手动触发 mcp-status refresh |
## MCP 状态语义(对齐产品约束-012 指示灯体系)
| state | 灯色 | 说明 |
|---|---|---|
| connected | 绿 | 已连接 + N 工具(probe listTools 成功) |
| connecting | 黄 | 挂载中 / 重连中 |
| error | 红 | 不可达 / 失败(含原因) |
| disabled | 灰 | 无激活连接(未启用) |
## 实现步骤
1. QmtMcpManager(挂/卸/resync/probe/getStatus/dispose);
2. index.js 接线 + registerApi runtime
3. apimcp-status + sync-status.mcp + 热切换联动;
4. 客户端:SyncIndicators 四灯 + 设置页 MCP 状态条;
5. 构建(pnpm build)验证;
6. 宿主迁移(独立步骤,需老师确认 + editing-cordis-compositions 流程):卸载 dsh-skill-mcp-panel + 移除 cordis.patch.yml 受管块 → 重载 → 验证工具来自神之一手;
7. 验收。
## 风险与注意
- 同 serverName 冲突:插件实例与宿主受管块不可并存(先插件后移除,或先移除后插件接管,二选一窗口);
- ctx.plugin 子 fiber 需要 tools 服务:one-divine-lot inject 列表评估(对齐 mcp_router inject [webServer, tools]);
- dsh-mcp-client 初始连接失败不抛(failOnStartupError:false),但**重复 serverName 会抛** → 挂载错误必须 catch,插件其余功能不受影响。
@@ -0,0 +1,53 @@
# 迭代复盘:18-QMTBridgeMCP内建
> 复盘日期:2026-09-08 迭代状态:**验收通过(2026-09-08)** —— 口径:服务端自动化验收全绿 + MCP 工具端到端实测 + UI 接线核验(构建产物含新控件)+ 真实调用顺带确认(老师参与验收:重启宿主 / 指令修正 / MCP 实测)
> 关联需求:R-020(已定稿,2026-09-08)| 关联计划:PLAN-018
## 结果
迭代 18 达成(R-020 全范围):**QMT Bridge MCP 能力内建**——神之一手在 apply 内动态挂载 DSH 官方 @deepseek-ai/dsh-mcp-clientserverName 固定 QMT_Bridge_MCPurl 自动派生自激活连接 baseUrl+/mcp;挂载/切换/卸载/状态全由神之一手插件内管理);**三方依赖移除**dsh-skill-mcp-panel 卸载 + 宿主 cordis.patch.yml 受管块清空);MCP 生命周期随 QMT 连接配置增删改与激活切换联动;状态出口 ×2(设置页「QMT 连接配置」MCP 状态条 + 会话头部指示灯区新增「MCP」灯);失败不崩(failOnStartupError:false + reconnect + 挂载 catch)。REST 直连架构不变(技术约束-003)。
## 验收证据(2026-09-08 实测)
| # | 验收项 | 结果 |
|---|---|---|
| 1 | 移除宿主受管块后工具仍可用 | ✅ cordis.patch.yml 清空 []、panel 已卸载;mcp__QMT_Bridge_MCP__qmt_list_apis 实际调用成功(QMT Bridge v2.0.0 返回) |
| 2 | MCP url 随激活自动派生 | ✅ 100.110.38.78:8610/mcp(激活 baseUrl+/mcpQ3 |
| 3 | 激活切换联动 | ✅ 切 QMT_CYY → 192.168.3.43:8610/mcp connected;切回 GEMWIN 恢复 |
| 4 | 删除激活自动切默认 + MCP 重挂 | ✅ 删死地址连接 → 自动切默认 GEMWIN → MCP 恢复 connected + 10 工具 |
| 5 | 失败不崩(Q5) | ✅ 激活死地址 → state=errorfetch failed),插件其余功能正常 |
| 6 | mcp-statusrefresh=检测按钮语义) | ✅ refresh:true 即时探测 connected + 10 |
| 7 | sync-status.mcp 域(头部灯数据源) | ✅ state/url/mounted/toolCount/latencyMs/checkedAt 齐全 |
| 8 | 客户端接线 | ✅ build 产物含「MCP」指示灯 + 设置页「检测 MCP」按钮 + header.actions/settings.section 注册 |
| 9 | MCP 工具端到端实测 | ✅ qmt_kline 取 000333.SZ 近一个月日 K30 根 OHLCV,区间 -4.27% |
| 10 | 构建 | ✅ typecheck + pnpm build 通过(lib/mcp/QmtMcpManager.js 产出) |
## 过程事实
1. **需求讨论定稿**:老师指令(2026-09-08)→ R-020 登记 → Q1-Q6 逐项拍板(只挂激活连接 / 移除三方 / url 自动派生 / 双状态出口 / 失败重连采纳 / 不做通用 MCP 管理);
2. **老师指令修正(关键)**:AI 一度把「动态挂载官方 dsh-mcp-client」误读为要自研协议,先撤依赖后澄清——老师明确:不要的是**三方 dsh-skill-mcp-panel****官方 @deepseek-ai/dsh-mcp-client 可以依赖**;文档同步更正(R-020 / PLAN-018 / 迭代目标 / 索引);
3. **依赖可行性实证(PLAN-018 步骤 1)**@deepseek-ai/dsh-mcp-client@0.1.1-rc.2registry 有)加为 dependencies,从插件 lib 实证可 import,模块导出 {name:mcp-client, inject:[tools], apply, Config} → ctx.plugin() 动态挂载可行(mcp_router 同款路径);@modelcontextprotocol/sdk@1.30.0 供独立状态探测(dsh-mcp-client 不暴露状态 API);
4. **实现**src/mcp/QmtMcpManager.js(挂/卸/resync/probe/getStatus/dispose);index.js 接线(start + dispose + registerApi runtime);api/qmt-connections.jsmcp-status + activate/update/remove 三热切换点 resync);api/market.jssync-status.mcp 域);客户端 SyncIndicators(四灯 + MCP+ SettingsSectionMCP 状态条 + 检测按钮);tsdown 增 mcp 域入口;
5. **宿主迁移(老师执行,验收方确认)**cordis.patch.yml 受管块清空 + dsh-skill-mcp-panel 卸载 + 宿主重启 → 神之一手实例接管;
6. **验收**:服务端 8 项 + MCP 实测 + 构建全绿(上表);
7. **提交推送**c700f2f(迭代1816 files+ 9382ace(迭代17 遗留文档补提交)→ origin/main。
## 经验教训(复盘沉淀)
### 1. 「去掉对三方插件的依赖」要先问清:依赖边界到底指哪一层
AI 把老师「不希望依赖三方插件来加入 MCP 能力」误推到「也不能依赖 DSH 官方 mcp-client → 要自研 MCP 协议」,先动手撤了官方依赖、准备重造轮子;老师澄清后回撤。沉淀:**需求里「不依赖 X」先精确定位 X 是谁(管理面板 vs 执行插件),并确认官方能力是否允许复用;架构岔路口先问一句,别先动手拆**。
### 2. 动态挂载子插件 = ctx.plugin(module, config),前提是模块在插件依赖树内可解析
mcp_router 实证了「import 官方模块 → ctx.plugin()」路径;one-divine-lot 照做时关键前置是把官方模块加进自己的 dependencies(而非 peer 裸声明),并用 createRequire 从插件 lib 路径实证解析成功再动代码。沉淀:**动态挂载依赖模块时,先验证「插件运行路径能解析到模块」这个前置条件(技术约束-003 语义外的新增实践)**。
### 3. 状态查询与连接管理分离:dsh-mcp-client 不暴露状态 API
官方插件只管连接与工具注册,无状态查询口 → 用 @modelcontextprotocol/sdk 独立只读探测(initialize + tools/list 拿工具数/延迟),与官方自管连接互不干扰。沉淀:**外部能力库若不开状态口,用协议级只读探测补状态出口,不试图扒内部字段**。
### 4. 验收期间临时造数要小心「先清后补」
用死地址测失败语义时两次 add 同名 TEST-DEAD 未清理(返回结构读错导致 id 取空),留下两条残留连接;发现后立即清理、用正确返回结构重测。沉淀:**验收脚本对临时数据的增删要成对且校验返回值;测试前后核对连接列表与激活态**。
## 关联
- R-020(已定稿 → 已实现 → 本迭代验收通过);宿主三方 dsh-skill-mcp-panel 已移除(其「技能/MCP」设置菜单与受管块职责移交完成)
- 新增依赖:@deepseek-ai/dsh-mcp-client@0.1.1-rc.2、@modelcontextprotocol/sdk@1.30.0
- 若需新增设计约束条目(如「插件内建 MCP 客户端注册规范」)——本迭代未新增,留待后续讨论(R-020 定稿时标注"如需…于定稿时补充"未触发)
@@ -0,0 +1,19 @@
# 迭代 18QMT Bridge MCP 能力内建(去三方插件依赖)
## 目标(一句话)
神之一手插件在 apply 内动态挂载 DSH 官方 dsh-mcp-clientserverName 固定 QMT_Bridge_MCPurl 自动派生自激活连接 baseUrl+/mcp,挂载/切换/卸载/状态均由神之一手插件内管理),MCP 能力不再依赖三方 dsh-skill-mcp-panel(卸载其插件 + 移除宿主 cordis.patch.yml 受管块)。
## 目标描述
- 依据 R-0202026-09-08 定稿,Q1-Q6+ PLAN-018
- Q1 只挂激活连接;Q2 移除三方块由神之一手管理;Q3 url 自动派生;Q4 状态出口=设置页卡片检查 + 会话头部 MCP 指示灯;Q5 失败不崩+重连;Q6 不做通用 MCP 管理;
- REST 直连架构不变(技术约束-003)。
## 讨论过程
| 日期 | 轮次 | 要点 |
|---|---|---|
| 2026-09-08 | 需求讨论 Q1-Q6 | R-020 定稿(见 R-020.md |
| 2026-09-08 | 立项 | 老师:「启动,这个不是一个大需求」→ 快速迭代 |
| 2026-09-08 | 指令澄清 | 老师修正:不要的是三方 dsh-skill-mcp-panel;依赖官方 @deepseek-ai/dsh-mcp-client 没问题 → 方案定为官方库动态挂载 + 移除三方面板 |
@@ -0,0 +1,10 @@
# 迭代 18 验收标准:QMT Bridge MCP 能力内建
> 验收方法/验收目标见 PLAN-018「验收标准 / 验收方法」逐条。
1. 移除宿主 cordis.patch.yml QMT_Bridge_MCP 受管块后,模型仍能用 mcp__QMT_Bridge_MCP__qmt_* 工具(来源=神之一手动态挂载,工具集一致);
2. 激活连接切换 → MCP 实例 url 跟随;编辑/删除激活连接 → 同步重挂无残留;
3. 连接列表为空 → 不挂实例(mcp-status=disabled),其余功能不受影响;
4. QMT 不可达 → 插件不崩、MCP 灯红、恢复后自动重连绿灯;
5. 设置页连接卡片 MCP 状态 + 检查按钮;会话头部 MCP 指示灯四灯齐全;
6. 现有功能无回归;typecheck + build 通过。
@@ -0,0 +1,45 @@
# 技术实现方案:19-指示灯状态自适应探测
> 迭代编号:19 依据:R-022 + R-023 | 涉及约束:产品约束-012(指示灯体系)、技术约束-016、技术约束-020/021(本迭代新增)
## 方案总览
两只连接类状态缓存统一改「**结果驱动 setTimeout 自适应链**」:每次探测完成(成功/失败/异常)后按结果排下一轮。
| 维度 | QmtHealthMonitorR-022 | QmtMcpManagerR-023 |
|---|---|---|
| 健康/已连接 | intervalMs 默认 5 分钟(稳态,原设计) | intervalMs 默认 5 分钟 |
| 异常/未知/未连接 | retryMs 默认 10 秒快速重试 | retryMs 默认 10 秒快速重试 |
| 探测目标 | 激活 baseUrl + /healthGET | 激活 baseUrl + /mcpSDK Client 握手,只读) |
| 停止调度 | stop()mounted 守卫) | 无激活连接 / dispose() / unmount() |
| 并发防护 | _inFlight 去重 | _probing 去重 |
| 前端/API | 零改动(读缓存秒回) | 零改动(getStatus 读缓存秒回) |
## 实现步骤
### R-022 QmtHealthMonitorsrc/data-source/QmtHealthMonitor.js
1. 常量 HEALTH_RETRY_MS=10s;构造注入 { intervalMs, retryMs }(测试用短间隔,对齐 QuoteSync 先例);
2. 固定 setInterval(5min) → _nextDelay()/_scheduleNext()setTimeout 链,探测完成重排);
3. resolveActiveBaseUrl 移入 try:解析失败也落 healthy:false 缓存走快速重试
(原实现解析在 try 外,抛错场景灯会灰且不自动重试——顺带修复的隐性缺陷);
4. start() 立即探测不变;stop() 用 clearTimeout + mounted 守卫。
### R-023 QmtMcpManagersrc/mcp/QmtMcpManager.js
1. 常量 PROBE_RETRY_MS=10s;构造注入 { intervalMs, retryMs }
2. probe() 结果写缓存后 _scheduleNext()target 为空早退置 disabled 并取消定时器;
3. unmount() 取消定时器;dispose() 置 _disposed + 取消 + 卸载;_probing 防并发;
4. 探测本体(SDK Client connect/initialize/tools/list)与挂载/重连逻辑零改动。
### 回归
- scripts/test-health-monitor.mjs10 项:启动即探测/异常快速重试自动恢复/健康稳态不空转/stop 停止调度…)
- scripts/test-mcp-status.mjs12 项:无激活连接不调度/异常快速重试自动恢复/已连接稳态不空转/dispose 停止调度/节奏选择;
SDK 握手不可纯内存 mock,守护调度机制:stub probe 翻转状态 + 触达真实 _scheduleNext
## 涉及文件
- src/data-source/QmtHealthMonitor.js、src/mcp/QmtMcpManager.js(核心改动)
- scripts/test-health-monitor.mjs、scripts/test-mcp-status.mjs(新增回归)
- lib/*build 再产物,部署用)
@@ -0,0 +1,45 @@
# 迭代复盘:19-指示灯状态自适应探测
## 结论
达成(R-022 + R-023 全范围):会话头部两只「连接类」指示灯(QMT连接 / MCP)统一改为
**结果驱动自适应探测**——健康/已连接维持 5 分钟稳态(原设计不变),异常/未知每 10 秒快速重试;
QMT Bridge 与其 MCP 服务启动/恢复后两灯 ≤20s 自动回绿,不再等满 5 分钟 / 不再永不恢复。
前端指示灯与 sync-status / qmt-health / mcp-status 端点零改动(读缓存秒回语义不变)。
## 事实记录
### R-022 QMT 健康灯(src/data-source/QmtHealthMonitor.js
- 根因(已证实):健康缓存固定 5 分钟探测一次激活 /health;其余三灯有高频自愈回路
(持仓 10s / 行情 5s / MCP 客户端重连)。Bridge 在两次探测间启动/恢复时缓存停留在上次失败结果。
- 变更:setInterval(5min) → setTimeout 自适应链(_nextDelay/_scheduleNext,结果驱动);
HEALTH_RETRY_MS=10s;构造注入 { intervalMs, retryMs }resolveActiveBaseUrl 移入 try
(解析失败也落 healthy:false 走快速重试,原实现该场景灯灰且不自动重试);_inFlight 防并发保留。
### R-023 MCP 状态灯(src/mcp/QmtMcpManager.js
- 根因(已证实):状态缓存只在 挂载/切换/手动探测 时刷新,无定时回路;dsh-mcp-client 自带 reconnect
只恢复真实连接(fiber 层),不刷新插件状态缓存。
- 变更:probe() 结果写缓存后 _scheduleNext()PROBE_RETRY_MS=10s;构造注入 { intervalMs, retryMs }
无激活连接早退置 disabled 并取消定时器;unmount()/dispose() 停止调度;_probing 防并发;
探测本体与挂载/重连逻辑零改动。
### 验证
- scripts/test-health-monitor.mjs 10/10 绿;scripts/test-mcp-status.mjs 12/12 绿(调度机制守护);
- pnpm typecheck 0 错;pnpm build 通过(lib 两文件均含自适应逻辑);
- 存量回归 test-quote-sync / test-position-sync 均 0 退出。
## 待老师确认事项
1. R-022 / R-023 定稿确认(边界:健康/已连接稳态 5 分钟不变 / 异常 10s 重试 / 不新增配置 / API 前端零改动);
2. 真实环境人工验收(验收标准.md 第 8 条):停/启 QMT Bridge → QMT 灯与 MCP 灯 ≤20s 自动回绿、
点击仍即时探测、插件重载后缓存秒回。
## 经验沉淀(候选)
- 定时轮询类功能的恢复感知要与故障探测解耦:固定低频定时器做稳态,异常态必须用快速重试把「恢复」
也纳入自愈回路——灯类状态"看起来没生效"的根因通常是恢复路径的响应时延,而不是探测本身失效。
- 「外层有自管重连」不等于「状态缓存会自愈」:重连只恢复真实连接,展示层缓存若无自己的刷新回路,
必须单独加状态自适应探测(R-023 教训)。
@@ -0,0 +1,32 @@
# 迭代目标:19-指示灯状态自适应探测(QMT 健康灯 + MCP 灯)
## 目标
会话头部两只「连接类」指示灯(QMT连接 / MCP)改为**结果驱动自适应探测**:
健康/已连接时维持 5 分钟稳态探测(低开销),异常/未知时每 10 秒快速重试,
使 QMT Bridge 与其 MCP 服务启动/恢复后指示灯 ≤20s 自动回绿,不再等满 5 分钟。
## 目标描述
- **背景**:两只灯的数据分别来自 QmtHealthMonitor / QmtMcpManager 的内存状态缓存,刷新机制均为
固定/缺失低频定时回路——QMT 健康缓存固定每 5 分钟探测一次(R-022),MCP 状态缓存只在
挂载/切换/手动探测时刷新、无定时回路(R-023)。持仓(10s)/ 行情(5s)灯靠各自定时同步秒级自愈;
唯独两只连接灯恢复感知滞后,老师实测反馈「QMT连接灯一直没亮」「MCP 灯也不自动恢复」(2026-09-09)。
- **范围**:只改 QmtHealthMonitorR-022)与 QmtMcpManagerR-023)的探测调度;前端指示灯组件、
sync-status / qmt-health / mcp-status 端点零改动;健康稳态间隔 5 分钟不变;不新增配置项。
- **验收线**:两灯异常/未知态 ≤10s 重试;健康/已连接态维持 5 分钟不空转;服务恢复后缓存回正常态
沿快速重试被感知;无激活连接与 dispose 后停止调度。对应需求 R-022 + R-023。
## 目标讨论过程
1. 老师报告症状(2026-09-09):Bridge 启动后其他三灯自动点亮,QMT 灯不亮 → 定位为健康缓存固定 5 分钟探测。
2. AI 提议自适应节奏(健康 5min / 异常 10s),老师确认方向,迭代 19 实施(R-022)。
3. 老师复查发现 MCP 灯同样不自动恢复(R-023)→ 确认 QmtMcpManager 状态缓存无定时回路,
dsh-mcp-client 重连只恢复真实连接、不刷新本类缓存 → 同模式加自适应探测,并入本次迭代。
4. 决策点见 R-022 / R-023 决策记录。
## 对老师(项目主理人)的配合需求
- 确认 R-022 / R-023 定稿与迭代 19 验收(核实标准见验收标准.md);
- 真实环境人工验收:停/启 QMT Bridge → QMT 灯 ≤20s 回绿、MCP 灯 ≤20s 回绿;点击灯/「检测 MCP」仍即时探测;
插件重载后灯按缓存秒回并正常。
@@ -0,0 +1,29 @@
# 验收标准:19-指示灯状态自适应探测
## 验收标准线
| # | 标准 | 判定 |
|---|---|---|
| 1 | QMT 健康:异常/未知态按 retryMs(默认 10s)快速重试 | test-health-monitor [2]:≥2 次快速重试且缓存 healthy=false |
| 2 | QMT 健康:Bridge 恢复后 ≤ 一个重试周期缓存回 healthy=true(灯回绿),无需等 5 分钟 | 同脚本 [2] |
| 3 | QMT 健康:健康态维持稳态间隔(默认 5 分钟)不空转 | 同脚本 [3] |
| 4 | QMT 健康:stop() 后停止自动调度 | 同脚本 [4] |
| 5 | MCP:无激活连接置 disabled 且不调度(真实 probe 早退不触 SDK | test-mcp-status [1] |
| 6 | MCP:异常态按 retryMs 快速重试;MCP 服务恢复后自动探测到 connected(灯回绿) | 同脚本 [2] |
| 7 | MCP:已连接态按 intervalMs 稳态不空转;dispose() 后停止调度;_nextDelay 节奏正确 | 同脚本 [3][4][5] |
| 8 | 启动即探测 + 读缓存秒回语义不变(QMT 健康 / MCP 状态) | 两脚本 [1]sync-status/qmt-health/mcp-status 端点零改动 |
| 9 | 构建产物(lib/)含两处自适应逻辑;typecheck 0 错;存量不回归 | pnpm typecheck + pnpm buildtest-quote-sync / test-position-sync 通过 |
## 验收方法
- 自动化:node scripts/test-health-monitor.mjs10/10+ node scripts/test-mcp-status.mjs12/12)全绿;
pnpm typecheckpnpm build;存量回归脚本 0 退出。
- 人工(老师真实环境):重启插件后四灯状态秒回;停 QMT Bridge → QMT 灯 ≤10s 转红、MCP 灯 ≤10s 转红、
持仓/行情灯按 30s/15s 阈值转黄;**重新启动 QMT Bridge → QMT 灯与 MCP 灯 ≤20s 自动回绿**(本次修复核心);
点击 QMT 灯 /「检测 MCP」仍即时探测并刷新悬停详情。
## 验收目标
修复确认:QMT Bridge 及其 MCP 服务启动/恢复后,会话头部 QMT连接灯与 MCP 灯自动回绿(≤20s),
与其余两盏数据灯恢复节奏一致;健康/已连接稳态无额外探测开销;前端与 API 行为不变。
验收通过后 R-022 / R-023 定稿并归档(老师确认)。
@@ -0,0 +1,88 @@
# 技术实现方案:20-策略列设置拖拽排序
## 总体思路
在 ColumnSettingsPopover.jsx 内复用 Tab 设置(SettingsSection.jsx TabSettings)已验证的
**原生 HTML5 Drag & Drop** 模式:行 draggable + onDragStart/onDragOver/onDrop + 落点高亮 + drop 重排,
零第三方依赖。持久化沿用现有 strategy-columns/update 整表覆盖 RPC,把「保存」语义从手动按钮改为
每次变更(drop / 勾选 / ↑↓)立即提交。
## 改动点(单文件:src/client/views/ColumnSettingsPopover.jsx
### 1. 状态
- 已有 `dragKey`(未被使用,正好用于 DnD):当前被拖行的 column.key
- 新增 `overKey`:当前悬停落点的 column.key(用于高亮);
- `saving` 保持:正在写库时禁止再次拖动/勾选。
### 2. 持久化函数(替换原 save)
- 新增 `persist(nextList, msg)`:提交整表 `{key, visible}` → strategy-columns/update
成功 Toast 提示 + onSaved() 刷新;失败 Toast 错误 + 本地回滚(还原为服务端最新列配置)。
- 勾选显隐:切完立即 persist(不再等「保存」按钮);
- ↑↓ 箭头:移动完立即 persist;
- drop 重排:重排完立即 persist(对齐 Tab 设置)。
### 3. 拖拽事件(照搬 TabSettings 模式)
```jsx
<div key={c.key} draggable={!saving}
onDragStart={(e) => { setDragKey(c.key); e.dataTransfer.effectAllowed = 'move'; }}
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setOverKey(c.key); }}
onDragLeave={() => { if (overKey === c.key) setOverKey(null); }}
onDrop={(e) => { e.preventDefault(); handleDrop(c.key); }}
style={{ cursor: saving ? 'default' : 'grab',
background: overKey === c.key ? 'color-mix(in srgb, var(--dsw-alias-state-business-primary, #1565c0) 12%, transparent)' : 'transparent' }}>
<span></span>
<button></button><button></button>
<input type="checkbox" ... />
...
</div>
```
### 4. handleDrop(照搬 TabSettings 逻辑,key 定位)
```js
const handleDrop = (targetKey) => {
if (!dragKey || dragKey === targetKey) { setDragKey(null); setOverKey(null); return; }
const from = list.findIndex((c) => c.key === dragKey);
const to = list.findIndex((c) => c.key === targetKey);
if (from < 0 || to < 0) { setDragKey(null); setOverKey(null); return; }
const next = list.slice();
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
setDragKey(null); setOverKey(null);
persist(next, `${moved.label}」已移动`, next);
};
```
### 5. UI 文案
- 标题下提示改为:「勾选显示列,按住 ≡ 拖动调整顺序;代码/名称/操作固定」;
- 底部按钮区:去掉「保存」,保留「关闭」;
- 每行最前加 ≡ 手柄(配色沿用 Tab 设置 `#999`/label-tertiary)。
## 边界与不做
- 服务端、API、表格渲染零改动(strategy-columns/update 已是整表覆盖语义);
- 固定列不参与排序,不进列表(现状维持);
- 不做方案 C(表格表头直接拖列头),不引入第三方 DnD 库;
- `saving` 期间禁用拖拽与勾选,防并发写。
## 回归
- scripts/test-r013-columns.mjs(列配置归一化/读写的服务端逻辑,未改动应全绿);
- npm run typecheck + buildtsdown 产物,弹层 JSX 变更需重编 web bundle);
- 页面人工验收:拖拽重排 / 落点高亮 / drop 即存 / ↑↓ 微调 / 刷新保持。
## 实现纪要(2026-09-10,老师验收反馈后修订)
> 老师反馈:初版按原方案做的是「整行高亮」(overKey → 目标行淡蓝底),拖动时无法判断会插入目标列的**前面还是后面**,
> 观感不符合习惯。定稿修订为 **间隙高亮线**:
- 落点判定:拖动悬停时读鼠标在目标行内的 **Y 坐标**getBoundingClientRect),上半 → 插入该行**之前**(线画行上缘),
下半 → 插入该行**之后**(线画行下缘);两行之间显示 3px 主色圆角高亮线(absolute 定位在行自身 padding 内,left/right 6
无负偏移无裁剪风险),指示精确插入位置。
- drop 判定:drop 事件内直接按坐标重算 before(不依赖 state 闭包,避免滞后);无操作原地(insertAt 等于原槽位)
跳过写库;移除后目标下标左移(to > from 时 to-1)。
- 状态:overKey(整行)→ overPos { idx, half }(间隙线锚点行 + 上下缘);保留 onDragEnd 清理防拖出弹层残留。
- 边界维持:固定列不参与、服务端零改动、↑↓ 兜底保留、立即持久化不变。
@@ -0,0 +1,47 @@
# 迭代复盘:20-策略列设置拖拽排序
## 结论
达成(R-024):策略 tab「列设置」弹层的列排序从 ↑↓ 箭头点按升级为**鼠标选中后拖动排序**,
复用 R-011 Tab 设置已验证的原生 HTML5 DnD 模式;老师 2026-09-10 页面确认交互直观,验收通过。
## 事实记录
### src/client/views/ColumnSettingsPopover.jsx(单文件改动,服务端零改动)
- 排序交互升级:每行新增 ≡ 拖拽手柄(整行 draggable),拖到目标位置松手即重排;↑↓ 箭头保留作兜底微调。
- **落点立即持久化**:勾选显隐 / 拖拽 / ↑↓ 任一操作立即调 strategy-columns/update 整表提交;弹层去掉「保存」
按钮只留「关闭」,Toast 反馈;saving 期间禁用拖拽与勾选防并发写;失败回滚到服务端最新配置。
- **落点指示修订(老师验收反馈)**:初版为整行高亮(overKey → 目标行淡蓝底),老师反馈无法判断插入目标列的
前/后;改为**行间间隙高亮线**——按鼠标在目标行内的 Y 坐标取上半/下半,决定线画在该行上缘(插其前)/下缘(插其后),
明确指示精确插入位置;drop 事件内按坐标重算(不依赖 state 闭包),拖回原位置不重复写库,onDragEnd 清理残留。
- 修复:间隙线渲染曾误将 style 对象直接作 React child`{gapLineStyle(i)}`),改为条件渲染 `<div style={gapLineStyle(i)}/>`
### 验证
- pnpm typecheck 0 错;pnpm build 通过(client bundle 185KB 含间隙线逻辑,grep 确认编译产物);
- test-r013-columns.mjs 回归:11 过 / 5 败 —— **与本次改动无关**git stash 基线验证同样 11/5);
失败根因:脚本仍按 R-013 时期期望「6 个基础列」,而 COLUMN_META 已有 10 列(R-015 新增涨停/跌停/今开/最高 4 个默认隐藏列),
测试期望未随迭代 13 更新的存量债务,不在本迭代范围,列为遗留事项。
### 文档
- docs/05-需求池/R-024.md:定稿 + 变更记录(整行高亮 → 间隙高亮线);
- docs/03-设计约束/UI交互约束.md:新增 UI约束-007(列表/弹层排序交互规范:间隙高亮线标准,Tab 设置是否统一待老师评估);
- docs/04-迭代记录/20-策略列设置拖拽排序/:迭代目标 + 技术实现方案(含实现纪要)+ 验收标准(10 项)。
## 遗留事项(待老师决策)
1. **test-r013-columns.mjs 期望过期**COLUMN_META 现 10 列(R-015 新增 4 行情列默认隐藏),脚本期望仍为 6 列,
默认列数相关 5 项断言失败。建议后续小迭代同步更新脚本期望(或重构为从 COLUMN_META 推导期望),本次未动。
2. **Tab 设置整行高亮是否统一为间隙高亮线**:UI约束-007 列设置弹层已用间隙线,Tab 设置(UI约束-003)仍为整行高亮;
老师反馈列设置「直观多了」——若 Tab 设置同样已存在「无法判断插入前/后」的观感问题,可同模式调整(独立小任务,本次未动)。
## 经验沉淀(候选)
- 排序类拖拽的落点指示,**间隙线(两行之间)比整行高亮更符合用户观感**:整行高亮只能表达「落在哪一行」,
无法表达「插入该行前还是后」;主流列表/表格拖拽(如 Notion、表格列排序)均用间隙指示。
- 服务端整表覆盖语义(updateStrategyColumns 整表提交)天然适配前端即时持久化,无需逐条增量接口;
「保存按钮」在每次变更即写库后失去存在意义(本迭代据此移除)。
- 回归脚本的期望值要随数据模型演进同步更新(test-r013-columns 教训:R-015 扩展 COLUMN_META 后脚本未跟随,
留下 5 项静默失败,直到本迭代回归才暴露)。
@@ -0,0 +1,30 @@
# 迭代目标:20-策略列设置拖拽排序
## 目标
策略 tab「列设置」弹层(ColumnSettingsPopover)的列排序交互从 **↑↓ 箭头点按** 升级为
**鼠标选中后拖动排序**(原生 HTML5 DnD),并对齐 Tab 设置的即时持久化体验。
## 目标描述
- **背景**:策略 tab 的可配置列(基础数据列 + 自定义字段列)在「列设置」弹层中管理,排序目前依赖
↑↓ 箭头(R-013 迭代 11 产物,当时刻意简化,代码注释自述「用箭头替代 DnD」)。老师反馈希望
改成鼠标选中后拖动排序(2026-09-10)。
- **结论**:复用 R-011 Tab 设置已验证的原生 HTML5 DnD 模式(draggable 行 + 落点高亮 + drop 重排),
零第三方依赖;落点立即持久化(strategy-columns/update 整表覆盖),弹层去掉「保存」按钮只留「关闭」。
- **范围**:仅改 src/client/views/ColumnSettingsPopover.jsx 单文件;服务端/API/表格渲染零改动;
固定列(代码/名称/操作/展开箭头)不参与排序,维持现状。
- **验收线**:拖拽可重排列顺序且落点高亮可见;drop 后立即写库(无需点保存);↑↓ 箭头仍可微调;
勾选显隐立即生效;刷新后列顺序保持。对应需求 R-024(已定稿)。
## 目标讨论过程
1. 老师反馈(2026-09-10):策略 tab 列设计可否做成鼠标选中后拖动排序。
2. AI 调研:Tab 设置(R-011)已实现同款原生 HTML5 DnDSettingsSection.jsx),可零依赖复用;
列设置弹层存在未使用的 dragKey 状态(原计划 DnD 的残留)。
3. 登记 R-024(讨论中)→ 方案草案 A/B/C → 老师确认方案 A(弹层内拖拽);
保存时机 / 箭头去留按 AI 推荐默认(立即持久化 + ↑↓ 保留兜底)。
## 对老师(项目主理人)的配合需求
- 无(纯前端交互优化,服务端零改动,验收走页面人工确认)。
@@ -0,0 +1,32 @@
# 验收标准:20-策略列设置拖拽排序
## 验收线(在哪里验收)
DSH Web GUI → 神之一手 → 任一策略 tab → 工具条「列设置」按钮 → 弹层。
页面侧人工验收;服务端逻辑零改动,跑 scripts/test-r013-columns.mjs 回归确认无破坏。
## 验收方法(逐项操作)
| # | 操作 | 预期 |
|---|---|---|
| 1 | 打开「列设置」弹层,鼠标按住某行 ≡ 手柄(含整行)拖动到另一行附近移动鼠标 | 目标行上缘/下缘显示**主色间隙高亮线**(插入线),明确指示落点在目标列之前还是之后,非整行高亮 |
| 2 | drop 后直接关闭弹层(不点任何保存) | 策略表格表头列顺序已按新顺序展示(立即持久化生效) |
| 3 | 刷新页面 / 重新打开弹层 | 列顺序保持拖拽后的结果(写库成功) |
| 4 | 点 ↑ / ↓ 箭头 | 仍可逐格微调,且立即持久化(关闭弹层后表格列顺序同步变化) |
| 5 | 勾选/取消勾选某一列 | 显隐立即生效(关闭弹层后,表格对应列隐藏/出现) |
| 6 | 拖动到目标行上半区 vs 下半区各松手一次 | 上半区 = 插入该目标列**之前**;下半区 = 插入该目标列**之后**(以落点线位置为准) |
| 7 | 拖动期间把行拖回原位置或原地松手 | 无异常,列顺序不变(不重复写库) |
| 8 | 固定列(代码/名称/操作) | 弹层列表不含固定列,不参与拖拽,恒显示(维持现状) |
| 9 | 自定义字段列(某策略配了 configSchema) | 与基础列一样可拖拽排序、可勾选显隐 |
| 10 | 保存中(快速连续拖动) | 无并发写异常;saving 期间拖拽/勾选禁用,Toast 提示保存结果 |
## 验收目标
- 功能正交:拖拽排序 / ↑↓ 微调 / 显隐勾选 三者并存且各自立即持久化;
- 零回归:scripts/test-r013-columns.mjs 全绿、typecheck + build 通过;
- 视觉一致性:≡ 手柄沿用 Tab 设置(UI约束-003)同套样式;落点为**间隙高亮线**(主色 token,UI约束-004 无硬编码色值);
- 服务端零改动确认:git diff 无 src/api / src/settings.js 变更。
## 判定
- 老师页面人工确认 10 项全过 + 服务端回归全绿 ⇒ 迭代 20 验收通过,R-024 归档至 已完成/ 并更新索引实现状态。
@@ -0,0 +1,84 @@
# 技术实现方案:21-Tab设置排序间隙高亮统一
## 总体思路
把 SettingsSection.jsx 的 TabSettings 组件落点指示从「整行高亮(overId → 行背景 color-mix 12%)」
改为「行间间隙高亮线(overPos { idx, half } → 目标行上缘/下缘 3px 主色线)」,与 ColumnSettingsPopover.jsx
(R-024)逐点对齐;其余交互(≡ 手柄、开关、立即持久化、徽标、saving 禁用)不动。
## 改动点(单文件:src/client/views/SettingsSection.jsx,仅 TabSettings 组件)
### 1. 状态
- `overId`(整行高亮锚点)→ `overPos { idx, half }`(间隙线锚点行下标 + 上/下缘);
- `dragId` 保留。
### 2. 悬停判定(与 R-024 全同)
```jsx
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 });
};
```
### 3. drop(事件内按坐标重算,不依赖 state 闭包)
```jsx
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);
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);
const insertAt = to > from ? to - 1 : to;
next.splice(insertAt, 0, moved);
const ordered = next.map((x, i) => ({ ...x, order: i }));
setDragId(null);
setOverPos(null);
await persist(ordered, '「' + moved.name + '」已移动');
};
```
### 4. 行渲染(tr 上挂间隙线样式)
tr 为表格行,直接内嵌 div 会破坏表格结构(tr 只允许 td/th)——**间隙线以 td 为定位锚点绘制**:
首选在每个 td 内各画一段 3px 高亮线(结构合规、视觉连续),实现纪要记录最终选型。
```jsx
<tr
key={t.id}
draggable={!saving}
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={{ cursor: saving ? 'default' : 'grab' }}
>
<td style={{ position: 'relative', padding: '8px 6px' }}> {gapLine(i, 'td1')} </td>
...
</tr>
```
间隙线样式(与 R-024 全同):absolute、left/right 内缩 6、height 3、borderRadius 2、
背景 primary tokentop:0(插前)/ bottom:0(插后)。
## 边界与不做
- 服务端/API/表格列结构/持久化逻辑零改动;
- 不动「策略分组」子 tab(该处无排序交互);
- 不引入第三方 DnD 库。
## 回归
- pnpm typecheck + buildSettingsSection 变更需重编 web bundle);
- 页面人工验收:Tab 设置拖动 → 间隙线位置正确(上=前/下=后)、整行高亮消失、drop 即存、
拖回原位不重复写库、刷新页面后顺序保持。
@@ -0,0 +1,26 @@
# 迭代目标:21-Tab设置排序间隙高亮统一
## 目标
设置页「Tab 设置」的分组 tab 拖拽排序落点指示,从**整行高亮**统一为**行间间隙高亮线**,
与策略 tab 列设置弹层(R-024/UI约束-007)完全一致——明确指示插入目标行的前/后。
## 目标描述
- **背景**:迭代 20 复盘遗留第 2 项——R-024 将列设置弹层落点改为间隙高亮线后老师确认「直观多了」,
但设置页 Tab 设置(R-011/UI约束-003)仍是整行高亮(overId → 目标行淡蓝底),无法判断插入前/后,
两处观感不一致。2026-09-10 老师指令:Tab 设置也统一为间隙高亮线形式。
- **范围**:仅改 src/client/views/SettingsSection.jsx 的 TabSettings 组件落点指示;表格结构、≡ 手柄、
显隐开关、立即持久化(tabs/update)、徽标、saving 禁用全部维持不变;服务端/API 零改动。
- **验收线**:拖动时目标行上缘/下缘显示主色间隙线(上半区=插前、下半区=插后);整行高亮消失;
拖回原位不重复写库;drop 后立即持久化。对应需求 R-025(已定稿)。
## 目标讨论过程
1. 迭代 20 复盘(2026-09-10)列遗留第 2 项:Tab 设置整行高亮是否统一为间隙线,留待老师评估。
2. 老师指令(2026-09-10):设置页分组 tab 排序也改上面(列设置弹层)的拖动高亮形式 → 登记 R-025 已定稿,
迭代 21 实施。
## 对老师(项目主理人)的配合需求
- 无(纯前端交互统一,服务端零改动,验收走页面人工确认)。
@@ -0,0 +1,27 @@
# 验收标准:21-Tab设置排序间隙高亮统一
## 验收线(在哪里验收)
DSH Web GUI → 设置页 → 「Tab 设置」子 tab → 拖动任一标签行排序。
页面侧人工验收;服务端逻辑零改动。
## 验收方法(逐项操作)
| # | 操作 | 预期 |
|---|---|---|
| 1 | 打开「Tab 设置」,按住某行 ≡ 手柄(含整行)拖动到另一行附近移动鼠标 | 目标行上缘/下缘显示**主色间隙高亮线**(插入线),明确指示落点在目标 tab 之前还是之后;原整行淡蓝高亮消失 |
| 2 | 拖动到目标行上半区 vs 下半区各松手一次 | 上半区 = 插入该 tab **之前**;下半区 = 插入该 tab **之后**(以落点线位置为准) |
| 3 | drop 后落点立即生效 | Toast「已移动」,刷新页面后 tab 顺序保持(tabs/update 已写库) |
| 4 | 拖动期间把行拖回原位置或原地松手 | 无异常,顺序不变,且不触发重复写库 |
| 5 | 既有交互回归 | ≡ 手柄、显示/隐藏开关、内置/策略徽标、saving 期间拖拽禁用——全部维持原行为 |
| 6 | 策略分组子 tab | 无排序交互,不受影响(仍为 新增/重命名/删除) |
## 验收目标
- 功能对准:Tab 设置落点与列设置弹层(R-024)**同一套间隙线交互**,观感一致;
- 零回归:既有 tabs 持久化(整表 order + visible)、显隐开关、策略改名联动 name join 不受影响;
- typecheck + build 通过;无新增硬编码色值(UI约束-004)。
## 判定
- 老师页面人工确认 6 项全过 + build 通过 ⇒ 迭代 21 验收通过,R-025 归档至 已完成/ 并更新索引实现状态。
@@ -0,0 +1,164 @@
# 迭代 22 UI 交互设计:字段配置入口迁移 + 策略计算字段
> 依据:**R-027(重构·第一阶段,已定稿)** + **R-026(计算字段·第二阶段,已定稿)** + PLAN-019 日期:2026-09-10
> 状态:**已定稿**(D-1D-7 老师逐项拍板;2026-09-10 因入口迁移(R-027)同步修订;D-8~D-9 为 AI 默认项,老师可否决)
> 视觉基线:UI约束-004--dsw-* token)、UI约束-006ExpandChevron)、UI约束-007(间隙高亮线);UI约束-005 待随本次修订
> 复用:ColumnSettingsPopover(列设置)、StrategyFieldsEditor(改造为弹层内容)、ToastFieldCellEditor 不参与(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 默认项,老师如有异议直接改判)。
@@ -0,0 +1,250 @@
# 技术实现方案:22-策略计算字段与策略 tab 静态化
> 依据:R-027(重构·第一阶段)+ R-026(计算字段·第二阶段)+ PLAN-019 + `UI交互设计.md`(已定稿)
> 日期:2026-09-10 | 状态:**方案稿(待老师过审后进入实现)**
> 约束依据:技术约束-003(REST 直连)/011(回归独立数据目录)/012(策略定义存储)/015configSchema)、UI约束-004/005/006/007;本次修订与新增见 §7
## 0. 总览
| 阶段 | 需求 | 一句话 | 主要落点 |
|---|---|---|---|
| 一 | R-027 | 策略 tab 静态化 + 字段配置入口迁到策略 tab 内 | `settings.js` / `api/strategies.js` / `SettingsSection.jsx` / `StrategyTab.jsx` / `StrategyFieldsEditor.jsx` |
| 二 | R-026 | 新增「计算(formula)」字段类型:中文变量公式引用服务端缓存数据集现算 | `formula/`(新)/ `PositionManager` 或 API 层附着 / `StrategyTab.jsx` / `StrategyFieldsEditor.jsx` |
**关键设计决策(本方案定)**
1. **计算在服务端 API 层附着**`strategy-positions` 返回前),不改 `PositionManager` 的份额语义;
2. **聚合口径只读内存缓存**QuoteHub.quotes / instruments),即使 miss 也**不做读穿透**——`strategy-positions` 是高频读路径,不引入网络往返;行情新鲜度由 QuoteSync 5s 覆盖持仓 code 保证;
3. **无 formula 字段的策略零开销**(不取行情、不进引擎);
4. 存储真相源单一:字段定义迁到 `strategyFields``strategies` 收窄为内置身份表(读取兼容旧定义)。
## 1. 第一阶段:静态化与入口迁移(R-027)
### 1.1 数据模型(src/settings.js
**常量(新增/收窄)**
```js
/** 内置策略(静态,R-027):身份固定,不可增删改名 */
export const BUILTIN_STRATEGIES = [
{ id: 'grid-supermarket', name: '网格超市' },
{ id: 'manual-t', name: '手动做T' },
];
```
- `DEFAULT_STRATEGIES` 退役(由 `BUILTIN_STRATEGIES` 取代,内部先行别名过渡)。
**schema 变更**
```js
strategies: z.array(z.object({ id, name, configSchema })).default([]), // 收窄:仅身份表,改为内置两项(兼容读取)
strategyFields: z.dict(z.array(fieldSchema)).default({}), // 新增:策略字段定义真相源 { [strategyId]: configSchema }
strategyColumns: // 不变
```
- `fieldSchema``{ key, label, type(text|number|boolean|enum|formula), enum[], def, unit, formula, decimals }``formula`/`decimals` 为第二阶段新增,见 §2.1)。
**读取归一化(单一真相源 + 旧数据兼容)**
```js
export function getStrategies(scope) {
const v = scope?.get() ?? {};
const legacy = new Map((v.strategies ?? []).map(s => [s.id, s.configSchema])); // 旧:定义挂 strategies[]
return BUILTIN_STRATEGIES.map(s => ({
...s,
configSchema: Array.isArray(v.strategyFields?.[s.id])
? v.strategyFields[s.id]
: (Array.isArray(legacy.get(s.id)) ? legacy.get(s.id) : []), // 旧值回填视图(首次保存即落到 strategyFields
}));
}
export function getStrategyFields(scope, strategyId) { /* 同上单策略版,未知 id → [] */ }
export async function updateStrategyFields(scope, strategyId, configSchema) {
assertBuiltinStrategy(strategyId); // 非内置 → 抛 strategy-not-found
const cur = scope.get() ?? {};
await scope.update({ ...cur, strategyFields: { ...(cur.strategyFields ?? {}), [strategyId]: normalizeFields(configSchema) } });
}
```
- 兼容:存量 `strategies[].configSchema`(本机 = 网格超市 5 字段 / 手动做T 1 字段)读取时回填,前端表单提交**全量** → 首次保存即写入 `strategyFields`**无需迁移脚本**
- 自建策略(本机实测无):读取时忽略,其 `strategy_holdings` 行保留在库(不删数据)。
**tabs 归一化(`normalizeTabs` 收窄)**
- 默认序列 = 内置 tab(3)+ 内置策略 tab(2);
- 读取:按存量 `tabs``visible/order` 应用偏好,**丢弃** `kind='strategy'``refId` 不在 `BUILTIN_STRATEGIES` 中的条目(自建策略 tab 退役);
- 新增策略 tab 条目若存量缺失(旧数据无策略 tab)→ 按默认序补齐;
- 删除:`appendStrategyTab` / `removeStrategyTab`(不再有增删场景)。
**退役清单(实施确认)**`addStrategy` / `removeStrategy` / `renameStrategy` / `updateStrategies` / `appendStrategyTab` / `removeStrategyTab` / `DEFAULT_STRATEGIES` 已删除;`generateStrategyId` / `PINYIN_MAP` **保留**QMT 连接 id 生成 `generateQmtConnectionId` 仍依赖,本轮不动)。
### 1.2 API 变更(src/api/strategies.js
| 端点 | 处理 |
|---|---|
| `strategies` | 保留(返回内置两项 + 各自 configSchema |
| `strategies/add` | **退役** |
| `strategies/remove` | **退役** |
| `strategies/update` | **退役**(整表写入不再需要;重命名随静态化取消) |
| `strategies/schema-update` | **新增**`{ strategyId, configSchema }` → 服务端校验(§2.5)→ `updateStrategyFields` → 返回该策略 `{ id, name, configSchema }` |
| `tabs` / `tabs/update` | 保留(显隐 + 排序为唯一可变项) |
| `strategy-positions` | 保留 + 第二阶段附 `computed`(§2.4 |
| `formula/trial` | **新增**(第二阶段,§2.5 |
- `STRATEGY_METHODS` 集合同步增删;`api/index.js` 头部注释同步。
- `handleStrategy` 解构补 `marketHub, dataSource`(计算字段取数用)。
### 1.3 设置页收敛(SettingsSection.jsx
- 删除子 tab 项 `{ key:'strategies', label:'策略分组' }` 与渲染分支、`StrategyGroupSettings` 组件、`StrategyFieldsEditor` 引入、新增/重命名/删除逻辑与删除确认弹窗(L208–436 区块);
- 子 tab 仅剩 `tabs` / `qmt``activeTab` 初值维持 `'tabs'`
### 1.4 字段配置弹层(前端)
**`src/client/views/FieldConfigDialog.jsx`(新增)**
- props`{ strategyId, strategyName, configSchema, onSaved, onClose }`
- 形态:锚定弹层(`width 520``maxHeight 70vh`、内部滚动),视觉沿用 `ColumnSettingsPopover`
- 底部动作:`[关闭]`(次) + `[保存字段]`(主,saving 时 disabled);
- **未保存改动**`dirty`)时 关闭/外点/Esc → 二次确认「有未保存的字段改动,确定放弃?」(D-8)。
**`src/client/views/StrategyFieldsEditor.jsx`(改造)**
- 由「设置页整表保存」改为「弹层内容 + 单策略保存」:`onSave(fields)` → 调 `one-divine-lot/strategies/schema-update { strategyId, configSchema: fields }`
- 类型下拉增 `计算`(第二阶段);`dirty` 状态上报父弹层(用于放弃确认)。
**`src/client/views/StrategyTab.jsx`(改造)**
- 顶栏「列设置」左侧新增「字段配置」按钮 → 打开 `FieldConfigDialog``onSaved``load()`(列与数据同步刷新);
- configSchema 复用既有加载(L236239)。
## 2. 第二阶段:计算字段(R-026)
### 2.1 字段模型扩展(settings.js fieldSchema
```js
{ key, label, type: '…'|'formula', enum: [], def, unit: '',
formula: z.string().default(''), // 仅 type='formula':表达式串(中文变量)
decimals: z.number().default(2) } // 仅 type='formula':显示小数位 0-4
```
- `normalizeFields``decimals` 夹取 04 整数;`formula` 去首尾空白;`type='formula'` 时忽略 `def`
- 值不落库:`strategy_holdings.values` 与 formula 字段无关(读写路径零改动)。
### 2.2 公式引擎(`src/formula/evaluator.js`,纯函数零依赖)
```js
validateFormula(expr, allowedVars) { ok: true, vars: Set<string> }
| { ok: false, error: { code: 'empty'|'syntax'|'unknown-var'|'unknown-func'|'arity', message, position } }
evaluateFormula(expr, ctx) number | null // ctx: { [中文变量名]: number|null }
evaluateFormulaCompiled(ast, ctx) number | null // 编译一次多行复用(性能)
```
- **词法**:数字(整数/小数)、标识符(`\p{L}` 起头,含中文,可含数字/下划线)、运算符 `+ - * / × ÷ ( ) ,``×``*``÷``/` 归一化);
- **语法优先级**:括号 > 一元负号 > `* / / ÷` > `+ -`;函数调用 `name(arg, …)`
- **内置函数**`round(x[, n=0])``abs(x)``min(a, b, …)``max(a, b, …)`
- **求值语义**:引用的任一变量为 `null/undefined/NaN` → 整式返回 `null`(缺值短路,不产出 NaN);除零、非法运算、结果非有限 → `null`
- **安全**:自写解析,不使用 `eval/Function`(技术约束新增条目,§7)。
### 2.3 变量目录(`src/formula/variables.js`
```js
export const VARIABLE_GROUPS = [
{ group: '行情', source: 'quote', items: [
{ name: '现价', field: 'lastPrice', desc: '最新成交价' },
{ name: '昨收', field: 'lastClose' }, { name: '今开', field: 'open' },
{ name: '最高', field: 'high' }, { name: '最低', field: 'low' },
{ name: '成交量', field: 'volume' }, { name: '成交额', field: 'amount' } ] },
{ group: '合约', source: 'instrument', items: [
{ name: '涨停价', field: 'upStopPrice' }, { name: '跌停价', field: 'downStopPrice' } ] },
{ group: '持仓', source: 'row', items: [
{ name: '份额', field: 'shares' }, { name: '成本价', field: 'avgPrice' }, { name: '最后成交价', field: 'lastTradePrice' } ] },
{ group: '自定义字段', source: 'values', items: [] }, // 运行时按策略非 formula 字段的 label 动态展开
];
export const BUILTIN_VAR_NAMES = new Set([...行情/合约/持仓 items name]);
export function buildContext({ row, quote, instrument, fieldDefs }) { [中文名]: number|null }
```
- 自定义字段变量名 = 字段 `label`;值取 `row.values?.[key]`(缺省回退 `def`);非数字值(text/enum/boolean)→ `Number(...)` 失败即 `null`
- **不收录 formula 字段**(零循环依赖)。
### 2.4 计算服务与数据通路
**`src/formula/FormulaService.js`(新增)**
```js
computeRows({ strategyId, rows, fieldDefs, marketHub }) rows 附加 computed: { [fieldKey]: number|null }
```
1.`type='formula'` 字段 → **直接返回原 rows**(零开销);
2. `codes = rows.map(r => r.code)` → 行情**只读内存缓存**`marketHub.quotes.get(code)`、合约 `marketHub.instruments.get(code)`(与 `api/market.js#projectQuote` 同口径;**不读穿透**,miss → 相关变量 null);
3. 逐行 `buildContext`(含该策略非 formula 字段的值)→ 每个 formula 字段 `evaluateFormulaCompiled` 一次;
4. 结果:`computed[field.key] = number|null`(引擎返回原始数值,格式化交前端)。
**接线**`api/strategies.js``strategy-positions` 分支)
```js
const rows = await manager.getStrategyPositions(args.strategyId);
return await formulaService.computeRows({ strategyId: args.strategyId, rows,
fieldDefs: getStrategyFields(settings, args.strategyId), marketHub });
```
- 历史持仓端点(`strategy-holdings/history`**不计算**:前端对历史行显示 `—`
### 2.5 API(校验与试算)
**`strategies/schema-update`(含字段校验)**
- 结构校验:`key` 非空且唯一(同策略内)、`label` 非空且唯一(D-1)、`type` 合法;
- formula 字段:`validateFormula(formula, allowedVars)``allowedVars` = 内置变量名 本策略**非 formula** 字段的 label
- 名称冲突:`label ∈ BUILTIN_VAR_NAMES` → 拒绝(`variable-name-conflict`);
- 失败统一抛 `{ code:'field-validation', message }`(前端 Toast + 公式框描红 + 文案)。
**`formula/trial`(新增)**
```js
// args: { strategyId, formula, decimals?, unit? }
// 1) 校验公式;2) 取该策略首行份额 > 0 的持仓;3) 取缓存行情/合约 → buildContext → 求值
// → { code, name, value: number|null, reason?: 'no-holding'|'no-data'|null }
```
### 2.6 前端渲染(StrategyTab.jsx / ColumnSettingsPopover.jsx
- 列取值:`computed?.[key]``null``—`tertiary);数值 → `toFixed(decimals)` 去尾零 + `unit` 拼接;
- 表头:formula 字段列名前缀 `ƒ`tertiary`title="计算字段(只读)"`),列头/单元格 `title` = `= <公式原文>`
- 只读:formula 列**不挂** `FieldCellEditor`、不绑编辑 onClick`fieldDef.type === 'formula'` 分支直接渲染文本);
- 列设置弹层:列名前缀 `ƒ``normalizeStrategyColumns` 的字段列附 `type` 供 UI 判断;排序/显隐零改动);
- 历史行:无 `computed``—`
## 3. 涉及文件与改动清单
```
src/settings.js # BUILTIN_STRATEGIES / strategyFields / normalizeTabs 收窄 / 退役 CRUD 辅助
src/api/strategies.js # 退役 add|remove|update;新增 schema-update、formula/trialstrategy-positions 附着 computed
src/api/index.js # 头部端点注释同步
src/formula/evaluator.js # 新增:词法/语法/求值/校验
src/formula/variables.js # 新增:变量目录 + buildContext
src/formula/FormulaService.js # 新增:批量现算(缓存只读)
src/client/views/SettingsSection.jsx # 移除「策略分组」子 tab
src/client/views/FieldConfigDialog.jsx # 新增:字段配置弹层
src/client/views/StrategyFieldsEditor.jsx# 改造:弹层内容 + 单策略保存 + formula 分支(公式框/变量选择器/试算/小数位)
src/client/views/StrategyTab.jsx # 顶栏「字段配置」按钮 + 计算列只读渲染
src/client/views/ColumnSettingsPopover.jsx # 列名 ƒ 前缀(仅文案)
scripts/test-r027-static-strategies.mjs # 新增回归(第一阶段)
scripts/test-r026-formula-fields.mjs # 新增回归(第二阶段:引擎 + 目录 + 校验 + 附着 + 试算)
scripts/test-r011-tabs.mjs # 更新(策略 tab 静态化后断言调整)
scripts/test-r013-custom-fields.mjs # 更新(改用 updateStrategyFields
```
## 4. 回归与验证
- 回归脚本均用独立数据目录(`ODL_TEST_DATA_DIR`,技术约束-011),内存 mock settings scope(沿用 test-r013 惯例);
- **test-r027**:内置策略恒两项;旧 settingsstrategies[].configSchema)读取回填;`updateStrategyFields` 单策略写入且不污染另一策略;非内置 strategyId → 拒绝;`normalizeTabs` 丢弃自建策略条目且保留 visible/order`strategies/add|remove|update` 端点已退役(unknown method);
- **test-r026**:引擎(中文变量 / 优先级 / 括号 / 一元负 / 函数 arity / 语法错误位置 / 缺值短路 / 除零 → null);变量目录(三组内置名、自定义 label 动态、formula 字段不入目录);`schema-update` 校验(未知变量 / 名称冲突 / 重复 key / 重复 label);`strategy-positions` 附着 `computed`(有/无 formula 字段两条路径);`formula/trial`(正常 / 无持仓 / 缺数据);
- `pnpm typecheck` + `pnpm build` 通过;
- 老师人工验收(见 `验收标准.md`)。
## 5. 风险与取舍
| 项 | 取舍 / 缓解 |
|---|---|
| 现算开销 | 仅含 formula 字段的策略付出;行情只读内存(无网络);行数为数十级 → 毫秒内;未直接引入缓存 |
| 行情 miss | 不读穿透 → 该行算式 `—`(等 QuoteSync 下轮 5s 刷新);换取 `strategy-positions` 路径零网络 |
| 中文标识符 | 自写词法支持 `\p{L}`;不用第三方库(均不支持中文变量) |
| 旧数据 | 读取回填 + 首次保存落 `strategyFields`;不写迁移脚本、不删库数据 |
| 自建策略 | 读取忽略 + tab 丢弃;其持仓数据保留在库(不自动清份额),需要时人工处理 |
| 双份真相源风险 | `strategies[].configSchema` **只读兼容**、写路径唯一走 `strategyFields`(避免双写打架) |
## 6. 实施顺序
1. settings 层(常量 / strategyFields / tabs 归一化 / 退役)+ test-r027
2. API 层(端点退役 + schema-update+ 设置页收敛 + 字段配置弹层 + test-r013 更新;
3. 公式引擎 + 变量目录 + FormulaService + test-r026(引擎部分);
4. schema-update 字段校验 + formula/trial + strategy-positions 附着;
5. 前端 formula 分支(公式框/选择器/试算/小数位)+ 只读计算列;
6. typecheck + build + 全量回归 → 老师人工验收 → 复盘。
## 7. 约束落地(实施时执行)
- **修订**:产品约束-002/003/004(标签体系 → 固定两策略 + 字段自定义)、产品约束-009(Tab 统一管理中去掉策略条目动态化)、UI约束-002(设置页子 tab 构成)、UI约束-003(Tab 设置策略行不再随 CRUD 变化)、UI约束-005(字段配置入口迁移 + 更正为「表格列化 + 单元格内联编辑」现状);
- **新增**:UI约束-008(计算字段表单 + 只读计算列:`ƒ` 标记 / 悬停公式 / 小数位 / `—`)、产品功能约束(计算字段语义:不落库、只读、仅非公式变量)、技术方案约束(公式引擎自写零依赖不用 eval、变量目录单一入口、现算只读内存缓存)。
@@ -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/21R-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`**A1A11**(重构)+ **B1B12**(计算字段),重点四条:
1. 设置页无「策略分组」;策略 tab 恒两个、不可增删改名,但 Tab 设置仍可显隐/拖拽排序;
2. 策略 tab 顶栏「字段配置 / 列设置」可用,字段增删保存后表格列跟随、重启后定义仍在;
3. 计算字段可用:中文变量公式 + 变量选择器 + 试算 → 持仓列只读显示结果(`ƒ` 标记 + 悬停看公式);
4. 边界:非法公式/未知变量/名称冲突被拒 + 描红;缺数据与历史行显示 `—``strategy_holdings.values` 不含计算字段键。
## 经验沉淀(候选)
- **并行委派的契约必须在开工前冻结**:本轮前端由子代理并行实现,期间服务端 `settings.js` 仍在演进
(子代理观察到 mtime 变化、并在报告里提出「疑似工作区未冻结」)——契约冻结 + 文件集互斥是并行可行性的前提。
- **真相源迁移要全量搜读点**:本次 `normalizeStrategyColumns` 差点漏改,靠既有回归脚本的红灯暴露;
遗留的旧键兼容读取(而非直接删键)让迁移零风险、零脚本。
- **测试期望也需要跟着设计演进**`test-r013-columns` 的过期期望在迭代 20 复盘就被记录却未处理,
本轮顺手清偿,避免「红灯常态化」。
@@ -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-1D-7(计算字段交互)与 Q1~Q6(重构方案)—— 全部采纳 AI 建议;
- **后续**:技术方案过审后,按两阶段做页面人工验收——① 设置页无策略分组、策略 tab 不可增删改名、字段配置弹层可用;
② 公式配置与试算、持仓表只读计算列、缺数据/非法公式边界。
@@ -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 + B1B12)。
## 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. 判定线
**通过(全部满足)**
- A1A11 与 B1B12 全部通过;
- 回归脚本全绿 + typecheck/build 通过;
- 老师人工验收确认。
**任一即不通过**
- 计算字段写入 `values` 列(或落任何库表);
- 公式可执行表达式以外的行为(无 `eval/Function`、不可触达进程/文件能力);
- 无 formula 字段的策略出现额外取数开销或行为变化(回归);
- 既有四类字段编辑、列设置、历史持仓、份额/归属任一回归失败;
- 策略仍可被增删改名(静态化未达成)。
## 8. 明确不在本次验收范围
- 策略级聚合(跨行求和)、QMT 账户域/交易记录作为变量源、公式引用公式、历史时点计算、布尔型公式结果、公式框实时高亮/自动补全、完整 T-006 数据池;
- 自建策略的历史数据清理(本机无自建策略;如未来出现,另行讨论)。
@@ -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]` 取值) | 比较/逻辑/三元/自定义运算符 | 中 | 取值语义与「缺值短路」需自行包装 |
| **CELcel-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 条断言)。
@@ -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 老师反馈)
- 字段配置弹层的变量选择从「+ 插入变量 ▾」下拉改为**标签平铺**(分组成行、点选即插入公式框光标处);
已随迭代实施并重建 bundletypecheck/build/17 脚本回归全绿)。
## 遗留
- 判定「不满足」与「缺数据」同显示为 `—`:若老师希望区分(如缺数据显示 `?` 或 tooltip「数据缺失」),
可后续加 hover 原因;
- 由老师侧重载插件后手动添加两个字段(或授权我用 `strategies/schema-update` 端点直接写入 settings)。
@@ -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`):C1C8。
## 目标分解
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` C1C8 人工确认。
@@ -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` 一元取反、判定与数字混用的单字段、策略级聚合(跨行求和)、
历史持仓行的时点判定。
+60
View File
@@ -0,0 +1,60 @@
# 需求:R-021 神之一手数据加入会话(对象目录 + 添加机制;复盘为第一用例)· 已定稿(部分暂定)
> 登记:2026-09-08 来源:老师指令(原 R-019 + R-021 合并演进)| 状态:**已定稿(暂定项见文末)**
> 归属:迭代 17-策略会话(Phase 0)| 计划:PLAN-017 | 实现状态:已立项(设计阶段)
## 需求本质(2026-09-08 再定调,老师)
> **总体是:会话(agent)可以获取"神之一手"的数据;复盘只是 agent 获取到对应数据后的一种用法。本需求要确定两件事:① 数据怎么添加到会话 ② 哪些数据可以加。**
> (策略会话、粒度复盘、未来的盘中问答/分析 都是同一能力的消费方式——主干 = 「神之一手数据 → 会话」能力。)
## 一、数据怎么添加到会话(机制)——暂定 2026-09-08
| 维度 | 暂定结论(AI 建议,老师"暂定" | 开放注记 |
|---|---|---|
| 触发动因 | **A:人挑对象加入**——在数据展示处/会话内选一个"数据对象",生成它的数据上下文进会话 | B(agent 按需取数工具)不采纳,维持 R-019"预注入不挂工具"拍板;日后盘中问答需要实时取数时再独立讨论 |
| 注入形态 | 每个对象 = 一条只读「数据上下文」(折叠条目/引用),含快照时间;同对象重复加 = 新快照追加(不覆盖旧),模型以最近为准 | 通道形态待宿主技术调研 |
| 数据时效 | 注入"加入那一刻"的只读快照;要最新 = 重新添加 | — |
## 二、哪些数据可以加(对象目录)——候选目录,首批范围暂定
### 神之一手数据对象目录(候选全量)
账户:账户资金/资产、全部持仓(对账单快照)| 策略:策略(分组下持仓与交易)| 持仓:当前持仓行、历史(已清仓)持仓| 轮次:某一轮交易周期(round,待建模)| 交易:单笔委托/成交| 关系视图:持仓 ↔ 交易| 行情:单 code 现价(随行,不单独成对象)| 汇总:策略汇总/未分配/同步健康 | 未来:决策日志/统计/复盘记录(Phase 1/3
### 首批范围(复盘闭环所需,暂定草案,待老师细调)
| # | 对象 | 加入后 agent 拿到的数据上下文(草案) | 为什么复盘需要 |
|---|---|---|---|
| P1 | 策略 | 策略定义 + 当前持仓 + 关联交易聚合摘要 | 整策略复盘/会话依据 |
| P2 | 持仓(当前/历史) | 持仓完整档案:基本信息+自定义字段+建仓至今买卖链+现价盈亏+(历史=清仓信息) | 复盘一个持仓 |
| P3 | 持仓↔交易关系 | 该持仓全部关联委托/成交(按 holding 聚合) | 追溯买卖过程 |
| P4 | 单笔委托 | 时间/方向/量/价/费用/归属 + 该持仓上下文 | 复盘某次动作 |
| P5 | 轮次 round | 一轮建→平 全动作+结果(需本轮引入 round 建模) | 复盘最小单元(网格/做T 一轮) |
| P6 | 行情随行 | 对象行内现价/涨跌等(不单独成对象;历史 K 线不在本期数据域) | 复盘需当前价对照 |
**候选后补**:账户资产 / 全部持仓对账单 / 策略汇总 / 未分配 / 同步健康(供 Phase 1 盘中辅助与 Phase 2 日结)。
## 三、用例视角(复盘仅是用法之一)
- 复盘 = 挑对象(P1-P6 任意组合,通常 P2/P3/P4/P5)→ 数据上下文进会话 → agent 基于事实线引导复盘 → 结论留会话;
- 其他用例(未来消费方式):盘中状态问答、策略分析、计划讨论——同一"对象目录 + 添加机制"。
## 定稿边界(合并两轮结论)
**做**:数据对象目录 + 添加机制(人挑对象 → 只读数据上下文注入会话);首批对象数据上下文生成;折叠条目呈现;换/刷新/叠加;round 建模(若 P5 首批保留)。
**不做**:DSH 宿主工作区;账本写与交易执行;agent 按需取数工具(暂定);复盘结论落库/统计/决策日志实现(Phase 1/2/3,预留模型位);历史 K 线注入;通用数据视图(全部持仓/交易记录)作为对象目录外的会话依据特例——(对象目录已含"全部持仓",是否启用由首批范围定)。
## 待定/开放项
1. 首批对象范围与 P1-P6 取舍(老师可增删,含是否本期引入 round=P5);
2. 宿主注入通道形态(技术调研);
3. 各对象"数据上下文"的精确字段规格(服务端实现细节,技术方案阶段);
4. 会话侧呈现细节(UI 交互设计延后,老师先前指令)。
## 关联
- 迭代 17-策略会话(Phase 0)| R-010(持仓↔交易关系载体)| 终极目标-002/006/007
- 未来:Phase 1 决策留痕/盘中辅助、Phase 2 日结统计、Phase 3 复盘库(产品逻辑设计 §0.5)
- 归档:R-019 已并入本需求(已完成/R-019.md 为讨论史存档)
## 验收
**(技术方案定稿后补入 04-迭代记录/17-策略会话/验收标准.md)**
+37
View File
@@ -0,0 +1,37 @@
# 需求:R-022 QMT 连接健康灯自适应探测(Bridge 启动/恢复后快速回绿)· 讨论中
> 登记:2026-09-09 | 来源:老师反馈(会话头部 QMT 指示灯)| 状态:**讨论中(AI 提议定稿,待老师确认)**
> 归属:迭代 19-指示灯状态自适应探测(R-022:QMT 健康灯)| 实现状态:已实现(待验收)
## 症状(老师反馈,2026-09-09
启动 QMT Bridge 后,会话头部四个指示灯中「持仓数据 / 行情数据 / MCP」几秒内自动点亮,
唯独「QMT连接」灯一直不亮(停留在红/灰态)——「QMT 连接状态自动检查好像没有生效」。
## 根因
QmtHealthMonitor 只在**插件启动时 + 每 5 分钟**探测一次激活 Bridge /health2026-09-01 迭代 04 定的
「服务端 5 分钟定时探测 + 缓存」机制),结果缓存内存;前端 10s 轮询 sync-status 只读缓存、不触发探测。
其余三灯靠高频自愈回路:PositionSync 10s / QuoteSync 5s 定时拉 Bridge、MCP 客户端自带失败重连,
Bridge 恢复后秒级回绿。唯独健康缓存要等满 5 分钟下一次探测——Bridge 在两次探测间启动/恢复时,
灯长期停留在上一次失败结果,观感即「自动检查没生效」(反向同理:Bridge 中途宕机,灯最多 5 分钟不转红)。
## 方案(AI 提议,老师拍板确认)
**自适应探测节奏(owner = QmtHealthMonitor,单点改动)**
- 健康 → 每 5 分钟探测一次(**稳态,维持原设计低开销**);
- 异常/未知(含从未探测成功)→ **每 10 秒快速重试**(与前端 sync-status 10s 轮询对齐,
与持仓/行情 5-10s 恢复节奏一致)——Bridge 启动后灯 ≤20s 自动回绿。
**边界(不做)**:前端读缓存秒回不变;API/前端零改动;健康稳态间隔不放宽不放窄(5 分钟不变);
不新增配置项(间隔为常量,测试经构造参数注入);不做 read-path 触发探测(定时器已覆盖,避免耦合)。
## 决策记录
| 项 | 结论 | 讨论 |
|---|---|---|
| 异常重试间隔 | 10s | 与前端 10s 轮询对齐;5s 会翻倍失败探测开销(每次失败探测最长挂 5s 超时) |
| 健康稳态间隔 | 5 分钟维持 | 原设计为省探测开销;异常态才快速重试,健康态不空转 |
| 防并发/调度 | setTimeout 链 + _inFlight 去重 | 沿用原防并发语义;手动探测/热切换共用同一节奏重排 |
| baseUrl 解析异常 | 落 healthy:false 缓存走快速重试 | 原实现解析在 try 外,异常会丢缓存导致灯灰且不重试 |
+36
View File
@@ -0,0 +1,36 @@
# 需求:R-023 MCP 状态灯自适应探测(MCP 服务恢复后自动回绿)· 讨论中
> 登记:2026-09-09 | 来源:老师反馈(R-022 之后实测 MCP 灯同样不自动恢复)| 状态:**讨论中(AI 提议定稿,待老师确认)**
> 归属:迭代 19-指示灯状态自适应探测 | 实现状态:已实现(待验收)
## 症状(老师反馈,2026-09-09
QMT 健康灯修复后复查发现:会话头部 MCP 指示灯也存在同样的「状态不自动恢复」——
MCP 服务(QMT Bridge /mcp)启动/恢复后,MCP 灯停留在上次失败态,不会自动回绿。
## 根因
QmtMcpManager 的**状态缓存只在 挂载 / 配置切换 / 手动探测(点击「检测 MCP」)时刷新**,无任何定时回路;
sync-status 端点每 10s 读的只是这份缓存。dsh-mcp-client 自带的 reconnect 只恢复**真实连接**
(fiber 层),并不刷新插件自己的状态缓存——所以「连接恢复了、灯还是红的」。
## 方案(AI 提议,与 R-022 同模式,owner = QmtMcpManager
**结果驱动自适应探测**(对齐 QmtHealthMonitor):
- connected → 每 5 分钟探测一次(稳态,低开销);
- 非 connectederror/异常)→ **每 10 秒快速重试**——MCP 服务恢复后灯 ≤20s 自动回绿;
- 无激活连接(url 为空)不探测不调度;dispose() 后停止调度;
- 探测仍走 SDK 独立只读握手(Client connect → initialize + tools/list),与 dsh-mcp-client 自管连接互不干扰。
**边界(不做)**:不接管/不替代 dsh-mcp-client 的重连(那是真实连接层,保持现状 Q5);
不新增配置项(间隔为常量,测试经构造参数注入);前端/API 零改动(getStatus 读缓存语义不变)。
## 决策记录
| 项 | 结论 | 讨论 |
|---|---|---|
| 非连接态重试间隔 | 10s | 与前端 sync-status 10s 轮询、R-022 QMT 灯一致;MCP 探测最坏挂 8s 超时,快失败场景成本可忽略 |
| 已连接稳态间隔 | 5 分钟 | 与 R-022 一致;MCP 握手比重(initialize+tools/list),健康态不空转 |
| 调度停止条件 | 无激活连接 / dispose / unmount | 无 url 时探测早退置 disabled 并取消定时器,避免空转 |
| 并发防护 | _probing 去重(同 QmtHealthMonitor._inFlight | 定时器与手动/热切换探测可能撞车 |
+40
View File
@@ -0,0 +1,40 @@
# 需求:R-025 设置页 Tab 设置排序落点统一为间隙高亮线 · 已定稿
> 登记:2026-09-10 | 来源:老师指令(迭代 20 复盘遗留第 2 项确认)| 状态:**已定稿(2026-09-10 老师指令)**
> 归属:迭代 21-Tab设置排序间隙高亮统一 | 实现状态:未开始
## 诉求(老师,2026-09-10
> 神之一手设置页的分组 tab 排序(Tab 设置),也改一下上面(列设置弹层)的拖动高亮显示形式。
## 背景
- R-024/迭代 20 列设置弹层拖拽落点已从整行高亮改为**行间间隙高亮线**(UI约束-007),老师确认「直观多了」;
- 复盘遗留第 2 项:设置页「Tab 设置」的排序仍是整行高亮(overId → 行淡蓝底,UI约束-003),存在同样的
「无法判断插入前/后」观感问题;老师指令统一为间隙线形式。
## 定稿结论(2026-09-10 老师指令)
设置页「Tab 设置」排序落点从整行高亮改为**行间间隙高亮线**(复用 UI约束-007 / R-024 已验证模式):
- 拖动悬停时按鼠标在目标行内的 Y 坐标(上半/下半)判定,间隙线画在目标行上缘(插其前)/下缘(插其后);
- drop 事件内按坐标重算插入位置(不依赖 state 闭包);拖回原位置(to===from 或 to===from+1)跳过写库;
- 保留 onDragEnd 清理残留;整行高亮背景(color-mix 12%)移除;
- 其余维持不变:≡ 手柄、显隐开关、落点立即持久化(tabs/update)、「刷新页面后生效」提示、内置/策略徽标、
saving 期间禁用拖拽。
## 决策记录
| 项 | 结论 | 讨论 |
|---|---|---|
| 范围 | 仅 Tab 设置(SettingsSection.jsx TabSettings)落点指示 | 老师指令;不动表格结构、不动持久化、不动开关 |
| 落点形式 | 行间间隙高亮线(上缘=前/下缘=后) | 与 R-024 弹层全同,UI约束-007 已定义 |
| 判定 | 目标行内 Y 坐标上下半区 | 与 R-024 全同 |
| 无操作 | to===from 或 to===from+1 跳过写库 | 复用 R-024 数学式 |
| 服务端/API | 零改动 | tabs/update 整表语义不变 |
| 约束更新 | UI约束-003 变更:落点从整行高亮改间隙线;UI约束-007 补「Tab 设置已统一」 | 2026-09-10 老师指令 |
## 关联
- 上游:UI约束-007(排序交互间隙线标准)、R-024(同款模式实证)、迭代 20 复盘遗留第 2 项;
- 迭代 20 复盘遗留第 1 项(test-r013-columns 期望过期)不在本需求范围。
+127
View File
@@ -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-015QuoteHub 盘口内存快照)、R-014PositionSync 账户域)、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 调整为「第一阶段=重构、第二阶段=本需求」);
下一步:技术实现方案 + 验收标准(本轮按老师指令未做技术方案与编码)。
+85
View File
@@ -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`(手动做T1 字段: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** 列配置覆盖;
- 存量 settingsstrategies 数组 + 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(入口条款)。
+54
View File
@@ -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`(变量表 / 运算符 / 函数 / 示例 / 报错对照 / 常见问题)。
@@ -3,6 +3,12 @@
> 登记:2026-09-02 | 来源:架构梳理讨论 + 老师指令 | 状态:**已定稿(老师逐项拍板 4 问)**
> 归属:迭代 12 计划:PLAN-013
> **归档头(2026-09-08** | 需求状态:已完成 | 归档日期:2026-09-08 | 实现迭代:12-持仓内存快照(持仓内存快照)
> 讨论记录索引:R-014.md 正文;实现细节见 04-迭代记录/12-持仓内存快照/
> 归档路径:05-需求池/已完成/R-014.md
> 迭代状态标记:已实现(迭代 12,回归 34/34 + typecheck + build 通过)→ 已归档(2026-09-08 老师归档指令确认验收)
## 需求描述
原实盘持仓数据在**每次请求时穿透 QMT**(`PositionManager.getAllPositions()` 实时拉 `/trade/positions`),带来两个问题:
@@ -29,4 +35,4 @@
## 验收
`docs/04-迭代记录/12-持仓内存快照/验收标准.md`
`docs/04-迭代记录/12-持仓内存快照/验收标准.md`
@@ -3,6 +3,12 @@
> 登记:2026-09-02 | 来源:架构演进讨论(老师逐项拍板)| 状态:**已定稿**
> 归属:迭代 13 计划:PLAN-014
> **归档头(2026-09-08** | 需求状态:已完成 | 归档日期:2026-09-08 | 实现迭代:13-盘口内存快照(盘口数据内存化+指示灯)
> 讨论记录索引:R-015.md 正文;实现细节见 04-迭代记录/13-盘口内存快照/
> 归档路径:05-需求池/已完成/R-015.md
> 迭代状态标记:已实现(迭代 13test-quote-sync 全绿 + 存量回归通过)→ 已归档(2026-09-08 老师归档指令确认验收)
## 需求描述
持仓数据已完成「内存快照统一管理」(R-014/迭代 12)。本轮把**盘口(市场行情)数据**收敛为同一模式:
@@ -25,4 +31,4 @@
## 验收
`docs/04-迭代记录/13-盘口内存快照/验收标准.md`
`docs/04-迭代记录/13-盘口内存快照/验收标准.md`
+69
View File
@@ -0,0 +1,69 @@
# 需求:R-016 策略 tab 历史持仓展示(显示/隐藏已清仓持仓 + 清仓时间范围筛选)
> 登记:2026-09-07 | 来源:老师指令(优化功能需求讨论)| 状态:**已定稿(2026-09-07Q1-Q7 老师拍板)**
> 归属:迭代 14 | 计划:PLAN-015 实现状态:**已实现(2026-09-07,待老师人工验收)**
> **归档头(2026-09-08)** | 需求状态:已完成 | 归档日期:2026-09-08 | 实现迭代:14-策略tab历史持仓展示(策略 tab 历史持仓展示)
> 讨论记录索引:R-016.md 正文;实现细节见 04-迭代记录/14-策略tab历史持仓展示/
> 归档路径:05-需求池/已完成/R-016.md
> 迭代状态标记:已实现(迭代 14)→ 已归档(2026-09-08 老师归档指令确认验收)
## 需求描述(老师原始诉求)
**两个策略 tab**(做T / 网格超市)**title 旁边**添加以下功能:
1. **显示/隐藏已清仓的历史持仓**(开关);
2. 显示历史持仓时,**默认显示近一周清仓的持仓**,同时提供 **近 1 个月 / 近 3 个月 / 近半年 / 近 1 年** 几个范围选项。
## 背景
- 策略持仓行 = QMT 快照驱动(R-014),卖出清仓后 QMT 无该持仓 → 幽灵清仓把 strategy_holdings 行转历史(closed_at 非空)→ 策略 tab 当场少一行;
- 典型场景(2026-09-07 实际发生):早盘卖出大连热电 1000 股(做T),约 70 秒后该持仓行从做T tab 消失——当日交易记录还在(R-009 已入库、归属 holding_id=15 正确),但 tab 上锚点不可见;
- 历史数据一直在库(strategy_holdings 全量保留),只是**没有展示入口**;
- R-010 持仓行展开(关联委托汇总)以 holding_id 为锚,历史持仓行天然可复用——历史行展开 = 该轮做T/清仓的完整交易复盘(**本需求的核心目标:追溯该持仓相关的历史操作**,老师 2026-09-07 定调)。
## 讨论记录(2026-09-07Q1-Q7 老师拍板)
| # | 问题 | 结论(老师拍板) |
|---|---|---|
| Q1 | 历史行展示哪些字段? | **按建议**:代码 / 名称(取该 holding 最近关联委托的 name 兜底,无则 —)/ 清仓时间 / 持有天数;实时行情列(现价/涨幅/涨停等)历史行显示 **—**(不参与行情订阅);行展开沿用 R-010 看关联委托;成本价从委托推导本期不做 |
| Q2 | 清仓时份额怎么显示?(closeHolding 现把 shares 置 0 | **显示 0 就好了**(老师改判,否决 AI 的「保留 shares」方案):即使显示清仓时的份额也没多少意义,主要目标是追溯该持仓相关的历史操作;**不改 closeHolding 语义**,历史行份额直接显示 DB 原值(0) |
| Q3 | 排序与视觉区分? | **按建议**:当前持仓在前(维持现状排序),历史行追加在后按清仓时间降序;历史行灰显 + 「已清仓」徽标(含清仓日期);标题计数(N 只)只数当前持仓 |
| Q4 | 开关与范围选择要不要记住偏好? | **按建议**:会话级即可(默认关,进 tab 重置为「只看当前」)——查看历史是临时行为,「策略 tab = 当前持仓」是主语义 |
| Q5 | 范围选项集合? | **按建议**:近一周(默认)/ 近 1 个月 / 近 3 个月 / 近半年 / 近 1 年,共 5 档;语义 = closed_at ∈ [now 范围, now];不加「全部历史」 |
| Q6 | 自定义字段值(R-013 values)在历史行怎么处理? | **按建议**:values 数据随行保留,历史行**只读展示**(无编辑入口;服务端本就拒绝已清仓行写入) |
| Q7 | 接口形态? | **按建议**:新增独立只读端点(strategy-holdings/historyargs: strategyId + range),当前持仓路径(strategy-positions / R-014 快照语义)零改动;前端两份结果合并渲染 |
## 边界(定稿)
**做**:两个策略 tab 的历史持仓开关 + 范围筛选(title 旁按钮区);历史行展示(Q1 字段集 + Q2 份额显示 0)+ 行展开复用 R-010;配套后端历史查询端点(SqliteStore 范围查询 + DataStore 门面 + API)。
**不做**:全部持仓 tab 不加此功能(老师点名两个策略 tab);历史行盈亏统计(R-002 Q4 不做统计原则);清仓方式标记(手动清仓 vs 幽灵自动清仓,数据未区分);历史行实时行情;成本价/收益推导;**不改 closeHolding 存储语义**shares 置 0 维持,Q2);范围记忆/偏好持久化(Q4 会话级);「全部历史」档位。
## 关联观察(上一轮会话遗留,不在本需求范围)
- 交易记录 tab 的归属候选(orders/attribution-candidates)只回**当前持仓**——清仓后当日委托无法再通过下拉关联到对应 holding(2026-09-07 大连热电实际遇到);是否立独立需求(如候选纳入近期清仓持仓)待老师定。
## 二轮补充(2026-09-07,Q8-Q9 老师拍板:今日已清仓默认层)
> 追加定稿记录(不改写 Q1-Q7 首轮定稿;本轮在首轮验收前补充,老师 2026-09-07 人工验收前的诉求收窄「历史持仓关 = 当天已清仓也看不见」的体验缺口)。
**老师原始补充诉求**:两个策略 tab 显示历史持仓的交互需要小的逻辑调整——**当前已清仓的持仓默认也显示**;「历史持仓」开关**不控制当天已清仓的持仓**(历史持仓关 ≠ 当天已清仓也被藏起)。
| # | 问题 | 结论(老师拍板 2026-09-07 |
|---|---|---|
| Q8 | 「当天」的边界口径? | **本地自然日 00:00 起**(今天清仓的始终显示;跨零点后自动归入更早历史,由「历史持仓」开关控制) |
| Q9 | 今日已清仓行的视觉与计数? | **沿用历史行灰显 + 「已清仓」徽标**;标题计数(N 只)仍只算当前持仓,不含已清仓行 |
**定稿语义(相对首轮 Q4「默认关 = 只看当前」的修订)**
- 已清仓行分两层:**L1 今日已清仓**range='today'closed_at ≥ 本地自然日 00:00**默认恒显示**,进 tab 即出现、随持仓重载刷新;**L2 历史范围层**week/month/quarter/halfYear/year,仅今天之前)仍由「历史持仓」开关控制(默认关);
- 交互文案同步收窄:「历史持仓」开关 = 显示/隐藏**今天之前**已清仓的历史持仓(title/提示更新);当天清仓的持仓始终显示;
- 展示(灰显/徽标/行情列 —/份额 0/只读字段/行展开)与计数(不含已清仓行)维持 Q1/Q2/Q3/Q6 不变,两层视觉无差异;
- 实现:range='today' 特判本地自然日零点(不走回溯毫秒档);前端 rowsToRender 在开关关时也追加 L1(按 holdingId 去重 L1∩L2,开启历史范围不重复出两行);零写路径变更维持。
**边界修订**:Q5 档位集语义微调为「**今天之前的**回溯范围」;「开关默认关 = 只看当前」修订为「开关默认关 = 当前持仓 + 今日已清仓」;其余边界(不做全部持仓 tab / 不做统计 / 不改 closeHolding / 会话级不持久化等)不变。
## 验收
`docs/04-迭代记录/14-策略tab历史持仓展示/验收标准.md`
+36
View File
@@ -0,0 +1,36 @@
# 需求:R-017 委托归属候选纳入近期清仓持仓
> 登记:2026-09-07 来源:老师指令(2026-09-07「处理一下,今天大连热电的交易记录,我没办法关联到手动做T持仓的大连热电上」)| 状态:**已定稿**
> 归属:迭代 15 | 计划:PLAN-016 实现状态:**已实现(2026-09-07,待老师人工验收)**
> 前置:R-016「关联观察」所列问题的正式立项(清仓后当日委托无法通过下拉关联到对应 holding,2026-09-07 大连热电实际发生——盘中手动归属后又被清为未关联,下拉已无手动做T候选)。
> **归档头(2026-09-08)** | 需求状态:已完成 | 归档日期:2026-09-08 | 实现迭代:15-归属候选纳入清仓持仓(归属候选纳入近期清仓持仓)
> 讨论记录索引:R-017.md 正文;实现细节见 04-迭代记录/15-归属候选纳入清仓持仓/
> 归档路径:05-需求池/已完成/R-017.md
> 迭代状态标记:已实现(迭代 15)后被 R-018 语义取代退役 → 已归档(2026-09-08
## 需求描述
交易记录 tab 的归属候选(orders/attribution-candidates)只回**当前持仓**——清仓后(幽灵清仓最快约 30s 转历史)当日委托在下拉中失去对应策略/持仓锚点,无法补关联。本轮:**候选 = 该 code 的当前持仓 + 近 7 天清仓的持仓**,清仓行带标记供 UI 区分展示。
## 讨论记录(2026-09-07
| # | 决策点 | 结论 |
|---|---|---|
| 1 | 立项与方向 | 老师指令确认(对 R-016 关联观察修复方案的采纳) |
| 2 | 候选窗口 | 近 7 天清仓(与 R-016 默认范围一致,AI 定;覆盖「当日做T当日盘后补关联」场景足够) |
| 3 | 锚点键 | 下拉 value 改用 **holdingId**(同策略可有多轮持仓行,strategyId 会撞值;holding_id 本就是归属锚点,R-009 定稿语义) |
| 4 | 标记展示 | 清仓候选在选项文本加「(已清仓 MM-DD)」后缀;已归属但候选列表缺失的委托显示「当前归属」占位选项(避免 select 空白) |
| 5 | 排序 | 当前持仓在前,清仓行按 closed_at DESC 追加 |
| 6 | 归属写入 | 不变:orders/set-attribution 无候选限制(本次数据修复即经此通道完成) |
## 边界
**做**getAttributionCandidates 纳入近 7 天清仓行(附 closed/closedAt);AttributionSelect 改 holdingId 键控 + 已清仓后缀 + 当前归属占位;回归脚本。
**不做**:候选窗口可配置;自动归属(Q3 手动原则不变);交易记录 tab 之外的候选消费方调整;历史数据修复工具(本次一次性修复经 set-attribution 完成,不固化成功能)。
## 验收
`docs/04-迭代记录/15-归属候选纳入清仓持仓/验收标准.md`
+74
View File
@@ -0,0 +1,74 @@
# 需求:R-018 交易关联驱动持仓份额动态调整(归属 = 账本写操作)
> 登记:2026-09-08 来源:老师指令(2026-09-08 新优化需求讨论)| 需求状态:**已完成**
> 归档日期:2026-09-08 | 实现迭代:16-交易关联驱动持仓份额动态调整(验收通过 2026-09-08
> 讨论记录索引:R-018.md 正文(Q1-Q5/R1-R7 + 数据域分界 + T2/T3);实现细节见 04-迭代记录/16-交易关联驱动持仓份额动态调整/
> 归档路径:本文件已移入 05-需求池/已完成/
> 迭代状态标记:已定稿(2026-09-08)→ 已实现(迭代 16)→ **已归档(验收通过 2026-09-08,口径=自动化为主体 + 只读走查 + 真实使用顺带确认)**
## 需求描述(一句话)
从**交易记录出发**给委托关联策略/持仓时,**自动同步调整该策略下持仓的份额**(关联即账本写操作),支持一笔委托拆多段分配给不同策略;取消/改归属时做逆操作回滚份额;账本生命周期只由关联交易 + 手动份额操作驱动,同步机制不写账本。
## 〇、数据域分界(最根本前提,2026-09-08 老师拍板)
**「全部持仓」= 对账单域**:唯一数据源 = PositionSyncQMT 快照,10s 内存同步,整体替换)。**包括幽灵清仓在内的一切同步机制只作用于对账单域**,QMT 说了算(老师原话:幽灵清仓本就是一个同步机制,不可以让幽灵把爪子伸太长)。
**「策略持仓」= 账本域(strategy_holdings**:写操作只来自两条通道——
1. **交易关联分配器**(本需求新增):建仓 / 加仓 / 减仓 / 清仓 / 作废;
2. **手动份额操作**(+ 添加持仓 / 移出 / 全部移入):无交易来源时的兜底通道(Q3:保留现在的建仓逻辑;份额以 QMT 持仓信息为基础创建,受「已分配 ≤ 总持仓」校验)。
**既有行为变更**:R-014 定稿的「幽灵自动清仓」(连续 3 轮消失 → closeHolding 本地全部策略当前持仓)**不再写 strategy_holdings**;账本行转历史只由「卖出单关联 → reduceShares 归 0 → closeHolding」产生。影响既有定稿:技术约束-017(幽灵清仓条款)、产品约束-011、R-014、迭代 12(已实现待验收)需修订;PositionSync 删减幽灵清仓逻辑(变更理由 = 本次讨论,写入变更记录)。
## 背景与现状
1. **QMT 实盘持仓**(对账单):PositionSync 10s 内存快照,真实 volume/price(技术约束-017);
2. **本地策略账本** strategy_holdingsshares + 生命周期(open/add/reduce/close),此前靠手动份额操作维护;账本 ≠ 对账单(R-014:holding_id 是交易锚点,不掺易变快照);
3. **交易归属** trade_orders.strategy_id/holding_id:手动设置(R-009/R-017),只做过滤/展示/追溯,**不改数量**——本需求改变这一点。
**展示行关系(防误解)**:策略持仓 tab 当前持仓行 = **QMT 快照 ∩ 账本 shares>0**(行由对账单驱动、份额由账本驱动)。卖出全清 → QMT 无此 code → 展示行随快照消失(≤10s,与幽灵清仓无关);此时账本行仍为当前持仓(closed_at NULL),作为**待关联锚点**保留,直到卖出单被关联。
## 讨论记录(2026-09-08
| # | 决策点 | 结论(老师拍板) |
|---|---|---|
| 1 | 归属候选按什么给?(Q1) | **策略级下拉**,不做预先判断(有无持仓/历史持仓),指向动作发生后按目标策略当时的活动持仓状态现场判定 |
| 2 | 驱动量基准?(Q4 | **已成交量**(撤单/废单不动账) |
| 3 | 交互模型?(R4 统一分配器) | 不分买卖两套流程:一笔委托已成交量可拆 0..N 段(策略×量);待分配余额 = 成交量 − 已分配段和;默认输入 = 余额全量可改小;允许部分关联(剩余保留「未关联」醒目提示);买入卖出均可拆多段 |
| 4 | 买入关联动作?(原始第 2 条) | 该策略有活动持仓 → `addShares` 加仓;无活动持仓(含曾有历史/作废行)→ `openHolding` 建**新仓**(不复活旧行;Q5) |
| 5 | 卖出关联动作?(原始第 3 条) | 活动仓 ≥ 段量 → `reduceShares`,归 0 → `closeHolding`;活动仓 < 段量 → **自动截断**段量=可容纳量归零清仓,余额保留继续分配;该策略无活动持仓 → 关联失败提示(R1) |
| 6 | 撤段逆操作?(R3,粒度=段) | 撤建仓买入段 → 持仓**作废**(新增第三态,非 closeHolding,不产生假清仓历史);撤加仓买入段 → `reduceShares` 减回,归 0 且无真实卖出 → 作废;撤卖出段 → **恢复活动仓 + 份额加回**(撤销语义,区别于 Q5 新买入不接旧仓) |
| 7 | 改归属语义? | 撤旧段 + 加新段,**原子**;撤卖出段若该 holding 已被真实卖出(非本单所致)无法恢复 → 提示不可撤 |
| 8 | 数据域分界?(本轮核心) | 幽灵清仓等同步机制只作用于对账单域;策略持仓写操作只来自交易关联 + 手动份额操作 |
| 9 | T1 卖出关联 vs 幽灵清仓时序竞态 | **关闭**:幽灵不写账本 → 卖出后账本行保留为待关联锚点,无 30s 竞态 |
| 10 | T2 R-0177 天清仓候选) | **退役确认**:新时序下清仓发生在卖出单关联之后,不存在「系统先清、用户后补」场景,候选回归「当前持仓 + 未关联」;R-017 的 holdingId 键控/占位安全逻辑保留 |
| 11 | T3 漏关联兜底 | **软提示确认**:检测「账本有活动行 + QMT 快照无此 code」时提示老师去关联/移出;不自动写账本(不违背分界) |
## 核心规则汇总
| 触发 | 判定 | 动作 |
|---|---|---|
| 买入段 | 该策略该 code 有活动持仓 | `addShares`(加仓,量=段量) |
| 买入段 | 无活动持仓(含曾有历史/作废行) | `openHolding` 建新仓(不复活旧行) |
| 卖出段 | 活动仓 ≥ 段量 | `reduceShares`;归 0 → `closeHolding`(账本转历史唯一途径) |
| 卖出段 | 活动仓 < 段量 | 自动截断:段量=可容纳量 → 归零清仓,余额保留继续分配 |
| 卖出段 | 该策略无活动持仓 | 关联失败提示 |
撤段逆操作:撤建仓买入 → 持仓作废(第三态);撤加仓买入 → 减回归 0 作废;撤卖出 → 恢复活动仓 + 份额加回。改归属 = 撤旧段 + 加新段(原子)。
## 存储 / 模型调整方向
1. **holding 第三态**strategy_holdings 增加作废标记(如 `void_at`);活动 = `closed_at IS NULL AND void_at IS NULL`;作废行不进 R-016 历史「已清仓」层,保留数据可追溯;活动唯一索引(同策略同 code)收窄至两态皆空;
2. **归属单列 → 分段表**:新表(如 `trade_order_attributions`order_id → strategy_id/holding_id/volume/direction/created_at),一笔委托 N 段;R-010 by-holding 与 R-009 history 策略过滤改经段表 join(同一单可出现在多个 holding 展开里);trade_orders 原两列退役或降级为「主归属」快照,已有单组归属迁移为第一段;
3. **软提示(T3**:账本有活动行 + QMT 快照无此 code 时的可见性提示(不动账本)。
## 边界
**做**:幽灵清仓账本侧退役(PositionSync 删幽灵清仓逻辑)+ 策略级候选 + 统一份额分配器(拆段/余额)+ 买卖份额联动 + 撤段逆操作(含作废第三态)+ 归属分段存储 + 查询/展示适配 + R-017 7 天候选退役(holdingId 键控/占位保留)+ 漏关联软提示 + 回归。
**不做**:自动归属推导仍不暴露(可另议);全部持仓 tab / 未分配 tab 份额联动 UI;跨策略「未分配余额」自动兜底;账本写操作自动化兜底(软提示不自动改账本)。
## 验收
见规划后的迭代验收标准(本需求涉及既有已实现功能变更:迭代 12 幽灵清仓语义、迭代 15 R-017 退役,验收需含存量回归)。
+79
View File
@@ -0,0 +1,79 @@
# 需求:R-019 策略会话(会话以策略当前数据为依据做讨论)· 已定稿
> 登记:2026-09-08 | 来源:老师指令(新需求)| 状态:**已定稿(2026-09-08F1/F2 老师确认)**
> 归属:迭代 17(待立项)| 计划:待出(PLAN-017)| 实现状态:未开始
> **归档头(2026-09-08)** | 需求状态:已完成(并入合并需求)| 归档日期:2026-09-08
> 合并承接:**R-021(策略会话复盘)**——老师 2026-09-08 指令将 R-019 与 R-021 合并、逻辑整合入 R-021;本文件为 R-019 线讨论史存档(Q1-Q4/R2-Q1..Q4/R3/F1-F2
> 归档路径:05-需求池/已完成/R-019.md
## 需求描述(老师原始诉求,原话)
> **新需求,让 AI 会话可以以策略为 Workspace。**
> 三轮讨论后收敛为:**让 AI 会话以某个策略的当前数据为讨论依据**(复盘 / 分析),改造现有对话窗口,会话开始前确认用哪个策略。
## 讨论记录(2026-09-08
### 第一轮(Q1-Q4,老师拍板)——语义收敛
| # | 问题 | 结论 |
|---|---|---|
| Q1 | 需求对象与形态 | 改造现有 DSH 对话窗口:会话以某个策略的持仓、交易记录为依据做复盘/分析 |
| Q2 | 「以策略为 Workspace」含义 | **不是物理工作目录/文件为主要依据**;是让策略数据作为会话的聊天依据 |
| Q3 | 入口与触发 | 在现有会话窗口(非独立新页面) |
| Q4 | 会话内能力边界 | **A 只读**:读该策略业务数据,不开放账本写 |
### 第二轮(R2-Q1..Q4,老师拍板)——机制与范围
| # | 问题 | 结论 |
|---|---|---|
| R2-Q1 | 聊天依据如何生效 | **A 预注入**:选定策略即开始聊,把该策略数据依据注入会话上下文(不挂按需取数工具) |
| R2-Q2 | 选择粒度 | C 单选策略/通用视图混选——后被 R3 收敛(本期仅策略) |
| R2-Q3 | 时间覆盖 | **A**:当前持仓 + 该策略全部历史交易(含已清仓) |
| R2-Q4 | 复盘产物 | **暂不落盘**(留会话历史);保存沉淀属 T-002/未来复盘管理 |
### 第三轮(R3,老师定调)——不用 DSH 原生工作区
**老师原话(2026-09-08**:目前不需要 dsh web 原本定义的工作区。会话的数据 = ① 会话注入的数据 + ② 会话过程中产生的数据;以后神之一手会加「复盘管理」,可能用数据库存沉淀;**目前会话只是以策略当前数据来做会话讨论**。
- 不做 DSH 宿主工作区(不注册目录 / 不动工作区分组与侧栏);
- 「会话开始前选策略」= 仿工作区"先选再开始"的体验,但只做会话级策略依据选择;
- 会话过程中产生的数据 = 会话历史本身;复盘管理(可能带 DB)为未来模块,本期不做。
### 第四轮(F1-F22026-09-08 老师确认)——定稿收口
| # | 问题 | 结论(老师拍板) |
|---|---|---|
| F1 | 入口形态(无宿主工作区下"会话开始前选策略") | **采纳 AI 建议**:对话视图首条消息前显示「讨论策略」选择区(仿 DSH workspace chip Choose 体验:显示「依据:做T ▾」),选策略即注入数据摘要开始聊;会话中可点 chip 换策略 / 点「刷新」注入最新数据 |
| F2 | 「策略当前数据」口径 | **采纳 AI 建议(R2-Q3=A 维持)**:注入 = 当前持仓明细 + 该策略全历史关联交易**聚合摘要**(按持仓/时间汇总买卖与结果,含已清仓;明细过长不进注入) |
## 定稿语义
- **对象**:现有 DSH 对话窗口(挂神之一手 tab 的会话),对话视图首条消息前出现「讨论策略」选择;
- **机制**:选策略 → 服务端取该策略当前数据 → 预注入会话上下文(摘要形态,含快照时间)→ 开始聊;换策略 / 点刷新 = 再次注入该策略最新摘要(模型以最近一次注入为依据);
- **数据范围**:当前持仓明细 + 全历史关联交易聚合摘要(含已清仓),只读;
- **能力**:会话内 AI 只读(无账本写 / 交易执行入口);产物不落盘。
## 定稿边界
**做**:对话开始前的策略选择 UI(F1 形态);策略当前数据摘要的生成与注入;会话中换策略/刷新的再注入。
**不做**:DSH 宿主工作区/目录(R3 明确不需要);账本写与交易执行(Q4=A);按需取数工具(R2-Q1=A);复盘产物落盘与复盘管理/DB(R2-Q4,未来 T-002/复盘管理另立);通用数据视图(全部持仓/交易记录)作为依据(R2-Q2 收敛,本期仅策略)。
## 计划阶段待办(定稿后第一件事:技术可行性调研)
1. **宿主注入通道调研**:DSH 会话如何把外部数据作为上下文注入给模型(injected-context 节点 / 会话 send / 其他公开通道),确认可行通道与形态(决定"摘要以消息形态进会话"还是"上下文节点形态");
2. 入口 UI 落点核实:对话视图首条消息前的输入区 accessory / workspace chip 同构位置可由插件挂载的 slot(及 composer 可见性管理,与现有"神之一手 tab 隐藏 composer" CSS 的交互);
3. settings 是否需记录策略-会话选择偏好(会话级,不持久化 or 持久化待定);
4. 数据摘要服务:服务端生成"策略当前数据摘要"的组成与规模边界(当前持仓字段集 / 交易聚合规则 / 上限裁剪)。
## 关联
- 终极目标:目标-002(策略)/ 目标-006(交易复盘)/ 目标-007(人机合一·只读);目标-001(复用宿主会话能力)
- 需求池:T-002 复盘记录工具(起草)——复盘管理/落库的未来归属
- 设计约束影响(计划阶段核对):技术约束-009(会话头部 slot 机制参照)/ 技术约束-014、产品约束-009(会话结构语义)、UI约束-001(chip 形态参照);如需新约束条目于计划阶段补充
## 验收
**(计划/迭代立项后写入迭代验收标准.md)**
+61
View File
@@ -0,0 +1,61 @@
# 需求:R-020 QMT Bridge MCP 能力内建(神之一手自注册 MCP,去三方插件依赖)
> 登记:2026-09-08 来源:老师指令(2026-09-08 新需求)| 需求状态:**已完成**
> 归档日期:2026-09-08 实现迭代:18-QMTBridgeMCP内建(验收通过 2026-09-08
> 讨论记录索引:R-020.md 正文(Q1-Q6 逐项拍板 + 老师澄清:移除对象=三方 dsh-skill-mcp-panel、官方 dsh-mcp-client 可依赖);实现细节见 docs/04-迭代记录/18-QMTBridgeMCP内建/(含 迭代复盘.md
> 归档路径:本文件已移入 05-需求池/已完成/
> 迭代状态标记:已定稿(2026-09-08)→ 已实现(迭代 18)→ **已归档(验收通过 2026-09-08**
## 需求描述(老师原始诉求)
> **老师原话(2026-09-08)**:当前我们给 DSH 配置了一个 MCP,用于连接 QMT bridge,这个配置是在另一个插件中配置的(三方插件 dsh-skill-mcp-panel 在 profile cordis.patch.yml 受管块里插入 QMT_Bridge_MCP 实例——管理 UI + 写宿主 patch;真正连接的是 DSH 官方 dsh-mcp-client)。我希望可以在神之一手插件的配置 tab 中,添加了 QMTBridge 的配置后,可以**自动检查 MCP 连接信息**,这样我们**不需要依赖三方插件来加入 MCP 的能力**。
>
> **2026-09-08 老师澄清(指令修正)**:不要的是**第三方 dsh-skill-mcp-panel**(设置菜单「技能 / MCP」+ 它写的宿主 patch 受管块);**依赖 DSH 官方 @deepseek-ai/dsh-mcp-client 是允许的**。实现 = 神之一手 apply 内动态挂载官方 dsh-mcp-client(参照 mcp_router 实证),**移除三方 dsh-skill-mcp-panel 及其受管块**。
## 定稿语义
- **MCP 能力内建**:神之一手在自己的 apply 内按 QMT 连接配置动态挂载 DSH 官方 @deepseek-ai/dsh-mcp-client(插件内管理:挂载/切换/卸载/状态都由神之一手控制),QMT Bridge 的 MCP 工具由神之一手自己接入;不再依赖三方 dsh-skill-mcp-panel 的面板与宿主 patch 受管块;
- **REST 直连不变**(技术约束-003):插件 UI/tab 数据仍走 RESTMCP 是插件自带的 Agent 侧能力;
- **MCP url 自动派生**:激活连接 baseUrl + /mcp,零新增配置字段。
## 讨论记录(2026-09-08,第一轮,Q1-Q6 老师逐项拍板 → 定稿)
| # | 问题 | 结论(老师拍板) |
|---|---|---|
| Q1 | MCP 客户端挂载粒度 | **只使用当前激活的那个连接**:仅激活连接挂 1 个 dsh-mcp-client 实例(serverName 固定 QMT_Bridge_MCP,切换激活即热切换 MCP url,工具名 mcp__QMT_Bridge_MCP__* 保持稳定);不逐连接各挂实例 |
| Q2 | 现有三方条目处置 | **现在的移除(2026-09-08 老师澄清:移除对象 = 第三方 dsh-skill-mcp-panel**:① 卸载/停用三方 dsh-skill-mcp-panel(其「技能 / MCP」设置菜单与宿主 patch 管理职责一并移除);② 删除 cordis.patch.yml 中其受管块(QMT_Bridge_MCP 不再由三方写入);**QMT 的 MCP 连接通过神之一手插件自己管理**——插件 apply 内动态挂载**官方 @deepseek-ai/dsh-mcp-client**(依赖官方库、管理在我们插件内),含连接 CRUD/激活切换的全生命周期联动 |
| Q3 | MCP url 与字段 | **自动来**:默认派生 baseUrl + /mcp,不需要每连接 mcpUrl 覆盖字段 |
| Q4 | 「自动检查 MCP 连接信息」口径 | **两处状态出口**:① 设置页「QMT 连接配置」连接卡片可检查(手动触发);② **对话窗口顶端状态指示灯区新增一个 MCP 状态**(与现有持仓/盘口同步指示灯并列) |
| Q5 | 失败与重连语义 | **采纳建议**:对齐 failOnStartupError:false + reconnect(初始连不上不拒绝插件激活;连接丢失自动指数退避重连;期间保留断开状态不崩插件) |
| Q6 | 范围确认 | **不做通用 MCP 管理**,只做 QMT 的 MCP 内建;通用任意 MCP 服务器管理面板不做(该域属 mcp-router / dsh-skill-mcp-panel,另议) |
## 定稿边界
**做**
1. 神之一手 apply 内动态挂载/卸载 dsh-mcp-client(唯一实例,serverName=QMT_Bridge_MCPurl=激活连接 baseUrl+/mcp);
2. 生命周期联动:启动(激活默认/上次连接)→ 挂载;添加/编辑/删除/激活连接 → MCP 挂/切/卸(对齐现有 qmt-connections API 热切换编排);激活连接被删自动切默认并重挂;
3. MCP 状态出口 ×2:设置页连接卡片 MCP 检查/状态;对话窗口顶端状态指示灯区新增 MCP 状态(三态风格对齐产品约束-012 指示灯体系);
4. 移除三方 dsh-skill-mcp-panel:卸载其插件 + 移除宿主 cordis.patch.yml 受管块(Q2;属宿主配置变更,按 editing-cordis-compositions 技能流程操作 + 老师确认);
5. 失败/重连语义按 Q5failOnStartupError:false + reconnect)。
**不做(本期)**:通用 MCP 服务器管理面板(Q6,含自建「任意 MCP 管理 UI」——Q6 明确不做);插件 UI/tab 数据源改走 MCP(技术约束-003 REST 直连不变);多 serverName 并存;自研 MCP 协议客户端(老师定:依赖官方 dsh-mcp-client 即可,不重造轮子)。
## 计划阶段待办(定稿后第一件事:技术可行性调研)
1. **依赖解析实证**one-divine-lot 运行时能否 import(@deepseek-ai/dsh-mcp-client)(宿主 CLI 依赖树 / profile node_modules 解析路径核实,对照 mcp_router 的 peerDependencies 声明与日志实证;确认后在 package.json 补 peer 依赖);
2. **动态挂载生命周期设计**add/update/remove/activate 连接 → MCP 实例 挂/切/卸 编排(对齐 qmt-connections API 现有热切换逻辑,MCP url 随激活 baseUrl);
3. **MCP 状态查询与展示**:服务端 MCP 状态端点(已连接/断开 + 工具数 + 错误/重连信息)+ 设置页卡片检查 + 会话头部指示灯新增 MCP 状态(对齐产品约束-012/UI约束的指示灯体系);
4. **宿主受管块迁移**cordis.patch.yml 移除 QMT_Bridge_MCP 块的精确操作步骤(保留块外内容逐字节不动);
5. 前端连接卡片表单是否需提示「MCP 将自动随激活连接启用」(Q4 落点 UI 文案)。
## 关联
- 终极目标:目标-001(DSH 插件形态·复用宿主 MCP 能力)、目标-008(真实交易系统接入)
- 既有需求:R-004(QMT 连接配置,本需求数据基础:settings.qmtConnections + 激活/默认语义 + 热切换编排);R-015(会话头部指示灯体系,MCP 状态灯并入同一区)
- 设计约束:技术约束-003(REST 直连不变)、技术约束-008(连接配置存储/热切换);定稿后需补新约束条目(技术约束:「插件内建 MCP 客户端注册规范」;产品约束:「MCP 状态出口(设置卡片 + 会话头部指示灯)」)
- 参考实现:mcp_routerds-plugins/mcp_router)——动态 ctx.plugin(dsh-mcp-client) 实证
## 验收
**(立项后写入迭代验收标准.md**
+46
View File
@@ -0,0 +1,46 @@
# 需求:R-024 策略 tab 列设置改为拖拽排序 · 已完成
> 归档日期:2026-09-10 | 需求状态:已完成 | 实现迭代:[迭代 20-策略列设置拖拽排序](../04-迭代记录/20-策略列设置拖拽排序/迭代复盘.md)
> 讨论记录索引:本文档(定稿 + 变更记录);登记:2026-09-10 | 来源:老师反馈(策略 tab 列设计细节优化)
> 实现状态:**已实现(迭代 20 验收通过,2026-09-10 老师页面确认)**
## 诉求(老师,2026-09-10
> 细节优化,策略 tab 下的列设计,可否做成鼠标选中后、**拖动排序**的形式?
## 现状
策略 tab「列设置」弹层(ColumnSettingsPopoverR-013 迭代 11 实现)管理可配置列(基础数据列 + 自定义字段列):
- 显隐:复选框勾选;
- 排序:**↑↓ 箭头按钮**(连续点按移动),保存按钮提交整份列配置(one-divine-lot/strategy-columns/update);
- 代码注释原话:「同 Tab 设置简化版:用箭头替代 DnD,零依赖稳妥」——当时刻意简化。
## 定稿结论(2026-09-10 老师确认方案 A
**方案 A:列设置弹层内拖拽排序(原生 HTML5 DnD,复用 R-011 Tab 设置已验证模式)**
- 弹层每行加 **≡ 拖拽手柄**(整行 draggable),按住拖动到目标行松手即重排;落点行高亮提示,视觉沿
Tab 设置(UI约束-003)同一套:color-mix(primary 12%) 淡底。
- **落点立即持久化**(对齐 Tab 设置体验):每次 drop 调一次 one-divine-lot/strategy-columns/update
整表提交(含不可见列保位置);点按手势(勾选显隐 / ↑↓ 微调)同步立即持久化 → **弹层去掉「保存」按钮,
只留「关闭」**,Toast 反馈保存结果。
- **↑↓ 箭头保留作兜底**(精准微调场景,与拖拽并存)。
- 固定列(代码/名称/操作/展开箭头)不参与排序,恒显示(维持现状)。
- 边界:不改服务端 APIstrategy-columns/update 已是整表覆盖语义,天然兼容);核心改动在
ColumnSettingsPopover.jsx 单文件;显隐勾选交互不变。
## 决策记录
| 项 | 结论 | 讨论 |
|---|---|---|
| 拖拽范围 | 方案 A:弹层内拖拽(≡ 手柄) | 老师 2026-09-10 确认;方案 C(表头直拖)改动大、需兼容百分比排序点击,暂不做 |
| 持久化时机 | 落点立即持久化(每次变更即写库) | 对齐 Tab 设置(UI约束-003)体验;strategy-columns/update 为整表覆盖语义,天然支持 |
| ↑↓ 箭头 | 保留作兜底 | 与拖拽并存,兼顾精准微调;不增加额外依赖 |
| 保存按钮 | 移除(改「关闭」) | 所有变更即时持久化后按钮失去意义,避免「忘了点保存」 |
| 服务端 | 零改动 | strategy-columns/update 已是整表覆盖 |
## 变更记录(追加式)
| 日期 | 变更 | 原因 |
|---|---|---|
| 2026-09-10 | 拖拽落点提示从**整行高亮**改为**行间(两列之间)间隙高亮线**,指示精确插入位置(前/后) | 老师反馈:整行高亮只能看出落在哪一行,无法判断会插入目标列的前面还是后面;间隙高亮是主流列表拖拽交互(Tab 设置的整行高亮是否同样调整待老师评估,见 UI约束-007) |
+20 -2
View File
@@ -25,7 +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-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 账本**——账本生命周期只由交易关联 + 手动份额操作驱动,账本转历史唯一途径=卖出单关联 closeHoldingR-017 7 天清仓候选退役(holdingId 键控/占位保留);漏关联软提示(账本有活动行+QMT 无此 code)。影响既有定稿:技术约束-017/产品约束-011/R-014/迭代12 幽灵清仓条款修订。详见 R-018.md | 老师指令(2026-09-08 新优化需求讨论) | P1 | 已定稿 | 2026-09-08 | 16-交易关联驱动持仓份额动态调整 | **已实现(已归档至 已完成/R-018.md2026-09-08 验收通过)** |
| 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_MCPurl 自动派生 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.md2026-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),待老师人工验收** |
## 渐进明细规划素材
@@ -41,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 升级为 SQLitenode:sqlite)数据库 + 数据存储管理能力;**变更既有设计约束**(数据存储设计.md「无需数据库」决策)。**2026-09-01 定稿转正为 R-008 并完成(迭代 06** | 已转需求 R-008 |
| T-009 | 策略自定义字段配置(策略细节配置) | 每个策略支持自定义字段(键值对),不同策略按自身需要添加各自的细节配置字段(如网格超市配网格间距/单格金额、长线持有配目标价/仓位上限)。**2026-09-02 定稿转正为 R-013**,草稿文件已清理 | 已转需求 R-013 |
| 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 |
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,151 @@
# 计算字段公式说明
> 适用:策略 tab →「字段配置」→ 添加/编辑字段 → 类型选「**计算**」
> 版本:2026-09-10R-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` 取反、判定与数字混用的单字段、跨行聚合(求和/平均)、历史持仓行时点计算、
策略以外的数据集(账户域持仓、交易记录聚合——尚未接入为变量)。
+3 -1
View File
@@ -33,7 +33,9 @@
}
},
"dependencies": {
"@deepseek-ai/schemastery": "^3.18.1"
"@deepseek-ai/dsh-mcp-client": "0.1.1-rc.2",
"@deepseek-ai/schemastery": "^3.18.1",
"@modelcontextprotocol/sdk": "1.30.0"
},
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
+797
View File
File diff suppressed because it is too large Load Diff
+126
View File
@@ -0,0 +1,126 @@
/**
* QmtHealthMonitor 自适应探测 回归测试(R-022 / 迭代 192026-09-09
*
* 运行:node scripts/test-health-monitor.mjs
* 隔离:纯内存 mockmock fetch + mock settings scope),用短间隔参数测真实 setTimeout 调度。
* 背景:原实现固定 5 分钟探测,QMT Bridge 恢复后指示灯要等满 5 分钟(老师反馈「自动检查没生效」);
* 本测试守护「异常/未知 → 快速重试,健康 → 稳态间隔」的自适应节奏。
*/
let passed = 0;
let failed = 0;
function ok(cond, name) {
if (cond) { passed++; console.log(' ✓ ' + name); }
else { failed++; console.error(' ✗ ' + name); }
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const { QmtHealthMonitor } = await import('../src/data-source/QmtHealthMonitor.js');
/** 可编程 settings scoperesolveActiveBaseUrl 依赖 scope.get() → qmtConnections */
function mockSettings(baseUrl) {
return {
get: () => ({
qmtConnections: {
list: [{ id: 'c1', name: 'bridge', baseUrl, order: 1 }],
activeId: 'c1',
defaultId: '',
},
}),
};
}
const BRIDGE_URL = 'http://bridge.test:8610';
/** 可编程 fetch mockbridgeUp=false 时抛连接错误 */
function mockFetch() {
const calls = [];
let up = true;
globalThis.fetch = async (url) => {
calls.push({ url: String(url), t: Date.now() });
if (!up) throw new Error('fetch failed: ECONNREFUSED bridge down');
return { ok: true, status: 200 };
};
return {
calls,
set up(v) { up = v; },
get up() { return up; },
};
}
const RETRY_MS = 40;
const INTERVAL_MS = 300;
console.log('[1] 启动即探测 + 缓存读取(健康)');
{
const fetch = mockFetch();
const monitor = new QmtHealthMonitor({
runtime: { settings: mockSettings(BRIDGE_URL), dataSource: { baseUrl: BRIDGE_URL } },
intervalMs: INTERVAL_MS, retryMs: RETRY_MS,
});
await monitor.probe(); // 等价 start() 的立即探测
ok(fetch.calls.length === 1, '启动探测发起 1 次');
ok(fetch.calls[0].url === BRIDGE_URL + '/health', '探测地址 = 激活 baseUrl + /health');
const st = monitor.getStatus();
ok(st.healthy === true && st.fromCache === true, '缓存 healthy=true 且 fromCache=true');
await monitor.stop();
}
console.log('[2] 异常 → 快速重试自动恢复(核心回归:Bridge 启动后灯 ≤ 重试间隔回绿)');
{
const fetch = mockFetch();
fetch.up = false;
const monitor = new QmtHealthMonitor({
runtime: { settings: mockSettings(BRIDGE_URL), dataSource: { baseUrl: BRIDGE_URL } },
intervalMs: INTERVAL_MS, retryMs: RETRY_MS,
});
monitor.start();
await sleep(RETRY_MS * 3); // 快速重试窗口内应已多次探测
ok(fetch.calls.length >= 2, '异常态多次快速重试(' + fetch.calls.length + ' 次 ≥ 2');
ok(monitor.getStatus().healthy === false, '异常缓存 healthy=false');
fetch.up = true; // 模拟 QMT Bridge 启动
const before = fetch.calls.length;
await sleep(RETRY_MS * 4); // 等下一个快速重试命中
const st = monitor.getStatus();
ok(fetch.calls.length > before, '恢复后被自动重探测到');
ok(st.healthy === true, '恢复后缓存 healthy=true(灯将回绿,未等 5 分钟稳态间隔)');
await monitor.stop();
}
console.log('[3] 健康稳态:回落到长间隔,不空转');
{
const fetch = mockFetch();
const monitor = new QmtHealthMonitor({
runtime: { settings: mockSettings(BRIDGE_URL), dataSource: { baseUrl: BRIDGE_URL } },
intervalMs: INTERVAL_MS, retryMs: RETRY_MS,
});
monitor.start();
await sleep(RETRY_MS * 4); // 小于稳态间隔:健康态不应按重试节奏空转
ok(fetch.calls.length === 1, '健康态在稳态间隔内只探测 1 次(无快速空转)');
await sleep(INTERVAL_MS + RETRY_MS * 2); // 跨过稳态间隔
ok(fetch.calls.length === 2, '跨过稳态间隔后又探测 1 次(共 2 次)');
await monitor.stop();
}
console.log('[4] stop() 后停止自动调度');
{
const fetch = mockFetch();
fetch.up = false;
const monitor = new QmtHealthMonitor({
runtime: { settings: mockSettings(BRIDGE_URL), dataSource: { baseUrl: BRIDGE_URL } },
intervalMs: INTERVAL_MS, retryMs: RETRY_MS,
});
monitor.start();
await sleep(RETRY_MS * 2);
const before = fetch.calls.length;
monitor.stop();
fetch.up = true;
await sleep(RETRY_MS * 4);
ok(fetch.calls.length === before, 'stop 后无自动探测');
}
console.log('----');
console.log(passed + ' passed, ' + failed + ' failed');
if (failed > 0) process.exit(1);
+149
View File
@@ -0,0 +1,149 @@
/**
* QmtMcpManager 状态自适应探测 回归测试(R-023 / 迭代 192026-09-09
*
* 运行:node scripts/test-mcp-status.mjs
* 说明:SDK 握手(initialize/tools.list)依赖真实网络端点,无法纯内存 mock;
* 本测试守护的是【调度机制】——probe 结果驱动 setTimeout 自适应链:
* connected → intervalMs 稳态;非 connected → retryMs 快速重试;
* 无激活连接不调度;dispose 停止调度。
* 用 stub probe(记录调用 + 翻转状态 + 触达真实 _scheduleNext)验证自动恢复数据流;
* 无激活连接场景用真实 probe(早退路径不触 SDK)。
*/
let passed = 0;
let failed = 0;
function ok(cond, name) {
if (cond) { passed++; console.log(' ✓ ' + name); }
else { failed++; console.error(' ✗ ' + name); }
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const { QmtMcpManager } = await import('../src/mcp/QmtMcpManager.js');
const RETRY_MS = 50;
const INTERVAL_MS = 350;
const BRIDGE_URL = 'http://bridge.test:8610';
/** settings scopebaseUrl 传 '' = 无激活连接 */
function mockSettings(baseUrl) {
return {
get: () => ({
qmtConnections: {
list: baseUrl ? [{ id: 'c1', name: 'bridge', baseUrl, order: 1 }] : [],
activeId: baseUrl ? 'c1' : '',
defaultId: '',
},
}),
};
}
const quietLogger = { info() {}, warn() {}, debug() {} };
console.log('[1] 无激活连接:置 disabled 且不调度(真实 probe 早退,不触 SDK');
{
const mgr = new QmtMcpManager({
ctx: {}, runtime: { settings: mockSettings(''), dataSource: {} },
logger: quietLogger, intervalMs: INTERVAL_MS, retryMs: RETRY_MS,
});
let probeCalls = 0;
const orig = mgr.probe.bind(mgr);
mgr.probe = async (u) => { probeCalls++; return orig(u); };
await mgr.start();
ok(mgr.getStatus().state === 'disabled', '无激活连接 → 状态 disabled');
await sleep(RETRY_MS * 4);
ok(probeCalls === 1, '无激活连接无定时探测(仅 start 一次)');
await mgr.dispose();
}
console.log('[2] 异常 → 快速重试自动恢复(核心回归:MCP 服务恢复后灯 ≤ 重试间隔回绿)');
{
const mgr = new QmtMcpManager({
ctx: {}, runtime: { settings: mockSettings(BRIDGE_URL), dataSource: {} },
logger: quietLogger, intervalMs: INTERVAL_MS, retryMs: RETRY_MS,
});
let bridgeUp = false;
let probeCalls = 0;
mgr.probe = async () => {
probeCalls++;
mgr.status = bridgeUp
? { state: 'connected', serverName: 'QMT_Bridge_MCP', url: BRIDGE_URL + '/mcp', mounted: true, toolCount: 9, error: null, latencyMs: 3, checkedAt: Date.now() }
: { state: 'error', serverName: 'QMT_Bridge_MCP', url: BRIDGE_URL + '/mcp', mounted: true, toolCount: 0, error: 'ECONNREFUSED', latencyMs: 3, checkedAt: Date.now() };
mgr._scheduleNext(); // 等价真实 probe 尾部
return mgr.status;
};
await mgr.probe(); // 启动首次探测(Bridge 未启动)
ok(mgr.getStatus().state === 'error', 'Bridge 未启动 → 状态 error');
await sleep(RETRY_MS * 3);
ok(probeCalls >= 3, '异常态按 retryMs 快速重试(' + probeCalls + ' 次 ≥ 3');
bridgeUp = true; // 模拟 MCP 服务启动
await sleep(RETRY_MS * 3);
ok(mgr.getStatus().state === 'connected', '恢复后被自动探测到 → connected(灯将回绿,未等 5 分钟稳态间隔)');
await mgr.dispose();
}
console.log('[3] 已连接稳态:回落到长间隔,不空转');
{
const mgr = new QmtMcpManager({
ctx: {}, runtime: { settings: mockSettings(BRIDGE_URL), dataSource: {} },
logger: quietLogger, intervalMs: INTERVAL_MS, retryMs: RETRY_MS,
});
let probeCalls = 0;
mgr.probe = async () => {
probeCalls++;
mgr.status = { state: 'connected', serverName: 'QMT_Bridge_MCP', url: BRIDGE_URL + '/mcp', mounted: true, toolCount: 9, error: null, latencyMs: 3, checkedAt: Date.now() };
mgr._scheduleNext();
return mgr.status;
};
await mgr.probe();
ok(probeCalls === 1 && mgr.getStatus().state === 'connected', '首探测 connected');
await sleep(RETRY_MS * 3); // 小于稳态间隔、大于重试节奏
ok(probeCalls === 1, '已连接态在稳态间隔内不按重试节奏空转');
await sleep(INTERVAL_MS + RETRY_MS * 2); // 跨过稳态间隔
ok(probeCalls === 2, '跨过稳态间隔后又探测 1 次');
await mgr.dispose();
}
console.log('[4] dispose() 停止自动调度');
{
const mgr = new QmtMcpManager({
ctx: {}, runtime: { settings: mockSettings(BRIDGE_URL), dataSource: {} },
logger: quietLogger, intervalMs: INTERVAL_MS, retryMs: RETRY_MS,
});
let bridgeUp = false;
let probeCalls = 0;
mgr.probe = async () => {
probeCalls++;
mgr.status = bridgeUp
? { state: 'connected', serverName: 'QMT_Bridge_MCP', url: BRIDGE_URL + '/mcp', mounted: true, toolCount: 9, error: null, latencyMs: 3, checkedAt: Date.now() }
: { state: 'error', serverName: 'QMT_Bridge_MCP', url: BRIDGE_URL + '/mcp', mounted: true, toolCount: 0, error: 'ECONNREFUSED', latencyMs: 3, checkedAt: Date.now() };
mgr._scheduleNext();
return mgr.status;
};
await mgr.probe();
await sleep(RETRY_MS * 2);
const before = probeCalls;
await mgr.dispose();
bridgeUp = true;
await sleep(RETRY_MS * 4);
ok(probeCalls === before, 'dispose 后无自动探测');
}
console.log('[5] _nextDelay 节奏选择');
{
const mgr = new QmtMcpManager({
ctx: {}, runtime: { settings: mockSettings(BRIDGE_URL), dataSource: {} },
logger: quietLogger, intervalMs: INTERVAL_MS, retryMs: RETRY_MS,
});
mgr.status = { state: 'connected' };
ok(mgr._nextDelay() === INTERVAL_MS, 'connected → intervalMs');
mgr.status = { state: 'error' };
ok(mgr._nextDelay() === RETRY_MS, 'error → retryMs');
mgr.status = mgr._disabledStatus();
ok(mgr._nextDelay() === RETRY_MS, 'disabled → retryMs(无连接时 probe 早退不调度)');
}
console.log('----');
console.log(passed + ' passed, ' + failed + ' failed');
if (failed > 0) process.exit(1);
+9 -41
View File
@@ -56,11 +56,11 @@ console.log('[1] startup warmup + basic sync');
{
const ds = mockDataSource({ positions: [pos('600519.SH', 100), pos('300057.SZ', 200)] });
const st = mockStorage([]);
const sync = new PositionSync({ runtime: { dataSource: ds, storage: st }, ghostRounds: 3 });
const sync = new PositionSync({ runtime: { dataSource: ds, storage: st } });
const r = await sync.syncNow();
ok(sync.getSnapshot().length === 2, 'snapshot has 2 rows');
ok(sync.getSyncedAt() > 0, 'syncedAt recorded');
ok(r && r.positions === 2 && Array.isArray(r.closed), 'returns {positions, closed}');
ok(r && r.positions === 2 && r.closed === undefined, 'returns {positions} (ghost-close retired, no closed)');
ok(st.calls.closed.length === 0, 'no local holdings -> no close');
}
@@ -116,8 +116,9 @@ console.log('[3] empty snapshot double-confirmation');
ok(s4.getSnapshot().length === 1, '3d empty + identity unknown -> old snapshot kept');
}
console.log('[4] ghost holdings auto-close (debounce + account guard)');
console.log('[4] R-018 data-domain boundary: ghost-close retired (sync never writes ledger)');
{
// 账本有活动行(本地仍记 000001.SZ),QMT 快照无此 code —— 同步机制不再自动 closeHolding(分界拍板)
const holdings = [
{ holdingId: 1, strategyId: 'grid', code: '000001.SZ', shares: 100 },
{ holdingId: 2, strategyId: 'manual-t', code: '000001.SZ', shares: 50 },
@@ -125,44 +126,11 @@ console.log('[4] ghost holdings auto-close (debounce + account guard)');
];
const ds = mockDataSource({ positions: [pos('600519.SH')] });
const st = mockStorage(holdings);
const sync = new PositionSync({ runtime: { dataSource: ds, storage: st }, ghostRounds: 3 });
await sync.syncNow();
ok(st.calls.closed.length === 0, '4a round1 miss: no close');
await sync.syncNow();
ok(st.calls.closed.length === 0, '4a round2 miss: no close');
const r3 = await sync.syncNow();
ok(st.calls.closed.length === 2 && st.calls.closed.every((c) => c.code === '000001.SZ'), '4a round3: 000001.SZ closed across 2 strategies');
ok(r3.closed.length === 1 && r3.closed[0].code === '000001.SZ' && r3.closed[0].strategies.length === 2, '4a close detail returned');
ok(!st.calls.closed.some((c) => c.code === '600519.SH'), '4a code still in snapshot untouched');
const st2 = mockStorage(holdings);
const ds2 = mockDataSource({ positions: [pos('600519.SH')] });
const sync2 = new PositionSync({ runtime: { dataSource: ds2, storage: st2 }, ghostRounds: 3 });
await sync2.syncNow(); await sync2.syncNow();
ds2._positions = [pos('600519.SH'), pos('000001.SZ')];
await sync2.syncNow();
ds2._positions = [pos('600519.SH')];
await sync2.syncNow();
ok(st2.calls.closed.length === 0, '4b reappear resets debounce counter');
const st3 = mockStorage(holdings);
const ds3 = mockDataSource({ positions: [pos('600519.SH')] });
const sync3 = new PositionSync({ runtime: { dataSource: ds3, storage: st3 }, ghostRounds: 3 });
await sync3.syncNow(); await sync3.syncNow();
ds3._accountId = 'ACC-2';
await sync3.syncNow(); await sync3.syncNow(); await sync3.syncNow();
ok(st3.calls.closed.length === 0, '4c accountId change resets debounce (never reaches 3 consecutive)');
const st4 = mockStorage(holdings);
const ds4 = mockDataSource({ positions: [pos('600519.SH')] });
const sync4 = new PositionSync({ runtime: { dataSource: ds4, storage: st4 }, ghostRounds: 3 });
await sync4.syncNow(); await sync4.syncNow(); // miss x2(差 1 轮到阈值)
ds4._accountId = null; // 身份丢失
await sync4.syncNow(); await sync4.syncNow(); // 期间跳过判定,不累计
ok(st4.calls.closed.length === 0, '4d identity unknown -> skip close judgement');
ds4._accountId = 'ACC-1'; // 身份恢复
await sync4.syncNow(); // 第 3 次 miss → 达阈值清仓
ok(st4.calls.closed.length === 2, '4d identity restored -> 3rd miss closes');
const sync = new PositionSync({ runtime: { dataSource: ds, storage: st } });
for (let i = 0; i < 5; i++) await sync.syncNow(); // 远超旧 3 轮阈值
ok(st.calls.closed.length === 0, '4a multiple rounds missing: ledger NOT closed (ghost retired)');
ok(sync.getSnapshot().every((p) => p.code === '600519.SH'), '4a snapshot only reflects QMT (600519.SH)');
ok(sync.stats.closedGhosts === undefined, '4a stats.closedGhosts removed');
}
console.log('[5] PositionManager snapshot read + read-through fallback');
+26 -14
View File
@@ -37,27 +37,39 @@ const dataSource = {
const runtime = { dataSource, storage, settings };
// 端点注册齐全
assert(TRADE_METHODS.has('orders/attribution-candidates'), '端点 orders/attribution-candidates 已注册');
assert(TRADE_METHODS.has('orders/set-attribution'), '端点 orders/set-attribution 已注册');
// 端点注册齐全R-018candidates/set-attribution 退役 → targets/set/segments
assert(TRADE_METHODS.has('orders/attribution-targets'), '端点 orders/attribution-targets 已注册');
assert(TRADE_METHODS.has('orders/attribution-set'), '端点 orders/attribution-set 已注册');
assert(TRADE_METHODS.has('orders/attribution-segments'), '端点 orders/attribution-segments 已注册');
assert(TRADE_METHODS.has('trades/history'), '端点 trades/history 已注册');
assert(!TRADE_METHODS.has('orders/attribution-candidates'), '端点 orders/attribution-candidates 已退役');
assert(!TRADE_METHODS.has('orders/set-attribution'), '端点 orders/set-attribution 已退役');
// 候选端点(含策略名
// attribution-targets 端点(策略级 + 策略名 + activityShares
storage.openHolding('manual-t', '300057.SZ', 1000);
const cands = await handleTrade('orders/attribution-candidates', { code: '300057.SZ' }, runtime);
assert(cands.length === 1 && cands[0].strategyName === '手动做T', '候选含策略名(手动做T');
const tgt = await handleTrade('orders/attribution-targets', { code: '300057.SZ' }, runtime);
assert(tgt.length === 2, 'targets 返回全部策略');
const tRow = tgt.find((x) => x.strategyId === 'manual-t');
assert(tRow && tRow.strategyName === '手动做T' && tRow.activityShares === 1000, 'targets 含策略名 + activityShares(手动做T 1000');
// set-attribution 端点
storage.upsertTradeOrders([{ orderId: 'X-1', tradeDate: '20260901', code: '300057.SZ', insertDate: '20260901', insertTime: '093000' }]);
const h = storage.sqlite.db.prepare('SELECT holding_id FROM strategy_holdings WHERE strategy_id=?').get('manual-t');
const setRes = await handleTrade('orders/set-attribution', { orderId: 'X-1', strategyId: 'manual-t', holdingId: h.holding_id }, runtime);
assert(setRes.ok === true, 'set-attribution 端点返回 ok');
const row = storage.sqlite.db.prepare('SELECT strategy_id FROM trade_orders WHERE order_id=?').get('X-1');
assert(row.strategy_id === 'manual-t', '归属已持久化');
// attribution-set 端点(R-018 账本写操作)
storage.upsertTradeOrders([{ orderId: 'X-1', tradeDate: '20260901', code: '300057.SZ', insertDate: '20260901', insertTime: '093000', tradedVolume: 1000 }]);
const { AttributionService } = await import('../src/trades/AttributionService.js');
const svc = new AttributionService({ store: storage.sqlite });
const rt = { ...runtime, attribution: svc };
const setRes = await handleTrade('orders/attribution-set', { orderId: 'X-1', segments: [{ strategyId: 'manual-t', volume: 1000 }] }, rt);
assert(setRes.ok === true && setRes.segments.length === 1, 'attribution-set 返回 ok + segments');
const row = storage.sqlite.db.prepare('SELECT strategy_id, holding_id FROM trade_orders WHERE order_id=?').get('X-1');
assert(row.strategy_id === 'manual-t' && row.holding_id != null, '归属已持久化(冗余列镜像)');
// trades/history 端点(策略过滤)
// attribution-segments 端点
const segs = await handleTrade('orders/attribution-segments', { orderId: 'X-1' }, rt);
assert(segs.length === 1 && segs[0].strategyName === '手动做T', 'attribution-segments 读段含策略名');
// trades/history 端点(策略过滤经段表)
const hist = await handleTrade('trades/history', { start: '20260901', end: '20260901', strategyId: 'manual-t' }, runtime);
assert(hist.orders.length === 1 && hist.orders[0].orderId === 'X-1', 'history 端点按策略过滤命中');
assert(Array.isArray(hist.orders[0].segments) && hist.orders[0].segments.length === 1, 'history orders 附加 segments');
storage.close();
rmSync(dir, { recursive: true, force: true });
+63 -42
View File
@@ -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 + " 失败");
+42 -66
View File
@@ -1,18 +1,16 @@
/**
* R-011 回归测试:tabs 统一管理数据层
* R-011 回归测试:tabs 统一管理数据层R-027 策略 tab 静态化)
* 独立内存 mock scopesettings 读写),验证:
* 1. 归一化迁移:旧布尔对象 tabs → 有序数组(内置保留原显隐,策略接续追加)
* 2. 迁移:旧隐藏策略 → visible=trueQ3
* 3. getTabs:策略行 name join strategiesQ4 自动跟随改名
* 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=trueQ3");
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 联动删除 tabD3 ----------
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 + " 失败");
+20 -7
View File
@@ -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);
+12 -5
View File
@@ -1,7 +1,7 @@
/**
* R-013 回归测试:策略自定义字段(定义随策略 configSchema + 值落 strategy_holdings.values
* 覆盖(独立数据目录,技术约束-011):
* 1. settings.configSchemaschema 归一化(旧策略无 configSchema → []);updateStrategies 保留定义
* 1. settings.configSchemaschema 归一化(旧策略无 configSchema → []);updateStrategyFields 单策略写入
* 2. SqliteStore_ensureHoldingValuesColumn 幂等补列;openHolding 后 values=nullreadValues/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 列');
+117
View File
@@ -0,0 +1,117 @@
/**
* R-016 / 迭代 14 回归:策略已清仓历史持仓查询 + API 端点
* 独立临时数据目录(技术约束-011:不碰真实数据)。
* 覆盖:清仓转历史可查 / 范围过滤边界(5 档 + range=today 自然日边界)/ closed_at DESC 排序 / 策略隔离 /
* 未清仓行不出现 / API 参数校验 / name 兜底 / 只读端点默认范围。
*/
import { SqliteStore } from '../src/storage/SqliteStore.js';
import { DataStore } from '../src/storage/DataStore.js';
import { handleStrategy } from '../src/api/strategies.js';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
let pass = 0, fail = 0;
function assert(cond, msg) {
if (cond) { pass++; console.log(' ✅ ' + msg); }
else { fail++; console.log(' ❌ ' + msg); }
}
const DAY = 86400000;
const tmp = mkdtempSync(join(tmpdir(), 'odl-r016-'));
const storage = new DataStore({ dataDir: tmp });
const runtime = { storage }; // handleStrategy 的 strategy-holdings/history 仅消费 storage
try {
console.log('\n[1] 建仓 → 清仓 → 历史可查');
await storage.openHolding('s1', '600719.SH', 1000);
const h1 = await storage.getCurrentHoldings('s1');
assert(h1.length === 1 && h1[0].shares === 1000, '建仓后当前持仓 1000');
await storage.closeHolding('s1', '600719.SH');
const cur = await storage.getCurrentHoldings('s1');
assert(cur.length === 0, '清仓后当前持仓为空');
const hist1 = await storage.getHoldingsHistory('s1', {});
assert(hist1.length === 1, '历史查询可查到 1 行');
assert(hist1[0].shares === 0 && hist1[0].closedAt > 0, 'shares=0Q2closeHolding 语义不变)且 closed_at 有值');
assert(hist1[0].holdingId === h1[0].holdingId, 'holding_id 锚点保留');
console.log('\n[2] 多行 + 排序(closed_at DESC+ 策略隔离 + 未清仓不出现');
await storage.openHolding('s1', '000001.SZ', 500);
await storage.closeHolding('s1', '000001.SZ');
await storage.openHolding('s1', '600000.SH', 300); // 未清仓
await storage.openHolding('s2', '600719.SH', 100);
await storage.closeHolding('s2', '600719.SH');
const all = await storage.getHoldingsHistory('s1', {});
assert(all.length === 2, 's1 历史 2 行(不含未清仓 600000.SH、不含 s2');
assert(all[0].closedAt >= all[1].closedAt, '按 closed_at DESC 排序');
const s2 = await storage.getHoldingsHistory('s2', {});
assert(s2.length === 1 && s2[0].code === '600719.SH', '策略隔离:s2 只见自己');
console.log('\n[3] 范围过滤(sinceMs 边界)');
// 人为回溯:000001.SZ 清仓时间改到 40 天前(临时测试库,允许直改)
const db = storage.sqlite.db;
const row40d = all.find((r) => r.code === '000001.SZ');
db.prepare('UPDATE strategy_holdings SET closed_at=? WHERE holding_id=?').run(Date.now() - 40 * DAY, row40d.holdingId);
const week = await storage.getHoldingsHistory('s1', { sinceMs: Date.now() - 7 * DAY });
assert(week.length === 1 && week[0].code === '600719.SH', '近一周:只有 600719.SH');
const month = await storage.getHoldingsHistory('s1', { sinceMs: Date.now() - 30 * DAY });
assert(month.length === 1, '近 1 个月:仍只有 600719.SH40 天前行被滤掉)');
const quarter = await storage.getHoldingsHistory('s1', { sinceMs: Date.now() - 90 * DAY });
assert(quarter.length === 2 && quarter[0].code === '600719.SH', '近 3 个月:2 行,40 天前行回归且 DESC 在前');
const year = await storage.getHoldingsHistory('s1', { sinceMs: Date.now() - 365 * DAY });
assert(year.length === 2, '近 1 年:2 行');
console.log('\n[4] API 端点(含 name 兜底 + 参数校验)');
// 关联委托(含 name
db.prepare(
"INSERT INTO trade_orders (order_id, trade_date, code, name, direction, insert_ts, fetched_at) VALUES (?,?,?,?,?,?,?)"
).run('T-001', '20260907', '600719.SH', '大连热电', 'sell', Date.now(), Date.now());
// 归属到 s1 的历史行(R-018:真相源 = 段表 —— 段 + 冗余列镜像)
db.prepare(
"INSERT INTO trade_order_attributions (order_id, strategy_id, holding_id, code, direction, volume, created_at) VALUES (?,?,?,?,?,?,?)"
).run('T-001', 's1', week[0].holdingId, '600719.SH', 'sell', 100, Date.now());
db.prepare('UPDATE trade_orders SET strategy_id=?, holding_id=? WHERE order_id=?').run('s1', week[0].holdingId, 'T-001');
const apiWeek = await handleStrategy('strategy-holdings/history', { strategyId: 's1', range: 'week' }, runtime);
assert(apiWeek.length === 1 && apiWeek[0].code === '600719.SH', '端点近一周返回 1 行');
assert(apiWeek[0].name === '大连热电', 'name 兜底取关联委托 name');
assert(apiWeek[0].values === null, 'values 未配置为 null');
const apiDefault = await handleStrategy('strategy-holdings/history', { strategyId: 's1' }, runtime);
assert(apiDefault.length === 1, '缺省 range = week');
let threw = null;
try { await handleStrategy('strategy-holdings/history', { range: 'week' }, runtime); } catch (e) { threw = e; }
assert(threw && threw.code === 'bad-request', '缺 strategyId → bad-request');
threw = null;
try { await handleStrategy('strategy-holdings/history', { strategyId: 's1', range: 'all' }, runtime); } catch (e) { threw = e; }
assert(threw && threw.code === 'bad-request', '非法 range → bad-request');
console.log('\n[4b] range=today(本地自然日 00:00 边界;R-016 二轮补充:今日已清仓默认层)');
const rowToday = all.find((r) => r.code === '600719.SH');
const todayStart = new Date(); todayStart.setHours(0, 0, 0, 0);
// 清仓时间挪到「昨天 23:59:59」→ today 应滤掉(昨天归历史范围层,不属默认层)
db.prepare('UPDATE strategy_holdings SET closed_at=? WHERE holding_id=?').run(todayStart.getTime() - 1000, rowToday.holdingId);
const apiTodayMiss = await handleStrategy('strategy-holdings/history', { strategyId: 's1', range: 'today' }, runtime);
assert(apiTodayMiss.length === 0, 'today:昨天 23:59:59 清仓 → 不出现(自然日边界下沿)');
// 挪回「今天 00:00:01」→ today 可见(00:00:00 含,00:00:01 > 00:00:00
db.prepare('UPDATE strategy_holdings SET closed_at=? WHERE holding_id=?').run(todayStart.getTime() + 1000, rowToday.holdingId);
const apiTodayHit = await handleStrategy('strategy-holdings/history', { strategyId: 's1', range: 'today' }, runtime);
assert(apiTodayHit.length === 1 && apiTodayHit[0].code === '600719.SH', 'today:今天 00:00:01 清仓 → 出现');
// 40 天前的 000001.SZ 不应出现在 today
assert(!apiTodayHit.some((r) => r.code === '000001.SZ'), 'today:不含 40 天前清仓的 000001.SZ');
// name 兜底对 today 同样生效
assert(apiTodayHit[0].name === '大连热电', 'today 行 name 兜底同样生效');
// 非法校验:today 之外仍拒绝未知档(回归:'all' 仍非法)
threw = null;
try { await handleStrategy('strategy-holdings/history', { strategyId: 's1', range: 'all' }, runtime); } catch (e) { threw = e; }
assert(threw && threw.code === 'bad-request', 'range 非法(all)→ bad-request 不受 today 引入影响');
console.log('\n[5] 现有端点零破坏(strategy-positions 快照路径不受影响)');
const rt2 = { manager: { async getStrategyPositions() { return [{ code: '600719.SH', shares: 100 }]; } }, settings: {} };
const pos = await handleStrategy('strategy-positions', { strategyId: 's1' }, rt2);
assert(pos.length === 1 && pos[0].shares === 100, 'strategy-positions 行为不变(mock 验证分发未变)');
} finally {
storage.close();
try { rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
}
console.log('\n=== 结果: ' + pass + ' 通过 / ' + fail + ' 失败 ===');
process.exitCode = fail > 0 ? 1 : 0;
+68
View File
@@ -0,0 +1,68 @@
/**
* R-017 / 迭代 15 回归 → R-018 / 迭代 16 语义修订 → R-027 迭代 22:策略 id 改用内置(manual-t / grid-supermarket):归属候选退役 7 天清仓窗口
* 新语义(R-018 T2 老师拍板):候选回归「当前活动持仓 + 未关联」;正式候选为策略级 attribution-targets。
* 覆盖:当前活动持仓候选 / 清仓/作废行不进候选 / 多策略 / 空 code / targets 端点。
*/
import { DataStore } from '../src/storage/DataStore.js';
import { handleTrade } from '../src/api/trades.js';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
let pass = 0, fail = 0;
function assert(cond, msg) {
if (cond) { pass++; console.log(' ✅ ' + msg); }
else { fail++; console.log(' ❌ ' + msg); }
}
const DAY = 86400000;
const tmp = mkdtempSync(join(tmpdir(), 'odl-r017-'));
const storage = new DataStore({ dataDir: tmp });
const runtime = {
storage,
settings: { get: () => ({}) }, // R-027:策略恒为内置两项(不再从 settings 读取)
};
try {
console.log('\n[1] 当前活动持仓候选(closed=false');
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('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('manual-t', '600000.SH', 300);
storage.sqlite.voidHolding('manual-t', '600000.SH');
cands = await storage.getTradeAttributionCandidates('600000.SH');
assert(cands.length === 0, '作废行不进候选');
console.log('\n[3] 空 code / 无持仓 code');
assert((await storage.getTradeAttributionCandidates('')).length === 0, '空 code → []');
assert((await storage.getTradeAttributionCandidates('999999.SH')).length === 0, '无持仓 code → []');
console.log('\n[4] R-018 attribution-targets(策略级候选端点)');
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 === 'manual-t');
assert(rowT && rowT.activityShares === 500 && rowT.hasActiveHolding === true, '有仓策略 activityShares=500 / hasActiveHolding=true');
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('manual-t', '000002.SZ');
const tgt2 = await handleTrade('orders/attribution-targets', { code: '000002.SZ' }, runtime);
assert(tgt2.find((x) => x.strategyId === 'manual-t').hasActiveHolding === false, '清仓后该策略 hasActiveHolding=false');
} finally {
storage.close();
try { rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
}
console.log('\n=== 结果: ' + pass + ' 通过 / ' + fail + ' 失败 ===');
process.exitCode = fail > 0 ? 1 : 0;
+164
View File
@@ -0,0 +1,164 @@
/**
* R-018 / 迭代 16 回归:交易关联驱动持仓份额动态调整
* 覆盖:数据域分界(PositionSync 不写账本)/ 归属分段表 / 分配器动作表(买建仓·买加仓·卖减仓·卖清仓·仓不足拒绝·无仓拒绝)/
* 撤段逆操作(撤建仓=作废·撤加仓=减回·撤清仓卖出段=恢复活动·乱序拒绝)/ 全量替换原子 / 存量迁移 / API / 软提示只读。
*/
import { DataStore } from '../src/storage/DataStore.js';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
let pass = 0, fail = 0;
function assert(cond, msg) {
if (cond) { pass++; console.log(' ✅ ' + msg); }
else { fail++; console.log(' ❌ ' + msg); }
}
const tmp = mkdtempSync(join(tmpdir(), 'odl-r018-'));
const storage = new DataStore({ dataDir: tmp });
await storage._ensure();
const now = Date.now();
function insOrder(orderId, code, direction, tradedVolume) {
storage.sqlite.db.prepare(
'INSERT INTO trade_orders (order_id, trade_date, code, name, exchange, direction, order_volume, traded_volume, insert_date, insert_time, insert_ts, fetched_at) ' +
'VALUES (?, \'20260908\', ?, \'X\', \'SH\', ?, ?, ?, \'20260908\', \'093000\', ?, ?)'
).run(orderId, code, direction, tradedVolume, tradedVolume, now, now);
}
const { PositionSync } = await import('../src/position/PositionSync.js');
const { AttributionService } = await import('../src/trades/AttributionService.js');
const svc = new AttributionService({ store: storage.sqlite });
console.log('\n[1] 数据域分界:PositionSync 不写账本(幽灵退役)');
{
const ds = {
async getPositions() { return [{ code: '600519.SH', volume: 100 }]; },
async getAsset() { return { accountId: 'ACC' }; },
async isAvailable() { return true; },
};
const holdings = [{ holdingId: 1, strategyId: 'grid', code: '000001.SZ', shares: 100 }];
const calls = { closed: 0 };
const st = { async getCurrentHoldings() { return holdings; }, async closeHolding() { calls.closed++; } };
const sync = new PositionSync({ runtime: { dataSource: ds, storage: st } });
for (let i = 0; i < 5; i++) await sync.syncNow();
assert(calls.closed === 0, '1a 多轮消失后账本不被 closeHolding(幽灵退役)');
assert(sync.stats.closedGhosts === undefined, '1b stats.closedGhosts 移除');
}
console.log('\n[2] 存储地基:作废态 / closed_shares / 段表 / 兼容迁移');
{
const h1 = await storage.openHolding('grid', '600519.SH', 1000);
await storage.addShares('grid', '600519.SH', 500);
const act = storage.sqlite._getActive('grid', '600519.SH');
assert(act.shares === 1500, '2a addShares 累加 1500');
await storage.closeHolding('grid', '600519.SH');
const closedRow = storage.sqlite.getHoldingById(h1.holdingId);
assert(closedRow.closedShares === 1500 && closedRow.shares === 0, '2b closeHolding 记录 closed_shares=1500 且 shares 置 0');
const re = storage.sqlite.reopenHolding(h1.holdingId);
assert(re && re.shares === 1500, '2c reopenHolding 恢复活动 shares=1500');
const hv = await storage.openHolding('grid', '300057.SZ', 300);
storage.sqlite.voidHolding('grid', '300057.SZ');
const vRow = storage.sqlite.getHoldingById(hv.holdingId);
assert(vRow.voidAt != null && vRow.closedAt == null, '2d voidHolding 置 void_at 非 close');
assert(storage.sqlite.getCurrentHoldings('grid').length === 1, '2e 作废行不在当前持仓');
assert(storage.sqlite.getHoldingsHistory('grid').length === 0, '2f 历史层无作废行(600519 已恢复活动、300057 作废不进历史)');
insOrder('M-1', '600519.SH', 'buy', 1000);
storage.sqlite.setOrderAttribution('M-1', 'grid', h1.holdingId);
const segs = storage.sqlite.getOrderAttributions('M-1');
assert(segs.length === 1 && segs[0].volume === 1000, '2g 兼容 setOrderAttribution 同步段表第一段');
}
console.log('\n[3] 分配器动作表:买建仓 / 买加仓 / 卖减仓 / 卖清仓 / 拒绝');
{
insOrder('B-1', '600000.SH', 'buy', 800);
svc.applySegment({ orderId: 'B-1', strategyId: 'grid', volume: 800 });
const a1 = storage.sqlite._getActive('grid', '600000.SH');
assert(a1 && a1.shares === 800, '3a 买入无仓 → openHolding 建仓 800');
insOrder('B-2', '600000.SH', 'buy', 300);
svc.applySegment({ orderId: 'B-2', strategyId: 'grid', volume: 300 });
const a2 = storage.sqlite._getActive('grid', '600000.SH');
assert(a2.shares === 1100, '3b 买入有仓 → addShares 1100');
insOrder('S-1', '600000.SH', 'sell', 600);
svc.applySegment({ orderId: 'S-1', strategyId: 'grid', volume: 600 });
const a3 = storage.sqlite._getActive('grid', '600000.SH');
assert(a3.shares === 500, '3c 卖出减仓 → 500');
insOrder('S-2', '600000.SH', 'sell', 500);
svc.applySegment({ orderId: 'S-2', strategyId: 'grid', volume: 500 });
assert(storage.sqlite._getActive('grid', '600000.SH') === null, '3d 卖出归零 → closeHolding 清仓');
insOrder('S-3', '600000.SH', 'sell', 999);
let err = null; try { svc.applySegment({ orderId: 'S-3', strategyId: 'grid', volume: 999 }); } catch (e) { err = e; }
assert(err && err.code === 'no-active-holding', '3e 卖出无仓 → no-active-holding 拒绝');
insOrder('B-3', '601111.SH', 'buy', 500);
svc.applySegment({ orderId: 'B-3', strategyId: 'grid', volume: 500 });
insOrder('S-4', '601111.SH', 'sell', 800);
err = null; try { svc.applySegment({ orderId: 'S-4', strategyId: 'grid', volume: 800 }); } catch (e) { err = e; }
assert(err && err.code === 'segment-exceeds', '3f 卖出段 > 活动仓 → segment-exceeds 拒绝');
}
console.log('\n[4] 撤段逆操作');
{
insOrder('B-V', '600800.SH', 'buy', 400);
svc.applySegment({ orderId: 'B-V', strategyId: 'grid', volume: 400 });
const hV = storage.sqlite._getActive('grid', '600800.SH');
svc.revokeSegment({ orderId: 'B-V', strategyId: 'grid' });
const vRow = storage.sqlite.getHoldingById(hV.holdingId);
assert(vRow.voidAt != null && vRow.closedAt == null, '4a 撤建仓买入段 → void 作废(无假清仓历史)');
assert(storage.sqlite.getHoldingsHistory('grid').filter((x) => x.code === '600800.SH').length === 0, '4a 作废不进历史层');
insOrder('B-R', '600900.SH', 'buy', 700);
svc.applySegment({ orderId: 'B-R', strategyId: 'grid', volume: 700 });
insOrder('S-R', '600900.SH', 'sell', 700);
svc.applySegment({ orderId: 'S-R', strategyId: 'grid', volume: 700 });
assert(storage.sqlite._getActive('grid', '600900.SH') === null, '4b 前置:已清仓');
svc.revokeSegment({ orderId: 'S-R', strategyId: 'grid' });
const aR = storage.sqlite._getActive('grid', '600900.SH');
assert(aR && aR.shares === 700, '4b 撤清仓卖出段 → 恢复活动 700');
insOrder('B-X1', '600300.SH', 'buy', 300);
svc.applySegment({ orderId: 'B-X1', strategyId: 'grid', volume: 300 });
insOrder('B-X2', '600300.SH', 'buy', 200);
svc.applySegment({ orderId: 'B-X2', strategyId: 'grid', volume: 200 });
svc.revokeSegment({ orderId: 'B-X2', strategyId: 'grid' });
const aX = storage.sqlite._getActive('grid', '600300.SH');
assert(aX.shares === 300, '4c 撤加仓买入段 → 减回 300');
insOrder('S-X', '600300.SH', 'sell', 300);
svc.applySegment({ orderId: 'S-X', strategyId: 'grid', volume: 300 });
let err = null;
try { svc.revokeSegment({ orderId: 'B-X1', strategyId: 'grid' }); } catch (e) { err = e; }
assert(err && err.code === 'segment-order-conflict', '4d 已清仓后撤更早买入段 → 冲突拒绝(逆序约束)');
}
console.log('\n[5] 全量替换 setSegments(多段 + 原子)');
{
await storage.openHolding('grid', '600999.SH', 500);
await storage.openHolding('manual', '600999.SH', 300);
insOrder('S-SPLIT', '600999.SH', 'sell', 800);
const r = svc.setSegments({ orderId: 'S-SPLIT', segments: [{ strategyId: 'grid', volume: 500 }, { strategyId: 'manual', volume: 300 }] });
assert(r.segments.length === 2, '5a 拆成两段');
assert(storage.sqlite._getActive('grid', '600999.SH') === null && storage.sqlite._getActive('manual', '600999.SH') === null, '5b 两段均归零清仓');
svc.revokeSegment({ orderId: 'S-SPLIT', strategyId: 'manual' });
const aM = storage.sqlite._getActive('manual', '600999.SH');
assert(aM && aM.shares === 300, '5c 撤 manual 段 → 该 holding 恢复活动 300(跨 holding 独立撤)');
assert(storage.sqlite._getActive('grid', '600999.SH') === null, '5c grid 段不受影响(保持清仓)');
let err = null;
try { svc.setSegments({ orderId: 'S-SPLIT', segments: [{ strategyId: 'grid', volume: 500 }, { strategyId: 'manual', volume: 600 }] }); } catch (e) { err = e; }
assert(err != null && err.code === 'segment-exceeds-order', '5d 段合计超已成交量 → 事务前拒绝');
const segsAfter = storage.sqlite.getOrderAttributions('S-SPLIT');
assert(segsAfter.length === 1 && segsAfter[0].strategyId === 'grid' && segsAfter[0].volume === 500, '5e 拒绝未改段表(manual 段已在 5c 撤销,grid 段保留)');
}
console.log('\n[6] 漏关联软提示只读');
{
const { handlePosition } = await import('../src/api/positions.js');
const sync = new PositionSync({ runtime: { dataSource: { getPositions: async () => [{ code: '600519.SH' }], getAsset: async () => ({ accountId: 'A' }), isAvailable: async () => true }, storage } });
await sync.syncNow();
const rt = { storage, positionSync: sync };
const hints = await handlePosition('positions/orphan-hints', {}, rt);
assert(Array.isArray(hints), '6a 返回数组');
assert(hints.every((h) => h.code !== '600519.SH'), '6b 快照内的 code 不提示');
}
storage.close();
try { rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
console.log('\n=== 结果: ' + pass + ' 通过 / ' + fail + ' 失败 ===');
process.exitCode = fail > 0 ? 1 : 0;
+218
View File
@@ -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', 'inferResultKindand → 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);
+139
View File
@@ -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);
+9 -8
View File
@@ -12,12 +12,13 @@
*
* 端点(POST /odl/api/<method>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 → 分仓摘要
* add-shares → 添加份额 { code, strategyId, shares }
@@ -59,8 +60,8 @@ const HANDLERS = [
* @param {import('@deepseek-ai/cordis').Context} ctx
* @param {object} runtime { manager, settings, dataSource, marketCache }
*/
export function registerApi(ctx, { manager, settings, dataSource, marketHub, marketFeed, qmtHealthMonitor, storage, tradeSync, positionSync }) {
const runtime = { ctx, manager, settings, dataSource, marketHub, marketFeed, qmtHealthMonitor, storage, tradeSync, positionSync };
export function registerApi(ctx, { manager, settings, dataSource, marketHub, marketFeed, qmtHealthMonitor, storage, tradeSync, positionSync, attribution, mcpManager }) {
const runtime = { ctx, manager, settings, dataSource, marketHub, marketFeed, qmtHealthMonitor, storage, tradeSync, positionSync, attribution, mcpManager };
ctx.effect(() => ctx.webServer.register({
kind: 'prefix',
+2 -1
View File
@@ -42,7 +42,7 @@ function projectQuote(hub, code, snap) {
* @param {object} args
* @param {object} runtime { settings, dataSource, marketHub, marketFeed(=QuoteSync), qmtHealthMonitor, positionSync }
*/
export async function handleMarket(method, args, { settings, dataSource, marketHub, marketFeed: marketSync, qmtHealthMonitor, positionSync }) {
export async function handleMarket(method, args, { settings, dataSource, marketHub, marketFeed: marketSync, qmtHealthMonitor, positionSync, mcpManager }) {
switch (method) {
case 'market-snapshot': {
const codes = Array.isArray(args.codes) ? args.codes : [];
@@ -86,6 +86,7 @@ export async function handleMarket(method, args, { settings, dataSource, marketH
? { ...marketSync.getStatus(), state: state(marketSync.getStatus()) }
: null,
qmt: qmtHealthMonitor ? qmtHealthMonitor.getStatus() : null,
mcp: mcpManager ? mcpManager.getStatus() : null, // R-020/迭代18QMT MCP 状态(会话头部指示灯)
};
}
case 'sync-now': {
+24 -3
View File
@@ -2,24 +2,45 @@
* 服务端 API:持仓域(从 api.js 拆分,2026-08-31
*
* 端点:
* positions → 全量持仓
* positions → 全量持仓
* positions/orphan-hints → R-018 漏关联软提示:账本活动行 ∩ QMT 快照缺失的 code(只读,不动账本)
*/
/** 持仓端点方法表 */
export const POSITION_METHODS = new Set([
'positions',
'positions/orphan-hints',
]);
/**
* 处理持仓端点
* @param {string} method
* @param {object} args
* @param {object} runtime { manager }
* @param {object} runtime { manager, storage, positionSync }
*/
export async function handlePosition(method, args, { manager }) {
export async function handlePosition(method, args, { manager, storage, positionSync }) {
switch (method) {
case 'positions':
return await manager.getAllPositions();
case 'positions/orphan-hints': {
// R-018 T3:漏关联软提示。账本有活动行(closed/void 双空)但 QMT 快照无此 code → 提示去关联/移出。
// 只读不写(同步机制与账本分界:软提示不自动改账本)。
const snapshot = positionSync && Array.isArray(positionSync.getSnapshot())
? positionSync.getSnapshot()
: null;
if (!storage) return [];
const holdings = await storage.getCurrentHoldings(); // 全策略当前活动持仓
if (!snapshot) return []; // 快照不可用(从未同步/未注入)→ 不提示(避免误报)
const snapshotCodes = new Set(snapshot.map((p) => p.code).filter(Boolean));
return holdings
.filter((h) => !snapshotCodes.has(h.code))
.map((h) => ({
holdingId: h.holdingId,
strategyId: h.strategyId,
code: h.code,
shares: h.shares,
}));
}
default:
throw Object.assign(new Error('unknown position method: ' + method), { code: 'not-found' });
}
+14 -1
View File
@@ -28,6 +28,7 @@ export const QMT_METHODS = new Set([
'qmt-connections/set-default',
'qmt-connections/test',
'qmt-connections/active-host',
'qmt-connections/mcp-status', // R-020/迭代18QMT MCP 状态(probe / 缓存)
]);
/** 服务端代理测试连接:GET {baseUrl}/health(浏览器跨域规避,Q3 手动测试) */
@@ -63,7 +64,7 @@ async function testQmtConnection(ctx, args) {
* @param {object} args
* @param {object} runtime { manager, settings, dataSource, marketCache }
*/
export async function handleQmt(method, args, { ctx, settings, dataSource, marketFeed, qmtHealthMonitor }) {
export async function handleQmt(method, args, { ctx, settings, dataSource, marketFeed, qmtHealthMonitor, mcpManager }) {
switch (method) {
case 'qmt-connections': {
const state = getQmtConnections(settings);
@@ -80,6 +81,8 @@ export async function handleQmt(method, args, { ctx, settings, dataSource, marke
marketFeed?.setBaseUrl(value.baseUrl);
qmtHealthMonitor?.probe(); // 切换后立即补探健康
ctx?.logger?.info('[one-divine-lot] 激活配置地址已变更,数据源热切换 → ' + value.baseUrl);
// R-020/迭代18:MCP 目标地址随激活地址变更重挂
mcpManager?.resync(value.baseUrl).catch(() => {});
}
return value;
}
@@ -94,6 +97,8 @@ export async function handleQmt(method, args, { ctx, settings, dataSource, marke
qmtHealthMonitor?.probe(); // 切换后立即补探健康
ctx?.logger?.info('[one-divine-lot] 激活配置已删除,自动切换 → ' + active.name + ' (' + active.baseUrl + ')');
}
// R-020/迭代18:激活已变(或列表清空)→ MCP 跟随新激活 / 卸载
mcpManager?.resync(active?.baseUrl).catch(() => {});
return { ...afterState, active, removedWasActive: result.removedWasActive, removedWasDefault: result.removedWasDefault };
}
case 'qmt-connections/activate': {
@@ -105,6 +110,8 @@ export async function handleQmt(method, args, { ctx, settings, dataSource, marke
qmtHealthMonitor?.probe(); // 切换后立即补探健康
ctx?.logger?.info('[one-divine-lot] QMT 连接激活: ' + active.name + ' (' + active.baseUrl + ')');
}
// R-020/迭代18:激活切换 → MCP 实例跟随(卸旧挂新,url=新激活 baseUrl+/mcp
mcpManager?.resync(active?.baseUrl).catch(() => {});
return { ...state, active };
}
case 'qmt-connections/set-default':
@@ -132,6 +139,12 @@ export async function handleQmt(method, args, { ctx, settings, dataSource, marke
} catch { /* baseUrl 非法时返回空 */ }
return { name: active.name, baseUrl, host, wsUrl, activeId: state.activeId ?? '' };
}
case 'qmt-connections/mcp-status': {
// R-020/迭代18MCP 状态(缓存秒回;refresh=true 即时探测)
if (!mcpManager) return { state: 'disabled', error: 'mcpManager 不可用', checkedAt: 0 };
if (args.refresh) return await mcpManager.probe();
return mcpManager.getStatus();
}
default:
throw Object.assign(new Error('unknown qmt method: ' + method), { code: 'not-found' });
}
+149 -19
View File
@@ -2,24 +2,99 @@
* 服务端 API:策略 / 份额 / tabs 域(从 api.js 拆分,2026-08-31
*
* 端点:
* strategies / strategies/update / strategies/add / strategies/remove
* strategies(读;R-027:恒为内置两项)
* strategies/schema-updateR-027:写某策略字段定义,唯一写路径)
* strategy-positions / unallocated / summary
* strategy-holdings/historyR-016 迭代 14:已清仓历史持仓,范围筛选)
* add-shares / move-all-shares / remove-shares
* tabs / tabs/update
* formula/variablesR-026:公式变量目录)/ formula/trialR-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 起清仓的持仓
* 作为「今日已清仓」默认层(开关「历史持仓」不控制它,当天清仓默认显示),sinceMs 在 handler 内特判。 */
const HISTORY_RANGE_MS = {
week: 7 * 86400000,
month: 30 * 86400000,
quarter: 90 * 86400000,
halfYear: 182 * 86400000,
year: 365 * 86400000,
};
/** 本地自然日零点毫秒(range='today' 用:今天 00:00 起 = 当日清仓的持仓) */
function localDayStartMs() {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d.getTime();
}
/** 策略/份额/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',
@@ -31,35 +106,56 @@ export const STRATEGY_METHODS = new Set([
'holdings/values-update', // R-013:写某持仓行的自定义字段值
'strategy-columns', // 迭代11列显隐:读某策略列配置(归一化)
'strategy-columns/update', // 迭代11列显隐:写某策略列配置
'strategy-holdings/history', // R-016 迭代 14:某策略已清仓历史持仓(范围筛选,只读)
]);
/**
* 处理策略/份额/tabs 端点
* @param {string} method
* @param {object} args
* @param {object} runtime { manager, settings }
* @param {object} runtime { manager, settings, storage }
*/
export async function handleStrategy(method, args, { manager, settings }) {
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':
@@ -78,6 +174,40 @@ export async function handleStrategy(method, args, { manager, settings }) {
case 'strategy-columns/update':
await updateStrategyColumns(settings, args.strategyId, args.columns);
return getStrategyColumns(settings, args.strategyId);
case 'strategy-holdings/history': {
// R-016 迭代 14:某策略已清仓历史持仓(当前持仓快照路径零改动,技术约束-019)
if (!args.strategyId) {
throw Object.assign(new Error('缺少 strategyId 参数'), { code: 'bad-request' });
}
const range = args.range ?? 'week';
// R-016 二轮补充(2026-09-07 老师定):range='today' = 本地自然日 00:00 起清仓的持仓
// (前端默认层——当天清仓默认显示,与「历史持仓」开关解耦);其余 5 档维持回溯毫秒语义
let sinceMs;
if (range === 'today') {
sinceMs = localDayStartMs();
} else {
const ms = HISTORY_RANGE_MS[range];
if (!ms) {
throw Object.assign(new Error('range 非法: ' + range + 'today|week|month|quarter|halfYear|year'), { code: 'bad-request' });
}
sinceMs = Date.now() - ms;
}
const rows = await storage.getHoldingsHistory(args.strategyId, { sinceMs });
// name 兜底:该 holding 最近一笔关联委托的 name(前端免二次请求)
// R-018:真相源 = 段表 —— 历史行关联委托经段表 join(同一单多段也能兜到 name)
const ids = rows.map((r) => r.holdingId).filter((x) => x != null);
const nameMap = new Map();
if (ids.length > 0) {
const ph = ids.map(() => '?').join(',');
const orows = storage.sqlite.db.prepare(
'SELECT a.holding_id, t.name FROM trade_order_attributions a ' +
'JOIN trade_orders t ON t.order_id = a.order_id ' +
'WHERE a.holding_id IN (' + ph + ") AND t.name != '' ORDER BY t.insert_ts DESC"
).all(...ids);
for (const o of orows) if (!nameMap.has(o.holding_id)) nameMap.set(o.holding_id, o.name);
}
return rows.map((r) => ({ ...r, name: nameMap.get(r.holdingId) ?? null }));
}
default:
throw Object.assign(new Error('unknown strategy method: ' + method), { code: 'not-found' });
}
+78 -34
View File
@@ -1,12 +1,17 @@
/**
* 服务端 API:交易记录域(R-0072026-09-01
* 服务端 API:交易记录域(R-0072026-09-01R-018 迭代 16 归属改造
*
* 端点:
* orders → 当日委托(支持 code/status 过滤)
* trades → 当日成交
* trading-dates → 交易日历(日期导航)
* trades/history → 本地 SQLite 历史查询(R-009时间段/code/策略/方向过滤)
* trades/by-holding → 按 holding_id 查委托汇总(R-010:策略持仓行展开
* orders → 当日委托(code/status 过滤R-018 附加 segments 归属段
* trades → 当日成交
* trading-dates → 交易日历(日期导航)
* trades/history → 本地 SQLite 历史查询(时间段/code/策略/方向过滤;策略过滤经段表
* trades/by-holding → 按 holding_id 查委托汇总(R-010,段表 join
* orders/attribution-targets → R-018 策略级归属目标(候选 = 全部策略 + 该 code 活动份额)
* orders/attribution-set → R-018 全量替换归属段(segments 数组,原子)
* orders/attribution-segments → R-018 读某 order 的归属段
* orders/attribution-candidates / orders/set-attribution 已退役:R-017 7 天候选退役,
* 归属候选改策略级 targets;归属写改 attribution-set
*
* 数据流:前端轮询 → /odl/api/orders|trades → dataSource.getOrders/getTrades
* (服务端直连 QMT Bridge REST,技术约束-003
@@ -20,18 +25,19 @@ export const TRADE_METHODS = new Set([
'trades',
'trading-dates',
'trades/history', // R-009:本地 SQLite 历史查询(策略过滤)
'orders/attribution-candidates', // R-009:委托归属候选列表(该 code 当前持仓策略
'orders/set-attribution', // R-009:手动设置委托归属
'trades/by-holding', // R-010:按 holding_id 查委托汇总(策略持仓行展开
'trades/by-holding', // R-010:按 holding_id 查委托汇总(策略持仓行展开
'orders/attribution-targets', // R-018:策略级归属候选(含 activityShares
'orders/attribution-set', // R-018:归属段全量替换(账本写操作
'orders/attribution-segments', // R-018:读某 order 归属段
]);
/**
* 处理交易记录端点
* @param {string} method
* @param {object} args
* @param {object} runtime { dataSource }
* @param {object} runtime { dataSource, storage, attribution, settings }
*/
export async function handleTrade(method, args, { dataSource, storage, settings }) {
export async function handleTrade(method, args, { dataSource, storage, attribution, settings }) {
switch (method) {
case 'orders': {
const orders = await dataSource.getOrders({
@@ -40,18 +46,25 @@ export async function handleTrade(method, args, { dataSource, storage, settings
start: args.start,
end: args.end,
});
// R-009:今日实时委托附加【持久化归属】(本地库中用户已设置的 strategy_id/holding_id
// 未设置为 null(前端显示「未关联」,用户可手动设置);候选由 orders/attribution-candidates 提供
// R-018:今日实时委托附加【持久化归属】(段表真相源;order 带 segments 数组 + 兼容首段 strategyId/holdingId
if (storage && Array.isArray(orders) && orders.length > 0) {
const ids = orders.map((o) => o.orderId).filter(Boolean);
const ph = ids.map(() => '?').join(',');
const rows = storage.sqlite.db.prepare(
'SELECT order_id, strategy_id, holding_id FROM trade_orders WHERE order_id IN (' + ph + ')'
).all(...ids);
const attr = new Map(rows.map((r) => [r.order_id, { strategyId: r.strategy_id ?? null, holdingId: r.holding_id ?? null }]));
const idList = orders.map((o) => o.orderId).filter(Boolean);
const segMap = storage.getOrderSegmentsByOrderIds(idList); // Map<orderId, segment[]>
const strategyName = (sid) => {
if (!sid) return '';
const found = (getStrategies(settings) || []).find((s) => s.id === sid);
return found ? found.name : sid;
};
return orders.map((o) => {
const a = attr.get(o.orderId) ?? { strategyId: null, holdingId: null };
return { ...o, strategyId: a.strategyId, holdingId: a.holdingId };
const segs = segMap.get(o.orderId) ?? [];
const withName = segs.map((s) => ({ ...s, strategyName: strategyName(s.strategyId) }));
const first = withName[0];
return {
...o,
segments: withName,
strategyId: first ? first.strategyId : null, // 兼容首段(旧 UI 字段)
holdingId: first ? first.holdingId : null,
};
});
}
return orders;
@@ -66,19 +79,37 @@ export async function handleTrade(method, args, { dataSource, storage, settings
start: args.start,
end: args.end,
});
case 'orders/attribution-candidates': {
// R-009:委托归属候选(该 code 当前持仓策略,Q3 全手动选)
case 'orders/attribution-targets': {
// R-018(Q1):候选 = 策略级下拉。全部策略 + 该 code 每策略当前活动份额 activityShares0=无仓)。
// 不预筛(有无持仓/历史持仓由指向后服务端判定);holdingId 不再作下拉键。
if (!storage) throw Object.assign(new Error('本地存储不可用'), { code: 'storage-unavailable' });
const list = await storage.getTradeAttributionCandidates(args.code);
// 附策略名(settings 的策略 name,便于前端展示)
const nameMap = new Map((getStrategies(settings) || []).map((s) => [s.id, s.name]));
return list.map((c) => ({ ...c, strategyName: nameMap.get(c.strategyId) ?? c.strategyId }));
const code = args.code;
const strategies = getStrategies(settings) || [];
// 该 code 当前活动持仓(closed/void 双空)per strategygetCurrentHoldings 无过滤=全策略)
const allHoldings = await storage.getCurrentHoldings();
const holdings = code ? allHoldings.filter((h) => h.code === code) : [];
const shareByStrategy = new Map(holdings.map((h) => [h.strategyId, h.shares]));
return strategies.map((s) => ({
strategyId: s.id,
strategyName: s.name,
activityShares: Number(shareByStrategy.get(s.id) ?? 0),
hasActiveHolding: (shareByStrategy.get(s.id) ?? 0) > 0,
}));
}
case 'orders/set-attribution': {
// R-009:手动设置委托归属(Q4 可随时改,以最终为准)
case 'orders/attribution-set': {
// R-018:全量替换归属段(account写操作)。segments: [{strategyId, volume}]
if (!attribution || !storage) throw Object.assign(new Error('归属服务不可用'), { code: 'storage-unavailable' });
const res = attribution.setSegments({
orderId: args.orderId,
segments: Array.isArray(args.segments) ? args.segments : [],
});
return { ok: true, segments: res.segments };
}
case 'orders/attribution-segments': {
if (!storage) throw Object.assign(new Error('本地存储不可用'), { code: 'storage-unavailable' });
const ok = await storage.setTradeOrderAttribution(args.orderId, args.strategyId ?? null, args.holdingId ?? null);
return { ok };
const segs = storage.getOrderSegments(args.orderId);
const nameMap = new Map((getStrategies(settings) || []).map((s) => [s.id, s.name]));
return segs.map((s) => ({ ...s, strategyName: nameMap.get(s.strategyId) ?? s.strategyId }));
}
case 'trades/by-holding': {
// R-010:按 holding_id 查委托汇总(策略持仓行展开)
@@ -86,7 +117,7 @@ export async function handleTrade(method, args, { dataSource, storage, settings
return await storage.getTradeOrdersByHolding(args.holdingId);
}
case 'trades/history': {
// R-009:本地 SQLite 历史查询(时间段/code/策略/方向过滤)
// R-009:本地 SQLite 历史查询(时间段/code/策略/方向过滤R-018 orders 附加 segments
const opts = {
start: args.start,
end: args.end,
@@ -101,9 +132,22 @@ export async function handleTrade(method, args, { dataSource, storage, settings
storage.getTradeOrderHistory(opts),
storage.getTradeFillHistory(opts),
]);
return { orders, fills };
const idList = (orders ?? []).map((o) => o.orderId).filter(Boolean);
const segMap = storage.getOrderSegmentsByOrderIds(idList);
const strategyName = (sid) => {
if (!sid) return '';
const found = (getStrategies(settings) || []).find((s) => s.id === sid);
return found ? found.name : sid;
};
const withSegs = (orders ?? []).map((o) => {
const segs = segMap.get(o.orderId) ?? [];
const withName = segs.map((s) => ({ ...s, strategyName: strategyName(s.strategyId) }));
const first = withName[0];
return { ...o, segments: withName, strategyId: first ? first.strategyId : null, holdingId: first ? first.holdingId : null };
});
return { orders: withSegs, fills };
}
default:
throw Object.assign(new Error('unknown trade method: ' + method), { code: 'not-found' });
}
}
}
+110 -35
View File
@@ -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 (
<div style={{
position: 'absolute', right: 0, top: 34, zIndex: 1000,
background: 'var(--dsw-alias-bg-layer-1, #fff)', border: '1px solid var(--dsw-alias-border-l2, #ddd)', borderRadius: 8,
boxShadow: 'var(--dsw-shadow-lv3, 0 4px 12px rgba(0,0,0,0.12))', padding: 12, width: 260,
boxShadow: 'var(--dsw-shadow-lv3, 0 4px 12px rgba(0,0,0,0.12))', padding: 12, width: 286,
}}>
<div style={{ fontWeight: 600, fontSize: 13, marginBottom: 10 }}>列设置</div>
<div style={{ fontSize: 11, color: 'var(--dsw-alias-label-secondary, #888)', marginBottom: 8 }}>勾选显示列 调整顺序代码/名称/操作固定</div>
<div style={{ maxHeight: 280, overflowY: 'auto' }}>
<div style={{ fontSize: 11, color: 'var(--dsw-alias-label-secondary, #888)', marginBottom: 8 }}>
勾选显示列按住 拖到两行之间的落点线代码/名称/操作固定
</div>
<div style={{ maxHeight: 280, overflowY: 'auto', position: 'relative' }}>
{list.map((c, i) => (
<div key={c.key} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '3px 0', fontSize: 13 }}>
<div
key={c.key}
draggable={!saving}
onDragStart={(e) => { 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 && <div style={gapLineStyle(i)} />}
<span title="按住拖动排序" style={{ color: 'var(--dsw-alias-label-tertiary, #999)', cursor: saving ? 'default' : 'grab', userSelect: 'none' }}></span>
<span style={{ whiteSpace: 'nowrap' }}>
<button onClick={() => move(i, -1)} disabled={i === 0} style={{ ...rowBtn, opacity: i === 0 ? 0.3 : 1 }}></button>
<button onClick={() => move(i, 1)} disabled={i === list.length - 1} style={{ ...rowBtn, opacity: i === list.length - 1 ? 0.3 : 1 }}></button>
<button onClick={() => move(i, -1)} disabled={saving || i === 0} style={{ ...rowBtn, opacity: i === 0 ? 0.3 : 1 }}></button>
<button onClick={() => move(i, 1)} disabled={saving || i === list.length - 1} style={{ ...rowBtn, opacity: i === list.length - 1 ? 0.3 : 1 }}></button>
</span>
<input type="checkbox" checked={c.visible !== false} onChange={() => toggle(c.key)} />
<input type="checkbox" checked={c.visible !== false} onChange={() => toggle(c.key)} disabled={saving} />
<span style={kindTag(c.kind)}>{c.kind === 'base' ? '数据' : '字段'}</span>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{c.label}</span>
<span
style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
title={c.kind === 'field' && c.type === 'formula' ? '计算字段(只读)' : undefined}
>
{c.kind === 'field' && c.type === 'formula' ? 'ƒ ' + c.label : c.label}
</span>
</div>
))}
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 6, marginTop: 10 }}>
<button onClick={save} disabled={saving} style={{ padding: '4px 14px', cursor: 'pointer', border: 'none', borderRadius: 4, background: 'var(--dsw-alias-state-business-primary, #1565c0)', color: 'var(--dsw-alias-button-contrast-fill, #fff)', fontSize: 12 }}>
{saving ? '保存中...' : '保存'}
</button>
<button onClick={onClose} style={{ padding: '4px 14px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 4, background: 'var(--dsw-alias-bg-layer-1, #fff)', fontSize: 12 }}>关闭</button>
<button onClick={onClose} disabled={saving} style={{ padding: '4px 14px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 4, background: 'var(--dsw-alias-bg-layer-1, #fff)', fontSize: 12 }}>关闭</button>
</div>
</div>
);
}
}
+123
View File
@@ -0,0 +1,123 @@
/**
* 字段配置弹层(迭代 22R-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]);
/** 保存字段:调子组件 savestrategies/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 (
<div
ref={panelRef}
style={{
position: 'absolute', right: 0, top: 34, zIndex: 1000,
width: 520, maxWidth: 'calc(100vw - 24px)', maxHeight: '70vh',
display: 'flex', flexDirection: 'column',
background: 'var(--dsw-alias-bg-layer-1, #fff)', border: '1px solid var(--dsw-alias-border-l2, #ddd)', borderRadius: 8,
boxShadow: 'var(--dsw-shadow-lv3, 0 4px 12px rgba(0,0,0,0.12))',
}}
>
<div style={{ flex: 'none', padding: '12px 16px 8px' }}>
<strong style={{ fontSize: 14 }}>字段配置{strategyName || strategyId}</strong>
</div>
<div style={{ overflowY: 'auto', flex: 1, minHeight: 0, padding: '0 16px' }}>
<StrategyFieldsEditor
ref={editorRef}
strategyId={strategyId}
configSchema={configSchema}
saving={saving}
onDirtyChange={setDirty}
/>
</div>
<div style={{ flex: 'none', display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '10px 16px', borderTop: '1px solid var(--dsw-alias-border-l1, #eee)' }}>
<button
onClick={requestClose}
disabled={saving}
style={{ padding: '6px 16px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 4, background: 'var(--dsw-alias-bg-layer-1, #fff)', fontSize: 13 }}
>
关闭
</button>
<button
onClick={handleSave}
disabled={saving}
style={{ padding: '6px 20px', cursor: 'pointer', border: 'none', borderRadius: 4, background: 'var(--dsw-alias-state-success-primary, #2e7d32)', color: 'var(--dsw-alias-button-contrast-fill, #fff)', fontSize: 13, opacity: saving ? 0.7 : 1 }}
>
{saving ? '保存中…' : '保存字段'}
</button>
</div>
{confirmDiscard && (
<div style={{ position: 'fixed', inset: 0, background: 'var(--dsw-alias-bg-mask-1, rgba(0,0,0,0.4))', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1100 }}>
<div style={{ background: 'var(--dsw-alias-bg-layer-1, #fff)', padding: 24, borderRadius: 8, maxWidth: 420, width: '90%' }}>
<h3 style={{ margin: '0 0 12px', fontSize: 16 }}>放弃未保存的改动</h3>
<div style={{ fontSize: 14, color: 'var(--dsw-alias-label-secondary, #555)', marginBottom: 20 }}>有未保存的字段改动确定放弃</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 10 }}>
<button
onClick={() => setConfirmDiscard(false)}
style={{ padding: '6px 16px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 4, background: 'var(--dsw-alias-bg-layer-1, #fff)' }}
>
取消
</button>
<button
onClick={() => { setConfirmDiscard(false); onClose(); }}
style={{ padding: '6px 16px', cursor: 'pointer', border: 'none', borderRadius: 4, background: 'var(--dsw-alias-state-error-primary, #c62828)', color: 'var(--dsw-alias-button-contrast-fill, #fff)' }}
>
放弃改动
</button>
</div>
</div>
</div>
)}
</div>
);
}
+101 -261
View File
@@ -1,19 +1,16 @@
/**
* 神之一手设置 section
*
* 结构(R-011 迭代 09):
* - 个子 tabTab 设置 / 策略分组 / QMT 连接配置
* - Tab 设置:统一管理所有会话 tab(内置 + 策略分组混排),拖动排序(落点立即持久化)+ 显示/隐藏开关;
* 结构(迭代 22R-027 后):
* - 个子 tabTab 设置 / 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 设置子 tabR-011:统一管理所有会话 tab —— 内置 + 策略分组混排,拖动排序 + 显隐开关) */
/** Tab 设置子 tabR-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 (
<div>
<div style={{ fontSize: 12, color: 'var(--dsw-alias-label-secondary, #888)', marginBottom: 12 }}>
拖动调整所有标签页顺序开关控制显示/隐藏内置与策略标签可任意混排名称不支持在此修改策略名称在策略分组中修改
拖动调整所有标签页顺序开关控制显示/隐藏内置与策略标签可任意混排名称不支持在此修改
</div>
{error && <div style={{ color: 'var(--dsw-alias-state-error-primary, #c62828)', marginBottom: 12 }}>{error}</div>}
{!tabs && !error && <div style={{ color: 'var(--dsw-alias-label-secondary, #666)' }}>加载中...</div>}
@@ -145,25 +169,28 @@ function TabSettings() {
</tr>
</thead>
<tbody>
{tabs.map((t) => (
{tabs.map((t, i) => (
<tr
key={t.id}
draggable={!saving}
onDragStart={(e) => { 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',
}}
>
<td style={{ padding: '8px 6px', color: 'var(--dsw-alias-label-tertiary, #999)', textAlign: 'center' }}></td>
<td style={{ padding: '8px 6px' }}>
<td style={{ padding: '8px 6px', color: 'var(--dsw-alias-label-tertiary, #999)', textAlign: 'center', position: 'relative' }}>
{overPos && overPos.idx === i && <div style={gapLineStyle(i)} />}
</td>
<td style={{ padding: '8px 6px', position: 'relative' }}>
{overPos && overPos.idx === i && <div style={gapLineStyle(i)} />}
<span style={badgeStyle(t.kind)}>{t.kind === 'builtin' ? '内置' : '策略'}</span>
{t.name}
</td>
<td style={{ padding: '8px 6px' }}>
<td style={{ padding: '8px 6px', position: 'relative' }}>
{overPos && overPos.idx === i && <div style={gapLineStyle(i)} />}
<Switch checked={t.visible !== false} onChange={() => toggleVisible(t)} disabled={saving} />
</td>
</tr>
@@ -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 (
<div>
{changedMsg && (
<div style={{
padding: '10px 14px', marginBottom: 12, borderRadius: 6,
background: 'color-mix(in srgb, var(--dsw-alias-state-business-primary, #1565c0) 10%, var(--dsw-alias-bg-layer-1, #fff))', color: 'var(--dsw-alias-state-business-primary, #1565c0)', fontSize: 13, border: '1px solid var(--dsw-alias-border-l3, #90caf9)',
}}>
{changedMsg} <strong>刷新页面后生效</strong>
</div>
)}
{error && <div style={{ color: 'var(--dsw-alias-state-error-primary, #c62828)', marginBottom: 12 }}>{error}</div>}
<div style={{ display: 'flex', gap: 8, marginBottom: 16, alignItems: 'center' }}>
<input
type="text"
placeholder="新策略名称(如:长线持有)"
value={newName}
onChange={(e) => 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 }}
/>
<button
onClick={handleAdd}
disabled={saving}
style={{ padding: '6px 16px', cursor: 'pointer', border: 'none', borderRadius: 4, background: 'var(--dsw-alias-state-success-primary, #2e7d32)', color: 'var(--dsw-alias-button-contrast-fill, #fff)' }}
>
+ 新增策略
</button>
</div>
<div style={{ fontSize: 12, color: 'var(--dsw-alias-label-secondary, #888)', marginBottom: 12 }}>
顺序与显示请在Tab 设置中调整
</div>
{!strategies && !error && <div style={{ color: 'var(--dsw-alias-label-secondary, #666)' }}>加载中...</div>}
{strategies && (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
<thead>
<tr style={{ textAlign: 'left', borderBottom: '2px solid var(--dsw-alias-border-l2, #ddd)' }}>
<th style={{ padding: '8px 6px' }}>策略</th>
<th style={{ padding: '8px 6px' }}>ID</th>
<th style={{ padding: '8px 6px' }}>操作</th>
</tr>
</thead>
<tbody>
{strategies.map((s) => (
<Fragment key={s.id}>
<tr style={{ borderBottom: '1px solid var(--dsw-alias-border-l1, #eee)' }}>
<td style={{ padding: '8px 6px' }}>
{editingId === s.id ? (
<span>
<input
type="text"
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleRename(s.id)}
style={{ padding: '4px 6px', border: '1px solid #ccc', borderRadius: 3, marginRight: 6 }}
/>
<button onClick={() => handleRename(s.id)} disabled={saving} style={{ padding: '2px 8px', cursor: 'pointer' }}>保存</button>
<button onClick={() => setEditingId(null)} style={{ padding: '2px 8px', cursor: 'pointer', marginLeft: 4 }}>取消</button>
</span>
) : (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<button
onClick={() => { setExpandedId(expandedId === s.id ? null : s.id); }}
style={{ padding: '2px 4px', cursor: 'pointer', border: 'none', background: 'none', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', lineHeight: 0 }}
title={expandedId === s.id ? '收起配置' : '展开配置自定义字段'}
><ExpandChevron expanded={expandedId === s.id} /></button>
{s.name}
</span>
)}
</td>
<td style={{ padding: '8px 6px', color: 'var(--dsw-alias-label-secondary, #666)', fontSize: 12 }}>{s.id}</td>
<td style={{ padding: '8px 6px', whiteSpace: 'nowrap' }}>
<button
onClick={() => { setExpandedId(expandedId === s.id ? null : s.id); setEditingId(null); }}
style={{ padding: '2px 8px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 3, background: 'var(--dsw-alias-bg-layer-1, #fff)', marginRight: 4 }}
>{expandedId === s.id ? '收起字段' : '配置字段'}</button>
<button
onClick={() => { setEditingId(s.id); setEditingName(s.name); }}
style={{ padding: '2px 8px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 3, background: 'var(--dsw-alias-bg-layer-1, #fff)', marginRight: 4 }}
>重命名</button>
<button
onClick={() => setConfirmDelete({ id: s.id, name: s.name })}
style={{ padding: '2px 8px', cursor: 'pointer', border: '1px solid var(--dsw-alias-state-error-primary, #c62828)', borderRadius: 3, background: 'var(--dsw-alias-bg-layer-1, #fff)', color: 'var(--dsw-alias-state-error-primary, #c62828)' }}
>删除</button>
</td>
</tr>
{expandedId === s.id && (
<tr>
<td colSpan={3} style={{ padding: '4px 10px 8px', borderBottom: '1px solid var(--dsw-alias-border-l1, #eee)' }}>
<StrategyFieldsEditor
strategy={s}
saving={fieldSaving}
onSave={(fields) => handleSaveConfig(s.id, fields)}
/>
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
)}
{confirmDelete && (
<ConfirmDialog
title={'删除策略「' + confirmDelete.name + '」'}
message="该策略下已分配的份额将回到「未分配」,数据不会丢失。确定删除?"
onConfirm={() => handleDelete(confirmDelete.id)}
onCancel={() => setConfirmDelete(null)}
/>
)}
</div>
);
}
/** 设置 section 主组件:三个子 tab */
/** 设置 section 主组件:两个子 tab */
function SettingsSectionInner(props) {
const [activeTab, setActiveTab] = useState('tabs'); // tabs | strategies | qmt
const [activeTab, setActiveTab] = useState('tabs'); // tabs | qmt
return (
<div style={{ padding: 20, fontFamily: 'system-ui' }}>
@@ -417,7 +214,6 @@ function SettingsSectionInner(props) {
<div style={{ display: 'flex', gap: 4, borderBottom: '1px solid var(--dsw-alias-border-l2, #ddd)', marginBottom: 16 }}>
{[
{ key: 'tabs', label: 'Tab 设置' },
{ key: 'strategies', label: '策略分组' },
{ key: 'qmt', label: 'QMT 连接配置' },
].map((tab) => (
<button
@@ -434,7 +230,6 @@ function SettingsSectionInner(props) {
</div>
{activeTab === 'tabs' && <TabSettings />}
{activeTab === 'strategies' && <StrategyGroupSettings />}
{activeTab === 'qmt' && <QmtConnectionsSettings />}
</div>
);
@@ -520,6 +315,8 @@ function QmtConnectionsSettings() {
const [editingId, setEditingId] = useState(null);
const [formName, setFormName] = useState('');
const [formUrl, setFormUrl] = useState('');
const [mcpStatus, setMcpStatus] = useState(null); // R-020/迭代18MCP 状态
const [mcpChecking, setMcpChecking] = useState(false);
const call = useRpc();
const toast = useToast();
@@ -533,7 +330,14 @@ function QmtConnectionsSettings() {
}
}, [call]);
useEffect(() => { load(); }, [load]);
const loadMcp = useCallback(async (refresh) => {
try {
const res = await call('one-divine-lot/qmt-connections/mcp-status', { args: { refresh: !!refresh } });
if (res && res.ok) setMcpStatus(res.value);
} catch { /* 静默 */ }
}, [call]);
useEffect(() => { load(); loadMcp(false); }, [load, loadMcp]);
const openAdd = () => { setEditingId(null); setFormName(''); setFormUrl(''); setFormOpen(true); setTestResult(null); };
const openEdit = (c) => { setEditingId(c.id); setFormName(c.name); setFormUrl(c.baseUrl); setFormOpen(true); setTestResult(null); };
@@ -576,6 +380,7 @@ function QmtConnectionsSettings() {
const res = await call('one-divine-lot/qmt-connections/activate', { args: { id: c.id } });
if (res && res.ok) {
setState(res.value);
loadMcp(false); // R-020/迭代18:激活切换后刷新 MCP 状态
toast.success('已激活:' + c.name + '(立即生效)');
} else {
toast.error((res && res.error && res.error.message) || '激活失败');
@@ -679,6 +484,41 @@ function QmtConnectionsSettings() {
</button>
</div>
{/* R-020/迭代18:MCP 连接状态条(随激活连接;绿=已连接+N工具 / 黄=连接中 / 红=断开 / 灰=未启用) */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, minWidth: 0, fontSize: 12 }}>
<span style={{ flex: 'none', fontWeight: 600 }}>MCP 连接</span>
<span style={{
flex: 'none', width: 10, height: 10, borderRadius: '50%',
background: mcpStatus?.state === 'connected'
? 'var(--dsw-alias-state-success-primary, #2e7d32)'
: mcpStatus?.state === 'connecting'
? 'var(--dsw-alias-state-warn-primary, #f9a825)'
: mcpStatus?.state === 'error'
? 'var(--dsw-alias-state-error-primary, #c62828)'
: 'var(--dsw-alias-label-tertiary, #bbb)',
}} />
<EllipsisText
text={mcpStatus?.state === 'connected'
? '已连接' + (mcpStatus.toolCount != null ? '' + mcpStatus.toolCount + ' 个工具)' : '') + ' · ' + (mcpStatus.url || '')
: mcpStatus?.state === 'connecting' ? '连接中…'
: mcpStatus?.state === 'error' ? '断开 · ' + String(mcpStatus.error || '').slice(0, 80)
: mcpStatus ? '未启用(无激活连接)' : '检测中…'}
maxWidth={520}
title={mcpStatus?.error ? String(mcpStatus.error) : (mcpStatus?.url || '')}
/>
<button
onClick={async () => {
if (mcpChecking) return;
setMcpChecking(true);
try { await loadMcp(true); } finally { setMcpChecking(false); }
}}
disabled={mcpChecking}
style={{ flex: 'none', padding: '2px 10px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 4, background: 'var(--dsw-alias-bg-layer-1, #fff)', fontSize: 12 }}
>
{mcpChecking ? '检测中…' : '检测 MCP'}
</button>
</div>
{error && <div style={{ color: 'var(--dsw-alias-state-error-primary, #c62828)', marginBottom: 12 }}><EllipsisText text={error} maxWidth={560} /></div>}
{!state && !error && <div style={{ color: 'var(--dsw-alias-label-secondary, #666)' }}>加载中...</div>}
+290 -40
View File
@@ -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 (
<div style={{ padding: '10px 12px', background: 'var(--dsw-alias-bg-layer-2, #f5f5f5)', borderRadius: 6, margin: '4px 0 8px', fontSize: 13 }}>
<div style={{ padding: '10px 0 12px', fontSize: 13 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<strong style={{ fontSize: 13 }}>自定义字段{fields.length}</strong>
<span>
<button onClick={startAdd} style={solidBtn('var(--dsw-alias-state-success-primary, #2e7d32)')}>+ 添加字段</button>
<button onClick={saveAll} disabled={saving} style={solidBtn('var(--dsw-alias-state-business-primary, #1565c0)')}>{saving ? '保存中...' : '保存字段'}</button>
</span>
<button onClick={startAdd} disabled={saving} style={solidBtn('var(--dsw-alias-state-success-primary, #2e7d32)')}>+ 添加字段</button>
</div>
{saveError && (
<div style={{ marginBottom: 8, fontSize: 12, color: 'var(--dsw-alias-state-error-primary, #c62828)' }}>{saveError}</div>
)}
{fields.length === 0 && !draft && (
<div style={{ color: 'var(--dsw-alias-label-secondary, #888)', marginBottom: 6 }}>尚未配置字段 点击+ 添加字段为本策略添加自定义字段该策略下每个持仓按此定义填写值</div>
<div style={{ color: 'var(--dsw-alias-label-secondary, #888)', marginBottom: 6 }}>尚未配置字段 点击+ 添加字段为本策略添加该策略下每个持仓按此定义填写值</div>
)}
{fields.length > 0 && (
@@ -115,7 +261,7 @@ export function StrategyFieldsEditor({ strategy, onSave, saving }) {
<th style={{ padding: '4px 6px' }}>展示名</th>
<th style={{ padding: '4px 6px' }}>key</th>
<th style={{ padding: '4px 6px' }}>类型</th>
<th style={{ padding: '4px 6px' }}>选项/默认</th>
<th style={{ padding: '4px 6px' }}>公式/默认</th>
<th style={{ padding: '4px 6px' }}>操作</th>
</tr>
</thead>
@@ -124,9 +270,19 @@ export function StrategyFieldsEditor({ strategy, onSave, saving }) {
<tr key={fld.key + '-' + i} style={{ borderTop: '1px solid var(--dsw-alias-border-l1, #eee)' }}>
<td style={{ padding: '4px 6px' }}>{fld.label || fld.key}</td>
<td style={{ padding: '4px 6px', color: 'var(--dsw-alias-label-secondary, #888)', fontSize: 12 }}>{fld.key}</td>
<td style={{ padding: '4px 6px' }}>{TYPE_LABEL[fld.type] || fld.type}</td>
<td style={{ padding: '4px 6px' }}>
{TYPE_LABEL[fld.type] || fld.type}
{fld.type === 'formula' && fld.resultKind === 'boolean' ? '(判定)' : ''}
</td>
<td style={{ padding: '4px 6px', fontSize: 12, color: 'var(--dsw-alias-label-secondary, #666)' }}>
{fld.type === 'enum' ? (Array.isArray(fld.enum) ? fld.enum.join(' / ') : '')
{fld.type === 'formula' ? (
<span
title={fld.formula || ''}
style={{ display: 'inline-block', maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontFamily: MONO, verticalAlign: 'bottom', color: 'var(--dsw-alias-label-secondary, #666)' }}
>
{fld.formula || '—'}
</span>
) : 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 : ''))}
</td>
@@ -145,14 +301,108 @@ export function StrategyFieldsEditor({ strategy, onSave, saving }) {
<div style={{ marginBottom: 6 }}>
<input style={inputStyle} placeholder="字段展示名(如:网格间距)" value={draft.label} onChange={(e) => setDraft({ ...draft, label: e.target.value })} />
<input style={{ ...inputStyle, width: 120 }} placeholder="key(留空自动生成)" value={draft.key} onChange={(e) => setDraft({ ...draft, key: e.target.value })} />
<select style={inputStyle} value={draft.type} onChange={(e) => setDraft({ ...draft, type: e.target.value })}>
<select
style={inputStyle}
value={draft.type}
onChange={(e) => { setDraft({ ...draft, type: e.target.value }); setTrial(null); setFormulaError(false); }}
>
<option value="text">文本</option>
<option value="number">数字</option>
<option value="boolean">布尔</option>
<option value="enum">枚举</option>
<option value="formula">计算</option>
</select>
<input style={{ ...inputStyle, width: 60 }} placeholder="单位(可选)" title="展示在值后的单位(如 % / 元 / 手),可为空" value={draft.unit} onChange={(e) => setDraft({ ...draft, unit: e.target.value })} />
<input style={{ ...inputStyle, width: 60 }} placeholder="单位(可选)" title="展示在值后的单位(如 % / 元 / 手),可为空" value={draft.unit} onChange={(e) => { setDraft({ ...draft, unit: e.target.value }); setTrial(null); }} />
{draft.type === 'formula' && (
<label style={{ fontSize: 12, color: 'var(--dsw-alias-label-secondary, #666)' }}>
结果
<select
style={{ ...inputStyle, width: 76, marginLeft: 4 }}
title="数字 = 算式结果(可设小数位);判定 = 比较结果(满足显示 ✓,不满足显示 —)"
value={draft.resultKind}
onChange={(e) => { setTrial(null); setFormulaError(false); setDraft({ ...draft, resultKind: e.target.value }); }}
>
<option value="number">数字</option>
<option value="boolean">判定</option>
</select>
</label>
)}
</div>
{draft.type === 'formula' && (
<div style={{ marginBottom: 6 }}>
<textarea
ref={formulaRef}
rows={2}
placeholder={draft.resultKind === 'boolean'
? '判定公式,如:涨停价 > 基准值 + 网格大小'
: '计算公式,如:(现价 ÷ 成本价 - 1) × 100'}
value={draft.formula}
onChange={(e) => { setFormulaError(false); setTrial(null); setDraft({ ...draft, formula: e.target.value }); }}
style={{
width: '100%', boxSizing: 'border-box', fontFamily: MONO, fontSize: 12, padding: '4px 6px', borderRadius: 3,
border: formulaError ? '1px solid var(--dsw-alias-state-error-primary, #c62828)' : '1px solid var(--dsw-alias-border-l2, #ccc)',
background: 'var(--dsw-alias-bg-layer-1, #fff)', resize: 'vertical',
}}
/>
<div style={{ fontSize: 12, color: 'var(--dsw-alias-label-tertiary, #999)', marginTop: 4 }}>
运算+ - × ÷ 括号 判定&gt; &lt; &gt;= &lt;= == != 组合and / or
</div>
<div style={{ marginTop: 6 }}>
{menuGroups.map((g) => (
<div key={g.group} style={{ display: 'flex', alignItems: 'flex-start', gap: 4, marginTop: 3, flexWrap: 'wrap' }}>
<span style={{ flex: 'none', fontSize: 11, color: 'var(--dsw-alias-label-tertiary, #999)', padding: '4px 2px 2px 0' }}>{g.group}</span>
{g.items.length === 0 ? (
<span style={{ fontSize: 12, color: 'var(--dsw-alias-label-tertiary, #999)', padding: '3px 0 2px' }}>本策略暂无手填字段</span>
) : g.items.map((it) => (
<button
key={it.name}
onClick={() => insertVar(it.name)}
title={(it.desc ? it.desc + '' : '') + '点击插入到公式'}
style={{
padding: '2px 9px', fontSize: 12, lineHeight: '18px', borderRadius: 10, cursor: 'pointer',
border: '1px solid var(--dsw-alias-border-l2, #ccc)',
background: 'var(--dsw-alias-bg-layer-2, #f5f5f5)',
color: 'var(--dsw-alias-label-primary, #333)',
}}
>
{it.name}
</button>
))}
</div>
))}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 6, flexWrap: 'wrap' }}>
<button onClick={runTrial} disabled={trialLoading} style={{ ...btnStyle, marginRight: 0, opacity: trialLoading ? 0.6 : 1 }}>
{trialLoading ? '试算中…' : '试算'}
</button>
{draft.resultKind !== 'boolean' && (
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--dsw-alias-label-secondary, #666)' }}>
小数位
<input
type="number"
min={0}
max={4}
step={1}
value={draft.decimals}
onChange={(e) => { setTrial(null); setDraft({ ...draft, decimals: e.target.value }); }}
style={{ width: 56, padding: '2px 4px', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 3, background: 'var(--dsw-alias-bg-layer-1, #fff)', fontSize: 12 }}
/>
0-4
</label>
)}
</div>
{trial && (
<div style={{ marginTop: 4, fontSize: 12, color: trial.kind === 'ok' ? 'var(--dsw-alias-label-secondary, #666)' : 'var(--dsw-alias-state-error-primary, #c62828)' }}>
{trial.text}
</div>
)}
{formulaError && saveError && (
<div style={{ marginTop: 4, fontSize: 12, color: 'var(--dsw-alias-state-error-primary, #c62828)' }}>{saveError}</div>
)}
</div>
)}
{draft.type === 'enum' && (
<div style={{ marginBottom: 6 }}>
<input style={{ ...inputStyle, width: 220 }} placeholder="枚举选项(逗号分隔,如:低,中,高)" value={draft.enumText} onChange={(e) => setDraft({ ...draft, enumText: e.target.value })} />
@@ -178,11 +428,11 @@ export function StrategyFieldsEditor({ strategy, onSave, saving }) {
</label>
)}
<div>
<button onClick={saveDraft} style={solidBtn('var(--dsw-alias-state-success-primary, #2e7d32)')}>确定</button>
<button onClick={() => setDraft(null)} style={btnStyle}>取消</button>
<button onClick={saveDraft} disabled={saving} style={solidBtn('var(--dsw-alias-state-success-primary, #2e7d32)')}>确定</button>
<button onClick={() => setDraft(null)} disabled={saving} style={btnStyle}>取消</button>
</div>
</div>
)}
</div>
);
}
});
+256 -27
View File
@@ -8,6 +8,10 @@
* - 全部移入(O6:行内按钮,该票全部未分配份额移入当前策略)
* - 操作反馈:Toast 轻提示
* - 加载失败兜底:LoadState 三态
* - 历史持仓展示(R-016 迭代 14):title 旁开关 + 清仓时间范围筛选(近一周默认/1个月/3个月/半年/1年),
* 历史行灰显 + 「已清仓」徽标,展开复用 R-010 看关联交易(追溯该持仓的历史操作)
* - 今日已清仓默认层(R-016 二轮补充,2026-09-07 老师定):当天(本地自然日 00:00 起)已清仓的持仓
* **默认显示**、不受「历史持仓」开关控制——清仓转历史后 tab 不因此少行;开关只控制今天之前的历史
*
* 添加持仓区域为响应式布局(内联 <style> + 媒体查询):
* - 宽屏:三元素一行(选择标的 | 份额 | 确认按钮)
@@ -22,6 +26,7 @@ import { ToastProvider, useToast } from './Toast.jsx';
import { useMarket } from '../market/MarketDataProvider.jsx';
import { PriceCell } from './PriceCell.jsx';
import { ColumnSettingsPopover } from './ColumnSettingsPopover.jsx'; // 迭代11:列显隐设置
import { FieldConfigDialog } from './FieldConfigDialog.jsx'; // 迭代22:字段配置弹层(含计算字段)
import { FieldCellEditor } from './FieldCellEditor.jsx'; // 迭代11:自定义字段单元格点击编辑
import { ExpandChevron } from './ExpandChevron.jsx'; // 行展开图标共享组件
@@ -55,6 +60,19 @@ function fmtPct(pct) {
return sign + pct.toFixed(2) + '%';
}
/** 小数位夹取 0-4 整数(计算字段默认 2) */
function clampDec(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 元;迭代22 */
function fmtComputed(v, decimals, unit) {
const s = Number(v).toFixed(clampDec(decimals));
return unit ? s + ' ' + unit : s;
}
/** 添加持仓区域响应式样式(内联注入,含媒体查询) */
const ADD_FORM_CSS = `
.odl-add-form {
@@ -180,15 +198,45 @@ function StrategyTabInner({ strategyId, strategyName }) {
const [sortDir, setSortDir] = useState('desc'); // 'asc' | 'desc'
// R-013:本策略自定义字段定义(configSchema;从 strategies 端点取)
const [strategySchema, setStrategySchema] = useState(null);
// 迭代11列显隐:列配置(归一化有序数组 [{key,label,kind,visible}]+ 列设置弹层开关
// 迭代11列显隐:列配置(归一化有序数组 [{key,label,kind,visible,type?}]+ 列设置弹层开关
const [columns, setColumns] = useState(null);
const [columnOpen, setColumnOpen] = useState(false);
// 迭代22:字段配置弹层开关(R-026/R-027
const [fieldConfigOpen, setFieldConfigOpen] = useState(false);
// 迭代11:正在单元格内编辑的自定义字段({code, key} | null
const [editingCell, setEditingCell] = useState(null);
// R-016 迭代 14:历史持仓展示(title 旁开关 + 清仓范围筛选;会话级,不持久化,Q4)
// R-016 二轮补充(2026-09-07 老师定):「今日已清仓」是**默认层**range='today',恒显示),
// 开关只控制今天之前的历史范围层(week/month/quarter/halfYear/year)——开关关≠当天清仓也被藏起
const [showHistory, setShowHistory] = useState(false);
const [historyRange, setHistoryRange] = useState('week'); // week|month|quarter|halfYear|year
const [historyRows, setHistoryRows] = useState({}); // range → [已清仓持仓行](含 'today' 键)
const [historyLoading, setHistoryLoading] = useState(false);
const call = useRpc();
const toast = useToast();
const { getPrice, registerCodes } = useMarket();
/** R-016:拉某范围已清仓历史持仓(独立只读端点;当前持仓快照路径零改动)。
* range='today' = 本地自然日 00:00 起清仓(R-016 二轮补充:今日已清仓默认层) */
const loadHistory = useCallback(async (range) => {
setHistoryLoading(true);
try {
const res = await call('one-divine-lot/strategy-holdings/history', { args: { strategyId, range } });
if (res && res.ok && Array.isArray(res.value)) {
setHistoryRows((prev) => ({ ...(prev ?? {}), [range]: res.value }));
} else {
// 失败可见化(迭代 14 复盘:静默兜底=排障盲区)
toast.error('历史持仓加载失败: ' + ((res && res.error && res.error.message) || 'unknown'));
setHistoryRows((prev) => ({ ...(prev ?? {}), [range]: [] }));
}
} catch (err) {
toast.error('历史持仓加载失败: ' + (err?.message ?? String(err)));
setHistoryRows((prev) => ({ ...(prev ?? {}), [range]: [] }));
} finally {
setHistoryLoading(false);
}
}, [call, strategyId, toast]);
const load = useCallback(async () => {
setLoading(true);
setError(null);
@@ -220,14 +268,31 @@ function StrategyTabInner({ strategyId, strategyName }) {
setError(e.message);
} finally {
setLoading(false);
// R-016 二轮补充:今日已清仓层随每次持仓重载刷新(进 tab / 移出等操作后,
// 当天清仓的行立即转入默认层,不依赖「历史持仓」开关;loadHistory 自容错不外抛)
loadHistory('today');
}
}, [call, strategyId]);
}, [call, strategyId, loadHistory, registerCodes]);
useEffect(() => {
ensureAddFormCss();
load();
}, [load]);
/** 开关:开启即取最新(关闭仅隐藏,缓存保留;进 tab 默认关)。
* R-016 二轮补充:开关只控制「今天之前的历史范围层」;range='today'(当日清仓)恒显示、不受本开关控制 */
const toggleHistory = () => {
const next = !showHistory;
setShowHistory(next);
if (next) loadHistory(historyRange);
};
/** 切换范围:未缓存才拉取 */
const changeHistoryRange = (r) => {
setHistoryRange(r);
if (!historyRows[r]) loadHistory(r);
};
const handleAdd = async () => {
if (!selectedCode || !addShares) return;
const res = await call('one-divine-lot/add-shares', {
@@ -271,30 +336,30 @@ function StrategyTabInner({ strategyId, strategyName }) {
}
};
/** R-010:展开某持仓行 → 懒加载该 holding 的委托汇总 */
const toggleExpand = async (code, holdingId) => {
if (expandedCode === code) {
/** R-010:展开某持仓行 → 懒加载该 holding 的委托汇总R-016:键改唯一 rowKey,同码多行不串) */
const toggleExpand = async (rowKey, holdingId) => {
if (expandedCode === rowKey) {
// 折叠
setExpandedCode(null);
return;
}
setExpandedCode(code);
setExpandedCode(rowKey);
if (holdingId == null) {
setHoldingTrades((prev) => ({ ...(prev ?? {}), [code]: [] }));
setHoldingTrades((prev) => ({ ...(prev ?? {}), [rowKey]: [] }));
return;
}
// 懒加载:有缓存直接用,否则请求
if (holdingTrades && holdingTrades[code]) return;
setHoldingTradesLoading(code);
if (holdingTrades && holdingTrades[rowKey]) return;
setHoldingTradesLoading(rowKey);
try {
const res = await call('one-divine-lot/trades/by-holding', { args: { holdingId } });
if (res?.ok && Array.isArray(res.value)) {
setHoldingTrades((prev) => ({ ...(prev ?? {}), [code]: res.value }));
setHoldingTrades((prev) => ({ ...(prev ?? {}), [rowKey]: res.value }));
} else {
setHoldingTrades((prev) => ({ ...(prev ?? {}), [code]: [] }));
setHoldingTrades((prev) => ({ ...(prev ?? {}), [rowKey]: [] }));
}
} catch {
setHoldingTrades((prev) => ({ ...(prev ?? {}), [code]: [] }));
setHoldingTrades((prev) => ({ ...(prev ?? {}), [rowKey]: [] }));
} finally {
setHoldingTradesLoading(null);
}
@@ -325,6 +390,7 @@ function StrategyTabInner({ strategyId, strategyName }) {
}
return rows;
}, [positions, sortKey, sortDir, getPrice]);
//R-016rowsToRender 在此下方合成,历史行固定追加、不参与涨幅排序)
// 迭代11列显隐:可见列(表头/行渲染依据;未加载配置时=null,回退旧固定列)
const visibleColumns = useMemo(() => {
@@ -332,12 +398,88 @@ function StrategyTabInner({ strategyId, strategyName }) {
return columns.filter((c) => c.visible !== false);
}, [columns]);
// R-016 迭代 14:渲染行 = 当前持仓行 + 已清仓行。
// 二轮补充(2026-09-07 老师定):已清仓行分两层——
// L1「今日已清仓」(range='today',本地自然日 00:00 起清仓):**默认恒显示**,不受「历史持仓」开关控制
// L2「历史范围层」(week/month/...):仅开关开启时显示(用户主动查看更早的历史)
// 层间按 holdingId 去重('today' 含近 7 天清仓时,开启 week 不重复出两行);标题计数 (N 只) 只算当前持仓
const histRowsFor = (range) =>
(historyRows[range] ?? []).map((h) => ({
...h,
isHistory: true, // 历史行标识:灰显/只读/行情列 —
name: h.name ?? null, // name 兜底(接口已按关联委托补,无则 null → 显示 —)
}));
const rowsToRender = useMemo(() => {
const current = rowsForRender;
const today = histRowsFor('today'); // L1:今天已清仓 —— 恒定显示
if (!showHistory) return [...current, ...today];
// L2:历史范围层(含 today 在内同一端点返回)——去重:today 已显示的不重复追加
const todayIds = new Set(today.map((h) => h.holdingId));
const rest = histRowsFor(historyRange).filter((h) => !todayIds.has(h.holdingId));
return [...current, ...today, ...rest];
}, [rowsForRender, showHistory, historyRows, historyRange]);
/** 历史行徽标/时间格式化(清仓时间 YYYY-MM-DD HH:mm */
const fmtClosedAt = (ts) => {
if (!ts) return '—';
const d = new Date(Number(ts));
if (!isFinite(d.getTime())) return '—';
const p2 = (n) => String(n).padStart(2, '0');
return d.getFullYear() + '-' + p2(d.getMonth() + 1) + '-' + p2(d.getDate()) + ' ' + p2(d.getHours()) + ':' + p2(d.getMinutes());
};
/** 持有天数(同日 = 1,向上取整) */
const heldDays = (created, closed) => {
if (!created || !closed) return null;
const ms = Number(closed) - Number(created);
if (!isFinite(ms) || ms < 0) return null;
return Math.max(1, Math.ceil(ms / 86400000));
};
/** 渲染某数据列单元格内容(col: {key,label,kind})—— 返回 React 节点 */
const renderDataCell = (col, p) => {
// R-016:历史行——行情类列一律 —(不订阅行情);shares 显示 DB 原值(Q2:0 就好);自定义字段只读
if (p.isHistory) {
if (col.kind === 'field') {
const fieldDef0 = (strategySchema ?? []).find((f0) => f0.key === col.key);
if (!fieldDef0) return null;
// 迭代22:计算字段列历史行无 computed → 显示 —
if (fieldDef0.type === 'formula') return <span style={{ color: 'var(--dsw-alias-label-tertiary, #999)' }}></span>;
const has0 = p.values && Object.prototype.hasOwnProperty.call(p.values, col.key);
const v0 = has0 ? p.values[col.key] : fieldDef0.def;
let shown0;
if (fieldDef0.type === 'boolean') shown0 = v0 ? '开' : '关';
else if (v0 === undefined || v0 === null || v0 === '') shown0 = '—';
else shown0 = String(v0) + (fieldDef0.unit ? fieldDef0.unit : '');
return <span style={{ color: 'var(--dsw-alias-label-tertiary, #999)' }}>{shown0}</span>;
}
if (col.key === 'shares') return <span style={{ color: 'var(--dsw-alias-label-tertiary, #999)' }}>{p.shares}</span>;
return <span style={{ color: 'var(--dsw-alias-label-tertiary, #999)' }}></span>;
}
if (col.kind === 'field') {
// 自定义字段列:p.values[key] ?? def;点击单元格进入内联编辑(老师选 A)
const fieldDef = (strategySchema ?? []).find((f2) => f2.key === col.key);
if (!fieldDef) return null; // configSchema 已删该字段(列配置滞后,读时已跟随,双保险)
// 迭代22:计算字段列只读——取值 row.computed[key],不挂 FieldCellEditor、不绑点击编辑
if (fieldDef.type === 'formula') {
const raw = p.computed ? p.computed[col.key] : undefined;
// R-028 判定型:满足 → 「✓ 字段名」(success 绿);不满足 / 缺值 → —(tertiary
// 注意顺序:布尔必须先判(false 的 Number() 为 0,否则会被当成数字格式化)
if (typeof raw === 'boolean') {
return raw
? <span title={'= ' + (fieldDef.formula ?? '')} style={{ color: 'var(--dsw-alias-state-success-primary, #2e7d32)' }}>{'✓ ' + (fieldDef.label || col.label)}</span>
: <span title={'= ' + (fieldDef.formula ?? '')} style={{ color: 'var(--dsw-alias-label-tertiary, #999)' }}></span>;
}
const hasV = raw != null && Number.isFinite(Number(raw));
return (
<span
title={'= ' + (fieldDef.formula ?? '')}
style={!hasV ? { color: 'var(--dsw-alias-label-tertiary, #999)' } : undefined}
>
{hasV ? fmtComputed(Number(raw), fieldDef.decimals, fieldDef.unit) : '—'}
</span>
);
}
const isEditing = editingCell && editingCell.code === p.code && editingCell.key === col.key;
if (isEditing) {
return (
@@ -399,7 +541,7 @@ function StrategyTabInner({ strategyId, strategyName }) {
}
};
/** 表头列 label(排序箭头仅涨幅支持) */
/** 表头列 label(排序箭头仅涨幅支持;迭代22:计算字段列 ƒ 前缀 + 悬停公式原文 */
const headerCell = (col) => {
if (col.key === 'pctChange') {
return (
@@ -408,7 +550,19 @@ function StrategyTabInner({ strategyId, strategyName }) {
</th>
);
}
return <th style={{ padding: '8px 6px', lineHeight: '24px', whiteSpace: 'nowrap' }}>{col.label}</th>;
const fld0 = (strategySchema ?? []).find((f) => f.key === col.key);
const isFormulaCol = col.kind === 'field' && fld0 && fld0.type === 'formula';
return (
<th
style={{ padding: '8px 6px', lineHeight: '24px', whiteSpace: 'nowrap' }}
title={isFormulaCol ? '= ' + (fld0.formula ?? '') : undefined}
>
{isFormulaCol && (
<span title="计算字段(只读)" style={{ color: 'var(--dsw-alias-label-tertiary, #999)', marginRight: 2 }}>ƒ</span>
)}
{col.label}
</th>
);
};
const pctArrow = sortKey === 'pctChange' ? (sortDir === 'desc' ? ' ▼' : ' ▲') : '';
@@ -417,16 +571,53 @@ function StrategyTabInner({ strategyId, strategyName }) {
<div className="odl-root" style={{ padding: 16, fontFamily: 'system-ui' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12, flexWrap: 'wrap', gap: 8 }}>
<h3 style={{ margin: 0 }}>{name}{(positions ?? []).length} </h3>
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<button
onClick={() => setShowAdd(!showAdd)}
style={{ padding: '6px 12px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 4, background: 'var(--dsw-alias-interactive-bg-hover, #f5f5f5)' }}
>
{showAdd ? '取消' : '+ 添加持仓'}
</button>
{/* R-016 迭代 14:历史持仓开关 + 范围筛选(title 旁)。
R-016 二轮补充:今天已清仓的持仓默认显示(不受此开关控制),此开关只开关更早的历史(今天之前) */}
<button
onClick={toggleHistory}
title="显示/隐藏今天之前已清仓的历史持仓(当天已清仓的持仓始终显示)"
style={{
padding: '6px 12px', cursor: 'pointer', borderRadius: 4, fontSize: 13,
border: '1px solid ' + (showHistory ? 'var(--dsw-alias-state-business-primary, #1565c0)' : 'var(--dsw-alias-border-l2, #ccc)'),
background: showHistory ? 'color-mix(in srgb, var(--dsw-alias-state-business-primary, #1565c0) 12%, var(--dsw-alias-bg-layer-1, #fff))' : 'var(--dsw-alias-bg-layer-1, #fff)',
color: showHistory ? 'var(--dsw-alias-state-business-primary, #1565c0)' : 'var(--dsw-alias-label-primary, #333)',
}}
>
{showHistory ? '历史持仓 开' : '历史持仓 关'}
</button>
{showHistory && (
<select
value={historyRange}
onChange={(e2) => changeHistoryRange(e2.target.value)}
title="已清仓时间范围(按清仓时间回溯)"
style={{ padding: '5px 6px', fontSize: 13, border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 4, background: 'var(--dsw-alias-bg-layer-1, #fff)' }}
>
<option value="week">近一周</option>
<option value="month"> 1 个月</option>
<option value="quarter"> 3 个月</option>
<option value="halfYear">近半年</option>
<option value="year"> 1 </option>
</select>
)}
{showHistory && historyLoading && (
<span style={{ fontSize: 12, color: 'var(--dsw-alias-label-tertiary, #999)' }}>历史加载中...</span>
)}
<div style={{ position: 'relative' }}>
<button
onClick={() => setColumnOpen(!columnOpen)}
onClick={() => { setColumnOpen(false); setFieldConfigOpen(true); }}
style={{ padding: '6px 12px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 4, background: 'var(--dsw-alias-bg-layer-1, #fff)', fontSize: 13, marginLeft: 8 }}
>
字段配置
</button>
<button
onClick={() => { setFieldConfigOpen(false); setColumnOpen(!columnOpen); }}
style={{ padding: '6px 12px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 4, background: 'var(--dsw-alias-bg-layer-1, #fff)', fontSize: 13, marginLeft: 8 }}
>
列设置
@@ -439,6 +630,15 @@ function StrategyTabInner({ strategyId, strategyName }) {
onSaved={load}
/>
)}
{fieldConfigOpen && (
<FieldConfigDialog
strategyId={strategyId}
strategyName={name}
configSchema={Array.isArray(strategySchema) ? strategySchema : []}
onSaved={load}
onClose={() => setFieldConfigOpen(false)}
/>
)}
</div>
</div>
</div>
@@ -476,7 +676,7 @@ function StrategyTabInner({ strategyId, strategyName }) {
</div>
)}
{positions && positions.length === 0 ? (
{rowsToRender.length === 0 ? (
<div style={{ padding: 24, textAlign: 'center', color: 'var(--dsw-alias-label-tertiary, #999)' }}>
暂无持仓点击+ 添加持仓从全部持仓中添加
</div>
@@ -492,19 +692,24 @@ function StrategyTabInner({ strategyId, strategyName }) {
</tr>
</thead>
<tbody>
{rowsForRender.map((p) => {
const isExpanded = expandedCode === p.code;
const trades = (holdingTrades ?? {})[p.code];
const isLoading = holdingTradesLoading === p.code;
{rowsToRender.map((p) => {
const rowKey = p.isHistory ? 'h-' + p.holdingId : p.code; // 唯一行键:同码「当前行 + 历史行」不串展开与缓存
const isExpanded = expandedCode === rowKey;
const trades = (holdingTrades ?? {})[rowKey];
const isLoading = holdingTradesLoading === rowKey;
const hasHolding = p.holdingId != null;
const histStyle = p.isHistory
? { opacity: 0.72, color: 'var(--dsw-alias-label-tertiary, #999)' }
: null;
return (
<React.Fragment key={p.code}>
<React.Fragment key={rowKey}>
<tr
onClick={() => toggleExpand(p.code, p.holdingId)}
onClick={() => toggleExpand(rowKey, p.holdingId)}
style={{
borderBottom: '1px solid var(--dsw-alias-border-l1, #eee)',
cursor: hasHolding ? 'pointer' : 'default',
background: isExpanded ? 'var(--dsw-alias-interactive-bg-hover, #f5f5f5)' : 'transparent',
...(histStyle ?? {}),
}}
>
<td style={{ padding: '8px 6px', textAlign: 'center', width: 28 }}>
@@ -514,13 +719,30 @@ function StrategyTabInner({ strategyId, strategyName }) {
<span style={{ color: 'var(--dsw-alias-border-l3, #ccc)', fontSize: 10 }}>·</span>
)}
</td>
<td style={{ padding: '8px 6px' }}>{p.code}</td>
<td style={{ padding: '8px 6px' }}>{p.name}</td>
<td style={{ padding: '8px 6px', whiteSpace: 'nowrap' }}>
{p.code}
{p.isHistory && (
<span
title={'清仓时间 ' + fmtClosedAt(p.closedAt)}
style={{
marginLeft: 6, fontSize: 11, padding: '1px 6px', borderRadius: 3,
border: '1px solid var(--dsw-alias-border-l2, #ccc)',
color: 'var(--dsw-alias-label-tertiary, #999)',
background: 'var(--dsw-alias-bg-layer-2, #f5f5f5)',
}}
>
已清仓 {fmtClosedAt(p.closedAt).slice(0, 10)}
</span>
)}
</td>
<td style={{ padding: '8px 6px' }}>{p.isHistory ? (p.name ?? '—') : p.name}</td>
{(visibleColumns ?? []).map((col) => (
<td key={col.key} style={{ padding: '8px 6px', whiteSpace: 'nowrap' }}>{renderDataCell(col, p)}</td>
))}
<td style={{ padding: '8px 6px', whiteSpace: 'nowrap' }} onClick={(e) => e.stopPropagation()}>
{removeTarget && removeTarget.code === p.code ? (
{p.isHistory ? (
<span style={{ color: 'var(--dsw-alias-border-l3, #ccc)' }}></span>
) : removeTarget && removeTarget.code === p.code ? (
<span>
<input
type="number"
@@ -559,11 +781,18 @@ function StrategyTabInner({ strategyId, strategyName }) {
{isExpanded && (
<tr style={{ borderBottom: '1px solid var(--dsw-alias-border-l1, #eee)', background: 'var(--dsw-alias-bg-layer-2, #fafafa)' }}>
<td colSpan={4 + (visibleColumns?.length ?? 6)} style={{ padding: '4px 8px 12px' }}>
{p.isHistory && (
<div style={{ padding: '4px 8px', fontSize: 12, color: 'var(--dsw-alias-label-secondary, #666)' }}>
已清仓持仓 · 清仓时间 {fmtClosedAt(p.closedAt)} · 持有 {heldDays(p.createdAt, p.closedAt) ?? '—'} · 份额清仓后{p.shares}
</div>
)}
{isLoading ? (
<div style={{ padding: 8, color: 'var(--dsw-alias-label-tertiary, #999)', fontSize: 12 }}>交易记录加载中...</div>
) : !trades || trades.length === 0 ? (
<div style={{ padding: 8, color: 'var(--dsw-alias-label-tertiary, #999)', fontSize: 12 }}>
{hasHolding ? '该持仓暂无关联交易记录(可在「交易记录」tab 为委托设置归属)' : '该持仓无关联锚点(holding_id'}
{hasHolding
? (p.isHistory ? '该历史持仓暂无关联交易记录(归属设置须在清仓当日完成)' : '该持仓暂无关联交易记录(可在「交易记录」tab 为委托设置归属)')
: '该持仓无关联锚点(holding_id'}
</div>
) : (
<HoldingTradesTable trades={trades} />
+37 -1
View File
@@ -2,7 +2,7 @@
* SyncIndicators —— 数据同步指示灯组(R-015 / 迭代 13;2026-09-03 老师调整:三灯带全称标签)
*
* 位置:会话头部(QmtConnectionChip 内,QMT 下拉框右侧)。
* 形态:QMT连接(灯)|持仓数据(灯)|行情数据(灯)——统一圆点 + 全称标签。
* 形态:QMT连接(灯)|持仓数据(灯)|行情数据(灯)MCP(灯,R-020/迭代18——统一圆点 + 全称标签。
*
* 状态(产品约束-012):
* - QMT 连接:绿=健康 / 红=异常 / 灰=未知(qmt-health 缓存)
@@ -87,6 +87,19 @@ export function SyncIndicators() {
}
}, [call, healthRefreshing, load]);
// R-020/迭代18:MCP 状态即时刷新(点灯触发)
const [mcpRefreshing, setMcpRefreshing] = useState(false);
const refreshMcp = useCallback(async () => {
if (mcpRefreshing) return;
setMcpRefreshing(true);
try {
await call('one-divine-lot/qmt-connections/mcp-status', { args: { refresh: true } });
} catch { /* 静默 */ } finally {
setMcpRefreshing(false);
load();
}
}, [call, mcpRefreshing, load]);
if (!status) return null;
// QMT 连接灯:绿=健康 / 红=异常 / 灰=未知
@@ -115,6 +128,27 @@ export function SyncIndicators() {
return lines.filter(Boolean).join(' | ');
};
// MCP 灯(R-020/迭代18):绿=已连接(connected) / 黄=连接中(connecting) / 红=断开(error) / 灰=未启用(disabled/缺失)
const mcp = status.mcp;
const mcpColor = mcp?.state === 'connected'
? 'var(--dsw-alias-state-success-primary, #2e7d32)'
: mcp?.state === 'connecting'
? 'var(--dsw-alias-state-warn-primary, #f9a825)'
: mcp?.state === 'error'
? 'var(--dsw-alias-state-error-primary, #d32f2f)'
: 'var(--dsw-alias-label-tertiary, #bbb)';
const mcpTitleLines = [
'MCP' + ({
connected: '已连接' + (mcp?.toolCount != null ? '' + mcp.toolCount + ' 个工具)' : ''),
connecting: '连接中',
error: '断开' + (mcp?.error ? '' + String(mcp.error).slice(0, 60) : ''),
disabled: '未启用(无激活连接)',
}[mcp?.state] ?? '未知'),
mcp?.url ? mcp.url : null,
mcp?.checkedAt ? '检测于 ' + new Date(mcp.checkedAt).toLocaleTimeString() : null,
'点击重新检测',
].filter(Boolean).join(' | ');
const sep = <span style={{ color: 'var(--dsw-alias-border-l2, #ddd)', fontSize: 12, flex: 'none' }}></span>;
return (
@@ -138,6 +172,8 @@ export function SyncIndicators() {
onClick={() => syncNow('quote')}
dimmed={syncingDomain === 'quote'}
/>
{sep}
<IndicatorDot label="MCP" color={mcpColor} glow={mcp?.state === 'connected'} title={mcpTitleLines} onClick={refreshMcp} dimmed={mcpRefreshing} />
</span>
);
}
+334 -85
View File
@@ -12,6 +12,12 @@
* - 策略过滤下拉(全部 / 各策略 / 未关联):今日实时按归属过滤(前端),历史走服务端过滤
* - 历史范围从「接口开发中」占位切换为查本地 SQLitetrades/history
*
* R-018(迭代 16):归属 = 账本写操作(份额分配器)
* - 端点:orders/attribution-targets(策略级候选)/ orders/attribution-set(全量替换段,原子)
* - 交互:每笔委托归属列 = 段 chips;点开分配器:策略下拉 + 段量输入(默认余额全量,可改小)
* + 段列表(每段可撤)——每步操作即时 setSegments,买卖份额联动账本
* - 过滤按首段兼容字段(order.strategyId = 主段);多段订单按主段归属展示
*
* 方向:红买绿卖(Q7 定稿);状态中文映射;未成交显示 —
*/
@@ -111,11 +117,13 @@ export function TradeRecordsTab() {
// R-009:策略过滤(''=全部;'__unassigned__'=未关联;其他=strategyId
const [strategies, setStrategies] = useState([]);
const [strategyFilter, setStrategyFilter] = useState('');
const [attributionSaved, setAttributionSaved] = useState(null); // 归属保存提示(R-009
const [attributionCandidates, setAttributionCandidates] = useState({}); // R-009code → 归属候选列表缓存
const attributionCandidatesRef = useRef({}); // 同步缓存(loadCandidates 判重用)
const [attributionSaved, setAttributionSaved] = useState(null); // 归属保存提示(R-009/R-018
const [attributionTargets, setAttributionTargets] = useState({}); // R-018code → 策略级归属目标缓存(含 activityShares
const attributionTargetsRef = useRef({}); // 同步缓存(loadTargets 判重用)
// 同步 ref
useEffect(() => { attributionCandidatesRef.current = attributionCandidates; }, [attributionCandidates]);
useEffect(() => { attributionTargetsRef.current = attributionTargets; }, [attributionTargets]);
// R-018 T3:漏关联软提示(账本有活动行 + QMT 快照无此 code → 提示去关联/移出;只读不写账本)
const [orphanHints, setOrphanHints] = useState([]);
const call = useRpc();
@@ -192,54 +200,61 @@ export function TradeRecordsTab() {
}
}, [call, range, isHistoryRange, strategyFilter]);
// 拉取某 code 的归属候选(该 code 当前持仓策略;Q3 全手动选
const loadCandidates = useCallback(async (code) => {
// R-018拉取某 code 的归属目标(策略级候选 + activityShares;Q1 指向后判定,候选不预筛持仓
const loadTargets = useCallback(async (code) => {
if (!code) return;
// 已有缓存不重复请求
if (attributionCandidatesRef.current[code]) return;
if (attributionTargetsRef.current[code]) return;
try {
const res = await call('one-divine-lot/orders/attribution-candidates', { args: { code } });
const res = await call('one-divine-lot/orders/attribution-targets', { args: { code } });
if (res?.ok && Array.isArray(res.value)) {
setAttributionCandidates((prev) => ({ ...prev, [code]: res.value }));
setAttributionTargets((prev) => ({ ...prev, [code]: res.value }));
}
} catch { /* ignore */ }
}, [call]);
// 手动设置归属(Q4:可随时改,以最终为准)
const setAttribution = useCallback(async (orderId, code, strategyId, holdingId) => {
try {
const res = await call('one-divine-lot/orders/set-attribution', {
args: { orderId, strategyId: strategyId || null, holdingId: holdingId || null },
});
if (res?.ok) {
setAttributionSaved('归属已保存: ' + orderId);
setTimeout(() => setAttributionSaved(null), 2000);
// 立即更新本地 state(避免竞态:不等 load() 重新拉,归属立刻生效)
setOrders((prev) => (prev ?? []).map((o) =>
o.orderId === orderId ? { ...o, strategyId, holdingId } : o
));
setTrades((prev) => (prev ?? []).map((t) =>
t.orderId === orderId ? { ...t, strategyId, holdingId } : t
));
load(); // 后台刷新兜底(同步 TradeSync 可能更新的数据)
} else {
setAttributionSaved('保存失败: ' + ((res && res.error && res.error.message) || 'unknown'));
setTimeout(() => setAttributionSaved(null), 3000);
}
} catch (e) {
setAttributionSaved('保存失败: ' + e.message);
setTimeout(() => setAttributionSaved(null), 3000);
// R-018:全量替换归属段(segments = [{strategyId, volume}];账本写操作,原子)。返回新 segments
const saveSegments = useCallback(async (orderId, segments) => {
const res = await call('one-divine-lot/orders/attribution-set', {
args: { orderId, segments },
});
if (!res?.ok) {
throw new Error((res && res.error && res.error.message) || '保存失败');
}
const nextSegs = Array.isArray(res.value?.segments) ? res.value.segments : [];
// 立即更新本地 state(归属立刻生效;多段 order 主段 = 第一段)
const first = nextSegs[0];
setOrders((prev) => (prev ?? []).map((o) =>
o.orderId === orderId ? { ...o, segments: nextSegs, strategyId: first ? first.strategyId : null, holdingId: first ? first.holdingId : null } : o
));
setTrades((prev) => (prev ?? []).map((t) =>
t.orderId === orderId ? { ...t, segments: nextSegs, strategyId: first ? first.strategyId : null, holdingId: first ? first.holdingId : null } : t
));
load(); // 后台刷新兜底(同步 TradeSync 可能更新的数据)
return nextSegs;
}, [call, load]);
// 归属候选自动预载:数据加载后,为所有委托 code 预取候选(下拉选项齐全,避免只有「未关联」
// 归属目标自动预载:数据加载后,为所有委托 code 预取目标(策略下拉选项齐全)
useEffect(() => {
const codes = [...new Set((orders ?? []).map((o) => o.code).filter(Boolean))];
if (codes.length === 0) return;
for (const code of codes) {
loadCandidates(code);
loadTargets(code);
}
}, [orders, loadCandidates]);
}, [orders, loadTargets]);
// R-018 T3:拉取漏关联软提示(每 20s + 今日数据加载后)
const loadOrphanHints = useCallback(async () => {
try {
const res = await call('one-divine-lot/positions/orphan-hints', { args: {} });
if (res?.ok && Array.isArray(res.value)) setOrphanHints(res.value);
} catch { /* ignore(服务端不可用时静默) */ }
}, [call]);
useEffect(() => {
if (!isTodayRange) { setOrphanHints([]); return; }
loadOrphanHints();
const timer = setInterval(loadOrphanHints, 20000);
return () => clearInterval(timer);
}, [isTodayRange, loadOrphanHints, range]);
// 时间段变化 → 重新加载(展开重置)
useEffect(() => {
@@ -327,6 +342,21 @@ export function TradeRecordsTab() {
{attributionSaved}
</div>
)}
{/* R-018 T3:漏关联软提示(幽灵清仓退役后,账本活动行但对账单已无此票 → 提示去关联卖出单或移出) */}
{Array.isArray(orphanHints) && orphanHints.length > 0 && (
<div
style={{
marginBottom: 8, fontSize: 12, padding: '6px 10px', borderRadius: 4,
border: '1px solid var(--dsw-alias-state-warning-primary, #f9a825)',
background: 'color-mix(in srgb, var(--dsw-alias-state-warning-primary, #f9a825) 10%, var(--dsw-alias-bg-layer-1, #fff))',
color: 'var(--dsw-alias-label-secondary, #555)',
}}
title="幽灵自动清仓已退役:这些持仓对账单已无此票,账本仍记着 —— 请去「策略持仓」tab 移出或为当日卖出委托设置归属"
>
{orphanHints.length} 个持仓对账单已无此票账本待处理{orphanHints.slice(0, 3).map((h) => h.code).join('、')}
{orphanHints.length > 3 ? ' 等' : ''} 请在对应策略 tab 移出或在下方为当日卖出委托设置归属关联后份额自动清零转历史
</div>
)}
<LoadState loading={loading && !orders} error={error} onRetry={load} autoRetry={2}>
{!orders?.length ? (
@@ -339,7 +369,7 @@ export function TradeRecordsTab() {
</div>
</div>
) : (
<TradeTable rows={rows} toggleSort={toggleSort} sortKey={sortKey} sortDir={sortDir} arrow={arrow} toggleExpand={toggleExpand} expanded={expanded} attributionCandidates={attributionCandidates} loadCandidates={loadCandidates} setAttribution={setAttribution} />
<TradeTable rows={rows} toggleSort={toggleSort} sortKey={sortKey} sortDir={sortDir} arrow={arrow} toggleExpand={toggleExpand} expanded={expanded} targets={attributionTargets} loadTargets={loadTargets} saveSegments={saveSegments} onNotice={setAttributionSaved} />
)}
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--dsw-alias-label-tertiary, #999)' }}>
<span style={{ color: 'var(--dsw-alias-state-error-primary, #d32f2f)' }}> </span>
@@ -352,8 +382,8 @@ export function TradeRecordsTab() {
);
}
/** 交易记录表格(主行 + 展开明细 + R-009 手动归属列 */
function TradeTable({ rows, toggleSort, sortKey, sortDir, arrow, toggleExpand, expanded, attributionCandidates, loadCandidates, setAttribution }) {
/** 交易记录表格(主行 + 展开明细 + R-018 归属分配器 */
function TradeTable({ rows, toggleSort, sortKey, sortDir, arrow, toggleExpand, expanded, targets, loadTargets, saveSegments, onNotice }) {
return (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
@@ -383,9 +413,10 @@ function TradeTable({ rows, toggleSort, sortKey, sortDir, arrow, toggleExpand, e
order={o}
expanded={expanded.has(o.orderId)}
onToggle={() => toggleExpand(o.orderId)}
attributionCandidates={attributionCandidates[o.code] ?? []}
onLoadCandidates={() => loadCandidates(o.code)}
setAttribution={setAttribution}
targets={targets[o.code] ?? []}
loadTargets={() => loadTargets(o.code)}
saveSegments={saveSegments}
onNotice={onNotice}
/>
))}
</tbody>
@@ -393,13 +424,11 @@ function TradeTable({ rows, toggleSort, sortKey, sortDir, arrow, toggleExpand, e
);
}
/** 委托主行 + 展开明细(含 R-009 手动归属下拉 */
function OrderRows({ order, expanded, onToggle, attributionCandidates, onLoadCandidates, setAttribution }) {
/** 委托主行 + 展开明细(含 R-018 归属分配器入口 */
function OrderRows({ order, expanded, onToggle, targets, loadTargets, saveSegments, onNotice }) {
const hasTrades = order.trades.length > 0;
const strategyName = (sid) => {
if (!sid) return '未关联';
return order.strategyName || sid;
};
const segs = Array.isArray(order.segments) ? order.segments : [];
const totalSeg = segs.reduce((s, x) => s + Number(x?.volume ?? 0), 0);
return (
<>
<tr
@@ -432,11 +461,14 @@ function OrderRows({ order, expanded, onToggle, attributionCandidates, onLoadCan
<td style={{ ...tdStyle, textAlign: 'right' }}>{hasTrades ? fmtNum(order.aggStampTax, 2) : '—'}</td>
<td style={{ ...tdStyle, textAlign: 'right' }}>{hasTrades ? fmtNum(order.aggTransferFee, 2) : '—'}</td>
<td style={{ ...tdStyle }} onClick={(e) => e.stopPropagation()}>
<AttributionSelect
<AttributionCell
order={order}
candidates={attributionCandidates}
onLoadCandidates={onLoadCandidates}
setAttribution={setAttribution}
segments={segs}
totalSeg={totalSeg}
targets={targets}
loadTargets={loadTargets}
saveSegments={saveSegments}
onNotice={onNotice}
/>
</td>
</tr>
@@ -472,45 +504,262 @@ function OrderRows({ order, expanded, onToggle, attributionCandidates, onLoadCan
);
}
/** 委托归属选择下拉(R-009,Q3 全手动选;Q4 可随时改)
* 候选 = 该 code 当前持仓策略(orders/attribution-candidates);
* 未设置显示「未关联」,用户点选后持久化(orders/set-attribution
/**
* R-018 归属单元格(行内精简态):只显示段 chips / 未关联 + 剩余提示 + 一个入口按钮。
* 份额分配操作在 AttributionModal(弹窗)中完成 —— 不在表格行内堆大量控件
*/
function AttributionSelect({ order, candidates, onLoadCandidates, setAttribution }) {
const current = order.strategyId || '';
function AttributionCell({ order, segments, totalSeg, targets, loadTargets, saveSegments, onNotice }) {
const [open, setOpen] = useState(false);
const hasSeg = (segments ?? []).length > 0;
const aggVolume = Number(order.aggVolume ?? order.tradedVolume ?? 0);
const leftover = Math.max(0, aggVolume - totalSeg); // 未分配余额
const handleSelect = (ev) => {
const val = ev.target.value;
if (val === current) return;
if (val === '__unassigned__') {
setAttribution(order.orderId, order.code, null, null);
return;
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, minWidth: 110 }}>
{hasSeg ? (
<span style={{ display: 'inline-flex', flexWrap: 'wrap', gap: 2 }}>
{(segments ?? []).map((s) => (
<span
key={s.strategyId}
title={(s.strategyName || s.strategyId) + ' · ' + fmtNum(s.volume, 0) + ' 股'}
style={{
fontSize: 11, padding: '1px 5px', borderRadius: 3,
border: '1px solid var(--dsw-alias-state-success-primary, #2e7d32)',
color: 'var(--dsw-alias-state-success-primary, #1b5e20)',
background: 'color-mix(in srgb, var(--dsw-alias-state-success-primary, #2e7d32) 10%, var(--dsw-alias-bg-layer-1, #fff))',
}}
>
{s.strategyName || s.strategyId}
</span>
))}
</span>
) : (
<span style={{ color: 'var(--dsw-alias-label-tertiary, #999)', fontSize: 12 }}>未关联</span>
)}
{leftover > 1e-9 && (
<span
style={{ fontSize: 11, color: 'var(--dsw-alias-state-error-primary, #d32f2f)', whiteSpace: 'nowrap' }}
title={'未分配剩余 ' + fmtNum(leftover, 0) + ' 股'}
>
{fmtNum(leftover, 0)}
</span>
)}
<button
onClick={() => { loadTargets(); setOpen(true); }}
style={{
fontSize: 11, padding: '1px 8px', cursor: 'pointer', borderRadius: 3, whiteSpace: 'nowrap',
border: '1px solid var(--dsw-alias-border-l2, #ccc)',
background: 'var(--dsw-alias-bg-layer-1, #fff)', color: 'var(--dsw-alias-label-primary, #333)',
}}
title="分配该委托的归属策略/份额(关联 = 调整对应策略持仓份额)"
>
{hasSeg ? '调整' : '分配'}
</button>
{open && (
<AttributionModal
order={order}
segments={segments}
aggVolume={aggVolume}
totalSeg={totalSeg}
leftover={leftover}
targets={targets}
saveSegments={saveSegments}
onClose={() => setOpen(false)}
onNotice={onNotice}
/>
)}
</span>
);
}
/**
* R-018 归属分配弹窗(Modal):居中遮罩 + 面板。
* - 面板:委托摘要(code/名称/方向/已成交量)+ 已分配段列表(每段可撤)+ 新增段(策略下拉 + 量输入,默认余额全量)
* - 操作即时保存(setSegments 全量替换原子);顶部余额与状态实时反馈
*/
function AttributionModal({ order, segments, aggVolume, totalSeg, leftover, targets, saveSegments, onClose, onNotice }) {
const list = (targets ?? []).length > 0 ? targets : null;
const [selStrategy, setSelStrategy] = useState('');
const [amount, setAmount] = useState('');
const [busy, setBusy] = useState(false);
const segs = Array.isArray(segments) ? segments : [];
const total = segs.reduce((s, x) => s + Number(x?.volume ?? 0), 0);
const remaining = Math.max(0, aggVolume - total);
const fullyAllocated = remaining <= 1e-9;
const amountHint = remaining > 0 ? String(remaining) : '';
const notice = (msg, isErr) => {
onNotice?.(msg);
setTimeout(() => onNotice?.(null), isErr ? 3000 : 2000);
};
const run = async (nextSegments) => {
setBusy(true);
try {
await saveSegments(order.orderId, nextSegments);
notice('归属已保存: ' + order.orderId, false);
} catch (e) {
notice('保存失败: ' + (e?.message ?? String(e)), true);
} finally {
setBusy(false);
}
const cand = (candidates ?? []).find((c) => c.strategyId === val);
setAttribution(order.orderId, order.code, val, cand ? cand.holdingId : null);
};
const addSegment = async () => {
if (!selStrategy) { notice('请选择策略', true); return; }
const v = Number(amount);
if (!Number.isFinite(v) || v <= 0) { notice('请输入有效份额量', true); return; }
if (v > remaining + 1e-9) { notice('份额量超过未分配余额 ' + fmtNum(remaining, 0), true); return; }
const t = (targets ?? []).find((x) => x.strategyId === selStrategy);
const isSell = String(order.direction ?? '') === 'sell';
if (isSell && t && !t.hasActiveHolding) { notice('该策略无此持仓可减(关联失败)', true); return; }
// sell 且 activityShares < 输入量 → 自动截断为可容纳量(R4:自动截断续分)
let vol = v;
if (isSell && t && t.hasActiveHolding && Number(t.activityShares) + 1e-9 < v) {
vol = Number(t.activityShares);
notice('超出该策略可容纳,已自动截断为 ' + fmtNum(vol, 0) + '(余额保留)', false);
}
const merged = [...segs.filter((s) => s.strategyId !== selStrategy), { strategyId: selStrategy, volume: vol }];
await run(merged);
setAmount('');
};
const removeSegment = async (strategyId) => {
await run(segs.filter((s) => s.strategyId !== strategyId));
};
const clearAll = async () => {
await run([]);
};
return (
<select
value={current || '__unassigned__'}
onChange={handleSelect}
style={{
fontSize: 12,
padding: '2px 4px',
border: '1px solid ' + (current ? 'var(--dsw-alias-state-success-primary, #2e7d32)' : 'var(--dsw-alias-border-l2, #ccc)'),
borderRadius: 4,
background: current ? 'color-mix(in srgb, var(--dsw-alias-state-success-primary, #2e7d32) 12%, var(--dsw-alias-bg-layer-1, #fff))' : 'var(--dsw-alias-bg-layer-1, #fff)',
color: current ? 'var(--dsw-alias-state-success-primary, #1b5e20)' : 'var(--dsw-alias-label-tertiary, #999)',
maxWidth: 120,
}}
title="设置该委托的策略归属(可随时修改)"
>
<option value="__unassigned__">未关联</option>
{(candidates ?? []).map((c) => (
<option key={c.holdingId} value={c.strategyId}>{c.strategyName || c.strategyId}</option>
))}
<div style={{
position: 'fixed', inset: 0, background: 'var(--dsw-alias-bg-mask-1, rgba(0,0,0,0.4))',
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 10000,
}} onClick={() => !busy && onClose()}>
<div
onClick={(e) => e.stopPropagation()}
style={{
background: 'var(--dsw-alias-bg-layer-1, #fff)', borderRadius: 8,
maxWidth: 480, width: '92%', maxHeight: '80vh', overflow: 'auto',
boxShadow: '0 6px 24px rgba(0,0,0,0.18)',
}}
>
{/* 头部:委托摘要 + 关闭 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '14px 18px', borderBottom: '1px solid var(--dsw-alias-border-l1, #eee)' }}>
<h3 style={{ margin: 0, fontSize: 15 }}>
<span style={directionStyle(order.direction)}>{directionText(order.direction)}</span>
{' '}{order.code} {order.name || ''}
<span style={{ marginLeft: 8, color: 'var(--dsw-alias-label-secondary, #666)', fontWeight: 400, fontSize: 12 }}>
委托 {order.orderId}
</span>
</h3>
<button onClick={() => !busy && onClose()} disabled={busy} style={{ cursor: busy ? 'wait' : 'pointer', border: 'none', background: 'transparent', fontSize: 16, color: 'var(--dsw-alias-label-tertiary, #999)' }} title="关闭"></button>
</div>
</select>
{/* 余额统计行 */}
<div style={{ padding: '12px 18px 4px', display: 'flex', gap: 16, flexWrap: 'wrap', fontSize: 13 }}>
<span>已成交量 <b>{fmtNum(aggVolume, 0)}</b></span>
<span>已分配 <b>{fmtNum(total, 0)}</b></span>
<span>
待分配余额{' '}
<b style={{ color: fullyAllocated ? 'var(--dsw-alias-state-success-primary, #1b5e20)' : 'var(--dsw-alias-state-error-primary, #d32f2f)' }}>
{fmtNum(remaining, 0)}
</b>
</span>
{fullyAllocated && <span style={{ color: 'var(--dsw-alias-state-success-primary, #1b5e20)', fontSize: 12 }}>已全部分配</span>}
</div>
{/* 已分配段列表 */}
<div style={{ padding: '10px 18px' }}>
{segs.length === 0 ? (
<div style={{ color: 'var(--dsw-alias-label-tertiary, #999)', fontSize: 13, padding: '4px 0' }}>
尚未关联任何策略可在下方选择策略并分配份额关联会同步调整对应策略持仓
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{segs.map((s) => (
<div key={s.strategyId} style={{ display: 'flex', alignItems: 'center', gap: 8, background: 'var(--dsw-alias-bg-layer-2, #fafafa)', borderRadius: 6, padding: '6px 10px' }}>
<span style={{ flex: 1, fontSize: 13 }}>{s.strategyName || s.strategyId}</span>
<span style={{ fontSize: 13, fontWeight: 'bold' }}>{fmtNum(s.volume, 0)} </span>
<button
onClick={() => !busy && removeSegment(s.strategyId)}
disabled={busy}
style={{ cursor: busy ? 'wait' : 'pointer', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 4, background: 'var(--dsw-alias-bg-layer-1, #fff)', color: 'var(--dsw-alias-state-error-primary, #c62828)', fontSize: 11, padding: '2px 8px' }}
title="撤销该段(逆操作账本,恢复对应持仓份额)"
>
撤除
</button>
</div>
))}
</div>
)}
</div>
{/* 新增段 */}
<div style={{ padding: '6px 18px 14px', borderTop: '1px solid var(--dsw-alias-border-l1, #eee)' }}>
<div style={{ fontSize: 12, color: 'var(--dsw-alias-label-secondary, #666)', margin: '10px 0 6px' }}>
分配给策略{fullyAllocated ? '(已全部分配,如需调整请先撤除某段)' : ''}
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<select
value={selStrategy}
onChange={(e) => setSelStrategy(e.target.value)}
style={{ fontSize: 13, padding: '4px 6px', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 4, flex: 1, minWidth: 140 }}
disabled={fullyAllocated || busy}
title="选择归属策略(候选 = 全部策略;卖出需该策略有对应持仓)"
>
<option value="">选择策略</option>
{(list ?? []).map((t) => (
<option key={t.strategyId} value={t.strategyId}>
{t.strategyName || t.strategyId}{t.hasActiveHolding ? '(当前仓 ' + fmtNum(t.activityShares, 0) + '' : ''}
</option>
))}
</select>
<input
type="number"
min="1"
step={100}
value={amount}
placeholder={amountHint || '股数'}
onChange={(e) => setAmount(e.target.value)}
disabled={fullyAllocated || busy}
style={{ width: 110, padding: '4px 6px', fontSize: 13, border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 4 }}
title={'分配份额量(默认 = 待分配余额 ' + fmtNum(remaining, 0) + ',可改小)'}
/>
<button
onClick={addSegment}
disabled={fullyAllocated || busy}
style={{ cursor: busy ? 'wait' : 'pointer', padding: '4px 14px', fontSize: 13, border: 'none', borderRadius: 4, background: 'var(--dsw-alias-state-business-primary, #1565c0)', color: 'var(--dsw-alias-button-contrast-fill, #fff)' }}
>
分配
</button>
</div>
{list === null && (
<div style={{ fontSize: 12, color: 'var(--dsw-alias-label-tertiary, #999)', marginTop: 6 }}>策略加载中</div>
)}
</div>
{/* 底部操作 */}
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, padding: '10px 18px 14px', borderTop: '1px solid var(--dsw-alias-border-l1, #eee)' }}>
<button
onClick={() => !busy && clearAll()}
disabled={busy || segs.length === 0}
style={{ cursor: busy ? 'wait' : 'pointer', padding: '5px 12px', fontSize: 13, border: '1px solid var(--dsw-alias-state-error-primary, #c62828)', borderRadius: 4, background: 'var(--dsw-alias-bg-layer-1, #fff)', color: 'var(--dsw-alias-state-error-primary, #c62828)' }}
title="清除全部归属(逆操作:建仓买入段作废 / 卖出段恢复持仓)"
>
全部撤除
</button>
<button
onClick={() => !busy && onClose()}
disabled={busy}
style={{ cursor: busy ? 'wait' : 'pointer', padding: '5px 16px', fontSize: 13, border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 4, background: 'var(--dsw-alias-bg-layer-1, #fff)' }}
>
完成
</button>
</div>
</div>
</div>
);
}
+57 -22
View File
@@ -1,11 +1,17 @@
/**
* QmtHealthMonitor —— QMT 连接健康检查(2026-09-01 老师定)
* QmtHealthMonitor —— QMT 连接健康检查(2026-09-01 老师定2026-09-09 R-022 自适应探测修正
*
* 机制(服务端定时 + 缓存):
* - DSH 服务端每 5 分钟探测一次激活 QMT Bridge /health,结果缓存在内存;
* - DSH 服务端定时探测激活 QMT Bridge /health,结果缓存在内存;
* - 前端打开页面时读缓存(秒回,不等实时探测);
* - 激活配置切换时立即补探一次(热切换联动);
* - 前端点击/悬停可触发即时探测(on-demand),刷新缓存。
* - 前端点击/悬停可触发即时探测(on-demand),刷新缓存并重排下轮节奏
*
* 自适应探测节奏(R-022 / 迭代 192026-09-09):
* - 健康 → 每 5 分钟探测一次(稳态,低开销,维持原设计);
* - 异常/未知 → 每 10 秒快速重试——与持仓 10s / 行情 5s 指示灯恢复节奏对齐:
* QMT Bridge 启动/恢复后灯 ≤20s 自动回绿(原实现要等满 5 分钟,灯长期停留在失败态,
* 被老师反馈为「自动检查没有生效」)。
*
* 职责(单一):维护 QMT 健康状态缓存。
*/
@@ -13,36 +19,43 @@
import { resolveActiveBaseUrl } from '../api/common.js';
const HEALTH_TIMEOUT_MS = 5000;
const HEALTH_INTERVAL_MS = 5 * 60 * 1000; // 5 分钟定时探测
const HEALTH_INTERVAL_MS = 5 * 60 * 1000; // 健康稳态:5 分钟定时探测
const HEALTH_RETRY_MS = 10 * 1000; // 异常/未知:10 秒快速重试(恢复感知)
export class QmtHealthMonitor {
/**
* @param {object} opts
* @param {object} opts.runtime { settings, dataSource } —— 解析激活 baseUrl
* @param {object} [opts.logger]
* @param {number} [opts.intervalMs] 健康稳态探测间隔(默认 5 分钟;测试可调小)
* @param {number} [opts.retryMs] 异常/未知重试间隔(默认 10 秒;测试可调小)
*/
constructor({ runtime, logger } = {}) {
constructor({ runtime, logger, intervalMs = HEALTH_INTERVAL_MS, retryMs = HEALTH_RETRY_MS } = {}) {
this.runtime = runtime;
this.logger = logger;
this.intervalMs = intervalMs;
this.retryMs = retryMs;
this.cache = null; // { baseUrl, healthy, latencyMs, httpStatus, healthError, checkedAt }
this.timer = null;
this.timer = null; // setTimeout 句柄(自适应调度)
this.mounted = true;
this._inFlight = null; // 防止并发探测
}
/** 启动:立即探测一次 + 每 5 分钟定时 */
/** 启动:立即探测一次,之后按结果自适应调度(健康 5 分钟 / 异常 10 秒) */
start() {
this.mounted = true;
this.probe(); // 启动即探测
this.timer = setInterval(() => this.probe(), HEALTH_INTERVAL_MS);
this.logger?.info?.('[one-divine-lot] QmtHealthMonitor 已启动(5 分钟定时探测)');
this.probe().catch(() => {}); // 启动即探测;完成后 _scheduleNext() 排下轮
this.logger?.info?.(
'[one-divine-lot] QmtHealthMonitor 已启动(自适应探测:健康 ' + Math.round(this.intervalMs / 1000) +
's / 异常 ' + Math.round(this.retryMs / 1000) + 's 重试)'
);
}
/** 停止(插件释放时) */
stop() {
this.mounted = false;
if (this.timer) {
clearInterval(this.timer);
clearTimeout(this.timer);
this.timer = null;
}
}
@@ -54,20 +67,41 @@ export class QmtHealthMonitor {
: { healthy: null, baseUrl: '', latencyMs: 0, healthError: null, checkedAt: 0, fromCache: false };
}
/** 立即探测(启动/定时/前端点击/配置切换共用),结果写缓存 */
/** 下轮探测延迟:健康 → 稳态间隔;异常/未知(含从未探测成功)→ 快速重试 */
_nextDelay() {
return this.cache?.healthy === true ? this.intervalMs : this.retryMs;
}
/** 自适应调度下一轮探测(每次探测完成后调用;手动探测/热切换共用同一节奏) */
_scheduleNext() {
if (!this.mounted) return;
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
this.timer = setTimeout(() => {
this.timer = null;
this.probe().catch(() => {});
}, this._nextDelay());
}
/**
* 立即探测(启动/定时/前端点击/配置切换共用),结果写缓存并重排下轮节奏。
* 任意失败(含 baseUrl 解析异常)都落 healthy:false 缓存——异常态走快速重试。
*/
async probe() {
if (!this.mounted) return this.cache;
// 防并发:同一时刻只探测一次
if (this._inFlight) return this._inFlight;
const baseUrl = resolveActiveBaseUrl(this.runtime.settings, this.runtime.dataSource);
const startedAt = Date.now();
this._inFlight = (async () => {
let result;
let baseUrl;
try {
baseUrl = resolveActiveBaseUrl(this.runtime.settings, this.runtime.dataSource);
const startedAt = Date.now();
const res = await fetch(baseUrl + '/health', {
signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS),
});
result = {
return {
baseUrl,
healthy: res.ok,
latencyMs: Date.now() - startedAt,
@@ -76,22 +110,23 @@ export class QmtHealthMonitor {
checkedAt: Date.now(),
};
} catch (e) {
result = {
baseUrl,
return {
baseUrl: baseUrl ?? '',
healthy: false,
latencyMs: Date.now() - startedAt,
latencyMs: 0,
httpStatus: null,
healthError: e?.message ?? String(e),
checkedAt: Date.now(),
};
}
this.cache = result;
return result;
})();
try {
return await this._inFlight;
const result = await this._inFlight;
this.cache = result;
return result;
} finally {
this._inFlight = null;
this._scheduleNext();
}
}
}
+54
View File
@@ -0,0 +1,54 @@
/**
* 计算字段现算服务(R-026 / 迭代 22 阶段二)
*
* 职责:给定某策略的持仓行 + 字段定义,为每行算出全部 formula 字段的值(computed)。
* 数据口径(技术方案 §2.4):
* - 行情/合约**只读内存缓存**QuoteHub.quotes / instruments),miss → 相关变量 null → 该格 `—`;
* 不做读穿透(strategy-positions 是高频读路径,不引入网络往返)
* - 无 formula 字段 → 直接返回原数组(零开销)
* - 值不落库(策略定义存 settings,值每次现算)
*/
import { compileFormula, evaluateAst } from './evaluator.js';
import { buildContext } from './variables.js';
/**
* 为持仓行附加 computed
* @param {object} opts
* @param {Array} opts.rows strategy-positions 行
* @param {Array} opts.fieldDefs 本策略字段定义
* @param {object|null} [opts.hub] QuoteHub(读 quotes / instruments 内存缓存)
* @param {(code:string)=>object|null} [opts.quoteOf] 自定义行情取数(测试注入)
* @param {(code:string)=>object|null} [opts.instrumentOf] 自定义合约取数(测试注入)
* @returns {Array} rows(含 formula 字段时每行附 computed: { [key]: number|null }
*/
export function computeRows({ rows, fieldDefs, hub = null, quoteOf, instrumentOf } = {}) {
const list = Array.isArray(rows) ? rows : [];
const formulaFields = (Array.isArray(fieldDefs) ? fieldDefs : [])
.filter((f) => f && f.type === 'formula' && String(f.formula ?? '').trim());
if (list.length === 0 || formulaFields.length === 0) return list;
const compiled = formulaFields
.map((f) => ({ key: f.key, ast: compileFormula(f.formula).ast }))
.filter((c) => c.ast);
if (compiled.length === 0) return list;
const getQuote = quoteOf ?? ((code) => hub?.quotes?.get(code) ?? null);
const getInstrument = instrumentOf ?? ((code) => hub?.instruments?.get(code) ?? null);
return list.map((row) => {
const code = row?.code;
const quote = code ? getQuote(code) : null;
const instrument = code ? getInstrument(code) : null;
const ctx = buildContext({ row, quote, instrument, fieldDefs });
const computed = {};
for (const c of compiled) {
let v = null;
try { v = evaluateAst(c.ast, ctx); } catch { v = null; }
// R-028:判定型(布尔)直通;数字型要求有限数;其余(缺值/异常)→ null
computed[c.key] = (typeof v === 'boolean')
? v
: ((typeof v === 'number' && Number.isFinite(v)) ? v : null);
}
return { ...row, computed };
});
}
+352
View File
@@ -0,0 +1,352 @@
/**
* 公式引擎(R-026 / 迭代 22 阶段二):自写小型表达式解析与求值
*
* 设计约束:
* - 零依赖、不使用 eval/Function(安全边界);
* - 支持中文标识符(中文变量名,如 现价 / 成本价 / 份额);
* - 四则运算 + 括号 + 一元负号 + 函数 round/abs/min/max
* - **判定(R-028**:比较运算 > < >= <= == != 与逻辑 and/or(含 && ||、≥ ≤ ≠ 归一),
* 结果可为布尔(判定型字段)或数字(数字型字段,由 `inferResultKind` 静态判定);
* - 求值语义:引用的任一变量缺值(null/undefined/NaN)→ 整式返回 null(缺值短路);
* 除零、非法运算、结果非有限 → null;绝不抛异常、绝不产出 NaN。
*/
/** 函数白名单:名称 → [最小参数个数, 最大参数个数] */
export const FUNCS = {
round: [1, 2],
abs: [1, 1],
min: [1, Infinity],
max: [1, Infinity],
};
const MAX_LEN = 500; // 表达式长度上限
const MAX_DEPTH = 32; // 递归深度上限
/** 全角 → 半角归一化(中文输入法友好:()+-×÷,*/都可以写) */
function normalizeSrc(src) {
return String(src ?? '')
.replace(/[]/g, '(').replace(/[]/g, ')')
.replace(/[]/g, '+').replace(/[-—]/g, '-')
.replace(/[×*]/g, '*').replace(/[÷/]/g, '/')
.replace(/[]/g, ',')
.replace(/≥/g, '>=').replace(/≤/g, '<=').replace(/≠/g, '!=')
.replace(//g, '>').replace(//g, '<').replace(//g, '=');
}
const IDENT_START = /[\p{L}_]/u;
const IDENT_PART = /[\p{L}\p{Nd}_]/u;
/**
* 词法分析 → token 数组
* @returns {{ok: true, tokens: Array<{type:'num'|'ident'|'op', value:any, pos:number}>} | {ok:false, error:object}}
*/
function tokenize(src) {
const s = normalizeSrc(src);
const tokens = [];
let i = 0;
while (i < s.length) {
const c = s[i];
if (c === ' ' || c === '\t' || c === '\n' || c === '\r') { i++; continue; }
if (c >= '0' && c <= '9' || (c === '.' && s[i + 1] >= '0' && s[i + 1] <= '9')) {
let j = i;
while (j < s.length && (s[j] >= '0' && s[j] <= '9' || s[j] === '.')) j++;
const raw = s.slice(i, j);
const num = Number(raw);
if (!Number.isFinite(num)) return { ok: false, error: { code: 'syntax', message: `数字格式错误: ${raw}`, position: i } };
tokens.push({ type: 'num', value: num, pos: i });
i = j;
continue;
}
if (IDENT_START.test(c)) {
let j = i + 1;
while (j < s.length && IDENT_PART.test(s[j])) j++;
tokens.push({ type: 'ident', value: s.slice(i, j), pos: i });
i = j;
continue;
}
const two = s.slice(i, i + 2);
if (['>=', '<=', '==', '!=', '&&', '||'].includes(two)) {
tokens.push({ type: 'op', value: two, pos: i });
i += 2;
continue;
}
if ('+-*/()<>=!,'.includes(c)) {
tokens.push({ type: 'op', value: c, pos: i });
i++;
continue;
}
return { ok: false, error: { code: 'syntax', message: `无法识别的字符: ${c}`, position: i } };
}
return { ok: true, tokens };
}
/** 递归下降解析 → AST */
function parse(tokens) {
let p = 0;
let depth = 0;
const peek = () => tokens[p];
const isOp = (v) => { const t = peek(); return t && t.type === 'op' && t.value === v; };
const err = (message, position) => { throw { code: 'syntax', message, position }; };
const CMP_OPS = new Set(['>', '<', '>=', '<=', '==', '!=']);
const isKeyword = (t, kw) => !!(t && t.type === 'ident' && String(t.value).toLowerCase() === kw);
/** 最低优先级:or → and → 比较 → 加减 → 乘除 → 一元 → 主 */
function expression() {
let node = andExpr();
while (isKeyword(peek(), 'or') || isOp('||')) {
p++;
node = { kind: 'binary', op: 'or', left: node, right: andExpr() };
}
return node;
}
function andExpr() {
let node = comparison();
while (isKeyword(peek(), 'and') || isOp('&&')) {
p++;
node = { kind: 'binary', op: 'and', left: node, right: comparison() };
}
return node;
}
function comparison() {
let node = additive();
while (peek() && peek().type === 'op' && CMP_OPS.has(peek().value)) {
const op = tokens[p++].value;
node = { kind: 'binary', op, left: node, right: additive() };
}
return node;
}
function additive() {
let node = term();
while (peek() && peek().type === 'op' && (peek().value === '+' || peek().value === '-')) {
const op = tokens[p++].value;
node = { kind: 'binary', op, left: node, right: term() };
}
return node;
}
function term() {
let node = unary();
while (peek() && peek().type === 'op' && (peek().value === '*' || peek().value === '/')) {
const op = tokens[p++].value;
node = { kind: 'binary', op, left: node, right: unary() };
}
return node;
}
function unary() {
if (isOp('-') || isOp('+')) {
const op = tokens[p++].value;
const node = unary();
return op === '-' ? { kind: 'neg', value: node } : node;
}
return primary();
}
function primary() {
if (depth++ > MAX_DEPTH) err('公式过于复杂(嵌套过深)', 0);
try {
const t = peek();
if (!t) err('公式不完整', p > 0 ? tokens[p - 1].pos + 1 : 0);
if (t.type === 'num') { p++; return { kind: 'num', value: t.value }; }
if (t.type === 'ident') {
p++;
if (isOp('(')) {
p++;
const args = [];
if (!isOp(')')) {
args.push(expression());
while (isOp(',')) { p++; args.push(expression()); }
}
if (!isOp(')')) err('缺少右括号', t.pos);
p++;
return { kind: 'call', name: t.value, args, pos: t.pos };
}
return { kind: 'var', name: t.value, pos: t.pos };
}
if (t.type === 'op' && t.value === '(') {
p++;
const node = expression();
if (!isOp(')')) err('缺少右括号', t.pos);
p++;
return node;
}
err(`意外的符号: ${t.value}`, t.pos);
} finally { depth--; }
}
const ast = expression();
if (p < tokens.length) {
const t = tokens[p];
err(`多余的内容: ${t.value}`, t.pos);
}
return ast;
}
/**
* 编译公式(词法 + 语法),不校验变量名
* @returns {{ok:true, ast:object} | {ok:false, error:{code:string,message:string,position:number}}}
*/
export function compileFormula(expr) {
const src = String(expr ?? '').trim();
if (!src) return { ok: false, error: { code: 'empty', message: '公式不能为空', position: 0 } };
if (src.length > MAX_LEN) return { ok: false, error: { code: 'syntax', message: '公式过长(上限 ' + MAX_LEN + ' 字符)', position: MAX_LEN } };
const tk = tokenize(src);
if (!tk.ok) return tk;
try {
return { ok: true, ast: parse(tk.tokens) };
} catch (e) {
if (e && e.code === 'syntax') return { ok: false, error: { code: 'syntax', message: e.message, position: e.position ?? 0 } };
return { ok: false, error: { code: 'syntax', message: '公式解析失败', position: 0 } };
}
}
/**
* 校验公式:语法 + 变量存在性 + 函数名与参数个数
* @param {string} expr 公式串
* @param {Iterable<string>} allowedVars 允许的变量名(内置名 ∪ 本策略非公式字段展示名)
* @returns {{ok:true, vars:string[]} | {ok:false, error:{code:string,message:string,position:number}}}
*/
export function validateFormula(expr, allowedVars) {
const compiled = compileFormula(expr);
if (!compiled.ok) return compiled;
const allowed = new Set(allowedVars ?? []);
const used = new Set();
let failure = null;
const walk = (node) => {
if (failure || !node) return;
if (node.kind === 'var') {
if (!allowed.has(node.name)) {
failure = { code: 'unknown-var', message: `未知变量『${node.name}』——该字段已删除或名称有误`, position: node.pos ?? 0 };
return;
}
used.add(node.name);
return;
}
if (node.kind === 'call') {
const arity = FUNCS[node.name];
if (!arity) {
failure = { code: 'unknown-func', message: `未知函数『${node.name}』(可用:round/abs/min/max`, position: node.pos ?? 0 };
return;
}
if (node.args.length < arity[0] || node.args.length > arity[1]) {
failure = { code: 'arity', message: `函数『${node.name}』参数个数不对`, position: node.pos ?? 0 };
return;
}
node.args.forEach(walk);
return;
}
if (node.kind === 'binary') { walk(node.left); walk(node.right); return; }
if (node.kind === 'neg') { walk(node.value); }
};
walk(compiled.ast);
if (failure) return { ok: false, error: failure };
return { ok: true, vars: [...used] };
}
/** 数值判定:有限数才有效,否则 null(中间步骤不做归一,保精度) */
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
/**
* 最终结果浮点归一:消除 11.2-10.5=0.7000000000000002 ×1000 之类的噪声
* (仅最终值处理;|v| ≥ 1e12 不动,避免精度损失)
*/
function normalizeResult(v) {
if (typeof v === 'boolean') return v; // 判定型结果直通(R-028
if (typeof v !== 'number' || !Number.isFinite(v)) return null;
if (Math.abs(v) >= 1e12) return v;
const r = Math.round(v * 1e10) / 1e10;
return Number.isFinite(r) ? r : null;
}
/**
* 求值(缺值短路:任一操作数为 null → 结果 null)
* @param {object} ast compileFormula 的 ast
* @param {Object<string, number|null>} ctx 变量上下文
* @returns {number|null}
*/
function evalNode(ast, ctx = {}) {
if (!ast) return null;
switch (ast.kind) {
case 'num':
return num(ast.value);
case 'var': {
const v = ctx[ast.name];
if (v === null || v === undefined) return null;
const n = typeof v === 'number' ? v : Number(v);
return Number.isFinite(n) ? n : null;
}
case 'neg': {
const v = evalNode(ast.value, ctx);
return v === null ? null : num(-v);
}
case 'binary': {
const { op } = ast;
// 逻辑(R-028):缺值短路(任一操作数 null → null)
if (op === 'and' || op === 'or') {
const l = evalNode(ast.left, ctx);
if (l === null) return null;
const r = evalNode(ast.right, ctx);
if (r === null) return null;
return op === 'and' ? (!!l && !!r) : (!!l || !!r);
}
const l = evalNode(ast.left, ctx);
if (l === null) return null;
const r = evalNode(ast.right, ctx);
if (r === null) return null;
// 比较(R-028:判定)
if (op === '>') return l > r;
if (op === '<') return l < r;
if (op === '>=') return l >= r;
if (op === '<=') return l <= r;
if (op === '==') return l === r;
if (op === '!=') return l !== r;
if (op === '+') return num(l + r);
if (op === '-') return num(l - r);
if (op === '*') return num(l * r);
if (op === '/') return r === 0 ? null : num(l / r);
return null;
}
case 'call': {
const args = ast.args.map((a) => evalNode(a, ctx));
if (args.some((a) => a === null)) return null;
if (ast.name === 'round') {
const digits = args.length > 1 ? Math.trunc(args[1]) : 0;
const f = Math.pow(10, Math.max(0, Math.min(10, digits)));
return num(Math.round(args[0] * f) / f);
}
if (ast.name === 'abs') return num(Math.abs(args[0]));
if (ast.name === 'min') return num(Math.min(...args));
if (ast.name === 'max') return num(Math.max(...args));
return null;
}
default:
return null;
}
}
/**
* 结果类型静态推断(R-028):顶层为比较/逻辑运算 → 'boolean'(判定型),否则 'number'
* @param {object} ast compileFormula 的 ast
* @returns {'boolean'|'number'}
*/
export function inferResultKind(ast) {
if (!ast) return 'number';
if (ast.kind === 'neg') return inferResultKind(ast.value);
if (ast.kind === 'binary') {
const { op } = ast;
if (op === 'and' || op === 'or') return 'boolean';
if (['>', '<', '>=', '<=', '==', '!='].includes(op)) return 'boolean';
}
return 'number';
}
/** 对外求值入口:递归求值 + 最终结果浮点归一 */
export function evaluateAst(ast, ctx = {}) {
return normalizeResult(evalNode(ast, ctx));
}
/** 便捷:编译 + 求值(单次用;多次求值请先 compileFormula 复用 ast */
export function evaluateFormula(expr, ctx) {
const compiled = compileFormula(expr);
if (!compiled.ok) return null;
return evaluateAst(compiled.ast, ctx);
}

Some files were not shown because too many files have changed in this diff Show More