Compare commits
5 Commits
dd98b4d45b
...
d122d88bce
| Author | SHA1 | Date | |
|---|---|---|---|
| d122d88bce | |||
| 2aacd8a25d | |||
| 859938c4f1 | |||
| 2790b92378 | |||
| b3526c7693 |
@@ -0,0 +1,66 @@
|
||||
# 计划:交易记录本地存储(SQLite)+ 策略关联(阶段航点)
|
||||
|
||||
> 编号:PLAN-008 | 粒度:阶段航点(大粒度) | 创建:2026-09-01 | 状态:**已完成(2026-09-01 迭代 07 验收通过)**
|
||||
> 派生自终极目标:目标-006(交易复盘)、目标-003(按策略监控市场)
|
||||
> 依据需求:**R-009(已定稿,2026-09-01,Q1-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_ts(insert_date+insert_time 合成毫秒时间戳,join 持仓窗口用);
|
||||
2. **定时同步**:服务端 TradeSync 模块 —— 定时(60s)拉当日 orders+trades → UPSERT 落库(幂等);插件启动同步一次(预热今日数据);前端今日轮询写穿(机会式);
|
||||
3. **策略关联(外键链推导)**:策略/持仓归属不在写入期计算,查询期由委托时间(insert_ts)JOIN strategy_holdings 生命周期窗口(created_at ≤ t < closed_at,closed_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**:装配 TradeSync(dispose 清理)+ storage 注入 api runtime;
|
||||
8. **前端**:TradeRecordsTab 策略过滤 + 历史范围本地展示;
|
||||
9. **构建测试**:pnpm run build + typecheck + 独立数据目录回归测试(技术约束-011);
|
||||
10. **验收**:对照迭代 07 验收标准逐条核验,记录迭代复盘。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- trade_orders + trade_fills 两表落地(零冗余 strategy_id/holding_id,insert_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-02,Q1-Q3 确认)** —— 符合入范围门槛
|
||||
> 设计约束:技术约束-012(数据存储设计)、技术约束-001/003/004(沿用)
|
||||
|
||||
## 目标
|
||||
|
||||
策略持仓 tab 每个持仓行(Holding)可展开,展开显示与该 holding 关联的交易记录(仅委托汇总,不分笔成交),实现「持仓 ↔ 交易」的双向追溯(R-009 反向:交易→持仓已实现,本迭代持仓→交易)。
|
||||
|
||||
## 范围
|
||||
|
||||
**做**:
|
||||
1. **strategy-positions 附加 holding_id**:PositionManager.getStrategyPositions 返回时,查 strategy_holdings 附加当前持仓的 holding_id(Q1);
|
||||
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**:getOrdersByHolding(WHERE 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):老师确认现价列 + 涨跌色 + 变化高亮 |
|
||||
| 产品约束-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 | 生效 | - | 讨论确认:为保证公平性,抽取必须不可预测 |
|
||||
-->
|
||||
@@ -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 |
|
||||
| 技术约束-007 | 插件安装用 dsh plugin add(自动 reconcile bundles),不直接用 pnpm add;bundle 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) |
|
||||
| 技术约束-012 | 插件数据存储遵循 **docs/03-设计约束/数据存储设计.md**:存储引擎为 **SQLite(node:sqlite)**——strategy_holdings + market_quotes_cache 两表(持仓生命周期表 + 行情极简两列缓存表);存储层单票生命周期操作;策略定义仍存 DSH settings;旧 JSON(store.json / store.market.json / allocations.json)经一次性迁移脚本 + 启动自动迁移(幂等、迁移前自动备份)后废弃;仅存储引擎替换,对外行为不变 | 2026-09-01 | 生效 | - | 变更(2026-09-01 R-008 定稿 + 迭代 06 实施):JSON data store → SQLite(node:sqlite,零依赖分发;strategy_holdings 持仓生命周期表+market_quotes_cache 两表;存储层单票生命周期;自动迁移;JSON 废弃);R-006 原 JSON 设计为上一版本基线 |
|
||||
| 技术约束-012 | 插件数据存储遵循 **docs/03-设计约束/数据存储设计.md**:存储引擎为 **SQLite(node:sqlite)**——strategy_holdings + market_quotes_cache + **trade_orders + trade_fills(交易记录两表,R-009)**;存储层单票生命周期操作;交易记录两表零冗余 strategy_id/holding_id(外键链推导策略归属);策略定义仍存 DSH settings;旧 JSON(store.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 数据误删事故沉淀:回归测试误删大连热电/万顺新材份额分配,老师定「测试用独立数据目录」 |
|
||||
| 技术约束-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 机制与内置插件注册方式 |
|
||||
|
||||
| 技术约束-013 | 交易记录本地存储(R-009):QMT 当日交易数据(委托/成交)由服务端 TradeSync 定时同步落 SQLite(启动预热 + 60s 定时 + UPSERT 幂等,只同步当日);trade_orders(委托主行,order_id 主键 + insert_ts 派生时间列 + **strategy_id/holding_id 手动归属列**)+ trade_fills(成交明细,trade_id 主键、order_id 外键)两表;**委托归属由用户在交易记录 tab 手动设置**(候选 = 该 code 当前持仓策略 + 未关联,全手动选、可随时改、以最终为准);**UPSERT 不覆盖归属列**(手动指定为插件逻辑);本地历史查询走 trades/history 端点(策略过滤 = 用户设置的归属);今日实时仍走 QMT Bridge;QMT 委托/成交 code 无后缀、持仓带后缀 —— 数据源映射层统一 normalizeInstrumentCode 归一化;委托交易日 = insertDate(tradeDate 兜底) | 2026-09-01 | 生效 | - | 新增(2026-09-01 R-009 定稿 + 迭代 07 实施):两表 + 定时同步 + 本地历史查询;变更 1(2026-09-01):+code 归一化 + tradeDate 兜底;变更 2(2026-09-01 老师二次定稿):归属改**手动设置**(trade_orders 冗余 strategy_id+holding_id,UPSERT 不覆盖归属列),弃算法推导 |
|
||||
<!-- 示例条目(确认格式后删除):
|
||||
| 技术约束-001 | 示例:技术栈以 Node.js / TypeScript 为准,不引入未讨论的新框架 | 2026-08-26 | 生效 | - | 讨论确认:优先复用 DSH 既有能力,新框架需论证 |
|
||||
-->
|
||||
+92
-2
@@ -261,8 +261,98 @@ CREATE TABLE IF NOT EXISTS market_quotes_cache (
|
||||
| MarketFeed | 行情获取(WS + REST 定时刷新 + 启动 prime) | src/component/MarketFeed.js |
|
||||
| migrate 脚本 | 一次性迁移脚本 | scripts/migrate-json-to-sqlite.mjs |
|
||||
|
||||
## 10. 约束条目(引用)
|
||||
## 10. 交易记录存储设计(R-009 落实,2026-09-01 迭代 07 实施)
|
||||
|
||||
- 技术约束-012:数据存储遵循本文件(SQLite 存储设计);
|
||||
> 依据:R-009(Q1-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, -- 交易日 YYYYMMDD(m_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_ts(insert_date+insert_time 合成毫秒) | join 持仓生命周期窗口用(created_at ≤ t < closed_at) |
|
||||
| 策略/持仓归属 | **用户手动设置**(交易记录 tab 下拉:候选 = 该 code 当前持仓策略 + 未关联);持久化到 trade_orders.strategy_id + holding_id;UPSERT 不覆盖归属列;可随时改以最终为准 | 一票多策略无法算法区分(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:测试/回归脚本禁止在真实数据上执行写操作(独立数据目录);
|
||||
- 本文件为新领域设计约束,后续数据存储变更以本文件为最终依据。
|
||||
@@ -0,0 +1,120 @@
|
||||
# 技术实现方案:07-交易记录本地存储(SQLite)+ 策略关联
|
||||
|
||||
> 依据:PLAN-008 | 需求:R-009(Q1-Q8 定稿)| 设计约束:技术约束-012(数据存储设计)、技术约束-011(测试隔离)
|
||||
|
||||
## 技术选型
|
||||
|
||||
- **存储**:沿用 SqliteStore(node: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, -- 交易日 YYYYMMDD(m_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(改)
|
||||
└─ 装配 TradeSync(dispose 停止)+ 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 当日交易数据(委托 + 成交)本地持久化到 SQLite(trade_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_ts)join 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 补 tradeDate(insertDate 兜底)+ mapTrade code 归一化;
|
||||
- **沉淀**:QMT 各接口的证券代码格式不一致(委托/成交无后缀、持仓带后缀),语义化映射层必须统一归一化;交易「日」概念在委托接口 = insertDate(无独立 tradeDate 字段)。
|
||||
|
||||
|
||||
### 5. 迁移持仓 created_at = 迁移时间戳 → FK 链窗口失真(真实数据发现 + 方案 A 修正)
|
||||
- **发现**:迁移自 JSON 的持仓 created_at 全部是迁移时刻时间戳(2026-09-01 17:36),晚于当日真实交易时间 → 当日委托 join 窗口不匹配 → 全部判「未关联」;
|
||||
- **决策(方案 A,2026-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.db(updated_at / mtime)。
|
||||
@@ -0,0 +1,28 @@
|
||||
# 迭代目标:07-交易记录本地存储(SQLite)+ 策略关联
|
||||
|
||||
> 迭代编号:07 | 创建:2026-09-01 | 状态:进行中
|
||||
> 依据计划:PLAN-008 | 需求:R-009(已定稿,2026-09-01)
|
||||
|
||||
## 目标描述
|
||||
|
||||
将 QMT 当日交易数据(委托 + 成交)本地持久化到 SQLite(trade_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_orders(order_id 主键 + insert_ts 派生列)+ trade_fills(trade_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.js:trades/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_id(PositionManager 查 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.SZ(holding 13)→ 看到博纳影业卖出委托汇总;
|
||||
- 前端:展开/折叠、懒加载、汇总表列正确;
|
||||
- 回归:份额操作、交易记录、行情不回归。
|
||||
|
||||
## 验收目标
|
||||
|
||||
- 持仓 ↔ 交易双向追溯闭环(R-009 交易→持仓 + 本迭代持仓→交易);
|
||||
- 为按 holding 复盘交易铺路(目标-006)。
|
||||
@@ -0,0 +1,146 @@
|
||||
# R-009 交易记录本地存储(SQLite)+ 策略关联 · 已完成
|
||||
|
||||
> 归档日期:2026-09-01 | 需求状态:**已完成**
|
||||
> 原索引:docs/05-需求池/需求池索引.md(主索引保留 R-009 条目,指向本归档)
|
||||
> 实现迭代:07-交易记录本地存储SQLite与策略关联(验收通过,迭代复盘见 docs/04-迭代记录/07-交易记录本地存储SQLite与策略关联/迭代复盘.md)
|
||||
> 关联计划:PLAN-008(docs/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-008(SQLite 存储)、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, -- 交易日 YYYYMMDD(m_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_ts(insert_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_id(Q1);**UPSERT 不覆盖归属列**(Q2,手动指定为插件逻辑);**全手动选**(Q3,不自动预填候选值);**可随时改、以最终为准**(Q4);算法推导降级为候选列表来源。
|
||||
@@ -0,0 +1,63 @@
|
||||
# R-010 策略持仓行展开关联交易记录(Holding → 交易汇总)· 已完成
|
||||
|
||||
> 归档日期:2026-09-02 | 需求状态:**已完成**
|
||||
> 原索引:docs/05-需求池/需求池索引.md(主索引保留 R-010 条目,指向本归档)
|
||||
> 实现迭代:08-策略持仓行展开关联交易记录(老师确认,迭代复盘见 docs/04-迭代记录/08-策略持仓行展开关联交易记录/迭代复盘.md)
|
||||
> 关联计划:PLAN-009(docs/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_id(R-009 已建立:用户手动设置归属时写入 holding_id)。
|
||||
|
||||
## 现状(代码审查 2026-09-02)
|
||||
|
||||
| 项 | 现状 |
|
||||
|---|---|
|
||||
| 策略持仓 tab | StrategyTab.jsx:表格展示策略持仓(代码/名称/现价/策略份额/总持仓/操作),行不可展开 |
|
||||
| 持仓数据 | strategy-positions 端点返回 QMT 持仓 + shares,**不含 holding_id**(需附加) |
|
||||
| holding 关联 | trade_orders.holding_id(R-009 手动归属写入),按 holding_id 可精确查询交易 |
|
||||
| 交易汇总 | trade_orders 单行即委托汇总(order_volume/traded_volume/amount/费用等),天然是汇总 |
|
||||
|
||||
## 待讨论点(Q1-Q3,AI 建议见各条)
|
||||
|
||||
### Q1 持仓行如何带出 holding_id(AI 建议: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_id(Q1)+ trades/by-holding 端点(Q2)+ 持仓行展开 UI(懒加载,仅委托汇总)(Q3);**R-010 转已定稿,进入迭代 08 实施**。
|
||||
- 2026-09-02 老师补充 Q4(成本价 + 最后一笔成交价)并确认规则(取最新有成交的 tradedPrice,无成交默认成本价)。
|
||||
@@ -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-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 升级为 SQLite(node: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-01,T-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-策略持仓行展开关联交易记录 | **已实现(已归档)** |
|
||||
|
||||
## 渐进明细规划素材
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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');
|
||||
|
||||
// mapOrder:tradeDate 兜底 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 保留');
|
||||
|
||||
// mapTrade:code 归一化 + 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);
|
||||
@@ -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);
|
||||
@@ -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-attribution,Q4 可改)
|
||||
* 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-t(300057.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);
|
||||
@@ -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);
|
||||
@@ -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, '一票多策略候选 ≥2(grid + 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
@@ -59,8 +59,8 @@ const HANDLERS = [
|
||||
* @param {import('@deepseek-ai/cordis').Context} ctx
|
||||
* @param {object} runtime { manager, settings, dataSource, marketCache }
|
||||
*/
|
||||
export function registerApi(ctx, { manager, settings, dataSource, marketHub, marketFeed, qmtHealthMonitor }) {
|
||||
const runtime = { 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, storage, tradeSync };
|
||||
|
||||
ctx.effect(() => ctx.webServer.register({
|
||||
kind: 'prefix',
|
||||
|
||||
+64
-3
@@ -5,16 +5,24 @@
|
||||
* orders → 当日委托(支持 code/status 过滤)
|
||||
* trades → 当日成交
|
||||
* trading-dates → 交易日历(日期导航)
|
||||
* trades/history → 本地 SQLite 历史查询(R-009:时间段/code/策略/方向过滤)
|
||||
* trades/by-holding → 按 holding_id 查委托汇总(R-010:策略持仓行展开)
|
||||
*
|
||||
* 数据流:前端轮询 → /odl/api/orders|trades → dataSource.getOrders/getTrades
|
||||
* (服务端直连 QMT Bridge REST,技术约束-003)
|
||||
*/
|
||||
|
||||
import { getStrategies } from '../settings.js';
|
||||
|
||||
/** 交易记录端点方法表 */
|
||||
export const TRADE_METHODS = new Set([
|
||||
'orders',
|
||||
'trades',
|
||||
'trading-dates',
|
||||
'trades/history', // R-009:本地 SQLite 历史查询(策略过滤)
|
||||
'orders/attribution-candidates', // R-009:委托归属候选列表(该 code 当前持仓策略)
|
||||
'orders/set-attribution', // R-009:手动设置委托归属
|
||||
'trades/by-holding', // R-010:按 holding_id 查委托汇总(策略持仓行展开)
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -23,15 +31,31 @@ export const TRADE_METHODS = new Set([
|
||||
* @param {object} args
|
||||
* @param {object} runtime { dataSource }
|
||||
*/
|
||||
export async function handleTrade(method, args, { dataSource }) {
|
||||
export async function handleTrade(method, args, { dataSource, storage, settings }) {
|
||||
switch (method) {
|
||||
case 'orders':
|
||||
return await dataSource.getOrders({
|
||||
case 'orders': {
|
||||
const orders = await dataSource.getOrders({
|
||||
code: args.code,
|
||||
status: args.status,
|
||||
start: args.start,
|
||||
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':
|
||||
return await dataSource.getTrades({
|
||||
start: args.start,
|
||||
@@ -42,6 +66,43 @@ export async function handleTrade(method, args, { dataSource }) {
|
||||
start: args.start,
|
||||
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:
|
||||
throw Object.assign(new Error('unknown trade method: ' + method), { code: 'not-found' });
|
||||
}
|
||||
|
||||
@@ -75,6 +75,19 @@ export function apply(ctx) {
|
||||
ctx.logger?.warn?.('[one-divine-lot] connection 服务不可用');
|
||||
}
|
||||
|
||||
// 神之一手 tab 激活时隐藏会话输入框(composer)
|
||||
// 借鉴 dsh-context 的纯 CSS 方案::has(.odl-root) 命中 → 隐藏 [data-composer-seat]
|
||||
// 我们的 tab 根元素带 odl-root class;chat/其他 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) =>
|
||||
createElement(ConnectionProvider, { connection },
|
||||
createElement(MarketDataProvider, null, render(props)));
|
||||
|
||||
@@ -39,7 +39,7 @@ export function AllPositionsTab(props) {
|
||||
}, [load]);
|
||||
|
||||
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>
|
||||
<LoadState loading={loading} error={error} onRetry={load} autoRetry={2}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
export function PlaceholderTab({ title }) {
|
||||
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: 13 }}>功能开发中,敬请期待</div>
|
||||
</div>
|
||||
|
||||
@@ -15,13 +15,43 @@
|
||||
* - 窄屏(<480px):每元素一行,两端对齐
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useRpc } from './connection.jsx';
|
||||
import { LoadState } from './LoadState.jsx';
|
||||
import { ToastProvider, useToast } from './Toast.jsx';
|
||||
import { useMarket } from '../market/MarketDataProvider.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 = `
|
||||
.odl-add-form {
|
||||
@@ -110,6 +140,13 @@ function StrategyTabInner({ strategyId, strategyName }) {
|
||||
const [selectedCode, setSelectedCode] = useState('');
|
||||
const [addShares, setAddShares] = useState('');
|
||||
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 toast = useToast();
|
||||
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);
|
||||
|
||||
// 计算每行涨幅 + 按涨幅排序(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 (
|
||||
<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 }}>
|
||||
<h3 style={{ margin: 0 }}>{name}({(positions ?? []).length} 只)</h3>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
@@ -244,60 +336,124 @@ function StrategyTabInner({ strategyId, strategyName }) {
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<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 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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(positions ?? []).map((p) => (
|
||||
<tr key={p.code} style={{ borderBottom: '1px solid #eee' }}>
|
||||
<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', fontWeight: 'bold' }}>{p.shares}</td>
|
||||
<td style={{ padding: '8px 6px' }}>{p.volume}</td>
|
||||
<td style={{ padding: '8px 6px', whiteSpace: 'nowrap' }}>
|
||||
{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>
|
||||
{rowsForRender.map((p) => {
|
||||
const isExpanded = expandedCode === p.code;
|
||||
const trades = (holdingTrades ?? {})[p.code];
|
||||
const isLoading = holdingTradesLoading === p.code;
|
||||
const hasHolding = p.holdingId != null;
|
||||
return (
|
||||
<React.Fragment key={p.code}>
|
||||
<tr
|
||||
onClick={() => toggleExpand(p.code, p.holdingId)}
|
||||
style={{
|
||||
borderBottom: '1px solid #eee',
|
||||
cursor: hasHolding ? 'pointer' : 'default',
|
||||
background: isExpanded ? '#f5f5f5' : 'transparent',
|
||||
}}
|
||||
>
|
||||
<td style={{ padding: '8px 6px', textAlign: 'center', width: 28 }}>
|
||||
{hasHolding ? (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 14 14"
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
transition: 'transform .15s ease',
|
||||
transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)',
|
||||
color: '#2e7d32',
|
||||
verticalAlign: 'middle',
|
||||
}}
|
||||
>
|
||||
<path
|
||||
d="M4.5 2.5 L9.5 7 L4.5 11.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<span style={{ color: '#ccc', fontSize: 10 }}>·</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>
|
||||
</tr>
|
||||
))}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</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) */
|
||||
export function StrategyTab(props) {
|
||||
return (
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
/**
|
||||
* TradeRecordsTab —— 交易记录 tab(R-007 v3,2026-09-01)
|
||||
* TradeRecordsTab —— 交易记录 tab(R-007 v3 + R-009 v5,2026-09-01)
|
||||
*
|
||||
* 单表合并视图:
|
||||
* - 主行 = 一笔委托(按 m_strOrderSysID 唯一),聚合成交信息(成交量/加权均价/金额/手续费)
|
||||
* - 点击展开/折叠 → 逐笔成交明细(成交时间/价/量/额/手续费/成交号)
|
||||
* - 排序:默认按委托时间降序(最新在前);点击表头手动排序
|
||||
* - 时间段查询:标题 + RangeSelector 同一行(今日/本周/上周快捷 + 手动起止日期)
|
||||
* - 时间段查询:标题 + RangeSelector 同一行(今日/本周/本月快捷 + 手动起止日期)
|
||||
* - 轮询:仅「今日」范围 3-5s 自动刷新;其他范围一次性查询
|
||||
*
|
||||
* R-009(迭代 07):
|
||||
* - 策略过滤下拉(全部 / 各策略 / 未关联):今日实时按归属过滤(前端),历史走服务端过滤
|
||||
* - 历史范围从「接口开发中」占位切换为查本地 SQLite(trades/history)
|
||||
*
|
||||
* 方向:红买绿卖(Q7 定稿);状态中文映射;未成交显示 —
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { useRpc } from './connection.jsx';
|
||||
import { LoadState } from './LoadState.jsx';
|
||||
import { RangeSelector } from './RangeSelector.jsx';
|
||||
@@ -79,8 +83,7 @@ function mergeOrdersAndTrades(orders, trades) {
|
||||
const weightedPrice = totalVol > 0
|
||||
? ts.reduce((s, t) => s + t.price * t.volume, 0) / totalVol
|
||||
: 0;
|
||||
// 费用:按整笔委托合并计算(同一委托分笔成交只收一次佣金,calcOrderFees)
|
||||
const rateWan = ts[0]?.commissionRateWan ?? 0; // 佣金费率(万分之,来自成交,账号级一致)
|
||||
const rateWan = ts[0]?.commissionRateWan ?? 0;
|
||||
const fees = calcOrderFeesLocal(ts, rateWan);
|
||||
return {
|
||||
...o,
|
||||
@@ -102,10 +105,20 @@ export function TradeRecordsTab() {
|
||||
const [error, setError] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
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); // 最近交易日
|
||||
// R-009:策略过滤(''=全部;'__unassigned__'=未关联;其他=strategyId)
|
||||
const [strategies, setStrategies] = useState([]);
|
||||
const [strategyFilter, setStrategyFilter] = useState('');
|
||||
const [attributionSaved, setAttributionSaved] = useState(null); // 归属保存提示(R-009)
|
||||
const [attributionCandidates, setAttributionCandidates] = useState({}); // R-009:code → 归属候选列表缓存
|
||||
const attributionCandidatesRef = useRef({}); // 同步缓存(loadCandidates 判重用)
|
||||
// 同步 ref
|
||||
useEffect(() => { attributionCandidatesRef.current = attributionCandidates; }, [attributionCandidates]);
|
||||
|
||||
// 默认加载「今日」(preset='today',单向意图):lastDate 到位且 range 未设置时自动设为今日
|
||||
const call = useRpc();
|
||||
|
||||
// 默认加载「今日」(preset='today',单向意图)
|
||||
useEffect(() => {
|
||||
if (lastDate && !range) {
|
||||
setRange({ start: lastDate, end: lastDate });
|
||||
@@ -114,20 +127,48 @@ export function TradeRecordsTab() {
|
||||
const [expanded, setExpanded] = useState(new Set());
|
||||
const [sortKey, setSortKey] = useState('insertTime');
|
||||
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',单向)→ 轮询
|
||||
const isTodayRange = preset === 'today';
|
||||
// 是否历史范围(非今日意图)→ 本期占位(QMT Bridge 历史接口开发中)
|
||||
// 是否历史范围(非今日意图)→ 查本地 SQLite(R-009)
|
||||
const isHistoryRange = !!range && preset !== 'today';
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!range) return;
|
||||
if (isHistoryRange) {
|
||||
// 历史范围:本期占位(老师将完善 QMT Bridge 历史接口)
|
||||
setOrders([]);
|
||||
setTrades([]);
|
||||
setLoading(false);
|
||||
// R-009 历史范围:查本地 SQLite(trades/history),不再占位
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
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;
|
||||
}
|
||||
setLoading(true);
|
||||
@@ -148,7 +189,56 @@ export function TradeRecordsTab() {
|
||||
} finally {
|
||||
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(() => {
|
||||
@@ -171,9 +261,15 @@ export function TradeRecordsTab() {
|
||||
return () => clearInterval(timer);
|
||||
}, [call, isTodayRange, range]);
|
||||
|
||||
// 合并 + 排序
|
||||
// 合并 + 策略过滤(今日实时按归属过滤前端;历史已在服务端过滤)+ 排序
|
||||
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;
|
||||
return merged.slice().sort((a, b) => {
|
||||
const va = a[sortKey];
|
||||
@@ -181,7 +277,7 @@ export function TradeRecordsTab() {
|
||||
if (typeof va === 'string' && typeof vb === 'string') return va.localeCompare(vb) * dir;
|
||||
return (va - vb) * dir;
|
||||
});
|
||||
}, [orders, trades, sortKey, sortDir]);
|
||||
}, [orders, trades, sortKey, sortDir, strategyFilter]);
|
||||
|
||||
const toggleSort = (key) => {
|
||||
if (sortKey === key) {
|
||||
@@ -204,69 +300,105 @@ export function TradeRecordsTab() {
|
||||
const arrow = (key) => (sortKey === key ? (sortDir === 'desc' ? ' ▼' : ' ▲') : '');
|
||||
|
||||
return (
|
||||
<div style={{ padding: 16, fontFamily: 'system-ui' }}>
|
||||
{/* 标题 + 时间段选择同一行(2026-09-01 老师反馈) */}
|
||||
<div className="odl-root" style={{ padding: 16, fontFamily: 'system-ui' }}>
|
||||
{/* 标题 + 策略过滤 + 时间段选择同一行(R-007/R-009) */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12, flexWrap: 'wrap', gap: 12 }}>
|
||||
<h3 style={{ margin: 0 }}>交易记录</h3>
|
||||
<RangeSelector preset={preset} onPresetChange={setPreset} range={range} onChange={setRange} onLastDate={setLastDate} />
|
||||
</div>
|
||||
|
||||
{isHistoryRange ? (
|
||||
<div style={{ padding: 32, textAlign: 'center', color: '#999' }}>
|
||||
<div style={{ fontSize: 15, marginBottom: 8 }}>{range.start} ~ {range.end}(自定义时间段)</div>
|
||||
<div style={{ fontSize: 13 }}>历史数据接口开发中,敬请期待(老师完善 QMT Bridge 历史接口后接入)</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
{/* R-009:策略过滤下拉(全部/各策略/未关联) */}
|
||||
<select
|
||||
value={strategyFilter}
|
||||
onChange={(e) => setStrategyFilter(e.target.value)}
|
||||
style={{ fontSize: 12, padding: '2px 6px', border: '1px solid #ccc', borderRadius: 4, color: '#555' }}
|
||||
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>
|
||||
) : (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
/** 委托主行 + 展开明细 */
|
||||
function OrderRows({ order, expanded, onToggle }) {
|
||||
/** 交易记录表格(主行 + 展开明细 + R-009 手动归属列) */
|
||||
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 strategyName = (sid) => {
|
||||
if (!sid) return '未关联';
|
||||
return order.strategyName || sid;
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<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.aggStampTax, 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>
|
||||
{expanded && hasTrades && (
|
||||
<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 }}>
|
||||
<thead>
|
||||
<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 thClickStyle = { ...thStyle, cursor: 'pointer', userSelect: 'none' };
|
||||
const tdStyle = { padding: '8px 6px', lineHeight: '24px', whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums' };
|
||||
@@ -156,6 +156,56 @@ export class DataStore {
|
||||
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() {
|
||||
this.sqlite.close();
|
||||
|
||||
@@ -69,17 +69,40 @@ export class PositionManager {
|
||||
return { ...(map[code] ?? {}) };
|
||||
}
|
||||
|
||||
/** 某策略下的持仓(份额 > 0 的标的) */
|
||||
/** 某策略下的持仓(份额 > 0 的标的;R-010 附加 holding_id + 成本价/最后一笔成交价) */
|
||||
async getStrategyPositions(strategyId) {
|
||||
const [positions, dataset] = await Promise.all([
|
||||
const [positions, dataset, holdings] = await Promise.all([
|
||||
this.getAllPositions(),
|
||||
this.storage.getDataset(strategyId),
|
||||
this.storage.getCurrentHoldings(strategyId), // 含 holding_id(同策略同 code 当前持仓唯一)
|
||||
]);
|
||||
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
|
||||
.map((p) => {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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.2;R-009 迭代 07 补 tradeDate + code 归一化) */
|
||||
mapOrder(o) {
|
||||
const direction = o.m_nOffsetFlag === 48 ? 'buy' : o.m_nOffsetFlag === 49 ? 'sell' : '';
|
||||
const code = this.normalizeInstrumentCode(o.m_strInstrumentID, o.m_strExchangeID);
|
||||
return {
|
||||
orderId: o.m_strOrderSysID ?? '',
|
||||
code: o.m_strInstrumentID ?? '',
|
||||
code,
|
||||
name: o.m_strInstrumentName ?? '',
|
||||
exchange: o.m_strExchangeID ?? '',
|
||||
direction, // 'buy' | 'sell' | ''
|
||||
@@ -181,6 +201,7 @@ export class QmtBridgeRestDataSource {
|
||||
limitPrice: +(o.m_dLimitPrice ?? 0),
|
||||
tradedPrice: +(o.m_dTradedPrice ?? 0),
|
||||
amount: +(o.m_dTradeAmount ?? 0),
|
||||
tradeDate: o.m_strTradeDate ?? o.m_strInsertDate ?? '', // 交易日:委托无独立字段,用 insertDate 兜底
|
||||
insertDate: o.m_strInsertDate ?? '',
|
||||
insertTime: o.m_strInsertTime ?? '',
|
||||
cancelInfo: o.m_strCancelInfo ?? '',
|
||||
@@ -227,7 +248,7 @@ export class QmtBridgeRestDataSource {
|
||||
return {
|
||||
tradeId: t.m_strTradeID ?? '',
|
||||
orderId: t.m_strOrderSysID ?? '', // 关联委托
|
||||
code: t.m_strInstrumentID ?? '',
|
||||
code: this.normalizeInstrumentCode(t.m_strInstrumentID, t.m_strExchangeID),
|
||||
name: t.m_strInstrumentName ?? '',
|
||||
exchange: t.m_strExchangeID ?? '',
|
||||
direction, // 'buy' | 'sell' | ''
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
* 表结构(技术实现方案):
|
||||
* - strategy_holdings 策略持仓生命周期表(一笔 = 一次「建仓→清仓」,历史保留)
|
||||
* - 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 整体读写):
|
||||
* 持仓生命周期:openHolding / addShares / reduceShares / closeHolding
|
||||
@@ -47,6 +49,53 @@ CREATE TABLE IF NOT EXISTS market_quotes_cache (
|
||||
last_close REAL NOT NULL,
|
||||
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 {
|
||||
@@ -73,9 +122,35 @@ export class SqliteStore {
|
||||
mkdirSync(this.dataDir, { recursive: true });
|
||||
this.db = new DatabaseSync(this.dbPath);
|
||||
this.db.exec(SCHEMA_SQL);
|
||||
// 存量库迁移:trade_orders 补手动归属列(R-009 二次定稿;幂等)
|
||||
this._ensureTradeAttributionColumns();
|
||||
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() {
|
||||
if (this.db) {
|
||||
@@ -344,4 +419,342 @@ export class SqliteStore {
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 交易记录(trade_orders / trade_fills,R-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_at(closed_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);
|
||||
// 方案 A(2026-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_id(fill → 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 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* TradeSync —— 交易记录本地同步(R-009 / 迭代 07,2026-09-01)
|
||||
*
|
||||
* 职责:把 QMT Bridge 当日交易数据(委托 + 成交)定时同步落 SQLite(trade_orders + trade_fills)。
|
||||
* - 启动预热:插件启动即同步一次(今日数据入库);
|
||||
* - 定时同步:60s 轮询拉当日 orders+trades → UPSERT(幂等,状态覆盖更新);
|
||||
* - 失败容忍:单次同步失败只记日志,不中断定时循环。
|
||||
*
|
||||
* 数据来源:QmtBridgeRestDataSource.getOrders() / getTrades()(仅当日,QMT 无历史接口)。
|
||||
* 存储:DataStore.upsertTradeOrders / upsertTradeFills(委托 SqliteStore,UPSERT 幂等)。
|
||||
*/
|
||||
|
||||
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
@@ -21,6 +21,7 @@ import { registerApi } from './api/index.js';
|
||||
import { MarketDataHub } from './component/MarketDataHub.js';
|
||||
import { MarketFeed } from './component/MarketFeed.js';
|
||||
import { QmtHealthMonitor } from './component/QmtHealthMonitor.js';
|
||||
import { TradeSync } from './component/TradeSync.js';
|
||||
|
||||
const name = 'one-divine-lot';
|
||||
|
||||
@@ -71,8 +72,12 @@ async function apply(ctx, config) {
|
||||
// S5: 分仓逻辑(份额分配/查询)
|
||||
const manager = new PositionManager({ dataSource, storage });
|
||||
|
||||
// S6: 服务端 HTTP API(R-004:注入 dataSource 以编排激活热切换)
|
||||
registerApi(ctx, { manager, settings, dataSource, marketHub, marketFeed, qmtHealthMonitor });
|
||||
// R-009:交易记录本地同步(启动预热 + 60s 定时 UPSERT 落库)
|
||||
const tradeSync = new TradeSync({ runtime: { dataSource, storage }, logger });
|
||||
tradeSync.start();
|
||||
|
||||
// S6: 服务端 HTTP API(R-004:注入 dataSource 以编排激活热切换;R-009:注入 storage 供 trades/history)
|
||||
registerApi(ctx, { manager, settings, dataSource, marketHub, marketFeed, qmtHealthMonitor, storage, tradeSync });
|
||||
|
||||
// 启动时可用性检查(日志,不阻塞)
|
||||
dataSource.isAvailable().then((ok) => {
|
||||
@@ -86,6 +91,7 @@ async function apply(ctx, config) {
|
||||
return () => {
|
||||
marketFeed.stop();
|
||||
qmtHealthMonitor.stop();
|
||||
tradeSync.stop(); // 停止交易记录定时同步(迭代 07)
|
||||
storage.close(); // 关闭 SQLite 连接(迭代 06)
|
||||
logger.info('[one-divine-lot] 插件已释放');
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user