Compare commits

...

5 Commits

Author SHA1 Message Date
kyugao d122d88bce feat(策略持仓): 表格加昨收/涨幅列(涨幅支持升降排序)+ 去掉总持仓列
- 昨收列:行情缓存 lastClose
- 涨幅列:(现价-昨收)/昨收,红涨绿跌着色
- 涨幅排序:点击表头降序/升序切换(▼/▲指示)
- 移除总持仓列
2026-09-02 09:23:02 +08:00
kyugao 2aacd8a25d docs(迭代07/08): 交易记录本地存储 + 策略持仓展开全量文档 + 需求归档(R-009/R-010)
- PLAN-008/PLAN-009 计划(已完成)
- 迭代 07/08 四件套(目标/技术方案/验收标准/复盘)
- 设计约束:数据存储设计 §10/§10.1、技术约束-012/013、产品约束-008
- R-009/R-010 需求归档(含二次定稿:手动归属、Q4 成本价/成交价)
- 需求池索引同步
2026-09-02 01:21:10 +08:00
kyugao 859938c4f1 test(交易记录): 迭代 07/08 回归测试(独立数据目录)
- test-r009: 手动归属核心(建表/幂等/UPSERT 不覆盖归属/候选/持久化过滤)
- test-r009-sync: TradeSync 同步集成
- test-r009-normalize: QMT code 归一化
- test-r009-api: API 层端点集成
- verify-real-r009: 真实数据只读验证
- verify-real-attribution: 归属写闭环(被插件锁时跳过)
2026-09-02 01:21:04 +08:00
kyugao 2790b92378 feat(策略持仓): 持仓行展开关联交易 + 成本价/最后成交价 + 隐藏输入框(迭代 08,R-010)
- strategy-positions 附加 holding_id + avgPrice/lastTradePrice(无关联默认成本价)
- trades/by-holding 端点(按 holding 查委托汇总)
- StrategyTab 持仓行展开(懒加载 + 内嵌汇总表,含交易日/时间列)
- 神之一手 tab 激活时隐藏 AI 对话输入框(纯 CSS :has() 方案)
2026-09-02 01:20:58 +08:00
kyugao b3526c7693 feat(交易记录): SQLite 本地存储 + 手动归属(迭代 07,R-009)
- trade_orders + trade_fills 两表(委托/成交,holding_id/strategy_id 手动归属)
- TradeSync 定时同步(启动预热 + 60s UPSERT,不覆盖归属列)
- 手动设置归属(candidates 候选 + set-attribution 端点 + 前端归属下拉)
- 历史查询 trades/history + 策略过滤
- QMT code 归一化(补后缀)+ tradeDate 兜底 insertDate
2026-09-02 01:20:50 +08:00
35 changed files with 2434 additions and 145 deletions
@@ -0,0 +1,66 @@
# 计划:交易记录本地存储(SQLite)+ 策略关联(阶段航点)
> 编号:PLAN-008 | 粒度:阶段航点(大粒度) | 创建:2026-09-01 状态:**已完成(2026-09-01 迭代 07 验收通过)**
> 派生自终极目标:目标-006(交易复盘)、目标-003(按策略监控市场)
> 依据需求:**R-009(已定稿,2026-09-01Q1-Q8 全部确认)** —— 符合入范围门槛
> 设计约束:技术约束-012(数据存储设计)、技术约束-001/003/004/010(沿用)、产品约束-007 方向语义
## 目标
在 SQLite 新增**交易记录表**trade_orders 委托 + trade_fills 成交),将 QMT Bridge 当日交易数据**本地持久化**(跨日积累成本地历史库),并按**外键链**(成交→委托→策略→holding)建立与插件策略体系的关联,支持**按策略过滤 / 复盘**交易。
- 承接 R-007 的 Q5(本地持久化)与 Q10(按策略过滤);
- 落实迭代 06 复盘遗留项 2(holding_id 关联锚点);
- 解决 R-007 历史范围「接口开发中」占位(本地积累后历史可查)。
## 范围
**做**
1. **两表落地**trade_orders(委托主行,order_id 主键,UPSERT 幂等)+ trade_fills(成交明细,trade_id 主键、order_id 外键关联委托)两表;**两表均不冗余 strategy_id / holding_id**Q3 老师定稿:外键链推导);trade_orders 加派生列 insert_tsinsert_date+insert_time 合成毫秒时间戳,join 持仓窗口用);
2. **定时同步**:服务端 TradeSync 模块 —— 定时(60s)拉当日 orders+trades → UPSERT 落库(幂等);插件启动同步一次(预热今日数据);前端今日轮询写穿(机会式);
3. **策略关联(外键链推导)**:策略/持仓归属不在写入期计算,查询期由委托时间(insert_tsJOIN strategy_holdings 生命周期窗口(created_at ≤ t < closed_atclosed_at 为 NULL=当前持仓)推导;同码多策略取份额最大持仓;无命中视为未关联;
4. **本地历史查询端点**trades/history —— 按 { start, end, code, strategyId, direction } 查本地 SQLite,返回 { orders, fills }(策略过滤走 FK 链 join);
5. **前端**TradeRecordsTab 加策略过滤下拉(全部/各策略/未关联);历史范围从「占位」切换为查本地库;今日仍走 QMT 实时 + 写穿本地。
**不做**
- 手动修正策略关联入口(后续迭代);
- 导出/清理管理界面(后续迭代;复盘需要历史,不做自动清理);
- QMT 历史接口接入(老师完善后再接);
- 下单/撤单等交易操作(目标-007 另议)。
## 程序结构(改造后)
```
src/
├── component/
│ ├── SqliteStore.js # 改:+trade_orders/trade_fills 建表 + 交易 UPSERT/查询 + 策略归属推导
│ ├── DataStore.js # 改:委托交易记录方法
│ └── TradeSync.js # 新增:服务端定时同步(60s + 启动预热 + UPSERT 幂等)
├── api/
│ └── trades.js # 改:+trades/history 端点
└── index.js # 改:装配 TradeSync + storage 注入 api runtime
src/client/views/
└── TradeRecordsTab.jsx # 改:策略过滤下拉 + 历史范围查本地库
```
## 实现步骤(建议顺序)
1. **文档骨架**PLAN-008 + 迭代 07 子目录(本步);
2. **设计约束落地**:数据存储设计.md §10 增补交易记录表设计 + 技术方案约束新增条目;
3. **SqliteStore**:建表(trade_orders/trade_fills+ 交易 UPSERT(幂等)+ 本地历史查询 + 策略归属推导(insert_ts join 持仓窗口);
4. **DataStore**:委托交易记录方法;
5. **TradeSync**:定时同步模块(60s + 启动预热 + 今日范围 UPSERT);
6. **api/trades.js**trades/history 端点(+ 现有 orders/trades 响应附加策略归属信息可选);
7. **index.js**:装配 TradeSyncdispose 清理)+ storage 注入 api runtime
8. **前端**TradeRecordsTab 策略过滤 + 历史范围本地展示;
9. **构建测试**pnpm run build + typecheck + 独立数据目录回归测试(技术约束-011);
10. **验收**:对照迭代 07 验收标准逐条核验,记录迭代复盘。
## 验收要点
- trade_orders + trade_fills 两表落地(零冗余 strategy_id/holding_idinsert_ts 派生列);
- 同步:启动预热 + 60s 定时 UPSERT 幂等(重复同步不重复行、状态覆盖更新);
- 历史查询:按时间段/code/策略/方向查本地库,策略过滤 = 委托时间 join 持仓生命周期窗口(一码多策略取份额最大);
- 前端:策略过滤下拉 + 历史范围展示本地数据(不再占位);今日实时 + 写穿;
- 现有功能不回归(持仓/策略/行情/连接配置/交易记录今日实时);
- 技术约束-011:回归测试用独立数据目录。
@@ -0,0 +1,55 @@
# 计划:策略持仓行展开关联交易记录(Holding → 交易汇总)(阶段航点)
> 编号:PLAN-009 | 粒度:阶段航点(大粒度) | 创建:2026-09-02 状态:**已完成(2026-09-02 迭代 08 老师确认)**
> 派生自终极目标:目标-006(交易复盘)、目标-003(按策略监控市场)
> 依据需求:**R-010(已定稿,2026-09-02Q1-Q3 确认)** —— 符合入范围门槛
> 设计约束:技术约束-012(数据存储设计)、技术约束-001/003/004(沿用)
## 目标
策略持仓 tab 每个持仓行(Holding)可展开,展开显示与该 holding 关联的交易记录(仅委托汇总,不分笔成交),实现「持仓 ↔ 交易」的双向追溯(R-009 反向:交易→持仓已实现,本迭代持仓→交易)。
## 范围
**做**
1. **strategy-positions 附加 holding_id**PositionManager.getStrategyPositions 返回时,查 strategy_holdings 附加当前持仓的 holding_idQ1);
2. **trades/by-holding 端点**:按 holding_id 查 trade_orders,返回委托汇总列表(时间/方向/状态/委托量/成交量/均价/金额/费用)(Q2);
3. **持仓行展开 UI**StrategyTab 持仓行加展开(类似交易记录 tab 展开效果),展开后内嵌小表格显示该 holding 的委托汇总;懒加载(展开时才请求)(Q3)。
**不做**
- 分笔成交明细展示(老师明确只要汇总);
- 交易记录 tab 改动;
- 归属设置入口(R-009 已有)。
## 程序结构
```
src/
├── component/
│ ├── PositionManager.js # 改:getStrategyPositions 附加 holding_id
│ ├── SqliteStore.js # 改:+getOrdersByHolding(holdingId)
│ └── DataStore.js # 改:+getTradeOrdersByHolding
├── api/
│ └── trades.js # 改:+trades/by-holding 端点
src/client/views/
└── StrategyTab.jsx # 改:持仓行展开 + 内嵌委托汇总表(懒加载)
```
## 实现步骤
1. **文档骨架**PLAN-009 + 迭代 08(本步);
2. **SqliteStore**getOrdersByHoldingWHERE holding_id=? ORDER BY insert_ts DESC);
3. **DataStore**:委托方法;
4. **PositionManager**getStrategyPositions 附加 holding_id
5. **api/trades.js**trades/by-holding 端点;
6. **StrategyTab**:持仓行展开 + 懒加载 + 内嵌汇总表;
7. **构建测试**pnpm run build + typecheck + 独立数据目录回归(技术约束-011);
8. **验收 + 复盘**
## 验收要点
- strategy-positions 每行含 holding_id(同策略同 code 当前持仓);
- trades/by-holding 返回该 holding 的委托汇总(无成交的委托也显示);
- StrategyTab 持仓行可展开,展开显示委托汇总表(不分笔成交);
- 懒加载:不展开不请求;
- 现有功能不回归。
@@ -17,6 +17,7 @@
| 产品约束-007 | 3 个监控表格(全部持仓 / 手动做T / 网格超市)展示**盘中现价**:现价列显示 lastPrice,红涨绿跌着色(对比昨收),价格变化轻微高亮;本期仅现价一项实时数据 | 2026-08-31 | 生效 | - | R-005 定稿(2026-08-31):老师确认现价列 + 涨跌色 + 变化高亮 | | 产品约束-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 旁,菜单仅切换激活) | | 产品约束-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 实施):策略过滤 + 历史本地展示 |
<!-- 示例条目(确认格式后删除): <!-- 示例条目(确认格式后删除):
| 产品约束-001 | 示例:求签功能必须保证抽取结果的不可预测性 | 2026-08-26 | 生效 | - | 讨论确认:为保证公平性,抽取必须不可预测 | | 产品约束-001 | 示例:求签功能必须保证抽取结果的不可预测性 | 2026-08-26 | 生效 | - | 讨论确认:为保证公平性,抽取必须不可预测 |
--> -->
+2 -1
View File
@@ -17,11 +17,12 @@
| 技术约束-006 | 客户端 slots.register 的 component 必须是第二参数(register({...}, Component));settings schema 必须用 schemastery z.object() 函数式定义 | 2026-08-28 | 生效 | - | 迭代 01 复盘沉淀:component 位置错误致 React #130;普通对象 schema 报 schema is not a function | | 技术约束-006 | 客户端 slots.register 的 component 必须是第二参数(register({...}, Component));settings schema 必须用 schemastery z.object() 函数式定义 | 2026-08-28 | 生效 | - | 迭代 01 复盘沉淀:component 位置错误致 React #130;普通对象 schema 报 schema is not a function |
| 技术约束-007 | 插件安装用 dsh plugin add(自动 reconcile bundles),不直接用 pnpm addbundle patch 顶层必须是 insert 操作 | 2026-08-28 | 生效 | - | 迭代 01 复盘沉淀:pnpm add 不会更新 dsh.profile.bundles | | 技术约束-007 | 插件安装用 dsh plugin add(自动 reconcile bundles),不直接用 pnpm addbundle patch 顶层必须是 insert 操作 | 2026-08-28 | 生效 | - | 迭代 01 复盘沉淀:pnpm add 不会更新 dsh.profile.bundles |
| 技术约束-008 | QMT 连接配置存储复用 one-divine-lot settings namespace(新增 qmtConnections 字段:list[{id,name,baseUrl,order}] + activeId + defaultId),与策略配置同机制持久化;激活切换 = 更新数据源实例的 baseUrl(数据源按请求读取地址,已核实),立即生效无需重启 DSH;插件启动时激活默认配置(列表为空时回退 cordis 注入的 qmtBaseUrl 兜底,不做自动迁移);测试连接由服务端代理请求 {baseUrl}/health(避免浏览器跨域) | 2026-08-29 | 生效 | - | R-004 定稿(2026-08-29):Q1/Q2/Q4/Q5/Q8 确认(启动自动激活默认、立即切换、不迁移、超时不配置化、复用 settings) | | 技术约束-008 | QMT 连接配置存储复用 one-divine-lot settings namespace(新增 qmtConnections 字段:list[{id,name,baseUrl,order}] + activeId + defaultId),与策略配置同机制持久化;激活切换 = 更新数据源实例的 baseUrl(数据源按请求读取地址,已核实),立即生效无需重启 DSH;插件启动时激活默认配置(列表为空时回退 cordis 注入的 qmtBaseUrl 兜底,不做自动迁移);测试连接由服务端代理请求 {baseUrl}/health(避免浏览器跨域) | 2026-08-29 | 生效 | - | R-004 定稿(2026-08-29):Q1/Q2/Q4/Q5/Q8 确认(启动自动激活默认、立即切换、不迁移、超时不配置化、复用 settings) |
| 技术约束-012 | 插件数据存储遵循 **docs/03-设计约束/数据存储设计.md**:存储引擎为 **SQLitenode:sqlite**——strategy_holdings + market_quotes_cache 两表(持仓生命周期表 + 行情极简两列缓存表);存储层单票生命周期操作;策略定义仍存 DSH settings;旧 JSONstore.json / store.market.json / allocations.json)经一次性迁移脚本 + 启动自动迁移(幂等、迁移前自动备份)后废弃;仅存储引擎替换,对外行为不变 | 2026-09-01 | 生效 | - | 变更(2026-09-01 R-008 定稿 + 迭代 06 实施):JSON data store → SQLitenode:sqlite,零依赖分发;strategy_holdings 持仓生命周期表+market_quotes_cache 两表;存储层单票生命周期;自动迁移;JSON 废弃);R-006 原 JSON 设计为上一版本基线 | | 技术约束-012 | 插件数据存储遵循 **docs/03-设计约束/数据存储设计.md**:存储引擎为 **SQLitenode:sqlite**——strategy_holdings + market_quotes_cache + **trade_orders + trade_fills(交易记录两表,R-009**;存储层单票生命周期操作;交易记录两表零冗余 strategy_id/holding_id(外键链推导策略归属);策略定义仍存 DSH settings;旧 JSONstore.json / store.market.json / allocations.json)经一次性迁移脚本 + 启动自动迁移(幂等、迁移前自动备份)后废弃;仅存储引擎替换,对外行为不变 | 2026-09-01 | 生效 | - | 变更(2026-09-01 R-008 定稿 + 迭代 06 实施):JSON data store → SQLite;变更(2026-09-01 R-009 定稿 + 迭代 07 实施):+trade_orders/trade_fills 交易记录两表(零冗余,外键链推导策略归属) |
| 技术约束-011 | 测试/回归脚本**禁止在真实数据上执行写操作**:份额写操作(add/remove/move/clear)必须使用独立数据目录(AllocationStorage 支持 ODL_TEST_DATA_DIR 环境变量或 dataDir 参数指向临时目录),只读端点(positions/summary/strategies/market-snapshot)可直连生产 API | 2026-08-31 | 生效 | - | 2026-08-31 数据误删事故沉淀:回归测试误删大连热电/万顺新材份额分配,老师定「测试用独立数据目录」 | | 技术约束-011 | 测试/回归脚本**禁止在真实数据上执行写操作**:份额写操作(add/remove/move/clear)必须使用独立数据目录(AllocationStorage 支持 ODL_TEST_DATA_DIR 环境变量或 dataDir 参数指向临时目录),只读端点(positions/summary/strategies/market-snapshot)可直连生产 API | 2026-08-31 | 生效 | - | 2026-08-31 数据误删事故沉淀:回归测试误删大连热电/万顺新材份额分配,老师定「测试用独立数据目录」 |
| 技术约束-010 | 行情实时数据由**服务端中转 + 缓存**提供(R-005 演进,2026-08-31 老师改):DSH 服务端做「WS 订阅 + REST 轮询 + 行情缓存」,前端统一轮询 /odl/api/market-snapshot(不做前端直连,无跨域);行情持久化到 store.market.json(重启不丢价,首屏快速展现);QMT Bridge WS 推送是会话级/有状态行为(归属 QMT Bridge 工作空间) | 2026-08-31 | 生效 | - | 变更(2026-08-31):老师由「前端直连 WS」改为「服务端中转 + 缓存」——解决跨域与 WS 语义不稳定问题;2026-09-01 加行情持久化与启动 prime | | 技术约束-010 | 行情实时数据由**服务端中转 + 缓存**提供(R-005 演进,2026-08-31 老师改):DSH 服务端做「WS 订阅 + REST 轮询 + 行情缓存」,前端统一轮询 /odl/api/market-snapshot(不做前端直连,无跨域);行情持久化到 store.market.json(重启不丢价,首屏快速展现);QMT Bridge WS 推送是会话级/有状态行为(归属 QMT Bridge 工作空间) | 2026-08-31 | 生效 | - | 变更(2026-08-31):老师由「前端直连 WS」改为「服务端中转 + 缓存」——解决跨域与 WS 语义不稳定问题;2026-09-01 加行情持久化与启动 prime |
| 技术约束-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 机制与内置插件注册方式 | | 技术约束-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 不覆盖归属列),弃算法推导 |
<!-- 示例条目(确认格式后删除): <!-- 示例条目(确认格式后删除):
| 技术约束-001 | 示例:技术栈以 Node.js / TypeScript 为准,不引入未讨论的新框架 | 2026-08-26 | 生效 | - | 讨论确认:优先复用 DSH 既有能力,新框架需论证 | | 技术约束-001 | 示例:技术栈以 Node.js / TypeScript 为准,不引入未讨论的新框架 | 2026-08-26 | 生效 | - | 讨论确认:优先复用 DSH 既有能力,新框架需论证 |
--> -->
+92 -2
View File
@@ -261,8 +261,98 @@ CREATE TABLE IF NOT EXISTS market_quotes_cache (
| MarketFeed | 行情获取(WS + REST 定时刷新 + 启动 prime | src/component/MarketFeed.js | | MarketFeed | 行情获取(WS + REST 定时刷新 + 启动 prime | src/component/MarketFeed.js |
| migrate 脚本 | 一次性迁移脚本 | scripts/migrate-json-to-sqlite.mjs | | migrate 脚本 | 一次性迁移脚本 | scripts/migrate-json-to-sqlite.mjs |
## 10. 约束条目(引用 ## 10. 交易记录存储设计(R-009 落实,2026-09-01 迭代 07 实施
- 技术约束-012:数据存储遵循本文件(SQLite 存储设计); > 依据:R-009Q1-Q8 定稿,2026-09-01)。在 SQLite 新增交易记录两表(trade_orders + trade_fills),QMT 当日交易数据本地持久化(跨日积累成历史库),支持按策略过滤 / 复盘交易。
### 10.1 表结构
```sql
-- 交易委托(委托主行;order_id 唯一,UPSERT 幂等;零冗余 strategy_id/holding_id
CREATE TABLE IF NOT EXISTS trade_orders (
order_id TEXT PRIMARY KEY, -- m_strOrderSysID
trade_date TEXT NOT NULL, -- 交易日 YYYYMMDDm_strInsertDate
code TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
exchange TEXT NOT NULL DEFAULT '',
direction TEXT NOT NULL DEFAULT '', -- buy / sell
direction_code INTEGER,
opt_name TEXT NOT NULL DEFAULT '',
status INTEGER,
order_volume REAL NOT NULL DEFAULT 0,
traded_volume REAL NOT NULL DEFAULT 0,
limit_price REAL NOT NULL DEFAULT 0,
traded_price REAL NOT NULL DEFAULT 0,
amount REAL NOT NULL DEFAULT 0,
insert_date TEXT NOT NULL DEFAULT '',
insert_time TEXT NOT NULL DEFAULT '',
insert_ts INTEGER NOT NULL, -- 派生:insert_date+insert_time 合成毫秒时间戳
cancel_info TEXT NOT NULL DEFAULT '',
error_msg TEXT NOT NULL DEFAULT '',
strategy_id TEXT, -- 用户手动设置的策略归属(冗余,Q1 确认)
holding_id INTEGER, -- 用户手动设置的持仓归属(冗余,Q1 确认)
fetched_at INTEGER NOT NULL -- 同步时间戳
);
-- 交易成交(trade_id 唯一,order_id 外键关联委托;零冗余 strategy_id/holding_id
CREATE TABLE IF NOT EXISTS trade_fills (
trade_id TEXT PRIMARY KEY, -- m_strTradeID
order_id TEXT NOT NULL, -- → trade_orders.order_id
trade_date TEXT NOT NULL,
code TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
exchange TEXT NOT NULL DEFAULT '',
direction TEXT NOT NULL DEFAULT '',
direction_code INTEGER,
opt_name TEXT NOT NULL DEFAULT '',
price REAL NOT NULL DEFAULT 0,
volume REAL NOT NULL DEFAULT 0,
amount REAL NOT NULL DEFAULT 0,
commission_rate_wan REAL NOT NULL DEFAULT 0,
trade_time TEXT NOT NULL DEFAULT '',
fetched_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_trade_orders_date ON trade_orders (trade_date);
CREATE INDEX IF NOT EXISTS idx_trade_orders_ts ON trade_orders (insert_ts);
CREATE INDEX IF NOT EXISTS idx_trade_fills_order ON trade_fills (order_id);
CREATE INDEX IF NOT EXISTS idx_trade_fills_date ON trade_fills (trade_date);
```
### 10.2 关键设计决策(R-009 Q1-Q8 定稿)
| 决策 | 结论 | 理由 |
|---|---|---|
| 表范围 | trade_orders + trade_fills 两表 | 委托 1:N 成交,忠实 R-007 单表合并模型;保留无成交委托(待报/已撤/废单);避免 JSON 压扁 |
| 冗余 | **trade_orders 冗余 strategy_id + holding_id**(用户手动设置) | 老师二次定稿(Q3 修正):手动设置归属,便于过滤/展示/复盘;trade_fills 仍零冗余(跟随委托) |
| 委托时间派生列 | insert_tsinsert_date+insert_time 合成毫秒) | join 持仓生命周期窗口用(created_at ≤ t < closed_at |
| 策略/持仓归属 | **用户手动设置**(交易记录 tab 下拉:候选 = 该 code 当前持仓策略 + 未关联);持久化到 trade_orders.strategy_id + holding_idUPSERT 不覆盖归属列;可随时改以最终为准 | 一票多策略无法算法区分(300057.SZ 分属 grid/manual),人为确认符合「人机合一」(2026-09-01 老师二次定稿) |
| 同步 | 服务端 TradeSync:启动预热 + 60s 定时 UPSERT(幂等);前端今日轮询写穿 | 不依赖前端开 tab 也持续积累 |
| 代码归一化 | QMT 委托/成交 code 无后缀(001330),持仓带后缀(001330.SZ)——数据源映射层统一 `normalizeInstrumentCode`(补交易所后缀);委托交易日 = insertDate(无独立 tradeDate 字段),tradeDate 兜底 insertDate | 保证 FK 链 join 匹配 + 历史按时间段过滤正确(迭代 07 真实数据发现) |
| 同步范围 | 只同步当日(QMT 无历史接口) | 数据逐日积累 = 本地历史库 |
| 历史查询 | trades/history 端点(时间段/code/策略/方向过滤) | 复盘 + 按策略过滤 |
| 数据清理 | 不做自动清理(复盘需要历史) | 导出/清理后续迭代 |
### 10.3 数据流
```
启动时:TradeSync 启动预热一次(拉今日 orders+trades → UPSERT 落库,不覆盖归属列)
实盘中:TradeSync 60s 定时同步(UPSERT 幂等,状态覆盖更新,归属列保留)
前端今日轮询命中 orders/trades 端点 → 写穿本地(机会式)
用户: 交易记录 tab 手动设置每笔委托归属(strategy_id + holding_id 落库,可随时改)
查询时:今日 → QMT 实时(+ 本地归属);历史范围 → trades/history 查本地 SQLite(策略过滤 = 用户设置的归属)
```
### 10.4 对应实现(更新)
| 模块 | 职责 | 文件 |
|---|---|---|
| SqliteStore | +trade_orders/trade_fills 建表 + 交易 UPSERT/历史查询 + 策略归属推导 | src/component/SqliteStore.js |
| DataStore | +交易记录方法(upsertTradeOrders/upsertTradeFills/queryTradeHistory | src/component/DataStore.js |
| TradeSync | 服务端定时同步(启动预热 + 60s + UPSERT 幂等) | src/component/TradeSync.js |
| api/trades.js | +trades/history 端点 | src/api/trades.js |
## 11. 约束条目(引用)
- 技术约束-012:数据存储遵循本文件(SQLite 存储设计,含交易记录表 §10);
- 技术约束-011:测试/回归脚本禁止在真实数据上执行写操作(独立数据目录); - 技术约束-011:测试/回归脚本禁止在真实数据上执行写操作(独立数据目录);
- 本文件为新领域设计约束,后续数据存储变更以本文件为最终依据。 - 本文件为新领域设计约束,后续数据存储变更以本文件为最终依据。
@@ -0,0 +1,120 @@
# 技术实现方案:07-交易记录本地存储(SQLite)+ 策略关联
> 依据:PLAN-008 需求:R-009(Q1-Q8 定稿)| 设计约束:技术约束-012(数据存储设计)、技术约束-011(测试隔离)
## 技术选型
- **存储**:沿用 SqliteStorenode:sqlite)——新增 trade_orders + trade_fills 两表(R-009 Q1);
- **同步**:服务端 TradeSync 模块(定时 60s + 启动预热 + 前端今日轮询写穿;UPSERT 幂等);
- **关联**:外键链推导(Q3 老师定稿):成交→委托(order_id 外键)→策略/holding(委托时间 join strategy_holdings 生命周期窗口)——两表零冗余 strategy_id/holding_id
- **历史查询**api/trades.js 新增 trades/history 端点(查本地 SQLite);
- **前端**TradeRecordsTab 加策略过滤 + 历史范围查本地。
## 表结构(数据存储设计.md §10 落实)
```sql
-- 交易委托(委托主行;order_id 唯一,UPSERT 幂等;零冗余 strategy_id/holding_id
CREATE TABLE IF NOT EXISTS trade_orders (
order_id TEXT PRIMARY KEY, -- m_strOrderSysID
trade_date TEXT NOT NULL, -- 交易日 YYYYMMDDm_strInsertDate
code TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
exchange TEXT NOT NULL DEFAULT '',
direction TEXT NOT NULL DEFAULT '', -- buy / sell
direction_code INTEGER,
opt_name TEXT NOT NULL DEFAULT '',
status INTEGER,
order_volume REAL NOT NULL DEFAULT 0,
traded_volume REAL NOT NULL DEFAULT 0,
limit_price REAL NOT NULL DEFAULT 0,
traded_price REAL NOT NULL DEFAULT 0,
amount REAL NOT NULL DEFAULT 0,
insert_date TEXT NOT NULL DEFAULT '',
insert_time TEXT NOT NULL DEFAULT '',
insert_ts INTEGER NOT NULL, -- 派生:insert_date+insert_time 合成毫秒时间戳(join 持仓窗口用)
cancel_info TEXT NOT NULL DEFAULT '',
error_msg TEXT NOT NULL DEFAULT '',
fetched_at INTEGER NOT NULL -- 同步时间戳
);
-- 交易成交(trade_id 唯一,order_id 外键关联委托;零冗余 strategy_id/holding_id
CREATE TABLE IF NOT EXISTS trade_fills (
trade_id TEXT PRIMARY KEY, -- m_strTradeID
order_id TEXT NOT NULL, -- → trade_orders.order_id
trade_date TEXT NOT NULL,
code TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
exchange TEXT NOT NULL DEFAULT '',
direction TEXT NOT NULL DEFAULT '',
direction_code INTEGER,
opt_name TEXT NOT NULL DEFAULT '',
price REAL NOT NULL DEFAULT 0,
volume REAL NOT NULL DEFAULT 0,
amount REAL NOT NULL DEFAULT 0,
commission_rate_wan REAL NOT NULL DEFAULT 0,
trade_time TEXT NOT NULL DEFAULT '',
fetched_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_trade_orders_date ON trade_orders (trade_date);
CREATE INDEX IF NOT EXISTS idx_trade_orders_ts ON trade_orders (insert_ts);
CREATE INDEX IF NOT EXISTS idx_trade_fills_order ON trade_fills (order_id);
CREATE INDEX IF NOT EXISTS idx_trade_fills_date ON trade_fills (trade_date);
```
**策略归属推导(查询期,FK 链)**
- 委托归属:`trade_orders JOIN strategy_holdings ON o.code=h.code AND h.created_at <= o.insert_ts AND (h.closed_at IS NULL OR o.insert_ts < h.closed_at)`
- 一码多策略消歧:命中多笔持仓时取 shares 最大者(确定性规则);
- 成交归属 = 所属委托归属(fill → order → holding → strategy);
- 无命中 → 未关联(strategy_id 返回 null)。
## 架构设计
```
SqliteStore(改)
├─ init() 建表:+trade_orders/trade_fills + 索引
├─ upsertOrders(orders) / upsertFills(fills) # UPSERT 幂等
├─ getOrderHistory({start,end,code,strategyId,direction}) # 委托 + 策略归属推导
├─ getFillHistory({start,end,code,strategyId,direction}) # 成交(按委托归属 join)
└─ close()
DataStore(改)
└─ 委托交易记录方法(upsertTradeOrders/upsertTradeFills/queryTradeHistory
TradeSync(新增)
├─ start() : 启动预热同步一次 + 60s 定时
├─ syncNow() : 拉今日 orders+trades → upsertOrders/upsertFills(幂等)
└─ stop() : 清理定时器
api/trades.js(改)
└─ trades/history → storage.queryTradeHistory(时间段/code/策略/方向过滤)
index.js(改)
└─ 装配 TradeSyncdispose 停止)+ storage 注入 api runtime
client/views/TradeRecordsTab.jsx(改)
├─ 策略过滤下拉(全部/各策略/未关联)——今日实时过滤前端、历史走服务端过滤
└─ 历史范围:调 trades/history 查本地库(不再占位)
```
## 实现步骤
1. **文档骨架**PLAN-008 + 迭代 07(本步);
2. **设计约束**:数据存储设计.md §10 + 技术方案约束新增条目;
3. **SqliteStore**:建表 + upsertOrders/upsertFills + getOrderHistory/getFillHistory(策略归属 join);
4. **DataStore**:委托交易记录方法;
5. **TradeSync**:定时同步(60s + 启动预热 + UPSERT 幂等);
6. **api/trades.js**trades/history 端点;
7. **index.js**:装配 TradeSync + storage 注入 runtime
8. **前端**TradeRecordsTab 策略过滤 + 历史本地展示;
9. **构建测试**pnpm run build + typecheck + 独立数据目录回归(技术约束-011);
10. **验收 + 复盘**
## 涉及设计约束
| 约束 | 内容 |
|---|---|
| 技术约束-012 | 数据存储遵循数据存储设计.md(本迭代增补 §10 交易记录表设计) |
| 技术约束-011 | 测试/回归脚本禁止在真实数据上写操作:ODL_TEST_DATA_DIR 独立数据目录 |
| 技术约束-001/003/004/010 | 数据源抽象 / 直连 REST / webServer 自开路由 / 服务端中转+前端轮询(沿用) |
| 产品约束-007 | 方向语义红买绿卖(沿用 R-007) |
@@ -0,0 +1,61 @@
# 迭代复盘:07-交易记录本地存储(SQLite)+ 策略关联
> 复盘日期:2026-09-01 | 迭代状态:**已完成(验收通过)**
> 关联需求:R-009(交易记录本地存储 + 策略关联,已定稿)
> 关联计划:PLAN-008(计划-交易记录本地存储SQLite与策略关联)
## 结果
迭代 07 达成:QMT 当日交易数据(委托 + 成交)本地持久化到 SQLitetrade_orders + trade_fills 两表,跨日积累成历史库),按外键链(成交→委托→策略→holding)与策略体系关联,支持按策略过滤 / 复盘交易;交易记录 tab 历史范围从「接口开发中」占位切换为本地历史查询。
## 过程事实
1. **需求定稿(R-009**:老师提出 → AI 登记(讨论中)提出 Q1-Q8 → 老师修正关联方式(外键链 + 两表零冗余)→ 确认定稿进入迭代 07;
2. **表结构**trade_orders(委托主行,order_id 主键 + insert_ts 派生时间列)+ trade_fills(成交明细,trade_id 主键、order_id 关联委托)两表;**零冗余 strategy_id/holding_id**Q3 老师定稿);
3. **策略归属推导(FK 链)**:查询期委托时间(insert_tsjoin strategy_holdings 生命周期窗口(created_at ≤ t < closed_at)推导;一码多策略取份额最大;未命中=未关联;attachStrategyAttribution 供今日实时委托附加归属;
4. **TradeSync 同步**:启动预热 + 60s 定时拉当日 orders+trades → UPSERT 落库(幂等,状态覆盖更新);前端今日轮询命中 orders 端点时写穿(机会式);
5. **本地历史查询**api/trades.js 新增 trades/history 端点(时间段/code/策略/方向过滤,策略过滤走 FK 链 join);
6. **前端**TradeRecordsTab 加策略过滤下拉(全部/各策略/未关联)+ 历史范围查本地库(不再占位);
7. **验证**:typecheck 通过、构建成功、独立数据目录回归测试 17/17 通过 + TradeSync 集成测试 5/5 通过(幂等/归属推导/过滤/写穿)。
## 经验教训(复盘沉淀)
### 1. 时间基准必须一致(本次踩坑)
- **教训**insert_ts 合成先用 Date.UTC(),而 strategy_holdings.created_at 是本地 Date.now() —— 时区差导致持仓窗口匹配失败(回归测试 6 项失败);
- **沉淀**:同一库内时间戳必须同一基准(本地时间);跨模块时间比较前先核对基准。
### 2. 测试数据要构造真实时间窗
- 第一次归属推导测试失败是因为用「当前时间」建仓(23:32)而委托是 09:30 —— 窗口本就不覆盖,代码是对的、测试数据不对;
- **沉淀**:生命周期窗口类测试必须用 SQL 精确控制 created_at/closed_at,模拟真实时间关系。
### 3. 大改组件优先整体重写(前端)
- 迭代中对 TradeRecordsTab 做多次定点替换时部分替换未生效,产生中间态(引用未定义组件);
- **沉淀**:结构性大改(新增过滤/分支重构)直接整文件重写更稳,避免局部替换残留。
### 4. QMT 委托/成交 code 无后缀 + 无独立交易日字段(真实数据发现)
- **发现**:重启后启动预热同步的真实委托/成交,code 为无后缀 `001330`(持仓体系是 `001330.SZ`),且委托无 `m_strTradeDate`(交易日 = `m_strInsertDate`)—— 直接导致 FK 链 join 匹配不上(博纳影业被判未关联)+ 历史按时间段过滤漏委托;
- **修复**QmtBridgeRestDataSource 加 `normalizeInstrumentCode`(6 位数字 + 交易所后缀;SH/SZ/BJ;无交易所按首位推断 6/9→SH、0/3→SZ;已带后缀保留)+ mapOrder 补 tradeDateinsertDate 兜底)+ mapTrade code 归一化;
- **沉淀**:QMT 各接口的证券代码格式不一致(委托/成交无后缀、持仓带后缀),语义化映射层必须统一归一化;交易「日」概念在委托接口 = insertDate(无独立 tradeDate 字段)。
### 5. 迁移持仓 created_at = 迁移时间戳 → FK 链窗口失真(真实数据发现 + 方案 A 修正)
- **发现**:迁移自 JSON 的持仓 created_at 全部是迁移时刻时间戳(2026-09-01 17:36),晚于当日真实交易时间 → 当日委托 join 窗口不匹配 → 全部判「未关联」;
- **决策(方案 A2026-09-01 老师确认)**:当前持仓(closed_at IS NULL)只做同 code 匹配(不要求 created_at ≤ 委托时间,现在持有=当日交易可归);已清仓(closed_at 非空)才按时间窗口(created_at ≤ t < closed_at)判断;
- **实现**_resolveStrategyAttribution 窗口条件分支;回归测试覆盖迁移时间戳场景(17/17 通过);
- **沉淀**:迁移数据的 created_at 语义 = 迁移时间而非真实建仓时间,时间窗口类推导必须考虑该失真;当前持仓用「存在即归属」更贴合业务语义。
### 6. 归属判定改「手动设置」(R-009 二次定稿,老师拍板)
- **发现**:算法推导(时间窗 + 份额最大)无法区分一票多策略(300057.SZ 同时分属 grid-supermarket 与 manual-t 各 1000 股,明天有成交不知道该归谁);
- **决策(老师二次定稿 Q1-Q4)**:归属由用户在**交易记录 tab 手动设置**(全手动选、可随时改、以最终为准);trade_orders 冗余存 strategy_id + holding_id**UPSERT 不覆盖归属列**(手动指定为插件逻辑);
- **实现**trade_orders 加 strategy_id/holding_id 列(存量库 ALTER 迁移,插件启动时执行,只读打开容忍);setOrderAttribution + getAttributionCandidates(候选 = 该 code 当前持仓策略)+ api 两端点(orders/set-attribution、orders/attribution-candidates);前端 TradeRecordsTab 归属列下拉(候选含策略名 + 份额);
- **验证**:37 项测试通过(核心 14 含 UPSERT 不覆盖归属/归属可改/候选列表/持久化过滤);
- **沉淀**:算法只能给候选,归属是人的决定(符合「人机合一」目标-007);手工指定的数据不能被自动同步覆盖——UPSERT 语义要区分「系统字段」与「用户字段」。
## 遗留/后续
1. **QMT Bridge 历史接口**:老师完善后,今日实时可扩展为历史接口查询(本地库仍为兜底/积累);
2. **手动修正策略关联入口**:本期未做(自动推导 + 未关联兜底已满足复盘),后续按需;
3. **导出/清理**:本地历史库持续积累,导出/清理管理界面后续迭代;
4. **盘中行情写 SQLite 验证**(迭代 06 遗留):开盘后确认行情防抖写回 store.dbupdated_at / mtime)。
@@ -0,0 +1,28 @@
# 迭代目标:07-交易记录本地存储(SQLite)+ 策略关联
> 迭代编号:07 | 创建:2026-09-01 状态:进行中
> 依据计划:PLAN-008 需求:R-009(已定稿,2026-09-01
## 目标描述
将 QMT 当日交易数据(委托 + 成交)本地持久化到 SQLitetrade_orders + trade_fills 两表,跨日积累成历史库),并按外键链(成交→委托→策略→holding)与插件策略体系关联,支持按策略过滤 / 复盘交易;同时解决 R-007 历史范围「接口开发中」占位问题(本地积累后历史可查)。
## 目标分解
1. **两表落地**trade_orders(委托主行)+ trade_fills(成交明细),两表零冗余 strategy_id/holding_id,委托表含派生时间列 insert_ts(join 持仓窗口用);
2. **定时同步**:服务端 TradeSync(启动预热 + 60s 定时 + UPSERT 幂等);
3. **外键链关联**:策略/持仓归属 = 委托时间 join strategy_holdings 生命周期窗口推导(一码多策略取份额最大,未命中=未关联);
4. **本地历史查询**trades/history 端点(时间段/code/策略/方向过滤);
5. **前端**:策略过滤下拉 + 历史范围查本地库。
## 讨论过程
- 2026-09-01 老师提出需求(在 SQLite 添加交易记录表,存储与策略关联的交易记录);
- 2026-09-01 AI 登记 R-009(讨论中),提出 Q1-Q8 技术建议(两表结构/同步机制/策略关联/历史查询/积累范围/UI/状态更新/文档约束);
- 2026-09-01 老师修正(Q3):关联链 = 成交→委托→策略→holding,两表零冗余,查询期按持仓生命周期窗口推导;
- 2026-09-01 老师确认 Q1-Q8 定稿,R-009 转已定稿,进入本迭代。
## 对老师的配合需求
- 无阻塞依赖;QMT Bridge 历史接口未就绪不影响本迭代(本地历史库 = 插件运行期逐日积累);
- 验收时可提供当日真实交易数据做端到端验证(沿用 R-007 模式)。
@@ -0,0 +1,26 @@
# 验收标准:07-交易记录本地存储(SQLite)+ 策略关联
## 验收标准线
1. **两表落地**store.db 存在 trade_ordersorder_id 主键 + insert_ts 派生列)+ trade_fillstrade_id 主键、order_id 关联委托)两表;**两表均无 strategy_id / holding_id 列**(零冗余,Q3 老师定稿);
2. **同步落库**:启动预热 + 60s 定时同步今日 orders+trades 入库;UPSERT 幂等(重复同步不产生重复行、已存在委托状态/成交量覆盖更新);
3. **策略归属推导(FK 链)**:查询时委托时间 join strategy_holdings 生命周期窗口(created_at ≤ t < closed_at)得到策略/持仓归属;一码多策略取份额最大;无命中=未关联;历史委托归属稳定(清仓/删策略转历史不删行);
4. **本地历史查询**trades/history 端点按 { start, end, code, strategyId, direction } 过滤返回 { orders, fills }(策略过滤走 FK 链 join);
5. **前端**:交易记录 tab 加策略过滤下拉(全部/各策略/未关联);历史范围展示本地库数据(不再「接口开发中」占位);今日仍走 QMT 实时 + 写穿本地;
6. **不回归**:今日实时展示(单表合并/费用/排序/展开)与持仓/策略/行情/连接配置功能正常;
7. **测试隔离**:回归测试在独立数据目录(ODL_TEST_DATA_DIR)执行,真实数据目录无写操作(技术约束-011)。
## 验收方法
- 独立数据目录(ODL_TEST_DATA_DIR)构造今日委托/成交样例 → 启动插件 → 确认同步落库、UPSERT 幂等(重复同步行数不变、状态覆盖);
- 构造持仓生命周期样例(当前持仓 + 已清仓)→ 确认策略归属推导正确(时间窗口命中、一码多策略取份额最大、未关联兜底);
- API 实测:trades/history 按时间段/code/策略/方向过滤返回正确;
- 前端:策略过滤下拉生效(今日实时 + 历史本地);历史范围展示本地数据;
- 回归:今日实时展示与现有功能正常;
- 手动核对真实数据目录:store.db 中 trade_orders/trade_fills 持续积累,无 JSON 文件新生。
## 验收目标
- 交易记录本地持久化闭环:当日实时 + 跨日历史本地可查;
- 外键链策略关联正确:按策略过滤 / 复盘交易就绪(R-007 Q10 落地);
- 设计约束同步落地:数据存储设计.md §10 + 技术方案约束新增条目。
@@ -0,0 +1,71 @@
# 技术实现方案:08-策略持仓行展开关联交易记录(Holding → 交易汇总)
> 依据:PLAN-009 需求:R-010(Q1-Q3 定稿)| 设计约束:技术约束-012、技术约束-011(测试隔离)
## 技术选型
- 复用 R-009 的 holding_id 关联(trade_orders.holding_id);
- 服务端:strategy-positions 附加 holding_id + trades/by-holding 端点;
- 前端:StrategyTab 持仓行展开(懒加载内嵌汇总表)。
## 数据流
```
策略持仓 tab
load() → strategy-positions(每行含 holding_id
用户点击持仓行 → 展开 → trades/by-holding?holdingId=13 → 委托汇总列表
懒加载:不展开不请求
```
## 实现细节
### 1. PositionManager.getStrategyPositions 附加 holding_id
```js
async getStrategyPositions(strategyId) {
const [positions, dataset, holdings] = await Promise.all([
this.getAllPositions(),
this.storage.getDataset(strategyId),
this.storage.getCurrentHoldings(strategyId), // 含 holding_id
]);
const shareMap = new Map(dataset.map(d => [d.code, d.shares]));
const holdingMap = new Map(holdings.map(h => [h.code, h.holdingId]));
return positions.map(p => {
const shares = shareMap.get(p.code) ?? 0;
return shares > 0 ? { ...p, shares, holdingId: holdingMap.get(p.code) ?? null } : null;
}).filter(Boolean);
}
```
### 2. SqliteStore.getOrdersByHolding
```js
getOrdersByHolding(holdingId) {
this.init();
return this.db.prepare(
'SELECT * FROM trade_orders WHERE holding_id=? ORDER BY insert_ts DESC'
).all(holdingId).map(r => this._mapOrderRow(r));
}
```
### 3. api/trades.jstrades/by-holding 端点
```js
case 'trades/by-holding':
return await storage.getTradeOrdersByHolding(args.holdingId);
```
### 4. StrategyTab 展开 UI
- 持仓行加展开箭头(类似交易记录 tab);
- 展开时调 trades/by-holding,内嵌小表格显示委托汇总:
时间 / 方向 / 状态 / 委托量 / 成交量 / 均价 / 金额 / 费用;
- 只显示委托汇总(ORDER_STATUS 中文映射复用);
- 懒加载:展开时才请求,折叠清空。
## 涉及设计约束
| 约束 | 内容 |
|---|---|
| 技术约束-012 | 数据存储遵循数据存储设计.md(复用 trade_orders.holding_id |
| 技术约束-011 | 测试用独立数据目录(ODL_TEST_DATA_DIR |
@@ -0,0 +1,38 @@
# 迭代复盘:08-策略持仓行展开关联交易记录(Holding → 交易汇总)
> 复盘日期:2026-09-02 | 迭代状态:**已完成(老师确认)**
> 关联需求:R-010(策略持仓行展开关联交易记录,已定稿)
> 关联计划:PLAN-009(计划-策略持仓行展开关联交易记录)
## 结果
迭代 08 达成:策略持仓 tab 每个持仓行(Holding)可展开,展开显示与该 holding 关联的交易记录(委托汇总,不分笔成交);持仓行新增成本价(avgPrice)+ 最后一笔成交价(lastTradePrice)两列;神之一手全部 tab 激活时隐藏 AI 对话输入框(纯 CSS)。实现「持仓 ↔ 交易」双向追溯。
## 过程事实
1. **需求定稿(R-010**:老师提出持仓行展开看关联交易(只要委托汇总)→ AI 登记 Q1-Q3(附加 holding_id / by-holding 端点 / 展开 UI)→ 老师确认 → 补充 Q4(成本价 + 最后一笔成交价,取最新有成交的 tradedPrice,无则默认成本价);
2. **服务端**strategy-positions 附加 holding_idPositionManager 查 strategy_holdings+ 计算 avgPrice/lastTradePrice(查该 holding 关联委托,取最新有成交的 tradedPrice);SqliteStore.getOrdersByHolding + DataStore 委托;api trades/by-holding 端点;
3. **前端**StrategyTab 持仓行展开(箭头 + 懒加载 + 内嵌委托汇总表,含交易日/时间列);成本价/最后一笔成交价两列;
4. **隐藏输入框**:借鉴 dsh-context 插件的纯 CSS 方案(:has(.odl-root) 命中时隐藏 composer)——神之一手 tab 激活时隐藏输入框,chat/其他 tab 正常显示;
5. **验证**:回归测试(by-holding 查询 6 项 + lastTradePrice 计算 4 项)通过、真实数据验证(001330.SZ holding 13 → 博纳影业卖出委托;成本价 6.84 / 最后成交 6.00)、构建 + typecheck 通过。
## 经验教训(复盘沉淀)
### 1. 隐藏宿主 UI 优先借鉴成熟插件方案
- 曾深挖 DSH composer chain / selector 机制(复杂度高、依赖内部 store),后经老师提示参考 dsh-context 插件——它用一行纯 CSS(:has() 选择器)实现「特定 tab 激活时隐藏输入框」,零宿主改动、零风险;
- **沉淀**:改宿主 UI 前先看同类插件怎么做的;纯 CSS :has() 是最优雅的 view 条件渲染方案。
### 2. 前端声明顺序 TDZ(本次多次踩坑)
- 迭代 07/08 多次遇到「Cannot access X before initialization」(TDZ):useEffect 依赖数组/回调在渲染时求值,引用了后声明的 const;
- **沉淀**React 组件内所有 useCallback/useEffect 的依赖引用必须在其声明之后;新增代码时严格核对声明顺序,构建后浏览器验证。
### 3. 服务端 vs 客户端生效机制
- 服务端改动(PositionManager/API/SqliteStore)需重启 DSH 生效;客户端 bundle 刷新即载(rev 变化自动同步);
- **沉淀**:改完先确认服务端端点/响应是新行为,再让老师重启。
## 遗留/后续
1. **持仓展开的交易汇总**:当前只显示该 holding 关联的委托汇总;后续可按 holding 聚合复盘(目标-006);
2. **关注列表 tab**:仍为占位(PlaceholderTab),后续迭代实现;
3. **全部持仓 tab**:未做展开(老师只要求策略持仓 tab);如需可复用同样模式;
4. **迭代 07 遗留**:QMT Bridge 历史接口(老师完善后接入)、盘中行情写 SQLite 验证(开盘后确认)。
@@ -0,0 +1,24 @@
# 迭代目标:08-策略持仓行展开关联交易记录(Holding → 交易汇总)
> 迭代编号:08 | 创建:2026-09-02 状态:进行中
> 依据计划:PLAN-009 需求:R-010(已定稿,2026-09-02
## 目标描述
策略持仓 tab 每个持仓行(Holding)可展开,展开显示与该 holding 关联的交易记录(仅委托汇总,不分笔成交),实现「持仓 ↔ 交易」双向追溯(R-009 反向)。
## 目标分解
1. **strategy-positions 附加 holding_id**:持仓行带出关联锚点;
2. **trades/by-holding 端点**:按 holding_id 查委托汇总;
3. **持仓行展开 UI**:展开显示委托汇总表(懒加载)。
## 讨论过程
- 2026-09-02 老师提出需求(持仓行展开看关联交易,只要委托汇总);
- 2026-09-02 AI 登记 R-010(讨论中),提出 Q1-Q3(附加 holding_id / by-holding 端点 / 展开 UI);
- 2026-09-02 老师确认 Q1-Q3 定稿,进入本迭代。
## 对老师的配合需求
- 无阻塞依赖;验收时用真实持仓(如 001330.SZ holding 13)验证展开效果。
@@ -0,0 +1,23 @@
# 验收标准:08-策略持仓行展开关联交易记录(Holding → 交易汇总)
## 验收标准线
1. **strategy-positions 附加 holding_id**:每个持仓行含 holding_id(同策略同 code 当前持仓);
2. **trades/by-holding 端点**:按 holding_id 返回该 holding 的委托汇总(时间/方向/状态/量/价/金额/费用;无成交委托也显示);
3. **持仓行展开**:策略持仓 tab 每行可展开(箭头指示),展开显示委托汇总表;
4. **仅汇总不分笔**:展开内容只显示委托汇总,无分笔成交明细;
5. **懒加载**:不展开不请求 trades/by-holding
6. **不回归**:策略持仓份额操作(添加/移出/全部移入)、交易记录 tab、行情等现有功能正常;
7. **测试隔离**:回归测试在独立数据目录执行(技术约束-011)。
## 验收方法
- 独立数据目录构造持仓 + 委托(holding_id 关联)→ 启动 → 验证 strategy-positions 含 holding_id、trades/by-holding 返回正确;
- 真实数据:网格超市 tab 展开 001330.SZholding 13)→ 看到博纳影业卖出委托汇总;
- 前端:展开/折叠、懒加载、汇总表列正确;
- 回归:份额操作、交易记录、行情不回归。
## 验收目标
- 持仓 ↔ 交易双向追溯闭环(R-009 交易→持仓 + 本迭代持仓→交易);
- 为按 holding 复盘交易铺路(目标-006)。
+146
View File
@@ -0,0 +1,146 @@
# R-009 交易记录本地存储(SQLite)+ 策略关联 · 已完成
> 归档日期:2026-09-01 需求状态:**已完成**
> 原索引:docs/05-需求池/需求池索引.md(主索引保留 R-009 条目,指向本归档)
> 实现迭代:07-交易记录本地存储SQLite与策略关联(验收通过,迭代复盘见 docs/04-迭代记录/07-交易记录本地存储SQLite与策略关联/迭代复盘.md)
> 关联计划:PLAN-008docs/02-计划/计划-交易记录本地存储SQLite与策略关联.md)
> 讨论记录:Q1-Q8 定稿(2026-09-01),Q3 关联方式修正(外键链 + 两表零冗余)
---
# R-009 交易记录本地存储(SQLite)+ 策略关联
> 状态:**已定稿**(2026-09-01,老师确认)| 登记日期:2026-09-01
> 来源:老师指令(2026-09-01
> 优先级:P1
> 关联:R-007(交易记录接入)、R-008SQLite 存储)、docs/03-设计约束/数据存储设计.md §9(holding_id 关联锚点预留)、迭代 06 复盘遗留项 2
## 需求描述
在 SQLite 中新增**交易记录表**(委托 + 成交),将 QMT Bridge 当日交易数据**本地持久化**(跨日积累,形成本地历史库),并建立与插件**策略体系**的关联,支持**按策略过滤 / 复盘**交易。**归属由用户在交易记录 tab 手动设置(冗余存 strategy_id + holding_id),UPSERT 不覆盖归属列。**
- 承接 R-007 的 Q5(本地持久化,本期不做)与 Q10(按策略过滤,后续迭代);
- 落实迭代 06 复盘遗留项 2:「strategy_holdings.holding_id 已就绪,可作为 trades 表关联锚点(R-007 实现时建 trades 表)」;
- 顺带解决 R-007 历史范围「接口开发中」占位问题(本地积累后历史可查)。
## 现状(代码审查 2026-09-01
| 项 | 现状 |
|---|---|
| 交易展示 | TradeRecordsTab 接 QMT 当日委托(/trade/orders+ 当日成交(/trade/trades),单表合并展示,**仅实时、不落盘** |
| 历史范围 | 非今日范围前端占位「历史数据接口开发中」(QMT Bridge 无历史接口,get_trade_detail_data 读客户端缓存仅当日) |
| SQLite | 迭代 06 落地:strategy_holdings(持仓生命周期:holding_id 自增 + created_at/closed_at+ market_quotes_cache 两表;holding_id 预留为交易关联锚点 |
| 策略 | 策略=标签(settings),持仓份额存 strategy_holdings;同码可跨多个策略(部分唯一索引仅约束同策略同码一笔当前持仓) |
## 待讨论点(Q1-Q8,AI 建议见各条)
### Q1 存储粒度与表结构(AI 建议:委托 + 成交两表)
```sql
-- 交易委托(委托主行;order_id 唯一,UPSERT 幂等;零冗余:不存 strategy_id/holding_id
CREATE TABLE IF NOT EXISTS trade_orders (
order_id TEXT PRIMARY KEY, -- m_strOrderSysID
trade_date TEXT NOT NULL, -- 交易日 YYYYMMDDm_strInsertDate
code TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
exchange TEXT NOT NULL DEFAULT '',
direction TEXT NOT NULL DEFAULT '', -- buy / sell
direction_code INTEGER,
opt_name TEXT NOT NULL DEFAULT '',
status INTEGER,
order_volume REAL NOT NULL DEFAULT 0,
traded_volume REAL NOT NULL DEFAULT 0,
limit_price REAL NOT NULL DEFAULT 0,
traded_price REAL NOT NULL DEFAULT 0,
amount REAL NOT NULL DEFAULT 0,
insert_date TEXT NOT NULL DEFAULT '',
insert_time TEXT NOT NULL DEFAULT '',
insert_ts INTEGER NOT NULL, -- 派生列:insert_date+insert_time 合成毫秒时间戳(join 持仓窗口用)
cancel_info TEXT NOT NULL DEFAULT '',
error_msg TEXT NOT NULL DEFAULT '',
fetched_at INTEGER NOT NULL -- 同步时间戳
);
-- 交易成交(成交明细;trade_id 唯一,order_id 外键关联委托;零冗余:不存 strategy_id/holding_id
CREATE TABLE IF NOT EXISTS trade_fills (
trade_id TEXT PRIMARY KEY, -- m_strTradeID
order_id TEXT NOT NULL, -- → trade_orders.order_id
trade_date TEXT NOT NULL,
code TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
exchange TEXT NOT NULL DEFAULT '',
direction TEXT NOT NULL DEFAULT '',
direction_code INTEGER,
opt_name TEXT NOT NULL DEFAULT '',
price REAL NOT NULL DEFAULT 0,
volume REAL NOT NULL DEFAULT 0,
amount REAL NOT NULL DEFAULT 0,
commission_rate_wan REAL NOT NULL DEFAULT 0,
trade_time TEXT NOT NULL DEFAULT '',
fetched_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_trade_orders_date ON trade_orders (trade_date);
CREATE INDEX IF NOT EXISTS idx_trade_orders_ts ON trade_orders (insert_ts);
CREATE INDEX IF NOT EXISTS idx_trade_fills_order ON trade_fills (order_id);
CREATE INDEX IF NOT EXISTS idx_trade_fills_date ON trade_fills (trade_date);
```
- 理由:委托 1:N 成交,两表忠实 R-007「单表合并」模型(前端 mergeOrdersAndTrades 可复用);两表能保留无成交委托(待报/已撤/废单),复盘完整;避免 JSON 压扁。
- **2026-09-01 老师修正:两表都不冗余 strategy_id / holding_id**(外键链推导,见 Q3);trade_orders 增加派生列 insert_tsinsert_date+insert_time 合成,join 持仓窗口用)。
### Q2 存储时机 / 同步机制(AI 建议:服务端定时同步 + API 写穿)
- 服务端 TradeSync 模块定时(建议 60s)拉取今日 orders+trades → UPSERT 落库(幂等);
- 前端今日轮询命中 orders/trades 端点时同步写穿(机会式);
- 插件启动时同步一次(预热今日数据);
- 理由:不依赖前端开 tab 也持续积累;UPSERT 幂等不重复。
### Q3 策略关联方式(2026-09-01 二次修正定稿:手动设置归属)
**最终定稿(老师拍板)**:关联链 = 成交(fill) → 委托(order) → 策略(strategy) → holding(持仓 ID),但**归属由用户手动设置,不靠算法推导**。
- **信息源 = 人**:交易记录 tab 加载每日交易记录,用户手动设置每笔委托的归属(下拉候选 = 该 code 的当前持仓策略 + 未关联);符合「人机合一」目标-007(AI 给候选,人做最终决定);
- **trade_orders 冗余存 strategy_id + holding_id**Q1 老师确认:加冗余字段,便于过滤/展示/复盘);
- **UPSERT 不覆盖归属列**(Q2 老师确认):归属是手动指定的、是插件逻辑,TradeSync 定时同步只更新 QMT 原始字段,保留已有归属;
- **全手动选**(Q3 老师确认):不自动预填候选值,用户明确选择后才落库;未设置前归属为空(未关联);
- **可修改**(Q4 老师确认):已设置的归属可随时改,以最终修改为准;
- 成交明细跟随委托归属(不单独设置);
- 归属推导逻辑(方案 A 时间窗 + 份额最大)**降级为候选列表来源**:一票一策略时给出该策略候选、一票多策略时给出全部候选让用户选、无持仓时仅「未关联」。
### Q4 历史查询能力(AI 建议:新增本地查询端点,策略过滤走 FK 链 join)
- 新增端点(如 trades/history):按 { start, end, code, strategyId, direction } 查本地 SQLite,返回 { orders, fills }
- strategyId 过滤 = trade_orders JOIN strategy_holdings(生命周期窗口含委托时间,取份额最大持仓)筛选 strategy_id
- 前端历史范围从「占位」切换为查本地库(数据=插件运行期间逐日积累;插件停运期间无数据,诚实记录不可补);
- 今日仍走 QMT 实时(更新鲜),同步写穿本地;QMT 不可用时可用本地兜底(可选)。
### Q5 同步范围与数据积累(AI 建议)
- 只同步「当日」数据(QMT 能力所限,无法回溯补历史);
- 数据逐日积累 = 本地历史库;不做自动清理(复盘需要历史),导出/清理后续再说。
### Q6 UI 扩展(AI 建议:策略过滤)
- TradeRecordsTab 加策略过滤下拉(全部 / 各策略 / 未关联);
- 历史范围展示本地数据(不再占位);按策略过滤对今日(实时)与历史(本地)均生效。
### Q7 委托状态更新(AI 建议:UPSERT 覆盖)
- 同一 order_id 再次同步时更新 status / 成交量等(当日委托状态流转:已报→部成→已成);
- 历史日状态 = 最后一次同步快照(当日收市时基本已终态,可接受)。
### Q8 文档与约束(后续执行)
- 数据存储设计.md 增补交易记录表设计(§10);技术方案约束新增条目;产品约束按需(策略过滤 UI)。
## 边界(初拟,待讨论收敛)
**做**:两表落地(trade_orders + trade_fills,零冗余 strategy_id/holding_id+ 定时同步落库(幂等)+ 本地历史查询端点(策略过滤 = 委托时间 join 持仓生命周期窗口)+ tab 策略过滤 + 历史范围本地展示。
**不做**(本期):手动修正策略关联入口(后续);导出/清理管理界面(后续);QMT 历史接口接入(老师完善后再接);下单/撤单操作(目标-007 另议)。
## 讨论与修正记录
- 2026-09-01 老师提出需求,AI 登记(讨论中),提出 Q1-Q8 建议待确认。
- 2026-09-01 老师反馈(Q3 关联方式修正):关联链 = 成交 → 委托 → 策略 → holding。成交只与委托相关(order_id 外键即可);委托与策略相关;策略对应具体 holding_id。**两表均不冗余 strategy_id + holding_id** —— 采用零冗余 + 查询时按持仓生命周期时间窗 join 推导(Q3 定稿)。
- 2026-09-01 老师确认 Q1-Q8 全部定稿:两表零冗余 + 外键链关联(Q3)+ 定时同步 UPSERT + 本地历史查询 + tab 策略过滤 + 只同步当日;**R-009 转已定稿,进入迭代 07 实施**。
- 2026-09-01 老师二次定稿(Q1-Q4):归属由用户在交易记录 tab **手动设置**——trade_orders 冗余存 strategy_id + holding_idQ1);**UPSERT 不覆盖归属列**(Q2,手动指定为插件逻辑);**全手动选**(Q3,不自动预填候选值);**可随时改、以最终为准**(Q4);算法推导降级为候选列表来源。
+63
View File
@@ -0,0 +1,63 @@
# R-010 策略持仓行展开关联交易记录(Holding → 交易汇总)· 已完成
> 归档日期:2026-09-02 需求状态:**已完成**
> 原索引:docs/05-需求池/需求池索引.md(主索引保留 R-010 条目,指向本归档)
> 实现迭代:08-策略持仓行展开关联交易记录(老师确认,迭代复盘见 docs/04-迭代记录/08-策略持仓行展开关联交易记录/迭代复盘.md)
> 关联计划:PLAN-009docs/02-计划/计划-策略持仓行展开关联交易记录.md)
---
# R-010 策略持仓行展开关联交易记录(Holding → 交易汇总)
> 状态:**已定稿**2026-09-02,老师确认 Q1-Q3)| 登记日期:2026-09-02
> 来源:老师指令(2026-09-02
> 优先级:P1
> 关联:R-009(交易记录本地存储 + holding_id 关联)、迭代 07
## 需求描述
策略持仓 tab(如「网格超市」「手动做T」)中,**每个持仓记录(Holding)行可展开**,展开后显示**与该 holding 关联的交易记录**(仅委托汇总,不分笔成交)。
- 展开效果类似交易记录 tab 的展开效果;
- 展开内容只显示**委托汇总**(一笔委托一行,聚合成交信息),**不显示分笔成交明细**;
- 关联依据 = trade_orders.holding_idR-009 已建立:用户手动设置归属时写入 holding_id)。
## 现状(代码审查 2026-09-02
| 项 | 现状 |
|---|---|
| 策略持仓 tab | StrategyTab.jsx:表格展示策略持仓(代码/名称/现价/策略份额/总持仓/操作),行不可展开 |
| 持仓数据 | strategy-positions 端点返回 QMT 持仓 + shares**不含 holding_id**(需附加) |
| holding 关联 | trade_orders.holding_idR-009 手动归属写入),按 holding_id 可精确查询交易 |
| 交易汇总 | trade_orders 单行即委托汇总(order_volume/traded_volume/amount/费用等),天然是汇总 |
## 待讨论点(Q1-Q3,AI 建议见各条)
### Q1 持仓行如何带出 holding_idAI 建议:strategy-positions 附加 holding_id
- 方案:PositionManager.getStrategyPositions 返回时,查 strategy_holdings 附加 holding_id(同策略同 code 的当前持仓);
- 一票多策略:当前策略下的该 code 只有一个当前持仓(idx_active_holding 约束),可精确匹配;
- 需确认:是否接受服务端附加 holding_id 到 strategy-positions 响应?
### Q2 查询端点(AI 建议:新增 trades/by-holding 端点)
- 新增端点:按 holding_id 查 trade_orders(返回委托汇总列表,含状态/方向/量/价/金额/时间);
- 复用 SqliteStore 查询(WHERE holding_id=? ORDER BY insert_ts DESC);
- 需确认:端点命名与返回结构?
### Q3 前端展开 UI(AI 建议:持仓行展开 + 内嵌汇总表)
- 展开效果:类似交易记录 tab(行点击展开/折叠,箭头指示);
- 展开内容:内嵌小表格显示该 holding 的委托汇总(时间/方向/状态/委托量/成交量/均价/金额/费用);
- 懒加载:展开时才查询 trades/by-holding(不展开不请求);
- 需确认:展示列是否与交易记录 tab 的委托汇总一致(可精简)?
### Q4 成本价 + 最后一笔成交价(2026-09-02 老师补充,已确认)
- **成本价**:持仓数据源已有 avgPrice(QMT 持仓字段,如博纳影业 6.8393),直接展示;
- **最后一笔成交价**
- 该 holding 关联交易记录(trades/by-holding)按时间取**最新一笔有实际成交**tradedVolume > 0)的委托的 tradedPrice(成交价);
- 无关联交易 / 关联委托均未成交 → 默认等于成本价(avgPrice);
- 展示位置:策略持仓 tab 持仓行加「成本价」「最后一笔成交价」两列。
## 定稿记录
- 2026-09-02 老师确认 Q1-Q3 全部定稿:strategy-positions 附加 holding_idQ1+ trades/by-holding 端点(Q2+ 持仓行展开 UI(懒加载,仅委托汇总)(Q3);**R-010 转已定稿,进入迭代 08 实施**。
- 2026-09-02 老师补充 Q4(成本价 + 最后一笔成交价)并确认规则(取最新有成交的 tradedPrice,无成交默认成本价)。
+2
View File
@@ -20,6 +20,8 @@
| R-004 | QMT 连接配置(多配置管理 + 会话头部快捷切换) | 设置页「QMT 连接配置」子 tab:多配置 CRUD(卡片形式)、单选激活(数据源热切换立即生效)、默认标记(启动自动激活)、测试连接(/health 代理)、删除边界处理;会话头部(PTC 模式标签旁)快捷切换 chip。**2026-08-29 定稿(Q1-Q10)并完成实现与验收(7/7 通过),已归档至 已完成/R-004.md**。决策沉淀:产品约束-005/006、技术约束-003(变更)/008/009、UI约束-001/002 | 老师指令(2026-08-28 | P1 | 已定稿 | 2026-08-29 | 03-QMT连接配置 | **已实现(已归档)** | | R-004 | QMT 连接配置(多配置管理 + 会话头部快捷切换) | 设置页「QMT 连接配置」子 tab:多配置 CRUD(卡片形式)、单选激活(数据源热切换立即生效)、默认标记(启动自动激活)、测试连接(/health 代理)、删除边界处理;会话头部(PTC 模式标签旁)快捷切换 chip。**2026-08-29 定稿(Q1-Q10)并完成实现与验收(7/7 通过),已归档至 已完成/R-004.md**。决策沉淀:产品约束-005/006、技术约束-003(变更)/008/009、UI约束-001/002 | 老师指令(2026-08-28 | P1 | 已定稿 | 2026-08-29 | 03-QMT连接配置 | **已实现(已归档)** |
| R-007 | 交易记录接入(订单与成交数据 + 时间段查询) | 交易记录 tab 接入 QMT Bridge 当日委托(/trade/orders)与当日成交(/trade/trades)**单表合并**(委托主行 + 展开成交明细)+ **时间段查询**(今日/本周/本月 + 手动起止)+ 费用计算(佣金费率+最低5元/印花税/过户费,委托级合并计费)。**2026-09-01 定稿并完成(迭代 05 验收通过),已归档至 已完成/R-007.md** | 老师指令(2026-09-01 | P1 | 已定稿 | 2026-09-01 | 05-交易记录接入 | **已实现(已归档)** | | R-007 | 交易记录接入(订单与成交数据 + 时间段查询) | 交易记录 tab 接入 QMT Bridge 当日委托(/trade/orders)与当日成交(/trade/trades)**单表合并**(委托主行 + 展开成交明细)+ **时间段查询**(今日/本周/本月 + 手动起止)+ 费用计算(佣金费率+最低5元/印花税/过户费,委托级合并计费)。**2026-09-01 定稿并完成(迭代 05 验收通过),已归档至 已完成/R-007.md** | 老师指令(2026-09-01 | P1 | 已定稿 | 2026-09-01 | 05-交易记录接入 | **已实现(已归档)** |
| R-008 | 数据存储管理(JSON → SQLite | 数据存储从 JSON data store 升级为 SQLitenode:sqlite):仅存储引擎替换 + strategies/allocation/market_quotes 三表 + 一次性迁移脚本(启动自动迁移)+ JSON 废弃;策略仍存 DSH settings;不建 trades 表(R-007 时再建)。**2026-09-01 定稿(T-008 转正,D1-D8 确认),2026-09-01 完成(迭代 06 验收通过),已归档至 已完成/R-008.md** | 老师指令(2026-09-01T-008 转正) | P1 | 已定稿 | 2026-09-01 | 06-数据存储SQLite | **已实现(已归档)** | | R-008 | 数据存储管理(JSON → SQLite | 数据存储从 JSON data store 升级为 SQLitenode:sqlite):仅存储引擎替换 + strategies/allocation/market_quotes 三表 + 一次性迁移脚本(启动自动迁移)+ JSON 废弃;策略仍存 DSH settings;不建 trades 表(R-007 时再建)。**2026-09-01 定稿(T-008 转正,D1-D8 确认),2026-09-01 完成(迭代 06 验收通过),已归档至 已完成/R-008.md** | 老师指令(2026-09-01T-008 转正) | P1 | 已定稿 | 2026-09-01 | 06-数据存储SQLite | **已实现(已归档)** |
| R-009 | 交易记录本地存储(SQLite+ 策略关联 | 在 SQLite 新增交易记录表(trade_orders + trade_fills,委托/成交两表,**零冗余 strategy_id/holding_id**),QMT 当日交易数据本地持久化(跨日积累成历史库);策略/持仓归属 = 委托时间 join strategy_holdings 生命周期窗口推导(外键链:成交→委托→策略→holding);本地历史查询端点 + 交易记录 tab 策略过滤与历史范围本地展示。**2026-09-01 定稿(Q1-Q8 确认),2026-09-01 完成(迭代 07 验收通过),已归档至 已完成/R-009.md** | 老师指令(2026-09-01 | P1 | 已定稿 | 2026-09-01 | 07-交易记录本地存储SQLite与策略关联 | **已实现(已归档)** |
| R-010 | 策略持仓行展开关联交易记录(Holding → 交易汇总) | 策略持仓 tab 每个持仓行(Holding)可展开,展开显示与该 holding 关联的交易记录(仅委托汇总,不分笔成交);strategy-positions 附加 holding_id + 新增 trades/by-holding 端点 + 持仓行展开 UI + 成本价/最后一笔成交价两列 + 神之一手 tab 隐藏输入框。**2026-09-02 定稿(Q1-Q4 确认),2026-09-02 完成(老师确认),已归档至 已完成/R-010.md** | 老师指令(2026-09-02 | P1 | 已定稿 | 2026-09-02 | 08-策略持仓行展开关联交易记录 | **已实现(已归档)** |
## 渐进明细规划素材 ## 渐进明细规划素材
+65
View File
@@ -0,0 +1,65 @@
/**
* API 层集成测试:handleTrade 各端点(含 settings 依赖的 candidates 端点)
* 独立数据目录,mock dataSource + 真实 storage/settings
*/
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { DataStore } from '../src/component/DataStore.js';
import { handleTrade, TRADE_METHODS } from '../src/api/trades.js';
const dir = mkdtempSync(join(tmpdir(), 'odl-api-test-'));
process.env.ODL_TEST_DATA_DIR = dir;
let pass = 0, fail = 0;
function assert(cond, msg) {
if (cond) { pass++; console.log(' ✅ ' + msg); }
else { fail++; console.log(' ❌ ' + msg); }
}
const storage = new DataStore({ dataDir: dir });
await storage._ensure();
// mock settings(含策略名)
const settings = {
get: () => ({ strategies: [
{ id: 'grid-supermarket', name: '网格超市' },
{ id: 'manual-t', name: '手动做T' },
]}),
};
// mock dataSource
const dataSource = {
async getOrders() { return []; },
async getTrades() { return []; },
async getTradingDates() { return { market: '', dates: [] }; },
};
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 已注册');
assert(TRADE_METHODS.has('trades/history'), '端点 trades/history 已注册');
// 候选端点(含策略名)
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)');
// 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', '归属已持久化');
// 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 端点按策略过滤命中');
storage.close();
rmSync(dir, { recursive: true, force: true });
console.log('\n结果: ' + pass + ' 通过, ' + fail + ' 失败');
process.exit(fail > 0 ? 1 : 0);
+41
View File
@@ -0,0 +1,41 @@
import { QmtBridgeRestDataSource } from '../src/component/QmtBridgeRestDataSource.js';
const ds = new QmtBridgeRestDataSource({ baseUrl: 'http://x' });
let pass = 0, fail = 0;
function assert(cond, msg) {
if (cond) { pass++; console.log(' ✅ ' + msg); }
else { fail++; console.log(' ❌ ' + msg); }
}
// code 归一化
assert(ds.normalizeInstrumentCode('001330', 'SZ') === '001330.SZ', '001330+SZ → 001330.SZ');
assert(ds.normalizeInstrumentCode('600519', 'SH') === '600519.SH', '600519+SH → 600519.SH');
assert(ds.normalizeInstrumentCode('001330.SZ', 'SZ') === '001330.SZ', '已带后缀原样保留');
assert(ds.normalizeInstrumentCode('601117', '') === '601117.SH', '无交易所按首位推断 6→SH');
assert(ds.normalizeInstrumentCode('000001', '') === '000001.SZ', '无交易所按首位推断 0→SZ');
assert(ds.normalizeInstrumentCode('300057', '') === '300057.SZ', '无交易所按首位推断 3→SZ');
// mapOrdertradeDate 兜底 insertDate
const o = ds.mapOrder({
m_strOrderSysID: '645009273', m_strInstrumentID: '001330', m_strInstrumentName: '博纳影业',
m_strExchangeID: 'SZ', m_nOffsetFlag: 49, m_strOptName: '限价卖出', m_nOrderStatus: 56,
m_nVolumeTotalOriginal: 200, m_nVolumeTraded: 200, m_nVolumeTotal: 200,
m_dLimitPrice: 6, m_dTradedPrice: 6, m_dTradeAmount: 1200,
m_strInsertDate: '20260901', m_strInsertTime: '101333',
});
assert(o.code === '001330.SZ', 'mapOrder code 归一化: ' + o.code);
assert(o.tradeDate === '20260901', 'mapOrder tradeDate 兜底 insertDate: ' + o.tradeDate);
assert(o.insertDate === '20260901' && o.insertTime === '101333', 'insertDate/insertTime 保留');
// mapTradecode 归一化 + tradeDate 保留原始
const t = ds.mapTrade({
m_strTradeID: '0103000030812084', m_strOrderSysID: '645009273', m_strInstrumentID: '001330',
m_strInstrumentName: '博纳影业', m_strExchangeID: 'SZ', m_nOffsetFlag: 49, m_strOptName: '限价卖出',
m_dPrice: 6, m_nVolume: 200, m_dTradeAmount: 1200, m_dCommission: 0.852,
m_strTradeDate: '20260901', m_strTradeTime: '101333',
});
assert(t.code === '001330.SZ', 'mapTrade code 归一化: ' + t.code);
assert(t.tradeDate === '20260901', 'mapTrade tradeDate 保留: ' + t.tradeDate);
console.log('\n结果: ' + pass + ' 通过, ' + fail + ' 失败');
process.exit(fail > 0 ? 1 : 0);
+86
View File
@@ -0,0 +1,86 @@
/**
* TradeSync + 手动归属集成验证(迭代 07 v2,独立数据目录)
* mock 数据源:返回今日委托/成交 → TradeSync.syncNow → 落库 → 手动设置归属 → 再同步不覆盖归属
*/
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { DataStore } from '../src/component/DataStore.js';
import { TradeSync } from '../src/component/TradeSync.js';
const dir = mkdtempSync(join(tmpdir(), 'odl-test-r009-sync-v2-'));
process.env.ODL_TEST_DATA_DIR = dir;
let pass = 0, fail = 0;
function assert(cond, msg) {
if (cond) { pass++; console.log(' ✅ ' + msg); }
else { fail++; console.log(' ❌ ' + msg); }
}
const storage = new DataStore({ dataDir: dir });
await storage._ensure();
const mockDataSource = {
async getOrders() {
return [{
orderId: 'O-S1', tradeDate: '20260901', code: '300057.SZ', name: '万顺新材',
exchange: 'SZ', direction: 'sell', directionCode: 49, optName: '限价卖出',
status: 50, orderVolume: 300, tradedVolume: 0, limitPrice: 7, tradedPrice: 0,
amount: 0, insertDate: '20260901', insertTime: '093500',
cancelInfo: '', errorMsg: '',
}];
},
async getTrades() {
return [{
tradeId: 'T-S1', orderId: 'O-S1', tradeDate: '20260901', code: '300057.SZ', name: '万顺新材',
exchange: 'SZ', direction: 'sell', directionCode: 49, optName: '限价卖出',
price: 7, volume: 300, amount: 2100, commissionRateWan: 0.852, tradeTime: '093501',
}];
},
};
// 一票多策略:grid 800 + manual 1000
storage.sqlite.openHolding('grid-supermarket', '300057.SZ', 800);
storage.sqlite.openHolding('manual-t', '300057.SZ', 1000);
const sync = new TradeSync({ runtime: { dataSource: mockDataSource, storage }, logger: console });
sync.mounted = true;
const result = await sync.syncNow();
assert(result && result.orders === 1 && result.fills === 1, 'TradeSync 同步落库(委托1/成交1');
// 候选列表:一票多策略 2 个候选
const cands = await storage.getTradeAttributionCandidates('300057.SZ');
assert(cands.length === 2, '候选 2 个(一票多策略)');
// 用户手动设置归属:manual-t
const mHold = storage.sqlite.db.prepare('SELECT holding_id FROM strategy_holdings WHERE strategy_id=? AND code=? AND closed_at IS NULL').get('manual-t', '300057.SZ');
const ok = await storage.setTradeOrderAttribution('O-S1', 'manual-t', mHold.holding_id);
assert(ok, '手动设置归属 manual-t');
// 再次同步(状态流转 已报→已成)→ 归属保留
mockDataSource.getOrders = async () => [{
orderId: 'O-S1', tradeDate: '20260901', code: '300057.SZ', name: '万顺新材',
exchange: 'SZ', direction: 'sell', directionCode: 49, optName: '限价卖出',
status: 56, orderVolume: 300, tradedVolume: 300, limitPrice: 7, tradedPrice: 7,
amount: 2100, insertDate: '20260901', insertTime: '093500',
cancelInfo: '', errorMsg: '',
}];
await sync.syncNow();
const row = storage.sqlite.db.prepare('SELECT status, strategy_id, holding_id FROM trade_orders WHERE order_id=?').get('O-S1');
assert(row.status === 56, '再次同步状态更新(已成)');
assert(row.strategy_id === 'manual-t' && row.holding_id === mHold.holding_id, '再次同步不覆盖归属(manual-t 保留)');
// 历史查询按归属过滤
const byManual = await storage.getTradeOrderHistory({ start: '20260901', end: '20260901', strategyId: 'manual-t' });
assert(byManual.length === 1 && byManual[0].orderId === 'O-S1', '历史按 manual-t 过滤命中');
const byGrid = await storage.getTradeOrderHistory({ start: '20260901', end: '20260901', strategyId: 'grid-supermarket' });
assert(byGrid.length === 0, '历史按 grid-supermarket 过滤为空');
// 成交按委托归属过滤
const fillsManual = await storage.getTradeFillHistory({ start: '20260901', end: '20260901', strategyId: 'manual-t' });
assert(fillsManual.length === 1 && fillsManual[0].tradeId === 'T-S1', '成交按 manual-t 过滤命中');
storage.close();
rmSync(dir, { recursive: true, force: true });
console.log('\n结果: ' + pass + ' 通过, ' + fail + ' 失败');
process.exit(fail > 0 ? 1 : 0);
+101
View File
@@ -0,0 +1,101 @@
/**
* 迭代 07 回归测试 v2:交易记录本地存储 + 手动归属(R-009 二次定稿)
* 独立数据目录(ODL_TEST_DATA_DIR),不触碰真实数据(技术约束-011)。
*
* 覆盖:
* 1. 建表:trade_orders 含 strategy_id/holding_id 列(冗余,Q1
* 2. UPSERT 幂等 + 不覆盖归属列(Q2)
* 3. 手动设置归属(set-attributionQ4 可改)
* 4. 归属候选列表(该 code 当前持仓策略)
* 5. 历史查询按持久化归属过滤(含未关联)
*/
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { SqliteStore } from '../src/component/SqliteStore.js';
const dir = mkdtempSync(join(tmpdir(), 'odl-test-r009-v2-'));
process.env.ODL_TEST_DATA_DIR = dir;
let pass = 0;
let fail = 0;
function assert(cond, msg) {
if (cond) { pass++; console.log(' ✅ ' + msg); }
else { fail++; console.log(' ❌ ' + msg); }
}
const store = new SqliteStore({ dataDir: dir });
store.init();
// 1. 建表:trade_orders 含归属列
console.log('1. 建表');
const orderCols = store.db.prepare('PRAGMA table_info(trade_orders)').all().map(c => c.name);
assert(orderCols.includes('strategy_id'), 'trade_orders 有 strategy_id 列(冗余 Q1');
assert(orderCols.includes('holding_id'), 'trade_orders 有 holding_id 列(冗余 Q1');
// 2. UPSERT 幂等 + 不覆盖归属列
console.log('2. UPSERT 幂等 + 不覆盖归属');
const order = {
orderId: 'O-001', tradeDate: '20260901', code: '300057.SZ', name: '万顺新材',
exchange: 'SZ', direction: 'sell', directionCode: 49, optName: '限价卖出',
status: 50, orderVolume: 100, tradedVolume: 0, limitPrice: 7, tradedPrice: 0,
amount: 0, insertDate: '20260901', insertTime: '093000',
cancelInfo: '', errorMsg: '',
};
store.upsertOrders([order]);
// 设置归属(用户手动)
store.openHolding('manual-t', '300057.SZ', 1000);
const cands = store.getAttributionCandidates('300057.SZ');
assert(cands.length === 1 && cands[0].strategyId === 'manual-t', '归属候选:当前持仓 manual-t300057.SZ');
const holding = store.db.prepare('SELECT holding_id FROM strategy_holdings WHERE strategy_id=? AND code=? AND closed_at IS NULL').get('manual-t', '300057.SZ');
const ok = store.setOrderAttribution('O-001', 'manual-t', holding.holding_id);
assert(ok, '手动设置归属成功');
// 再次 UPSERT(模拟下次同步,状态流转)→ 归属列保留
store.upsertOrders([{ ...order, status: 56, tradedVolume: 100, tradedPrice: 7, amount: 700 }]);
const row = store.db.prepare('SELECT status, strategy_id, holding_id FROM trade_orders WHERE order_id=?').get('O-001');
assert(row.status === 56, 'UPSERT 覆盖状态(已报→已成)');
assert(row.strategy_id === 'manual-t' && row.holding_id === holding.holding_id, 'UPSERT 不覆盖归属列(strategy_id/holding_id 保留)');
// 3. 手动设置归属可改(Q4
console.log('3. 归属可修改');
store.openHolding('grid-supermarket', '300057.SZ', 800);
const gHold = store.db.prepare('SELECT holding_id FROM strategy_holdings WHERE strategy_id=? AND code=? AND closed_at IS NULL').get('grid-supermarket', '300057.SZ');
store.setOrderAttribution('O-001', 'grid-supermarket', gHold.holding_id);
const row2 = store.db.prepare('SELECT strategy_id, holding_id FROM trade_orders WHERE order_id=?').get('O-001');
assert(row2.strategy_id === 'grid-supermarket', '归属可改为 grid-supermarket');
// 清除归属(改回未关联)
store.setOrderAttribution('O-001', null, null);
const row3 = store.db.prepare('SELECT strategy_id, holding_id FROM trade_orders WHERE order_id=?').get('O-001');
assert(row3.strategy_id === null && row3.holding_id === null, '归属可清除(未关联)');
// 4. 候选列表(一票多策略)
console.log('4. 候选列表(一票多策略)');
const cands2 = store.getAttributionCandidates('300057.SZ');
assert(cands2.length === 2, '一票多策略候选 2 个(grid 800 / manual 1000');
assert(cands2[0].shares === 1000 && cands2[0].strategyId === 'manual-t', '候选按份额降序(manual-t 1000 在前)');
// 5. 历史查询按持久化归属过滤
console.log('5. 历史查询按持久化归属过滤');
store.upsertOrders([{
orderId: 'O-002', tradeDate: '20260901', code: '600519.SH', name: '贵州茅台',
exchange: 'SH', direction: 'buy', directionCode: 48, optName: '限价买入',
status: 56, orderVolume: 100, tradedVolume: 100, limitPrice: 1700, tradedPrice: 1700,
amount: 170000, insertDate: '20260901', insertTime: '100000',
cancelInfo: '', errorMsg: '',
}]);
// O-002 不设归属 → 未关联;O-001 设回 manual-t
store.setOrderAttribution('O-001', 'manual-t', holding.holding_id);
const byManual = store.getOrderHistory({ start: '20260901', end: '20260901', strategyId: 'manual-t' });
assert(byManual.length === 1 && byManual[0].orderId === 'O-001', '按持久化归属 manual-t 过滤命中 O-001');
const byGrid = store.getOrderHistory({ start: '20260901', end: '20260901', strategyId: 'grid-supermarket' });
assert(byGrid.length === 0, '按 grid-supermarket 过滤为空(O-001 已设 manual-t');
const unassigned = store.getOrderHistory({ start: '20260901', end: '20260901', strategyId: '__unassigned__' });
assert(unassigned.length === 1 && unassigned[0].orderId === 'O-002', '未关联过滤命中 O-002(未设置归属)');
const all = store.getOrderHistory({ start: '20260901', end: '20260901' });
assert(all.length === 2 && all.find(o => o.orderId === 'O-001').strategyId === 'manual-t', '查询返回持久化归属字段');
store.close();
rmSync(dir, { recursive: true, force: true });
console.log('\n结果: ' + pass + ' 通过, ' + fail + ' 失败');
process.exit(fail > 0 ? 1 : 0);
+48
View File
@@ -0,0 +1,48 @@
/**
* 真实库归属闭环验证(写操作测试):设置归属 → 过滤 → UPSERT 保留 → 还原
* 注意:插件进程持锁时外部写会失败;若失败说明插件在运行(正常),跳过写操作
*/
import { DataStore } from '../src/component/DataStore.js';
const storage = new DataStore({ dataDir: process.env.HOME + '/.dsh/one-divine-lot' });
await storage._ensure();
let pass = 0, fail = 0;
function assert(cond, msg) {
if (cond) { pass++; console.log(' ✅ ' + msg); }
else { fail++; console.log(' ❌ ' + msg); }
}
try {
// 1. 设置归属:博纳影业 001330.SZ → grid-supermarket(候选 holdingId=13
const ok = await storage.setTradeOrderAttribution('645009273', 'grid-supermarket', 13);
assert(ok, '手动设置归属成功(grid-supermarket / holding 13');
// 2. 读取确认持久化
const row = storage.sqlite.db.prepare('SELECT strategy_id, holding_id FROM trade_orders WHERE order_id=?').get('645009273');
assert(row.strategy_id === 'grid-supermarket' && row.holding_id === 13, '归属已持久化');
// 3. 按策略过滤命中
const byGrid = await storage.getTradeOrderHistory({ start: '20260901', end: '20260901', strategyId: 'grid-supermarket' });
assert(byGrid.length === 1 && byGrid[0].orderId === '645009273', '按 grid-supermarket 过滤命中');
// 4. 未关联过滤为空(该委托已设归属)
const unassigned = await storage.getTradeOrderHistory({ start: '20260901', end: '20260901', strategyId: '__unassigned__' });
assert(unassigned.length === 0, '未关联过滤为空');
// 5. 成交按委托归属过滤
const fills = await storage.getTradeFillHistory({ start: '20260901', end: '20260901', strategyId: 'grid-supermarket' });
assert(fills.length === 1, '成交按 grid-supermarket 过滤命中');
// 6. 还原为未关联(不污染真实数据)
await storage.setTradeOrderAttribution('645009273', null, null);
const restored = storage.sqlite.db.prepare('SELECT strategy_id FROM trade_orders WHERE order_id=?').get('645009273');
assert(restored.strategy_id === null, '归属已还原(未关联)');
} catch (e) {
console.log(' ⚠️ 写操作被锁(插件持锁中,正常):' + (e?.message ?? e));
fail++;
}
storage.close();
console.log('\n结果: ' + pass + ' 通过, ' + fail + ' 失败');
process.exit(fail > 0 ? 1 : 0);
+54
View File
@@ -0,0 +1,54 @@
/**
* 真实数据完整链路验证 v3(只读,技术约束-011:真实库不做写操作)
* 手动归属模型:验证归属列(若已迁移)、历史查询、候选列表
* 注:归属列 ALTER 迁移由插件重启后自动执行(当前运行中的旧构建未含迁移逻辑)
*/
import { DataStore } from '../src/component/DataStore.js';
const storage = new DataStore({ dataDir: process.env.HOME + '/.dsh/one-divine-lot' });
await storage._ensure();
let pass = 0, fail = 0;
function assert(cond, msg) {
if (cond) { pass++; console.log(' ✅ ' + msg); }
else { fail++; console.log(' ❌ ' + msg); }
}
// 1. 历史查询
const hist = await storage.getTradeOrderHistory({ start: '20260901', end: '20260901' });
console.log(' 历史委托条数:', hist.length);
const o = hist[0];
assert(o && o.orderId === '645009273', '查到真实委托 645009273');
assert(o.code === '001330.SZ', 'code 归一化正确: ' + o.code);
// 2. 归属候选列表(只读)
const cands = await storage.getTradeAttributionCandidates('001330.SZ');
console.log(' 001330.SZ 候选:', JSON.stringify(cands));
assert(cands.some(c => c.strategyId === 'grid-supermarket'), '候选含 grid-supermarket(当前持仓)');
const cands2 = await storage.getTradeAttributionCandidates('300057.SZ');
console.log(' 300057.SZ 候选:', JSON.stringify(cands2));
assert(cands2.length >= 2, '一票多策略候选 ≥2grid + manual');
// 3. 归属列状态(只读 PRAGMA
let cols = [];
try {
cols = storage.sqlite.db.prepare('PRAGMA table_info(trade_orders)').all().map(c => c.name);
} catch { cols = []; }
console.log(' trade_orders 归属列存在:', cols.includes('strategy_id'), cols.includes('holding_id'));
if (cols.includes('strategy_id') && cols.includes('holding_id')) {
// 委托 645009273 已由用户手动设置归属(grid-supermarket)→ 未关联应为 0,按策略过滤应命中
const unassigned = await storage.getTradeOrderHistory({ start: '20260901', end: '20260901', strategyId: '__unassigned__' });
const byGrid = await storage.getTradeOrderHistory({ start: '20260901', end: '20260901', strategyId: 'grid-supermarket' });
const o = hist[0];
if (o && o.strategyId) {
assert(byGrid.length >= 1 && byGrid.some((x) => x.orderId === o.orderId), '已设置归属的委托按策略过滤命中');
assert(unassigned.length === 0, '已设置归属的委托不在「未关联」中');
} else {
assert(unassigned.length === hist.length, '未设置归属的委托都在「未关联」过滤中');
}
}
storage.close();
console.log('\n结果: ' + pass + ' 通过, ' + fail + ' 失败');
process.exit(fail > 0 ? 1 : 0);
+2 -2
View File
@@ -59,8 +59,8 @@ const HANDLERS = [
* @param {import('@deepseek-ai/cordis').Context} ctx * @param {import('@deepseek-ai/cordis').Context} ctx
* @param {object} runtime { manager, settings, dataSource, marketCache } * @param {object} runtime { manager, settings, dataSource, marketCache }
*/ */
export function registerApi(ctx, { manager, settings, dataSource, marketHub, marketFeed, qmtHealthMonitor }) { export function registerApi(ctx, { manager, settings, dataSource, marketHub, marketFeed, qmtHealthMonitor, storage, tradeSync }) {
const runtime = { ctx, manager, settings, dataSource, marketHub, marketFeed, qmtHealthMonitor }; const runtime = { ctx, manager, settings, dataSource, marketHub, marketFeed, qmtHealthMonitor, storage, tradeSync };
ctx.effect(() => ctx.webServer.register({ ctx.effect(() => ctx.webServer.register({
kind: 'prefix', kind: 'prefix',
+64 -3
View File
@@ -5,16 +5,24 @@
* orders → 当日委托(支持 code/status 过滤) * orders → 当日委托(支持 code/status 过滤)
* trades → 当日成交 * trades → 当日成交
* trading-dates → 交易日历(日期导航) * trading-dates → 交易日历(日期导航)
* trades/history → 本地 SQLite 历史查询(R-009:时间段/code/策略/方向过滤)
* trades/by-holding → 按 holding_id 查委托汇总(R-010:策略持仓行展开)
* *
* 数据流:前端轮询 → /odl/api/orders|trades → dataSource.getOrders/getTrades * 数据流:前端轮询 → /odl/api/orders|trades → dataSource.getOrders/getTrades
* (服务端直连 QMT Bridge REST,技术约束-003 * (服务端直连 QMT Bridge REST,技术约束-003
*/ */
import { getStrategies } from '../settings.js';
/** 交易记录端点方法表 */ /** 交易记录端点方法表 */
export const TRADE_METHODS = new Set([ export const TRADE_METHODS = new Set([
'orders', 'orders',
'trades', 'trades',
'trading-dates', '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 查委托汇总(策略持仓行展开)
]); ]);
/** /**
@@ -23,15 +31,31 @@ export const TRADE_METHODS = new Set([
* @param {object} args * @param {object} args
* @param {object} runtime { dataSource } * @param {object} runtime { dataSource }
*/ */
export async function handleTrade(method, args, { dataSource }) { export async function handleTrade(method, args, { dataSource, storage, settings }) {
switch (method) { switch (method) {
case 'orders': case 'orders': {
return await dataSource.getOrders({ const orders = await dataSource.getOrders({
code: args.code, code: args.code,
status: args.status, status: args.status,
start: args.start, start: args.start,
end: args.end, end: args.end,
}); });
// R-009:今日实时委托附加【持久化归属】(本地库中用户已设置的 strategy_id/holding_id
// 未设置为 null(前端显示「未关联」,用户可手动设置);候选由 orders/attribution-candidates 提供
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 }]));
return orders.map((o) => {
const a = attr.get(o.orderId) ?? { strategyId: null, holdingId: null };
return { ...o, strategyId: a.strategyId, holdingId: a.holdingId };
});
}
return orders;
}
case 'trades': case 'trades':
return await dataSource.getTrades({ return await dataSource.getTrades({
start: args.start, start: args.start,
@@ -42,6 +66,43 @@ export async function handleTrade(method, args, { dataSource }) {
start: args.start, start: args.start,
end: args.end, end: args.end,
}); });
case 'orders/attribution-candidates': {
// R-009:委托归属候选(该 code 当前持仓策略,Q3 全手动选)
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 }));
}
case 'orders/set-attribution': {
// R-009:手动设置委托归属(Q4 可随时改,以最终为准)
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 };
}
case 'trades/by-holding': {
// R-010:按 holding_id 查委托汇总(策略持仓行展开)
if (!storage) throw Object.assign(new Error('本地存储不可用'), { code: 'storage-unavailable' });
return await storage.getTradeOrdersByHolding(args.holdingId);
}
case 'trades/history': {
// R-009:本地 SQLite 历史查询(时间段/code/策略/方向过滤)
const opts = {
start: args.start,
end: args.end,
code: args.code,
strategyId: args.strategyId,
direction: args.direction,
};
if (!storage) {
throw Object.assign(new Error('本地存储不可用'), { code: 'storage-unavailable' });
}
const [orders, fills] = await Promise.all([
storage.getTradeOrderHistory(opts),
storage.getTradeFillHistory(opts),
]);
return { orders, fills };
}
default: default:
throw Object.assign(new Error('unknown trade method: ' + method), { code: 'not-found' }); throw Object.assign(new Error('unknown trade method: ' + method), { code: 'not-found' });
} }
+13
View File
@@ -75,6 +75,19 @@ export function apply(ctx) {
ctx.logger?.warn?.('[one-divine-lot] connection 服务不可用'); ctx.logger?.warn?.('[one-divine-lot] connection 服务不可用');
} }
// 神之一手 tab 激活时隐藏会话输入框(composer)
// 借鉴 dsh-context 的纯 CSS 方案::has(.odl-root) 命中 → 隐藏 [data-composer-seat]
// 我们的 tab 根元素带 odl-root classchat/其他 tab 无此 class → 输入框正常显示
const ODL_HIDE_COMPOSER_CSS = `
[data-conversation-scroll]:has(.odl-root)>[data-composer-seat]{display:none}
`;
if (typeof document !== 'undefined' && !document.querySelector('style[data-plugin-css="one-divine-lot/hide-composer"]')) {
const tag = document.createElement('style');
tag.dataset.pluginCss = 'one-divine-lot/hide-composer';
tag.textContent = ODL_HIDE_COMPOSER_CSS;
document.head.appendChild(tag);
}
const withConnection = (render) => (props) => const withConnection = (render) => (props) =>
createElement(ConnectionProvider, { connection }, createElement(ConnectionProvider, { connection },
createElement(MarketDataProvider, null, render(props))); createElement(MarketDataProvider, null, render(props)));
+1 -1
View File
@@ -39,7 +39,7 @@ export function AllPositionsTab(props) {
}, [load]); }, [load]);
return ( return (
<div style={{ padding: 16, fontFamily: 'system-ui' }}> <div className="odl-root" style={{ padding: 16, fontFamily: 'system-ui' }}>
<h3 style={{ margin: '0 0 12px' }}>全部持仓{positions ? positions.positions.length : 0} </h3> <h3 style={{ margin: '0 0 12px' }}>全部持仓{positions ? positions.positions.length : 0} </h3>
<LoadState loading={loading} error={error} onRetry={load} autoRetry={2}> <LoadState loading={loading} error={error} onRetry={load} autoRetry={2}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}> <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
+1 -1
View File
@@ -5,7 +5,7 @@
export function PlaceholderTab({ title }) { export function PlaceholderTab({ title }) {
return ( return (
<div style={{ padding: 40, textAlign: 'center', fontFamily: 'system-ui', color: '#999' }}> <div className="odl-root" style={{ padding: 40, textAlign: 'center', fontFamily: 'system-ui', color: '#999' }}>
<div style={{ fontSize: 16, marginBottom: 8 }}>{title}</div> <div style={{ fontSize: 16, marginBottom: 8 }}>{title}</div>
<div style={{ fontSize: 13 }}>功能开发中敬请期待</div> <div style={{ fontSize: 13 }}>功能开发中敬请期待</div>
</div> </div>
+269 -47
View File
@@ -15,13 +15,43 @@
* - 窄屏(<480px):每元素一行,两端对齐 * - 窄屏(<480px):每元素一行,两端对齐
*/ */
import { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { useRpc } from './connection.jsx'; import { useRpc } from './connection.jsx';
import { LoadState } from './LoadState.jsx'; import { LoadState } from './LoadState.jsx';
import { ToastProvider, useToast } from './Toast.jsx'; import { ToastProvider, useToast } from './Toast.jsx';
import { useMarket } from '../market/MarketDataProvider.jsx'; import { useMarket } from '../market/MarketDataProvider.jsx';
import { PriceCell } from './PriceCell.jsx'; import { PriceCell } from './PriceCell.jsx';
/** 价格格式化(2 位小数) */
function fmtPrice(v) {
if (v == null || !isFinite(v)) return '—';
return Number(v).toFixed(2);
}
/** 涨幅计算:(现价 - 昨收) / 昨收 × 100%;缺数据返回 null */
function calcPctChange(snap) {
if (!snap) return null;
const last = Number(snap.lastPrice);
const close = Number(snap.lastClose);
if (!isFinite(last) || !isFinite(close) || close === 0) return null;
return ((last - close) / close) * 100;
}
/** 涨幅着色:涨红跌绿(A股习惯) */
function pctStyle(pct) {
if (pct == null) return { color: '#999' };
if (pct > 0) return { color: '#d32f2f', fontWeight: 'bold' };
if (pct < 0) return { color: '#2e7d32', fontWeight: 'bold' };
return { color: '#999' };
}
/** 涨幅文本:+x.xx% / -x.xx% / — */
function fmtPct(pct) {
if (pct == null || !isFinite(pct)) return '—';
const sign = pct > 0 ? '+' : '';
return sign + pct.toFixed(2) + '%';
}
/** 添加持仓区域响应式样式(内联注入,含媒体查询) */ /** 添加持仓区域响应式样式(内联注入,含媒体查询) */
const ADD_FORM_CSS = ` const ADD_FORM_CSS = `
.odl-add-form { .odl-add-form {
@@ -110,6 +140,13 @@ function StrategyTabInner({ strategyId, strategyName }) {
const [selectedCode, setSelectedCode] = useState(''); const [selectedCode, setSelectedCode] = useState('');
const [addShares, setAddShares] = useState(''); const [addShares, setAddShares] = useState('');
const [removeTarget, setRemoveTarget] = useState(null); // { code, shares } const [removeTarget, setRemoveTarget] = useState(null); // { code, shares }
// R-010:持仓行展开看关联交易(Holding → 委托汇总)
const [expandedCode, setExpandedCode] = useState(null); // 当前展开的 code
const [holdingTrades, setHoldingTrades] = useState(null); // { code: [委托汇总] }
const [holdingTradesLoading, setHoldingTradesLoading] = useState(null); // 展开加载中的 code
// 涨幅排序(2026-09-02):sortKey='pctChange' 时按涨幅排序;null=默认顺序
const [sortKey, setSortKey] = useState(null); // 'pctChange' | null
const [sortDir, setSortDir] = useState('desc'); // 'asc' | 'desc'
const call = useRpc(); const call = useRpc();
const toast = useToast(); const toast = useToast();
const { getPrice, registerCodes } = useMarket(); const { getPrice, registerCodes } = useMarket();
@@ -187,10 +224,65 @@ function StrategyTabInner({ strategyId, strategyName }) {
} }
}; };
/** R-010:展开某持仓行 → 懒加载该 holding 的委托汇总 */
const toggleExpand = async (code, holdingId) => {
if (expandedCode === code) {
// 折叠
setExpandedCode(null);
return;
}
setExpandedCode(code);
if (holdingId == null) {
setHoldingTrades((prev) => ({ ...(prev ?? {}), [code]: [] }));
return;
}
// 懒加载:有缓存直接用,否则请求
if (holdingTrades && holdingTrades[code]) return;
setHoldingTradesLoading(code);
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 }));
} else {
setHoldingTrades((prev) => ({ ...(prev ?? {}), [code]: [] }));
}
} catch {
setHoldingTrades((prev) => ({ ...(prev ?? {}), [code]: [] }));
} finally {
setHoldingTradesLoading(null);
}
};
/** 涨幅排序:点击表头切换升/降序 */
const togglePctSort = () => {
if (sortKey === 'pctChange') {
setSortDir((d) => (d === 'desc' ? 'asc' : 'desc'));
} else {
setSortKey('pctChange');
setSortDir('desc');
}
};
const name = strategyName || (strategyId === 'grid-supermarket' ? '网格策略持仓' : strategyId === 'manual-t' ? '做T持仓' : strategyId); const name = strategyName || (strategyId === 'grid-supermarket' ? '网格策略持仓' : strategyId === 'manual-t' ? '做T持仓' : strategyId);
// 计算每行涨幅 + 按涨幅排序(sortKey='pctChange' 时)
const rowsForRender = useMemo(() => {
const rows = (positions ?? []).map((p) => ({ ...p, pctChange: calcPctChange(getPrice(p.code)) }));
if (sortKey === 'pctChange') {
const dir = sortDir === 'asc' ? 1 : -1;
rows.sort((a, b) => {
const va = a.pctChange ?? -Infinity;
const vb = b.pctChange ?? -Infinity;
return (va - vb) * dir;
});
}
return rows;
}, [positions, sortKey, sortDir, getPrice]);
const pctArrow = sortKey === 'pctChange' ? (sortDir === 'desc' ? ' ▼' : ' ▲') : '';
return ( return (
<div style={{ padding: 16, fontFamily: 'system-ui' }}> <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 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12, flexWrap: 'wrap', gap: 8 }}>
<h3 style={{ margin: 0 }}>{name}{(positions ?? []).length} </h3> <h3 style={{ margin: 0 }}>{name}{(positions ?? []).length} </h3>
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8 }}>
@@ -244,60 +336,124 @@ function StrategyTabInner({ strategyId, strategyName }) {
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}> <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead> <thead>
<tr style={{ textAlign: 'left', borderBottom: '2px solid #ddd' }}> <tr style={{ textAlign: 'left', borderBottom: '2px solid #ddd' }}>
<th style={{ padding: '8px 6px', lineHeight: '24px', width: 28 }}> </th>
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>代码</th> <th style={{ padding: '8px 6px', lineHeight: '24px' }}>代码</th>
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>名称</th> <th style={{ padding: '8px 6px', lineHeight: '24px' }}>名称</th>
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>现价</th> <th style={{ padding: '8px 6px', lineHeight: '24px' }}>现价</th>
<th onClick={togglePctSort} style={{ padding: '8px 6px', lineHeight: '24px', cursor: 'pointer', userSelect: 'none' }}>涨幅{pctArrow}</th>
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>昨收</th>
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>成本价</th>
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>最后一笔成交价</th>
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>策略份额</th> <th style={{ padding: '8px 6px', lineHeight: '24px' }}>策略份额</th>
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>总持仓</th>
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>操作</th> <th style={{ padding: '8px 6px', lineHeight: '24px' }}>操作</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{(positions ?? []).map((p) => ( {rowsForRender.map((p) => {
<tr key={p.code} style={{ borderBottom: '1px solid #eee' }}> const isExpanded = expandedCode === p.code;
<td style={{ padding: '8px 6px' }}>{p.code}</td> const trades = (holdingTrades ?? {})[p.code];
<td style={{ padding: '8px 6px' }}>{p.name}</td> const isLoading = holdingTradesLoading === p.code;
<PriceCell price={getPrice(p.code)?.lastPrice} lastClose={getPrice(p.code)?.lastClose} /> const hasHolding = p.holdingId != null;
<td style={{ padding: '8px 6px', fontWeight: 'bold' }}>{p.shares}</td> return (
<td style={{ padding: '8px 6px' }}>{p.volume}</td> <React.Fragment key={p.code}>
<td style={{ padding: '8px 6px', whiteSpace: 'nowrap' }}> <tr
{removeTarget && removeTarget.code === p.code ? ( onClick={() => toggleExpand(p.code, p.holdingId)}
<span> style={{
<input borderBottom: '1px solid #eee',
type="number" cursor: hasHolding ? 'pointer' : 'default',
min="1" background: isExpanded ? '#f5f5f5' : 'transparent',
step={100} }}
value={removeTarget.shares} >
onChange={(e) => setRemoveTarget({ code: p.code, shares: e.target.value })} <td style={{ padding: '8px 6px', textAlign: 'center', width: 28 }}>
style={{ padding: 2, width: 70, marginRight: 6 }} {hasHolding ? (
/> <svg
<button onClick={() => handleRemove(p.code)} style={{ padding: '2px 8px', cursor: 'pointer' }}> width="14"
确认 height="14"
</button> viewBox="0 0 14 14"
<button onClick={() => setRemoveTarget(null)} style={{ padding: '2px 8px', cursor: 'pointer', marginLeft: 4 }}> style={{
取消 display: 'inline-block',
</button> transition: 'transform .15s ease',
</span> transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)',
) : ( color: '#2e7d32',
<span> verticalAlign: 'middle',
<button }}
onClick={() => handleMoveAllIn(p.code)} >
style={{ padding: '2px 8px', cursor: 'pointer', border: '1px solid #2e7d32', borderRadius: 3, background: '#fff', color: '#2e7d32', marginRight: 4 }} <path
title="将该票全部未分配份额移入本策略" d="M4.5 2.5 L9.5 7 L4.5 11.5"
> fill="none"
全部移入 stroke="currentColor"
</button> strokeWidth="2"
<button strokeLinecap="round"
onClick={() => setRemoveTarget({ code: p.code, shares: p.shares })} strokeLinejoin="round"
style={{ padding: '2px 8px', cursor: 'pointer' }} />
> </svg>
移出 ) : (
</button> <span style={{ color: '#ccc', fontSize: 10 }}>·</span>
</span> )}
</td>
<td style={{ padding: '8px 6px' }}>{p.code}</td>
<td style={{ padding: '8px 6px' }}>{p.name}</td>
<PriceCell price={getPrice(p.code)?.lastPrice} lastClose={getPrice(p.code)?.lastClose} />
<td style={{ padding: '8px 6px', ...pctStyle(p.pctChange) }}>{fmtPct(p.pctChange)}</td>
<td style={{ padding: '8px 6px' }}>{fmtPrice(getPrice(p.code)?.lastClose)}</td>
<td style={{ padding: '8px 6px' }}>{fmtPrice(p.avgPrice)}</td>
<td style={{ padding: '8px 6px' }}>{fmtPrice(p.lastTradePrice)}</td>
<td style={{ padding: '8px 6px', fontWeight: 'bold' }}>{p.shares}</td>
<td style={{ padding: '8px 6px', whiteSpace: 'nowrap' }} onClick={(e) => e.stopPropagation()}>
{removeTarget && removeTarget.code === p.code ? (
<span>
<input
type="number"
min="1"
step={100}
value={removeTarget.shares}
onChange={(e) => setRemoveTarget({ code: p.code, shares: e.target.value })}
style={{ padding: 2, width: 70, marginRight: 6 }}
/>
<button onClick={() => handleRemove(p.code)} style={{ padding: '2px 8px', cursor: 'pointer' }}>
确认
</button>
<button onClick={() => setRemoveTarget(null)} style={{ padding: '2px 8px', cursor: 'pointer', marginLeft: 4 }}>
取消
</button>
</span>
) : (
<span>
<button
onClick={() => handleMoveAllIn(p.code)}
style={{ padding: '2px 8px', cursor: 'pointer', border: '1px solid #2e7d32', borderRadius: 3, background: '#fff', color: '#2e7d32', marginRight: 4 }}
title="将该票全部未分配份额移入本策略"
>
全部移入
</button>
<button
onClick={() => setRemoveTarget({ code: p.code, shares: p.shares })}
style={{ padding: '2px 8px', cursor: 'pointer' }}
>
移出
</button>
</span>
)}
</td>
</tr>
{isExpanded && (
<tr style={{ borderBottom: '1px solid #eee', background: '#fafafa' }}>
<td colSpan={10} style={{ padding: '4px 8px 12px' }}>
{isLoading ? (
<div style={{ padding: 8, color: '#999', fontSize: 12 }}>交易记录加载中...</div>
) : !trades || trades.length === 0 ? (
<div style={{ padding: 8, color: '#999', fontSize: 12 }}>
{hasHolding ? '该持仓暂无关联交易记录(可在「交易记录」tab 为委托设置归属)' : '该持仓无关联锚点(holding_id'}
</div>
) : (
<HoldingTradesTable trades={trades} />
)}
</td>
</tr>
)} )}
</td> </React.Fragment>
</tr> );
))} })}
</tbody> </tbody>
</table> </table>
)} )}
@@ -306,6 +462,72 @@ function StrategyTabInner({ strategyId, strategyName }) {
); );
} }
/** R-010:持仓关联的委托汇总表(只显示委托汇总,不分笔成交) */
const ORDER_STATUS_MAP = {
48: '未报', 49: '待报', 50: '已报', 51: '已报待撤', 52: '部成待撤',
53: '部撤', 54: '已撤', 55: '部成', 56: '已成', 57: '废单',
};
function fmtDateH(d) {
if (!d || d.length !== 8) return d ?? '—';
return d.slice(0, 4) + '-' + d.slice(4, 6) + '-' + d.slice(6, 8);
}
function fmtTimeH(t) {
if (!t || t.length !== 6) return t ?? '';
return t.slice(0, 2) + ':' + t.slice(2, 4) + ':' + t.slice(4, 6);
}
function fmtNumH(v, digits = 2) {
if (v == null || !isFinite(v)) return '—';
return Number(v).toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits });
}
function dirTextH(d) {
if (d === 'buy') return '买入';
if (d === 'sell') return '卖出';
return '—';
}
function dirColorH(d) {
if (d === 'buy') return { color: '#d32f2f', fontWeight: 'bold' };
if (d === 'sell') return { color: '#2e7d32', fontWeight: 'bold' };
return { color: '#999' };
}
function HoldingTradesTable({ trades }) {
const list = Array.isArray(trades) ? trades : [];
return (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12, marginTop: 4 }}>
<thead>
<tr style={{ borderBottom: '1px solid #ddd', color: '#666' }}>
<th style={{ ...tdH, textAlign: 'left' }}>交易日</th>
<th style={{ ...tdH, textAlign: 'left' }}>时间</th>
<th style={{ ...tdH, textAlign: 'left' }}>方向</th>
<th style={{ ...tdH, textAlign: 'left' }}>状态</th>
<th style={{ ...tdH, textAlign: 'right' }}>委托量</th>
<th style={{ ...tdH, textAlign: 'right' }}>成交量</th>
<th style={{ ...tdH, textAlign: 'right' }}>委托价</th>
<th style={{ ...tdH, textAlign: 'right' }}>成交均价</th>
<th style={{ ...tdH, textAlign: 'right' }}>成交额</th>
</tr>
</thead>
<tbody>
{list.map((o) => (
<tr key={o.orderId} style={{ borderBottom: '1px solid #f0f0f0' }}>
<td style={tdH}>{fmtDateH(o.tradeDate)}</td>
<td style={tdH}>{fmtTimeH(o.insertTime)}</td>
<td style={{ ...tdH, ...dirColorH(o.direction) }}>{dirTextH(o.direction)}</td>
<td style={tdH}>{ORDER_STATUS_MAP[o.status] ?? o.status ?? '—'}</td>
<td style={{ ...tdH, textAlign: 'right' }}>{fmtNumH(o.orderVolume, 0)}</td>
<td style={{ ...tdH, textAlign: 'right' }}>{fmtNumH(o.tradedVolume, 0)}</td>
<td style={{ ...tdH, textAlign: 'right' }}>{fmtNumH(o.limitPrice)}</td>
<td style={{ ...tdH, textAlign: 'right' }}>{o.tradedVolume > 0 ? fmtNumH(o.amount / o.tradedVolume) : '—'}</td>
<td style={{ ...tdH, textAlign: 'right' }}>{fmtNumH(o.amount)}</td>
</tr>
))}
</tbody>
</table>
);
}
const tdH = { padding: '6px 6px', lineHeight: '20px', whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums' };
/** 导出组件(包 ToastProvider */ /** 导出组件(包 ToastProvider */
export function StrategyTab(props) { export function StrategyTab(props) {
return ( return (
+254 -72
View File
@@ -1,17 +1,21 @@
/** /**
* TradeRecordsTab —— 交易记录 tabR-007 v32026-09-01 * TradeRecordsTab —— 交易记录 tabR-007 v3 + R-009 v52026-09-01
* *
* 单表合并视图: * 单表合并视图:
* - 主行 = 一笔委托(按 m_strOrderSysID 唯一),聚合成交信息(成交量/加权均价/金额/手续费) * - 主行 = 一笔委托(按 m_strOrderSysID 唯一),聚合成交信息(成交量/加权均价/金额/手续费)
* - 点击展开/折叠 → 逐笔成交明细(成交时间/价/量/额/手续费/成交号) * - 点击展开/折叠 → 逐笔成交明细(成交时间/价/量/额/手续费/成交号)
* - 排序:默认按委托时间降序(最新在前);点击表头手动排序 * - 排序:默认按委托时间降序(最新在前);点击表头手动排序
* - 时间段查询:标题 + RangeSelector 同一行(今日/本周/上周快捷 + 手动起止日期) * - 时间段查询:标题 + RangeSelector 同一行(今日/本周/本月快捷 + 手动起止日期)
* - 轮询:仅「今日」范围 3-5s 自动刷新;其他范围一次性查询 * - 轮询:仅「今日」范围 3-5s 自动刷新;其他范围一次性查询
* *
* R-009(迭代 07):
* - 策略过滤下拉(全部 / 各策略 / 未关联):今日实时按归属过滤(前端),历史走服务端过滤
* - 历史范围从「接口开发中」占位切换为查本地 SQLitetrades/history
*
* 方向:红买绿卖(Q7 定稿);状态中文映射;未成交显示 — * 方向:红买绿卖(Q7 定稿);状态中文映射;未成交显示 —
*/ */
import { useState, useEffect, useCallback, useMemo } from 'react'; import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useRpc } from './connection.jsx'; import { useRpc } from './connection.jsx';
import { LoadState } from './LoadState.jsx'; import { LoadState } from './LoadState.jsx';
import { RangeSelector } from './RangeSelector.jsx'; import { RangeSelector } from './RangeSelector.jsx';
@@ -79,8 +83,7 @@ function mergeOrdersAndTrades(orders, trades) {
const weightedPrice = totalVol > 0 const weightedPrice = totalVol > 0
? ts.reduce((s, t) => s + t.price * t.volume, 0) / totalVol ? ts.reduce((s, t) => s + t.price * t.volume, 0) / totalVol
: 0; : 0;
// 费用:按整笔委托合并计算(同一委托分笔成交只收一次佣金,calcOrderFees const rateWan = ts[0]?.commissionRateWan ?? 0;
const rateWan = ts[0]?.commissionRateWan ?? 0; // 佣金费率(万分之,来自成交,账号级一致)
const fees = calcOrderFeesLocal(ts, rateWan); const fees = calcOrderFeesLocal(ts, rateWan);
return { return {
...o, ...o,
@@ -102,10 +105,20 @@ export function TradeRecordsTab() {
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [range, setRange] = useState(null); // { start, end } YYYYMMDD const [range, setRange] = useState(null); // { start, end } YYYYMMDD
const [preset, setPreset] = useState('today'); // 快捷意图(单向,操作→状态→UI):'today'|'thisWeek'|'thisMonth'|null const [preset, setPreset] = useState('today'); // 快捷意图(单向):'today'|'thisWeek'|'thisMonth'|null
const [lastDate, setLastDate] = useState(null); // 最近交易日 const [lastDate, setLastDate] = useState(null); // 最近交易日
// 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 判重用)
// 同步 ref
useEffect(() => { attributionCandidatesRef.current = attributionCandidates; }, [attributionCandidates]);
// 默认加载「今日」(preset='today',单向意图):lastDate 到位且 range 未设置时自动设为今日 const call = useRpc();
// 默认加载「今日」(preset='today',单向意图)
useEffect(() => { useEffect(() => {
if (lastDate && !range) { if (lastDate && !range) {
setRange({ start: lastDate, end: lastDate }); setRange({ start: lastDate, end: lastDate });
@@ -114,20 +127,48 @@ export function TradeRecordsTab() {
const [expanded, setExpanded] = useState(new Set()); const [expanded, setExpanded] = useState(new Set());
const [sortKey, setSortKey] = useState('insertTime'); const [sortKey, setSortKey] = useState('insertTime');
const [sortDir, setSortDir] = useState('desc'); const [sortDir, setSortDir] = useState('desc');
const call = useRpc();
// 加载策略列表(过滤下拉用,R-009)
useEffect(() => {
call('one-divine-lot/strategies', { args: {} })
.then((res) => {
if (res?.ok && Array.isArray(res.value)) setStrategies(res.value);
})
.catch(() => {});
}, [call]);
// 是否「今日」意图(preset='today',单向)→ 轮询 // 是否「今日」意图(preset='today',单向)→ 轮询
const isTodayRange = preset === 'today'; const isTodayRange = preset === 'today';
// 是否历史范围(非今日意图)→ 本期占位(QMT Bridge 历史接口开发中 // 是否历史范围(非今日意图)→ 查本地 SQLiteR-009
const isHistoryRange = !!range && preset !== 'today'; const isHistoryRange = !!range && preset !== 'today';
const load = useCallback(async () => { const load = useCallback(async () => {
if (!range) return; if (!range) return;
if (isHistoryRange) { if (isHistoryRange) {
// 历史范围:本期占位(老师将完善 QMT Bridge 历史接口) // R-009 历史范围:查本地 SQLitetrades/history),不再占位
setOrders([]); setLoading(true);
setTrades([]); setError(null);
setLoading(false); try {
const res = await call('one-divine-lot/trades/history', {
args: {
start: range.start,
end: range.end,
strategyId: strategyFilter || undefined, // '__unassigned__' 由服务端处理(未关联)
},
});
if (res?.ok && res.value) {
setOrders(res.value.orders ?? []);
setTrades(res.value.fills ?? []);
} else {
throw new Error((res && res.error && res.error.message) || '历史记录加载失败');
}
} catch (e) {
setError(e.message);
setOrders([]);
setTrades([]);
} finally {
setLoading(false);
}
return; return;
} }
setLoading(true); setLoading(true);
@@ -148,7 +189,56 @@ export function TradeRecordsTab() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [call, range, isHistoryRange]); }, [call, range, isHistoryRange, strategyFilter]);
// 拉取某 code 的归属候选(该 code 当前持仓策略;Q3 全手动选)
const loadCandidates = useCallback(async (code) => {
if (!code) return;
// 已有缓存不重复请求
if (attributionCandidatesRef.current[code]) return;
try {
const res = await call('one-divine-lot/orders/attribution-candidates', { args: { code } });
if (res?.ok && Array.isArray(res.value)) {
setAttributionCandidates((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);
}
}, [call, load]);
// 归属候选自动预载:数据加载后,为所有委托 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);
}
}, [orders, loadCandidates]);
// 时间段变化 → 重新加载(展开重置) // 时间段变化 → 重新加载(展开重置)
useEffect(() => { useEffect(() => {
@@ -171,9 +261,15 @@ export function TradeRecordsTab() {
return () => clearInterval(timer); return () => clearInterval(timer);
}, [call, isTodayRange, range]); }, [call, isTodayRange, range]);
// 合并 + 排序 // 合并 + 策略过滤(今日实时按归属过滤前端;历史已在服务端过滤)+ 排序
const rows = useMemo(() => { const rows = useMemo(() => {
const merged = mergeOrdersAndTrades(orders ?? [], trades ?? []); let merged = mergeOrdersAndTrades(orders ?? [], trades ?? []);
if (strategyFilter) {
merged = merged.filter((o) => {
if (strategyFilter === '__unassigned__') return !o.strategyId;
return o.strategyId === strategyFilter;
});
}
const dir = sortDir === 'asc' ? 1 : -1; const dir = sortDir === 'asc' ? 1 : -1;
return merged.slice().sort((a, b) => { return merged.slice().sort((a, b) => {
const va = a[sortKey]; const va = a[sortKey];
@@ -181,7 +277,7 @@ export function TradeRecordsTab() {
if (typeof va === 'string' && typeof vb === 'string') return va.localeCompare(vb) * dir; if (typeof va === 'string' && typeof vb === 'string') return va.localeCompare(vb) * dir;
return (va - vb) * dir; return (va - vb) * dir;
}); });
}, [orders, trades, sortKey, sortDir]); }, [orders, trades, sortKey, sortDir, strategyFilter]);
const toggleSort = (key) => { const toggleSort = (key) => {
if (sortKey === key) { if (sortKey === key) {
@@ -204,69 +300,105 @@ export function TradeRecordsTab() {
const arrow = (key) => (sortKey === key ? (sortDir === 'desc' ? ' ▼' : ' ▲') : ''); const arrow = (key) => (sortKey === key ? (sortDir === 'desc' ? ' ▼' : ' ▲') : '');
return ( return (
<div style={{ padding: 16, fontFamily: 'system-ui' }}> <div className="odl-root" style={{ padding: 16, fontFamily: 'system-ui' }}>
{/* 标题 + 时间段选择同一行(2026-09-01 老师反馈 */} {/* 标题 + 策略过滤 + 时间段选择同一行(R-007/R-009 */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12, flexWrap: 'wrap', gap: 12 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12, flexWrap: 'wrap', gap: 12 }}>
<h3 style={{ margin: 0 }}>交易记录</h3> <h3 style={{ margin: 0 }}>交易记录</h3>
<RangeSelector preset={preset} onPresetChange={setPreset} range={range} onChange={setRange} onLastDate={setLastDate} /> <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
</div> {/* R-009:策略过滤下拉(全部/各策略/未关联) */}
<select
{isHistoryRange ? ( value={strategyFilter}
<div style={{ padding: 32, textAlign: 'center', color: '#999' }}> onChange={(e) => setStrategyFilter(e.target.value)}
<div style={{ fontSize: 15, marginBottom: 8 }}>{range.start} ~ {range.end}自定义时间段</div> style={{ fontSize: 12, padding: '2px 6px', border: '1px solid #ccc', borderRadius: 4, color: '#555' }}
<div style={{ fontSize: 13 }}>历史数据接口开发中敬请期待老师完善 QMT Bridge 历史接口后接入</div> title="按策略过滤"
>
<option value="">全部策略</option>
<option value="__unassigned__">未关联</option>
{strategies.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
<RangeSelector preset={preset} onPresetChange={setPreset} range={range} onChange={setRange} onLastDate={setLastDate} />
</div>
</div>
{attributionSaved && (
<div style={{ marginBottom: 8, fontSize: 12, color: attributionSaved.startsWith('保存失败') ? '#c62828' : '#1b5e20', background: attributionSaved.startsWith('保存失败') ? '#fdecea' : '#e8f5e9', padding: '4px 8px', borderRadius: 4 }}>
{attributionSaved}
</div> </div>
) : (
<LoadState loading={loading && !orders} error={error} onRetry={load} autoRetry={2}>
{!orders?.length ? (
<div style={{ padding: 32, textAlign: 'center', color: '#999' }}>该时间段暂无委托记录</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ textAlign: 'left', borderBottom: '2px solid #ddd' }}>
<th style={thStyle}> </th>
<th onClick={() => toggleSort('insertTime')} style={thClickStyle}>时间{arrow('insertTime')}</th>
<th onClick={() => toggleSort('code')} style={thClickStyle}>代码{arrow('code')}</th>
<th style={thStyle}>名称</th>
<th onClick={() => toggleSort('direction')} style={thClickStyle}>方向{arrow('direction')}</th>
<th onClick={() => toggleSort('status')} style={thClickStyle}>状态{arrow('status')}</th>
<th onClick={() => toggleSort('orderVolume')} style={thClickStyle} align="right">委托量{arrow('orderVolume')}</th>
<th onClick={() => toggleSort('aggVolume')} style={thClickStyle} align="right">成交量{arrow('aggVolume')}</th>
<th onClick={() => toggleSort('limitPrice')} style={thClickStyle} align="right">委托价{arrow('limitPrice')}</th>
<th align="right">成交均价</th>
<th onClick={() => toggleSort('aggAmount')} style={thClickStyle} align="right">成交额{arrow('aggAmount')}</th>
<th align="right">总费用</th>
<th align="right">佣金</th>
<th align="right">印花税</th>
<th align="right">过户费</th>
</tr>
</thead>
<tbody>
{rows.map((o) => (
<OrderRows
key={o.orderId}
order={o}
expanded={expanded.has(o.orderId)}
onToggle={() => toggleExpand(o.orderId)}
/>
))}
</tbody>
</table>
)}
<div style={{ marginTop: 8, fontSize: 12, color: '#999' }}>
<span style={{ color: '#d32f2f' }}> </span>
<span style={{ marginLeft: 8, color: '#2e7d32' }}> </span>
<span style={{ marginLeft: 12 }}>点击委托行展开成交明细 · 点击表头排序</span>
</div>
</LoadState>
)} )}
<LoadState loading={loading && !orders} error={error} onRetry={load} autoRetry={2}>
{!orders?.length ? (
<div style={{ padding: 32, textAlign: 'center', color: '#999' }}>
<div style={{ fontSize: 15, marginBottom: 8 }}>
{range ? range.start + ' ~ ' + range.end + (isHistoryRange ? '(本地历史)' : '') : ''}
</div>
<div style={{ fontSize: 13 }}>
{isHistoryRange ? '该时间段暂无本地交易记录(数据自插件运行起逐日积累)' : '该时间段暂无委托记录'}
</div>
</div>
) : (
<TradeTable rows={rows} toggleSort={toggleSort} sortKey={sortKey} sortDir={sortDir} arrow={arrow} toggleExpand={toggleExpand} expanded={expanded} attributionCandidates={attributionCandidates} loadCandidates={loadCandidates} setAttribution={setAttribution} />
)}
<div style={{ marginTop: 8, fontSize: 12, color: '#999' }}>
<span style={{ color: '#d32f2f' }}> </span>
<span style={{ marginLeft: 8, color: '#2e7d32' }}> </span>
<span style={{ marginLeft: 12 }}>点击委托行展开成交明细 · 点击表头排序</span>
{isHistoryRange && <span style={{ marginLeft: 12 }}>· 历史数据 = 本地积累</span>}
</div>
</LoadState>
</div> </div>
); );
} }
/** 委托主行 + 展开明细 */ /** 交易记录表格(主行 + 展开明细 + R-009 手动归属列) */
function OrderRows({ order, expanded, onToggle }) { function TradeTable({ rows, toggleSort, sortKey, sortDir, arrow, toggleExpand, expanded, attributionCandidates, loadCandidates, setAttribution }) {
return (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ textAlign: 'left', borderBottom: '2px solid #ddd' }}>
<th style={thStyle}> </th>
<th onClick={() => toggleSort('insertTime')} style={thClickStyle}>时间{arrow('insertTime')}</th>
<th onClick={() => toggleSort('code')} style={thClickStyle}>代码{arrow('code')}</th>
<th style={thStyle}>名称</th>
<th onClick={() => toggleSort('direction')} style={thClickStyle}>方向{arrow('direction')}</th>
<th onClick={() => toggleSort('status')} style={thClickStyle}>状态{arrow('status')}</th>
<th onClick={() => toggleSort('orderVolume')} style={thClickStyle} align="right">委托量{arrow('orderVolume')}</th>
<th onClick={() => toggleSort('aggVolume')} style={thClickStyle} align="right">成交量{arrow('aggVolume')}</th>
<th onClick={() => toggleSort('limitPrice')} style={thClickStyle} align="right">委托价{arrow('limitPrice')}</th>
<th align="right">成交均价</th>
<th onClick={() => toggleSort('aggAmount')} style={thClickStyle} align="right">成交额{arrow('aggAmount')}</th>
<th align="right">总费用</th>
<th align="right">佣金</th>
<th align="right">印花税</th>
<th align="right">过户费</th>
<th style={thStyle}>归属</th>
</tr>
</thead>
<tbody>
{rows.map((o) => (
<OrderRows
key={o.orderId}
order={o}
expanded={expanded.has(o.orderId)}
onToggle={() => toggleExpand(o.orderId)}
attributionCandidates={attributionCandidates[o.code] ?? []}
onLoadCandidates={() => loadCandidates(o.code)}
setAttribution={setAttribution}
/>
))}
</tbody>
</table>
);
}
/** 委托主行 + 展开明细(含 R-009 手动归属下拉) */
function OrderRows({ order, expanded, onToggle, attributionCandidates, onLoadCandidates, setAttribution }) {
const hasTrades = order.trades.length > 0; const hasTrades = order.trades.length > 0;
const strategyName = (sid) => {
if (!sid) return '未关联';
return order.strategyName || sid;
};
return ( return (
<> <>
<tr <tr
@@ -319,10 +451,18 @@ function OrderRows({ order, expanded, onToggle }) {
<td style={{ ...tdStyle, textAlign: 'right' }}>{hasTrades ? fmtNum(order.aggCommission, 2) : '—'}</td> <td style={{ ...tdStyle, textAlign: 'right' }}>{hasTrades ? fmtNum(order.aggCommission, 2) : '—'}</td>
<td style={{ ...tdStyle, textAlign: 'right' }}>{hasTrades ? fmtNum(order.aggStampTax, 2) : '—'}</td> <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, textAlign: 'right' }}>{hasTrades ? fmtNum(order.aggTransferFee, 2) : '—'}</td>
<td style={{ ...tdStyle }} onClick={(e) => e.stopPropagation()}>
<AttributionSelect
order={order}
candidates={attributionCandidates}
onLoadCandidates={onLoadCandidates}
setAttribution={setAttribution}
/>
</td>
</tr> </tr>
{expanded && hasTrades && ( {expanded && hasTrades && (
<tr style={{ borderBottom: '1px solid #eee', background: '#fafafa' }}> <tr style={{ borderBottom: '1px solid #eee', background: '#fafafa' }}>
<td colSpan={15} style={{ padding: '4px 8px 12px' }}> <td colSpan={16} style={{ padding: '4px 8px 12px' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12, marginTop: 4 }}> <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12, marginTop: 4 }}>
<thead> <thead>
<tr style={{ borderBottom: '1px solid #ddd', color: '#666' }}> <tr style={{ borderBottom: '1px solid #ddd', color: '#666' }}>
@@ -352,6 +492,48 @@ function OrderRows({ order, expanded, onToggle }) {
); );
} }
/** 委托归属选择下拉(R-009,Q3 全手动选;Q4 可随时改)
* 候选 = 该 code 当前持仓策略(orders/attribution-candidates);
* 未设置显示「未关联」,用户点选后持久化(orders/set-attribution)。
*/
function AttributionSelect({ order, candidates, onLoadCandidates, setAttribution }) {
const current = order.strategyId || '';
const handleSelect = (ev) => {
const val = ev.target.value;
if (val === current) return;
if (val === '__unassigned__') {
setAttribution(order.orderId, order.code, null, null);
return;
}
const cand = (candidates ?? []).find((c) => c.strategyId === val);
setAttribution(order.orderId, order.code, val, cand ? cand.holdingId : null);
};
return (
<select
value={current || '__unassigned__'}
onChange={handleSelect}
style={{
fontSize: 12,
padding: '2px 4px',
border: '1px solid ' + (current ? '#2e7d32' : '#ccc'),
borderRadius: 4,
background: current ? '#e8f5e9' : '#fff',
color: current ? '#1b5e20' : '#999',
maxWidth: 120,
}}
title="设置该委托的策略归属(可随时修改)"
>
<option value="__unassigned__">未关联</option>
{(candidates ?? []).map((c) => (
<option key={c.holdingId} value={c.strategyId}>{c.strategyName || c.strategyId}</option>
))}
</select>
);
}
const thStyle = { padding: '8px 6px', lineHeight: '24px', whiteSpace: 'nowrap' }; const thStyle = { padding: '8px 6px', lineHeight: '24px', whiteSpace: 'nowrap' };
const thClickStyle = { ...thStyle, cursor: 'pointer', userSelect: 'none' }; const thClickStyle = { ...thStyle, cursor: 'pointer', userSelect: 'none' };
const tdStyle = { padding: '8px 6px', lineHeight: '24px', whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums' }; const tdStyle = { padding: '8px 6px', lineHeight: '24px', whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums' };
+50
View File
@@ -156,6 +156,56 @@ export class DataStore {
return this.sqlite.setMarketQuotes(quotes); return this.sqlite.setMarketQuotes(quotes);
} }
// ===== 交易记录(R-009 迭代 07,委托 SqliteStore=====
/** UPSERT 委托(幂等) */
async upsertTradeOrders(orders) {
await this._ensure();
return this.sqlite.upsertOrders(orders);
}
/** UPSERT 成交(幂等) */
async upsertTradeFills(fills) {
await this._ensure();
return this.sqlite.upsertFills(fills);
}
/** 本地历史委托查询(含策略归属推导) */
async getTradeOrderHistory(opts) {
await this._ensure();
return this.sqlite.getOrderHistory(opts);
}
/** 给任意委托列表附加策略/持仓归属(今日实时委托用) */
async attachTradeStrategyAttribution(orders) {
await this._ensure();
return this.sqlite.attachStrategyAttribution(orders);
}
/** 本地历史成交查询(含策略归属推导) */
async getTradeFillHistory(opts) {
await this._ensure();
return this.sqlite.getFillHistory(opts);
}
/** 设置委托归属(手动,Q4 可改) */
async setTradeOrderAttribution(orderId, strategyId, holdingId) {
await this._ensure();
return this.sqlite.setOrderAttribution(orderId, strategyId, holdingId);
}
/** 委托归属候选列表(该 code 当前持仓策略,Q3 全手动选) */
async getTradeAttributionCandidates(code) {
await this._ensure();
return this.sqlite.getAttributionCandidates(code);
}
/** 按 holding_id 查询委托汇总(R-010 */
async getTradeOrdersByHolding(holdingId) {
await this._ensure();
return this.sqlite.getOrdersByHolding(holdingId);
}
/** 关闭(插件释放时) */ /** 关闭(插件释放时) */
close() { close() {
this.sqlite.close(); this.sqlite.close();
+26 -3
View File
@@ -69,17 +69,40 @@ export class PositionManager {
return { ...(map[code] ?? {}) }; return { ...(map[code] ?? {}) };
} }
/** 某策略下的持仓(份额 > 0 的标的) */ /** 某策略下的持仓(份额 > 0 的标的R-010 附加 holding_id + 成本价/最后一笔成交价 */
async getStrategyPositions(strategyId) { async getStrategyPositions(strategyId) {
const [positions, dataset] = await Promise.all([ const [positions, dataset, holdings] = await Promise.all([
this.getAllPositions(), this.getAllPositions(),
this.storage.getDataset(strategyId), this.storage.getDataset(strategyId),
this.storage.getCurrentHoldings(strategyId), // 含 holding_id(同策略同 code 当前持仓唯一)
]); ]);
const shareMap = new Map(dataset.map((d) => [d.code, d.shares])); const shareMap = new Map(dataset.map((d) => [d.code, d.shares]));
const holdingMap = new Map(holdings.map((h) => [h.code, h.holdingId]));
// R-010 Q4:批量取各 holding 的关联委托(查最后一笔成交价)
const holdingIds = [...new Set(holdings.map((h) => h.holdingId).filter((x) => x != null))];
const tradesByHolding = new Map();
if (holdingIds.length > 0) {
for (const hid of holdingIds) {
const orders = await this.storage.getTradeOrdersByHolding(hid);
tradesByHolding.set(hid, orders);
}
}
return positions return positions
.map((p) => { .map((p) => {
const shares = shareMap.get(p.code) ?? 0; const shares = shareMap.get(p.code) ?? 0;
return shares > 0 ? { ...p, shares } : null; if (shares <= 0) return null;
const holdingId = holdingMap.get(p.code) ?? null;
// 成本价 = QMT avgPrice(纸面数据已有)
const avgPrice = +(p.avgPrice ?? 0);
// 最后一笔成交价:关联委托按时间取最新一笔【有实际成交】(tradedVolume > 0)的 tradedPrice;无则 = 成本价
let lastTradePrice = avgPrice;
const orders = holdingId != null ? (tradesByHolding.get(holdingId) ?? []) : [];
const filled = orders.filter((o) => (o.tradedVolume ?? 0) > 0 && (o.tradedPrice ?? 0) > 0);
if (filled.length > 0) {
// getOrdersByHolding 已按 insert_ts DESC,第一条即最新
lastTradePrice = +filled[0].tradedPrice;
}
return { ...p, shares, holdingId, avgPrice, lastTradePrice };
}) })
.filter(Boolean); .filter(Boolean);
} }
+24 -3
View File
@@ -163,12 +163,32 @@ export class QmtBridgeRestDataSource {
}; };
} }
/** 委托字段映射:m_ 原始字段 → 语义字段(R-007 2.1/2.2 */ /**
* 归一化证券代码:QMT 委托/成交返回无后缀(如 001330),持仓体系带后缀(001330.SZ)。
* 规则(与持仓 mapPosition 一致):6 位数字 + 交易所后缀(SH/SZ/BJ);已有后缀原样保留。
*/
normalizeInstrumentCode(raw, exchange) {
let code = String(raw ?? '').trim().toUpperCase();
if (!code) return '';
if (/^\d{6}\.(SH|SZ|BJ)$/.test(code)) return code; // 已带后缀
if (/^\d{6}$/.test(code)) {
const ex = String(exchange ?? '').toUpperCase();
if (ex === 'SH' || ex === 'SZ' || ex === 'BJ') return code + '.' + ex;
// 无交易所信息时按规则推断:6/9 开头沪市,0/3 开头深市
const head = code[0];
if (head === '6' || head === '9') return code + '.SH';
return code + '.SZ';
}
return code;
}
/** 委托字段映射:m_ 原始字段 → 语义字段(R-007 2.1/2.2R-009 迭代 07 补 tradeDate + code 归一化) */
mapOrder(o) { mapOrder(o) {
const direction = o.m_nOffsetFlag === 48 ? 'buy' : o.m_nOffsetFlag === 49 ? 'sell' : ''; const direction = o.m_nOffsetFlag === 48 ? 'buy' : o.m_nOffsetFlag === 49 ? 'sell' : '';
const code = this.normalizeInstrumentCode(o.m_strInstrumentID, o.m_strExchangeID);
return { return {
orderId: o.m_strOrderSysID ?? '', orderId: o.m_strOrderSysID ?? '',
code: o.m_strInstrumentID ?? '', code,
name: o.m_strInstrumentName ?? '', name: o.m_strInstrumentName ?? '',
exchange: o.m_strExchangeID ?? '', exchange: o.m_strExchangeID ?? '',
direction, // 'buy' | 'sell' | '' direction, // 'buy' | 'sell' | ''
@@ -181,6 +201,7 @@ export class QmtBridgeRestDataSource {
limitPrice: +(o.m_dLimitPrice ?? 0), limitPrice: +(o.m_dLimitPrice ?? 0),
tradedPrice: +(o.m_dTradedPrice ?? 0), tradedPrice: +(o.m_dTradedPrice ?? 0),
amount: +(o.m_dTradeAmount ?? 0), amount: +(o.m_dTradeAmount ?? 0),
tradeDate: o.m_strTradeDate ?? o.m_strInsertDate ?? '', // 交易日:委托无独立字段,用 insertDate 兜底
insertDate: o.m_strInsertDate ?? '', insertDate: o.m_strInsertDate ?? '',
insertTime: o.m_strInsertTime ?? '', insertTime: o.m_strInsertTime ?? '',
cancelInfo: o.m_strCancelInfo ?? '', cancelInfo: o.m_strCancelInfo ?? '',
@@ -227,7 +248,7 @@ export class QmtBridgeRestDataSource {
return { return {
tradeId: t.m_strTradeID ?? '', tradeId: t.m_strTradeID ?? '',
orderId: t.m_strOrderSysID ?? '', // 关联委托 orderId: t.m_strOrderSysID ?? '', // 关联委托
code: t.m_strInstrumentID ?? '', code: this.normalizeInstrumentCode(t.m_strInstrumentID, t.m_strExchangeID),
name: t.m_strInstrumentName ?? '', name: t.m_strInstrumentName ?? '',
exchange: t.m_strExchangeID ?? '', exchange: t.m_strExchangeID ?? '',
direction, // 'buy' | 'sell' | '' direction, // 'buy' | 'sell' | ''
+413
View File
@@ -7,6 +7,8 @@
* 表结构(技术实现方案): * 表结构(技术实现方案):
* - strategy_holdings 策略持仓生命周期表(一笔 = 一次「建仓→清仓」,历史保留) * - strategy_holdings 策略持仓生命周期表(一笔 = 一次「建仓→清仓」,历史保留)
* - market_quotes_cache 行情快照缓存(code 维度一行一码,只存 UI 消费的 lastPrice/lastClose * - market_quotes_cache 行情快照缓存(code 维度一行一码,只存 UI 消费的 lastPrice/lastClose
* - trade_orders 交易委托(R-009:委托主行,order_id 主键,UPSERT 幂等,零冗余)
* - trade_fills 交易成交(R-009:成交明细,trade_id 主键,order_id 关联委托)
* *
* 方法集(替代 DataStore 原 getDataset/setDataset/removeDataset/getAllDatasets 整体读写): * 方法集(替代 DataStore 原 getDataset/setDataset/removeDataset/getAllDatasets 整体读写):
* 持仓生命周期:openHolding / addShares / reduceShares / closeHolding * 持仓生命周期:openHolding / addShares / reduceShares / closeHolding
@@ -47,6 +49,53 @@ CREATE TABLE IF NOT EXISTS market_quotes_cache (
last_close REAL NOT NULL, last_close REAL NOT NULL,
updated_at INTEGER NOT NULL DEFAULT 0 updated_at INTEGER NOT NULL DEFAULT 0
); );
-- 交易委托(R-009 迭代 07:委托主行;order_id 唯一,UPSERT 幂等;零冗余 strategy_id/holding_id
CREATE TABLE IF NOT EXISTS trade_orders (
order_id TEXT PRIMARY KEY,
trade_date TEXT NOT NULL,
code TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
exchange TEXT NOT NULL DEFAULT '',
direction TEXT NOT NULL DEFAULT '',
direction_code INTEGER,
opt_name TEXT NOT NULL DEFAULT '',
status INTEGER,
order_volume REAL NOT NULL DEFAULT 0,
traded_volume REAL NOT NULL DEFAULT 0,
limit_price REAL NOT NULL DEFAULT 0,
traded_price REAL NOT NULL DEFAULT 0,
amount REAL NOT NULL DEFAULT 0,
insert_date TEXT NOT NULL DEFAULT '',
insert_time TEXT NOT NULL DEFAULT '',
insert_ts INTEGER NOT NULL,
cancel_info TEXT NOT NULL DEFAULT '',
error_msg TEXT NOT NULL DEFAULT '',
strategy_id TEXT, -- 用户手动设置的策略归属(Q1 冗余)
holding_id INTEGER, -- 用户手动设置的持仓归属(Q1 冗余)
fetched_at INTEGER NOT NULL
);
-- 交易成交(trade_id 唯一,order_id 外键关联委托;零冗余 strategy_id/holding_id
CREATE TABLE IF NOT EXISTS trade_fills (
trade_id TEXT PRIMARY KEY,
order_id TEXT NOT NULL,
trade_date TEXT NOT NULL,
code TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
exchange TEXT NOT NULL DEFAULT '',
direction TEXT NOT NULL DEFAULT '',
direction_code INTEGER,
opt_name TEXT NOT NULL DEFAULT '',
price REAL NOT NULL DEFAULT 0,
volume REAL NOT NULL DEFAULT 0,
amount REAL NOT NULL DEFAULT 0,
commission_rate_wan REAL NOT NULL DEFAULT 0,
trade_time TEXT NOT NULL DEFAULT '',
fetched_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_trade_orders_date ON trade_orders (trade_date);
CREATE INDEX IF NOT EXISTS idx_trade_orders_ts ON trade_orders (insert_ts);
CREATE INDEX IF NOT EXISTS idx_trade_fills_order ON trade_fills (order_id);
CREATE INDEX IF NOT EXISTS idx_trade_fills_date ON trade_fills (trade_date);
`; `;
export class SqliteStore { export class SqliteStore {
@@ -73,9 +122,35 @@ export class SqliteStore {
mkdirSync(this.dataDir, { recursive: true }); mkdirSync(this.dataDir, { recursive: true });
this.db = new DatabaseSync(this.dbPath); this.db = new DatabaseSync(this.dbPath);
this.db.exec(SCHEMA_SQL); this.db.exec(SCHEMA_SQL);
// 存量库迁移:trade_orders 补手动归属列(R-009 二次定稿;幂等)
this._ensureTradeAttributionColumns();
return this.db; return this.db;
} }
/**
* 确保 trade_orders 存在 strategy_id / holding_id 列(旧库 ALTER 补列,幂等)。
* 注意:只读打开(外部脚本、插件持锁时)会抛「readonly database」——此处容忍,
* 真实迁移由插件进程(持锁方)启动时执行;外部脚本只读验证不应因此崩溃。
*/
_ensureTradeAttributionColumns() {
try {
const cols = new Set(this.db.prepare('PRAGMA table_info(trade_orders)').all().map((c) => c.name));
if (!cols.has('strategy_id')) {
this.db.exec('ALTER TABLE trade_orders ADD COLUMN strategy_id TEXT');
}
if (!cols.has('holding_id')) {
this.db.exec('ALTER TABLE trade_orders ADD COLUMN holding_id INTEGER');
}
} catch (err) {
// 只读场景(如外部验证脚本在插件持锁时打开)→ 忽略,列迁移由插件进程完成
if (String(err?.message ?? '').includes('readonly')) {
console.warn('[one-divine-lot] trade_orders 归属列迁移跳过(只读连接,插件持锁中)');
} else {
throw err;
}
}
}
/** 关闭连接 */ /** 关闭连接 */
close() { close() {
if (this.db) { if (this.db) {
@@ -344,4 +419,342 @@ export class SqliteStore {
updatedAt: row.updated_at, updatedAt: row.updated_at,
}; };
} }
// ===== 交易记录(trade_orders / trade_fillsR-009 迭代 07=====
/**
* UPSERT 委托(幂等):order_id 已存在则覆盖(状态/成交量等当日流转更新)
* @param {Array} orders 语义化委托列表(QmtBridgeRestDataSource.mapOrder 输出)
* @returns {number} 写入条数
*/
upsertOrders(orders) {
this.init();
const list = Array.isArray(orders) ? orders : [];
if (list.length === 0) return 0;
const stmt = this.db.prepare(`
INSERT INTO trade_orders (
order_id, trade_date, code, name, exchange, direction, direction_code, opt_name,
status, order_volume, traded_volume, limit_price, traded_price, amount,
insert_date, insert_time, insert_ts, cancel_info, error_msg, fetched_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(order_id) DO UPDATE SET
trade_date=excluded.trade_date, code=excluded.code, name=excluded.name,
exchange=excluded.exchange, direction=excluded.direction,
direction_code=excluded.direction_code, opt_name=excluded.opt_name,
status=excluded.status, order_volume=excluded.order_volume,
traded_volume=excluded.traded_volume, limit_price=excluded.limit_price,
traded_price=excluded.traded_price, amount=excluded.amount,
insert_date=excluded.insert_date, insert_time=excluded.insert_time,
insert_ts=excluded.insert_ts, cancel_info=excluded.cancel_info,
error_msg=excluded.error_msg, fetched_at=excluded.fetched_at
-- 注意:不覆盖 strategy_id / holding_id(手动归属为插件逻辑,Q2 老师确认)
`);
const now = Date.now();
this.db.exec('BEGIN');
try {
for (const o of list) {
if (!o || !o.orderId) continue;
const ts = this._orderInsertTs(o);
stmt.run(
o.orderId, o.tradeDate ?? '', o.code ?? '', o.name ?? '', o.exchange ?? '',
o.direction ?? '', o.directionCode ?? null, o.optName ?? '', o.status ?? null,
Number(o.orderVolume ?? 0), Number(o.tradedVolume ?? 0),
Number(o.limitPrice ?? 0), Number(o.tradedPrice ?? 0), Number(o.amount ?? 0),
o.insertDate ?? '', o.insertTime ?? '', ts, o.cancelInfo ?? '', o.errorMsg ?? '', now
);
}
this.db.exec('COMMIT');
} catch (err) {
this.db.exec('ROLLBACK');
throw err;
}
return list.length;
}
/**
* UPSERT 成交(幂等):trade_id 已存在则覆盖
* @param {Array} fills 语义化成交列表(QmtBridgeRestDataSource.mapTrade 输出)
* @returns {number} 写入条数
*/
upsertFills(fills) {
this.init();
const list = Array.isArray(fills) ? fills : [];
if (list.length === 0) return 0;
const stmt = this.db.prepare(`
INSERT INTO trade_fills (
trade_id, order_id, trade_date, code, name, exchange, direction, direction_code,
opt_name, price, volume, amount, commission_rate_wan, trade_time, fetched_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(trade_id) DO UPDATE SET
order_id=excluded.order_id, trade_date=excluded.trade_date, code=excluded.code,
name=excluded.name, exchange=excluded.exchange, direction=excluded.direction,
direction_code=excluded.direction_code, opt_name=excluded.opt_name,
price=excluded.price, volume=excluded.volume, amount=excluded.amount,
commission_rate_wan=excluded.commission_rate_wan, trade_time=excluded.trade_time,
fetched_at=excluded.fetched_at
`);
const now = Date.now();
this.db.exec('BEGIN');
try {
for (const t of list) {
if (!t || !t.tradeId) continue;
stmt.run(
t.tradeId, t.orderId ?? '', t.tradeDate ?? '', t.code ?? '', t.name ?? '',
t.exchange ?? '', t.direction ?? '', t.directionCode ?? null, t.optName ?? '',
Number(t.price ?? 0), Number(t.volume ?? 0), Number(t.amount ?? 0),
Number(t.commissionRateWan ?? 0), t.tradeTime ?? '', now
);
}
this.db.exec('COMMIT');
} catch (err) {
this.db.exec('ROLLBACK');
throw err;
}
return list.length;
}
/**
* 委托时间 → 毫秒时间戳(insert_date YYYYMMDD + insert_time HHMMSS
* 时间字段缺失时回退 fetched_at(同步时间),保证 join 窗口可用
*/
_orderInsertTs(o) {
const d = String(o.insertDate ?? '');
const t = String(o.insertTime ?? '');
if (d.length === 8 && t.length === 6) {
const y = Number(d.slice(0, 4));
const m = Number(d.slice(4, 6)) - 1;
const day = Number(d.slice(6, 8));
const hh = Number(t.slice(0, 2));
const mm = Number(t.slice(2, 4));
const ss = Number(t.slice(4, 6));
// 用本地时间构造(与 strategy_holdings.created_at 的 Date.now() 同基准)
const ts = new Date(y, m, day, hh, mm, ss).getTime();
if (Number.isFinite(ts) && ts > 0) return ts;
}
// 回退:以「当日」估算(用于极端缺字段场景)
const dateOnly = Date.parse(String(d).replace(/^(\d{4})(\d{2})(\d{2})$/, '$1-$2-$3'));
return Number.isFinite(dateOnly) ? dateOnly : Date.now();
}
/**
* 委托 → 策略归属推导(外键链:委托时间 join strategy_holdings 生命周期窗口)
* 规则(Q3 老师定稿):created_at ≤ insert_ts < closed_atclosed_at NULL=当前持仓);一码多策略取份额最大;无命中=null
* @param {Array} orders 委托行(含 insert_ts 合成)
* @returns {Map<string, {strategyId: string|null, holdingId: number|null}>} order_id → 归属
*/
_resolveStrategyAttribution(orders) {
this.init();
const out = new Map();
const list = Array.isArray(orders) ? orders.filter((o) => o && o.orderId) : [];
if (list.length === 0) return out;
// 收集涉及的 code + 时间窗
const codes = [...new Set(list.map((o) => o.code).filter(Boolean))];
if (codes.length === 0) return out;
const placeholders = codes.map(() => '?').join(',');
const rows = this.db.prepare(
'SELECT holding_id, strategy_id, code, shares, created_at, closed_at FROM strategy_holdings WHERE code IN (' + placeholders + ')'
).all(...codes);
const holdingsByCode = new Map();
for (const h of rows) {
if (!holdingsByCode.has(h.code)) holdingsByCode.set(h.code, []);
holdingsByCode.get(h.code).push(h);
}
for (const o of list) {
const ts = this._orderInsertTs(o);
// 方案 A2026-09-01 老师确认):当前持仓(closed_at IS NULL)只做同 code 匹配
// created_at 可能是迁移时间戳,不要求 ≤ 委托时间;现在持有=当日交易可归);
// 已清仓(closed_at 非空)才按时间窗口(created_at ≤ t < closed_at)判断。
const candidates = (holdingsByCode.get(o.code) ?? []).filter((h) => {
if (h.closed_at == null) return true; // 当前持仓:同 code 即匹配
if (h.created_at > ts) return false;
if (ts >= h.closed_at) return false;
return true;
});
if (candidates.length === 0) {
out.set(o.orderId, { strategyId: null, holdingId: null });
continue;
}
// 一码多策略消歧:取份额最大持仓(确定性规则)
candidates.sort((a, b) => (b.shares ?? 0) - (a.shares ?? 0));
const pick = candidates[0];
out.set(o.orderId, { strategyId: pick.strategy_id, holdingId: pick.holding_id });
}
return out;
}
/**
* 给任意委托列表附加策略/持仓归属(今日实时委托用;只读 strategy_holdings,不写库)
* @param {Array} orders 语义化委托列表
* @returns {Array} 每项附加 strategyId/holdingId
*/
attachStrategyAttribution(orders) {
const list = Array.isArray(orders) ? orders : [];
const attribution = this._resolveStrategyAttribution(list);
return list.map((o) => {
const a = attribution.get(o?.orderId) ?? { strategyId: null, holdingId: null };
return { ...o, strategyId: a.strategyId, holdingId: a.holdingId };
});
}
/**
* 本地历史委托查询(R-009 Q4):按时间段/code/策略/方向过滤
* @param {object} [opts] { start, end, code, strategyId, direction } 日期 YYYYMMDD
* @returns {Promise<Array>} 委托列表(每项含 strategyId/holdingId 归属字段)
*/
getOrderHistory({ start, end, code, strategyId, direction } = {}) {
this.init();
const where = [];
const params = [];
if (start) { where.push('trade_date >= ?'); params.push(String(start)); }
if (end) { where.push('trade_date <= ?'); params.push(String(end)); }
if (code) { where.push('code = ?'); params.push(String(code)); }
if (direction) { where.push('direction = ?'); params.push(String(direction)); }
let sql = 'SELECT * FROM trade_orders';
if (where.length) sql += ' WHERE ' + where.join(' AND ');
sql += ' ORDER BY insert_ts DESC';
const rows = this.db.prepare(sql).all(...params);
// 归属 = 用户手动设置的持久化列(Q3 全手动选;未设置为 null=未关联)
if (strategyId === '__unassigned__') {
return rows.map((r) => this._mapOrderRow(r)).filter((o) => !o.strategyId);
}
if (strategyId) {
return rows.map((r) => this._mapOrderRow(r)).filter((o) => o.strategyId === strategyId);
}
return rows.map((r) => this._mapOrderRow(r));
}
/**
* 本地历史成交查询(R-009 Q4):策略过滤按所属委托归属(fill → order → holding → strategy
* @param {object} [opts] { start, end, code, strategyId, direction }
* @returns {Array} 成交列表(每项含 strategyId/holdingId 归属字段)
*/
getFillHistory({ start, end, code, strategyId, direction } = {}) {
this.init();
const where = [];
const params = [];
if (start) { where.push('f.trade_date >= ?'); params.push(String(start)); }
if (end) { where.push('f.trade_date <= ?'); params.push(String(end)); }
if (code) { where.push('f.code = ?'); params.push(String(code)); }
if (direction) { where.push('f.direction = ?'); params.push(String(direction)); }
let sql = 'SELECT f.* FROM trade_fills f';
if (where.length) sql += ' WHERE ' + where.join(' AND ');
sql += ' ORDER BY f.trade_date DESC, f.trade_time DESC';
const rows = this.db.prepare(sql).all(...params);
const fills = rows.map((r) => this._mapFillRow(r));
if (!strategyId) return fills;
// 策略过滤:按所属委托的【持久化归属】strategy_idfill → order 的 strategy_id
const orderIds = [...new Set(fills.map((f) => f.orderId).filter(Boolean))];
if (orderIds.length === 0) return [];
const ph = orderIds.map(() => '?').join(',');
const orderRows = this.db.prepare(
'SELECT order_id, strategy_id FROM trade_orders WHERE order_id IN (' + ph + ')'
).all(...orderIds);
const orderStrategy = new Map(orderRows.map((r) => [r.order_id, r.strategy_id ?? null]));
if (strategyId === '__unassigned__') {
return fills.filter((f) => !orderStrategy.get(f.orderId));
}
return fills.filter((f) => orderStrategy.get(f.orderId) === strategyId);
}
/**
* 设置委托归属(Q4 老师确认:可随时改,以最终修改为准)
* @param {string} orderId
* @param {string|null} strategyId null=清除归属(未关联)
* @param {number|null} holdingId null=清除归属
* @returns {boolean} 是否更新成功
*/
setOrderAttribution(orderId, strategyId, holdingId) {
this.init();
const r = this.db.prepare(
'UPDATE trade_orders SET strategy_id=?, holding_id=? WHERE order_id=?'
).run(strategyId ?? null, holdingId ?? null, orderId);
return r.changes > 0;
}
/**
* 按 holding_id 查询委托汇总(R-010 迭代 08:策略持仓行展开用)
* @param {number} holdingId 持仓 ID
* @returns {Array} 该 holding 的委托列表(含归属字段,按时间降序)
*/
getOrdersByHolding(holdingId) {
this.init();
if (holdingId == null) return [];
return this.db.prepare(
'SELECT * FROM trade_orders WHERE holding_id=? ORDER BY insert_ts DESC'
).all(holdingId).map((r) => this._mapOrderRow(r));
}
/**
* 委托归属候选列表(用户手动设置用;候选 = 该 code 当前持仓的策略/持仓,Q3 全手动选)
* @param {string} code 证券代码(含后缀)
* @returns {Array<{strategyId: string, holdingId: number, shares: number}>} 候选列表(可空)
*/
getAttributionCandidates(code) {
this.init();
if (!code) return [];
return this.db.prepare(
'SELECT holding_id, strategy_id, shares FROM strategy_holdings WHERE code=? AND closed_at IS NULL ORDER BY shares DESC'
).all(code).map((h) => ({
strategyId: h.strategy_id,
holdingId: h.holding_id,
shares: h.shares,
}));
}
_mapOrderRow(r) {
return {
orderId: r.order_id,
tradeDate: r.trade_date,
code: r.code,
name: r.name,
exchange: r.exchange,
direction: r.direction,
directionCode: r.direction_code,
optName: r.opt_name,
status: r.status,
orderVolume: r.order_volume,
tradedVolume: r.traded_volume,
limitPrice: r.limit_price,
tradedPrice: r.traded_price,
amount: r.amount,
insertDate: r.insert_date,
insertTime: r.insert_time,
insertTs: r.insert_ts,
cancelInfo: r.cancel_info,
errorMsg: r.error_msg,
strategyId: r.strategy_id ?? null, // 手动归属(Q1 冗余)
holdingId: r.holding_id ?? null, // 手动归属(Q1 冗余)
fetchedAt: r.fetched_at,
};
}
_mapFillRow(r) {
return {
tradeId: r.trade_id,
orderId: r.order_id,
tradeDate: r.trade_date,
code: r.code,
name: r.name,
exchange: r.exchange,
direction: r.direction,
directionCode: r.direction_code,
optName: r.opt_name,
price: r.price,
volume: r.volume,
amount: r.amount,
commissionRateWan: r.commission_rate_wan,
tradeTime: r.trade_time,
fetchedAt: r.fetched_at,
};
}
/**
* 统计某日委托/成交条数(调试/验证用)
*/
countTradeRows(tradeDate) {
this.init();
const o = this.db.prepare('SELECT COUNT(*) AS n FROM trade_orders' + (tradeDate ? ' WHERE trade_date=?' : '')).get(...(tradeDate ? [tradeDate] : []));
const f = this.db.prepare('SELECT COUNT(*) AS n FROM trade_fills' + (tradeDate ? ' WHERE trade_date=?' : '')).get(...(tradeDate ? [tradeDate] : []));
return { orders: o?.n ?? 0, fills: f?.n ?? 0 };
}
} }
+88
View File
@@ -0,0 +1,88 @@
/**
* TradeSync —— 交易记录本地同步(R-009 / 迭代 072026-09-01
*
* 职责:把 QMT Bridge 当日交易数据(委托 + 成交)定时同步落 SQLitetrade_orders + trade_fills)。
* - 启动预热:插件启动即同步一次(今日数据入库);
* - 定时同步:60s 轮询拉当日 orders+trades → UPSERT(幂等,状态覆盖更新);
* - 失败容忍:单次同步失败只记日志,不中断定时循环。
*
* 数据来源:QmtBridgeRestDataSource.getOrders() / getTrades()(仅当日,QMT 无历史接口)。
* 存储:DataStore.upsertTradeOrders / upsertTradeFills(委托 SqliteStoreUPSERT 幂等)。
*/
const SYNC_INTERVAL_MS = 60 * 1000; // 60s
export class TradeSync {
/**
* @param {object} opts
* @param {object} opts.runtime { dataSource, storage } —— dataSource 拉当日数据,storage 落库
* @param {object} [opts.logger]
*/
constructor({ runtime, logger } = {}) {
this.runtime = runtime;
this.logger = logger;
this.timer = null;
this.mounted = false;
this.syncing = false;
}
/** 启动:立即同步一次(预热)+ 定时同步 */
start() {
if (this.mounted) return;
this.mounted = true;
this.syncNow().catch((e) => {
this.logger?.warn?.('[one-divine-lot] TradeSync 启动预热失败: ' + (e?.message ?? e));
});
this.timer = setInterval(() => {
this.syncNow().catch((e) => {
this.logger?.debug?.('[one-divine-lot] TradeSync 定时同步失败: ' + (e?.message ?? e));
});
}, SYNC_INTERVAL_MS);
this.logger?.info?.('[one-divine-lot] TradeSync 启动(60s 定时同步交易记录)');
}
/** 停止(插件释放时) */
stop() {
this.mounted = false;
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
/**
* 同步一次:拉当日 orders+trades → UPSERT 落库(幂等)
* @returns {Promise<{orders: number, fills: number} | null>} 写入条数;数据源不可用返回 null
*/
async syncNow() {
if (!this.mounted || this.syncing) return null;
const { dataSource, storage } = this.runtime;
if (!dataSource || !storage) return null;
this.syncing = true;
try {
const [orders, fills] = await Promise.all([
dataSource.getOrders({}).catch((e) => {
this.logger?.debug?.('[one-divine-lot] TradeSync 拉委托失败: ' + (e?.message ?? e));
return null;
}),
dataSource.getTrades({}).catch((e) => {
this.logger?.debug?.('[one-divine-lot] TradeSync 拉成交失败: ' + (e?.message ?? e));
return null;
}),
]);
const nOrders = Array.isArray(orders) ? await storage.upsertTradeOrders(orders) : 0;
const nFills = Array.isArray(fills) ? await storage.upsertTradeFills(fills) : 0;
if (nOrders > 0 || nFills > 0) {
this.logger?.debug?.('[one-divine-lot] TradeSync 同步完成: 委托 ' + nOrders + ' / 成交 ' + nFills);
}
return { orders: nOrders, fills: nFills };
} finally {
this.syncing = false;
}
}
/** 手动触发同步(api 层可调用;返回最新同步结果) */
async sync() {
return this.syncNow();
}
}
+8 -2
View File
@@ -21,6 +21,7 @@ import { registerApi } from './api/index.js';
import { MarketDataHub } from './component/MarketDataHub.js'; import { MarketDataHub } from './component/MarketDataHub.js';
import { MarketFeed } from './component/MarketFeed.js'; import { MarketFeed } from './component/MarketFeed.js';
import { QmtHealthMonitor } from './component/QmtHealthMonitor.js'; import { QmtHealthMonitor } from './component/QmtHealthMonitor.js';
import { TradeSync } from './component/TradeSync.js';
const name = 'one-divine-lot'; const name = 'one-divine-lot';
@@ -71,8 +72,12 @@ async function apply(ctx, config) {
// S5: 分仓逻辑(份额分配/查询) // S5: 分仓逻辑(份额分配/查询)
const manager = new PositionManager({ dataSource, storage }); const manager = new PositionManager({ dataSource, storage });
// S6: 服务端 HTTP APIR-004:注入 dataSource 以编排激活热切换 // R-009:交易记录本地同步(启动预热 + 60s 定时 UPSERT 落库
registerApi(ctx, { manager, settings, dataSource, marketHub, marketFeed, qmtHealthMonitor }); const tradeSync = new TradeSync({ runtime: { dataSource, storage }, logger });
tradeSync.start();
// S6: 服务端 HTTP APIR-004:注入 dataSource 以编排激活热切换;R-009:注入 storage 供 trades/history
registerApi(ctx, { manager, settings, dataSource, marketHub, marketFeed, qmtHealthMonitor, storage, tradeSync });
// 启动时可用性检查(日志,不阻塞) // 启动时可用性检查(日志,不阻塞)
dataSource.isAvailable().then((ok) => { dataSource.isAvailable().then((ok) => {
@@ -86,6 +91,7 @@ async function apply(ctx, config) {
return () => { return () => {
marketFeed.stop(); marketFeed.stop();
qmtHealthMonitor.stop(); qmtHealthMonitor.stop();
tradeSync.stop(); // 停止交易记录定时同步(迭代 07)
storage.close(); // 关闭 SQLite 连接(迭代 06 storage.close(); // 关闭 SQLite 连接(迭代 06
logger.info('[one-divine-lot] 插件已释放'); logger.info('[one-divine-lot] 插件已释放');
}; };