feat: 迭代03-05 交易记录接入、行情实时、QMT连接配置、data store 标准化
- R-004 QMT连接配置:多配置管理 + 会话头部快捷切换(03-QMT连接配置) - R-005 WS盘中价格实时更新:服务端中转+缓存+前端轮询(04-WS盘中价格实时更新) - R-006 策略数据 JSON data store 标准化(store.json/market.json/schema.json) - R-007 交易记录接入:当日订单+成交单表合并(委托主行+展开成交明细)、时间段查询(今日/本周/本月+手动起止)、费用计算(佣金费率万m_dCommission+最低5元、印花税卖出万5、过户费双向万0.1) - 架构治理:api 按领域拆分、component 目录、QmtHealthMonitor
This commit is contained in:
@@ -0,0 +1,115 @@
|
|||||||
|
# 计划:QMT 连接配置(多配置管理 + 会话头部快捷切换)(阶段航点)
|
||||||
|
|
||||||
|
> 编号:PLAN-003 | 粒度:阶段航点(大粒度) | 创建:2026-08-29 | 状态:**已完成(2026-08-29 验收通过,R-004 已归档)**
|
||||||
|
> 派生自终极目标:目标-001(DSH 插件形态)、目标-008(真实交易系统接入·统一数据源抽象)
|
||||||
|
> 依据需求:**R-004(已定稿,2026-08-29)** —— 符合入范围门槛(已定稿方可进入计划)
|
||||||
|
> 设计约束:产品约束-005/006、技术约束-003/008/009、UI约束-001/002、技术约束-004/005/006/007(沿用)
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
为插件增加 QMT Bridge 连接的多配置管理能力:设置页「QMT 连接配置」子 tab 管理
|
||||||
|
多个连接配置(CRUD / 激活 / 默认 / 测试连接),会话头部(PTC 模式标签旁)常驻
|
||||||
|
快捷切换控件;激活配置**立即切换**数据源地址(无需重启 DSH),重启后回到默认配置。
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
|
||||||
|
**做**(对应 R-004 定稿范围,决策编号 Q1-Q10):
|
||||||
|
1. **设置页「QMT 连接配置」子 tab**(产品约束-005、UI约束-002):
|
||||||
|
- 配置 CRUD:新增 / 编辑 / 重命名 / 删除(字段:名称 + HTTP 地址);
|
||||||
|
- 单选激活(一次只能激活一个),激活立即生效;
|
||||||
|
- 默认标记:启动时自动激活默认配置,重启后回到默认;
|
||||||
|
- 测试连接:手动触发,请求 `/health` 返回可达性与延迟;
|
||||||
|
- 删除边界:删激活配置→自动切默认;删默认→默认标记转列表第一条并激活之;禁删最后一条;
|
||||||
|
2. **会话头部快捷切换**(产品约束-006、UI约束-001、技术约束-009):
|
||||||
|
- slot `conversation.session.header.actions` 注册紧凑下拉 chip:`QMT: <激活配置名> ▾`;
|
||||||
|
- 菜单列出全部配置(激活项勾选),点选即激活 + toast 反馈;
|
||||||
|
- 头部仅切换,管理仍在设置页;
|
||||||
|
3. **生效机制**(技术约束-008):
|
||||||
|
- 激活/修改后立即切换(更新数据源实例 baseUrl,按请求读取已核实);
|
||||||
|
- 启动时自动激活默认配置;列表为空时回退 cordis 注入的 qmtBaseUrl 兜底;
|
||||||
|
4. **存储**(技术约束-008):复用 one-divine-lot settings namespace,新增 qmtConnections(list + activeId + defaultId)。
|
||||||
|
|
||||||
|
**不做**(本期):
|
||||||
|
- 认证/多账户支持(QMT Bridge 当前无鉴权);
|
||||||
|
- 连接状态监控与断线重连(后续);
|
||||||
|
- 超时(timeoutMs)配置化 —— 保持代码默认 15000ms(Q5);
|
||||||
|
- 宿主配置(cordis.patch.yml qmtBaseUrl)自动迁移 —— 首次使用手动录入(Q4);
|
||||||
|
- 激活前强制连通性校验 —— 测试连接为手动操作(Q3);
|
||||||
|
- 头部菜单管理能力(增删改/测试连接/设默认)—— 留在设置页(Q10)。
|
||||||
|
|
||||||
|
## 程序结构(改造后)
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── index.js # 服务端入口(改)
|
||||||
|
│ ├── 数据源创建后注册为可寻址组件;启动时按「默认配置」初始化激活地址(列表空→cordis qmtBaseUrl 兜底)
|
||||||
|
│ └── inject + ['settings', 'webServer'](不变)
|
||||||
|
├── settings.js # 设置管理(改)
|
||||||
|
│ ├── schema 增加 qmtConnections: { list: [{id,name,baseUrl,order}], activeId, defaultId }
|
||||||
|
│ ├── 新增 QMT 连接配置 CRUD(与策略 CRUD 同模式:列表读写 + 整表 update)
|
||||||
|
│ │ ├── getQmtConnections(scope) # 读列表(按 order 排序)
|
||||||
|
│ │ ├── addQmtConnection(scope, {name, baseUrl})
|
||||||
|
│ │ ├── renameQmtConnection(scope, id, {name, baseUrl})
|
||||||
|
│ │ ├── removeQmtConnection(scope, id) # 删除边界处理(产品约束-005)
|
||||||
|
│ │ ├── activateQmtConnection(scope, id) # 激活(写 activeId)
|
||||||
|
│ │ ├── setDefaultQmtConnection(scope, id) # 设默认(写 defaultId)
|
||||||
|
│ │ └── resolveStartupConnection(scope, config) # 启动激活地址解析(默认→激活→cordis 兜底)
|
||||||
|
│ └── generateQmtConnectionId(name, existingIds) # slug id(复用拼音映射与去重逻辑)
|
||||||
|
├── data-source/qmt-bridge-rest.js # 数据源(改:小改)
|
||||||
|
│ └── 新增 setBaseUrl(url) # 运行期更新实例地址(技术约束-008:按请求读取,改字段即生效)
|
||||||
|
│ (timeoutMs 不暴露 setter —— Q5)
|
||||||
|
├── api.js # 服务端 HTTP API(改)
|
||||||
|
│ ├── METHODS 增加:qmt-connections / qmt-connections/add
|
||||||
|
│ │ / qmt-connections/update / qmt-connections/remove
|
||||||
|
│ │ / qmt-connections/activate / qmt-connections/set-default
|
||||||
|
│ │ / qmt-connections/test(服务端代理 GET {baseUrl}/health,返回 {ok, latencyMs, status})
|
||||||
|
│ └── 编排:activate/update/remove 成功后调用 dataSource.setBaseUrl(激活地址)
|
||||||
|
├── client/
|
||||||
|
│ ├── index.js # 客户端入口(改)
|
||||||
|
│ │ └── slots.register({ name: 'conversation.session.header.actions', id: 'odl-qmt-switch', order: -9 }, QmtConnectionChip)
|
||||||
|
│ └── views/
|
||||||
|
│ ├── QmtConnectionChip.jsx # 新增:头部快捷切换控件(下拉 chip + 菜单 + toast 反馈)
|
||||||
|
│ └── SettingsSection.jsx # 改:新增第三个子 tab「QMT 连接配置」
|
||||||
|
│ └── 内部新组件 QmtConnectionsSettings:配置列表 + 行内操作 + 新增/编辑表单 + 删除确认弹窗
|
||||||
|
└── cordis.patch.yml # 不改:qmtBaseUrl 保留为兜底种子(Q4 不迁移)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 改动说明(按文件)
|
||||||
|
|
||||||
|
| 文件 | 改动 | 依据 |
|
||||||
|
|---|---|---|
|
||||||
|
| src/settings.js | schema 增 qmtConnections;新增 7 个 CRUD/激活/默认/启动解析函数 | 技术约束-008、产品约束-005 |
|
||||||
|
| src/data-source/qmt-bridge-rest.js | 新增 setBaseUrl(url);其余不动 | 技术约束-008(按请求读取地址) |
|
||||||
|
| src/api.js | 新增 7 个 qmt-connections 端点;activate/update/remove 编排 setBaseUrl | 技术约束-004(webServer 自开路由)、-008 |
|
||||||
|
| src/index.js | 启动时解析并设置激活地址;数据源实例传递给 api 注册 | 技术约束-008 |
|
||||||
|
| src/client/index.js | 注册会话头部 slot(odl-qmt-switch,order=-9) | 技术约束-009 |
|
||||||
|
| src/client/views/QmtConnectionChip.jsx | 新增头部切换控件(复用 ConnectionProvider/useRpc + Toast) | 产品约束-006、UI约束-001 |
|
||||||
|
| src/client/views/SettingsSection.jsx | 新增「QMT 连接配置」子 tab 与配置管理界面 | 产品约束-005、UI约束-002 |
|
||||||
|
| cordis.patch.yml | 不改动 | Q4(不迁移) |
|
||||||
|
|
||||||
|
## 实现步骤(建议顺序)
|
||||||
|
|
||||||
|
1. **服务端数据层**:settings.js 增 schema 与 CRUD/激活/默认/启动解析函数(纯函数,先行可测);
|
||||||
|
2. **数据源热切换**:qmt-bridge-rest.js 增 setBaseUrl;index.js 启动时 resolveStartupConnection 并设置初始激活地址;把 dataSource 传给 registerApi;
|
||||||
|
3. **服务端 API**:api.js 增 7 个端点 + 激活编排 setBaseUrl;本地 curl 验证 CRUD/激活/测试连接;
|
||||||
|
4. **设置页 UI**:SettingsSection.jsx 增「QMT 连接配置」子 tab(列表/表单/确认弹窗,复用 ConfirmDialog/Toast);走通设置页全流程;
|
||||||
|
5. **头部快捷切换**:client/index.js 注册头部 slot;新增 QmtConnectionChip.jsx(读列表/激活/失败反馈);验证与 PTC 标签并排显示与切换生效;
|
||||||
|
6. **构建安装**:服务端+客户端构建,dsh plugin add 安装,重载页面;
|
||||||
|
7. **验收**:对照下节验收标准逐条核验,记录迭代 03。
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
1. 设置页出现「QMT 连接配置」子 tab,配置 CRUD 完整可用(名称 + HTTP 地址);
|
||||||
|
2. 激活某配置后,数据源立即切到该地址(无需重启 DSH)——通过切换后刷新持仓/测试连接验证目标端点随激活配置变化;
|
||||||
|
3. 默认配置行为正确:启动自动激活默认;重启后回到默认(即使运行中切过其他配置);列表为空时按 cordis qmtBaseUrl 兜底且页面/数据源可用;
|
||||||
|
4. 测试连接返回可达性 + 延迟;激活不强制先测试;
|
||||||
|
5. 删除边界符合产品约束-005:删激活→自动切默认;删默认→默认转移列表第一条并激活;删最后一条被禁止;
|
||||||
|
6. 会话头部 PTC 标签旁出现 `QMT: <激活配置名>` chip,点选菜单项即激活并 toast 反馈;头部仅切换(无管理入口);
|
||||||
|
7. 现有功能(分仓/策略/设置现有子 tab)不回归。
|
||||||
|
|
||||||
|
## 验收方法
|
||||||
|
|
||||||
|
- 建第二个配置指向任一可达地址,激活后通过「测试连接」与持仓加载观察目标地址变化;
|
||||||
|
- 重启 DSH(或重载插件)验证回到默认配置;
|
||||||
|
- 删除激活/默认配置验证自动切换与默认转移;删至最后一条验证禁止提示;
|
||||||
|
- 头部 chip 切换后观察 toast 与数据源目标变化(配合测试连接)。
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# 计划:WS 盘中价格实时更新(前端直连 QMT Bridge WebSocket)(阶段航点)
|
||||||
|
|
||||||
|
> 编号:PLAN-004 | 粒度:阶段航点(大粒度) | 创建:2026-08-31 | 状态:**已完成(2026-09-01 迭代 04 验收通过,R-005 已实现)**
|
||||||
|
> 派生自终极目标:目标-001(DSH 插件形态)、目标-008(真实交易系统接入·统一数据源抽象)
|
||||||
|
> 依据需求:**R-005(已定稿,2026-08-31)** —— 符合入范围门槛(已定稿方可进入计划)
|
||||||
|
> 设计约束:技术约束-001/003/008(沿用)、技术约束-010(新增:前端直连 WS)、产品约束-007(新增:现价列展示)
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
在 3 个监控表格(全部持仓 / 手动做T / 网格超市)中实现**盘中价格实时更新**:前端直连 QMT Bridge WebSocket 订阅全市场 tick,事件驱动更新关注股票的现价(lastPrice),红涨绿跌着色 + 变化高亮。
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
|
||||||
|
**做**:
|
||||||
|
1. **WS 行情客户端**(前端):连接 `ws://<激活配置host>:8610/ws`,订阅 `whole` 全市场 tick;维护 code→lastPrice 实时价格 Map;
|
||||||
|
2. **订阅生命周期**:订阅前 REST 拉一次 `/data/tick` 快照补初值(WS 只推增量);连接断开静默重连 + 状态提示;切换激活 QMT 配置时 WS 随之切换;
|
||||||
|
3. **关注列表**:3 表格自动汇总出现的 code(全部持仓 + 手动做T + 网格超市),作为过滤集合;
|
||||||
|
4. **现价展示**:3 表格新增「现价」列(lastPrice),红涨绿跌着色(对比昨收 lastClose),价格变化轻微高亮;
|
||||||
|
5. **事件驱动**:收到 WS 推送 → 更新价格 Map → 通知订阅方(React state/事件)→ 表格行重渲染现价。
|
||||||
|
|
||||||
|
**不做**(本期):
|
||||||
|
- 市值/盈亏/五档实时展示;
|
||||||
|
- 插件服务端中转 WS;
|
||||||
|
- 关注列表手动配置;
|
||||||
|
- 断线重连 UI 引导(仅静默重连 + 状态提示)。
|
||||||
|
|
||||||
|
## 程序结构(改造后)
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── index.js # 服务端入口(小改):新增 API 暴露激活连接 host(供前端建 WS)
|
||||||
|
├── api.js # 服务端 API(改):新增 qmt-connections/active-host 端点(返回 { host, baseUrl, wsUrl })
|
||||||
|
├── client/
|
||||||
|
│ ├── index.js # 客户端入口(改):注册行情 Provider,包装 3 个表格
|
||||||
|
│ ├── market/
|
||||||
|
│ │ └── MarketDataProvider.jsx # 新增:WS 行情客户端 + 价格 Map + 订阅/重连/切换 + Context 下发
|
||||||
|
│ └── views/
|
||||||
|
│ ├── AllPositionsTab.jsx # 改:新增「现价」列 + 涨跌色 + 高亮
|
||||||
|
│ ├── StrategyTab.jsx # 改:新增「现价」列 + 涨跌色 + 高亮
|
||||||
|
│ └── PriceCell.jsx # 新增:现价单元格组件(格式化 + 涨跌色 + 高亮)
|
||||||
|
└── cordis.patch.yml # 不改(QMT 配置已在 settings)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 实现步骤(建议顺序)
|
||||||
|
|
||||||
|
1. **文档骨架**:R-005 转正 + PLAN-004 + 迭代 04 子目录 + 迭代说明(本步);
|
||||||
|
2. **服务端 API**:新增 `qmt-connections/active-host` 返回激活配置 host(前端据此建 WS);
|
||||||
|
3. **WS 行情客户端**:MarketDataProvider(连接/订阅/价格 Map/重连/配置切换);
|
||||||
|
4. **现价列 UI**:PriceCell + 3 表格接入;
|
||||||
|
5. **构建安装**:dsh plugin add,重载页面;
|
||||||
|
6. **验收**:对照验收标准逐条核验,记录迭代 04。
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# 计划:交易记录接入(订单与成交数据 + 日期导航)(阶段航点)
|
||||||
|
|
||||||
|
> 编号:PLAN-006 | 粒度:阶段航点(大粒度) | 创建:2026-09-01 | 状态:**进行中**
|
||||||
|
> 派生自终极目标:目标-006(交易复盘)、目标-003(按策略监控市场)
|
||||||
|
> 依据需求:**R-007(范围已定稿,2026-09-01)** —— 符合入范围门槛
|
||||||
|
> 设计约束:技术约束-001/003/004/010(沿用)、产品约束-007 方向(着色习惯,红买绿卖为交易方向新语义)
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
在「交易记录」tab 接入 QMT Bridge 当日订单(/trade/orders)与成交(/trade/trades)数据:**单表合并展示**(委托主行 + 按 m_strOrderSysID 展开成交明细)+ **基于交易日历的日期导航**(当日默认选中,历史接口占位,为老师完善 QMT Bridge 历史成交接口预留)。
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
|
||||||
|
**做**:
|
||||||
|
1. **数据源接入**:QmtBridgeRestDataSource 新增 getOrders() / getTrades() / getTradingDates()(语义化映射,核心字段见 R-007 2.1/2.2);
|
||||||
|
2. **服务端 API**:新增 api/trades.js(orders / trades / trading-dates 端点),纳入领域分发;
|
||||||
|
3. **交易记录 tab 落地**:占位 tab 替换为 TradeRecordsTab——**单表合并**:委托主行(聚合成交)+ 展开/折叠成交明细;
|
||||||
|
4. **日期导航**:DateNav 基于 /data/calendar/trading_dates,前后切换交易日,当日默认选中,历史日期展示「接口开发中」占位;
|
||||||
|
5. **排序**:委托主行默认降序(最新在前),支持表头点击手动排序;
|
||||||
|
6. **数据刷新**:服务端中转 + 前端轮询(3-5s,复用 market-snapshot 模式)。
|
||||||
|
|
||||||
|
**不做**:
|
||||||
|
- 历史(非当日)交易数据查询实现(老师完善 QMT Bridge 接口后接入);
|
||||||
|
- 交易数据本地持久化(Q5 本期不做);
|
||||||
|
- 按策略过滤交易(Q10 后续迭代);
|
||||||
|
- 下单/撤单等交易操作(目标-007 控制权另议)。
|
||||||
|
|
||||||
|
## 程序结构(改造后)
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── component/
|
||||||
|
│ └── QmtBridgeRestDataSource.js # 改:+getOrders/getTrades/getTradingDates
|
||||||
|
├── api/
|
||||||
|
│ ├── trades.js # 新增:orders/trades/trading-dates 端点
|
||||||
|
│ └── index.js # 改:合并 TRADE_METHODS
|
||||||
|
├── client/
|
||||||
|
│ ├── views/
|
||||||
|
│ │ ├── TradeRecordsTab.jsx # 新增:交易记录 tab(日期导航 + 单表合并)
|
||||||
|
│ │ ├── OrderRow.jsx # 新增:委托主行(聚合成交 + 展开)
|
||||||
|
│ │ ├── TradeDetailRows.jsx # 新增:展开成交明细行
|
||||||
|
│ │ └── DateNav.jsx # 新增:交易日历日期导航
|
||||||
|
│ └── index.js # 改:tradeRecords tab 渲染 TradeRecordsTab
|
||||||
|
```
|
||||||
|
|
||||||
|
## 实现步骤(建议顺序)
|
||||||
|
|
||||||
|
1. **文档骨架**:PLAN-006 + 迭代 05 子目录(本步);
|
||||||
|
2. **数据源**:getOrders/getTrades/getTradingDates(语义化映射 + 方向判定);
|
||||||
|
3. **服务端 API**:api/trades.js 三端点 + 领域分发注册;
|
||||||
|
4. **客户端合并逻辑**:fetch 两接口 → 按 m_strOrderSysID 分组 → 聚合;
|
||||||
|
5. **UI**:DateNav + OrderRow + TradeDetailRows + TradeRecordsTab;排序(默认降序 + 表头点击);
|
||||||
|
6. **构建安装**:pnpm run build + 页面刷新(客户端)或宿主重启(服务端);
|
||||||
|
7. **验收**:对照迭代 05 验收标准逐条核验,记录迭代复盘。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- 交易记录 tab 显示当日委托(单表合并,展开可见每笔成交);
|
||||||
|
- 委托主行:时间/代码/名称/方向/状态/委托量/成交量/委托价/成交均价/成交额/手续费;
|
||||||
|
- 方向红买绿卖;状态中文映射;未成交列显示 —;
|
||||||
|
- 日期导航基于交易日历,前后切换,当日默认,历史占位;
|
||||||
|
- 排序默认降序 + 表头手动排序;
|
||||||
|
- 现有功能(持仓/策略/行情/连接配置)不回归。
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# 计划:策略数据 JSON data store schema 标准化(阶段航点)
|
||||||
|
|
||||||
|
> 编号:PLAN-005 | 粒度:阶段航点(大粒度) | 创建:2026-08-31 | 状态:**已完成(2026-09-01 迭代 04 验收通过,R-006 已实现)**
|
||||||
|
> 派生自终极目标:目标-001(DSH 插件形态)
|
||||||
|
> 依据需求:**R-006(已定稿,2026-08-31)** —— 符合入范围门槛
|
||||||
|
> 设计约束:技术约束-011(测试数据隔离)、技术约束-012(新增:JSON data store schema)
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
将策略份额分配数据(现 allocations.json,code 为中心)标准化为 **JSON data store schema**:
|
||||||
|
- store.schema.json:描述数据集(dataset)的标准字段(类 JSON Schema);
|
||||||
|
- store.json:实际数据文件,策略为中心(strategies[].dataset = [{code, shares}]);
|
||||||
|
- 策略定义仍留 DSH settings(设置页交互不变);
|
||||||
|
- 旧 allocations.json 启动时自动迁移(保留备份)。
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
|
||||||
|
**做**:
|
||||||
|
1. 新增 DataStore 模块(src/component/DataStore.js):
|
||||||
|
- 定义 store.schema.json 与 store.json 结构;
|
||||||
|
- 读写 store.json(schema 校验 + 原子写);
|
||||||
|
- 迁移:启动时检测旧 allocations.json → 转换为 store.json(code 为中心 → 策略为中心),保留 allocations.json.bak;
|
||||||
|
- 数据集 CRUD:getDataset(strategyId) / setDataset(strategyId, items) / removeDataset(strategyId)。
|
||||||
|
2. PositionManager / api 改造:
|
||||||
|
- 份额读写改走 DataStore(替代 AllocationStorage);
|
||||||
|
- strategies/remove 联动删 store dataset(同现状联清份额模式);
|
||||||
|
- 现有端点(add-shares/remove-shares/strategy-positions/summary 等)行为不变。
|
||||||
|
3. 删除/归档 AllocationStorage(迁移完成后)。
|
||||||
|
|
||||||
|
**不做**:
|
||||||
|
- 策略定义迁移(保留 settings);
|
||||||
|
- 不引入数据库(数据量少,JSON 足够)。
|
||||||
|
|
||||||
|
## 实现步骤
|
||||||
|
|
||||||
|
1. DataStore 模块(schema + 读写 + 迁移 + dataset CRUD);
|
||||||
|
2. PositionManager 改接 DataStore;
|
||||||
|
3. api 编排(strategies/remove 联动删 dataset);
|
||||||
|
4. 构建 + 测试隔离模式验证迁移;
|
||||||
|
5. 验收 + 记录迭代。
|
||||||
@@ -9,7 +9,8 @@
|
|||||||
|
|
||||||
| 编号 | 约束说明 | 添加日期 | 生效状态 | 失效日期 | 最后一次变更描述 |
|
| 编号 | 约束说明 | 添加日期 | 生效状态 | 失效日期 | 最后一次变更描述 |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
| (待添加) | | | | | |
|
| UI约束-001 | 会话头部 QMT 连接切换 chip:紧凑形态 `QMT: <激活配置名> ▾`,与 PTC 模式标签并排;下拉菜单列出全部配置(当前激活项勾选标记),点选即激活;操作结果用顶部轻提示反馈(成功绿/失败红,约 3s 消失,沿用现有 Toast 体系) | 2026-08-29 | 生效 | - | R-004 定稿(2026-08-29):Q9/Q10 确认的头部控件形态 |
|
||||||
|
| UI约束-002 | 设置页「QMT 连接配置」子 tab:与「通用设置 / 策略分组」并列的第三个子 tab;配置以**卡片形式**展示(自适应网格 minmax(260px,1fr),激活卡片绿色描边):卡片含名称 + 激活/默认徽标 + HTTP 地址 + 行内操作(激活 / 设为默认 / 编辑 / 测试连接 / 删除)+ 新增/编辑表单(纵排两字段);删除沿用现有确认弹窗模式,删除最后一条时给出禁止提示;超时不设输入框(Q5);长文本(地址/错误信息)单行省略 + 悬停看全文,组件设 minWidth:0 防撑行 | 2026-08-29 | 生效 | - | 变更(2026-08-29 老师反馈):列表形式改卡片形式,约束组件宽度、长文本省略不换行;原定稿:设置页子 tab 形态沿用现有设置交互体系 |
|
||||||
|
|
||||||
<!-- 示例条目(确认格式后删除):
|
<!-- 示例条目(确认格式后删除):
|
||||||
| UI约束-001 | 示例:求签页面必须保持单屏完整,不出现滚动 | 2026-08-26 | 生效 | - | 讨论确认:移动端优先,避免滚动打断仪式感 |
|
| UI约束-001 | 示例:求签页面必须保持单屏完整,不出现滚动 | 2026-08-26 | 生效 | - | 讨论确认:移动端优先,避免滚动打断仪式感 |
|
||||||
|
|||||||
@@ -13,6 +13,9 @@
|
|||||||
| 产品约束-002 | 分仓通过「持仓标签」体系实现:在全量持仓之上,用不同标签对持仓进行逻辑分组,每个标签展示其下的仓位汇总(数量/市值/盈亏/占比) | 2026-08-27 | 生效 | - | 讨论确认(R-002):老师要求「分不同的持仓标签,各自有多少仓位」 |
|
| 产品约束-002 | 分仓通过「持仓标签」体系实现:在全量持仓之上,用不同标签对持仓进行逻辑分组,每个标签展示其下的仓位汇总(数量/市值/盈亏/占比) | 2026-08-27 | 生效 | - | 讨论确认(R-002):老师要求「分不同的持仓标签,各自有多少仓位」 |
|
||||||
| 产品约束-004 | 删除持仓策略时,该策略下已分配的份额自动回到「未分配」,数据不丢失(删除前需弹窗确认) | 2026-08-28 | 生效 | - | R-003 O3 定稿(2026-08-28):删除策略 = 份额回未分配 + 弹窗确认 |
|
| 产品约束-004 | 删除持仓策略时,该策略下已分配的份额自动回到「未分配」,数据不丢失(删除前需弹窗确认) | 2026-08-28 | 生效 | - | R-003 O3 定稿(2026-08-28):删除策略 = 份额回未分配 + 弹窗确认 |
|
||||||
| 产品约束-003 | 持仓标签可自定义、可增删改;标签维度候选包括策略/用途/风险等级等(具体维度待讨论确认) | 2026-08-27 | 生效 | - | 讨论确认(R-002):标签体系需支持自定义,维度待细化 |
|
| 产品约束-003 | 持仓标签可自定义、可增删改;标签维度候选包括策略/用途/风险等级等(具体维度待讨论确认) | 2026-08-27 | 生效 | - | 讨论确认(R-002):标签体系需支持自定义,维度待细化 |
|
||||||
|
| 产品约束-005 | QMT 连接配置多配置管理:设置页「QMT 连接配置」子 tab 维护多个连接配置(名称 + HTTP 地址,增/删/改/查);一次只能激活一个,激活配置为当前生效连接;默认 = 插件启动时自动激活的配置(运行中手动激活其他配置立即生效,重启回到默认);测试连接为手动操作(请求 /health 返回可达性与延迟,激活不强制先测);删除边界:删除激活配置自动切换到默认,删除默认配置则默认标记转移到列表第一条并激活之,禁止删除最后一条 | 2026-08-29 | 生效 | - | R-004 定稿(2026-08-29):Q1-Q6/Q8 逐条确认(不迁移宿主配置、超时不纳入表单) |
|
||||||
|
| 产品约束-007 | 3 个监控表格(全部持仓 / 手动做T / 网格超市)展示**盘中现价**:现价列显示 lastPrice,红涨绿跌着色(对比昨收),价格变化轻微高亮;本期仅现价一项实时数据 | 2026-08-31 | 生效 | - | R-005 定稿(2026-08-31):老师确认现价列 + 涨跌色 + 变化高亮 |
|
||||||
|
| 产品约束-006 | QMT 连接会话头部快捷切换:会话窗口顶栏(PTC 模式标签旁)常驻下拉控件(chip 显示 `QMT: <激活配置名>`),点开列出全部配置(激活项勾选),点选即激活并轻提示反馈;头部控件仅做切换,配置管理(增删改/测试连接/默认标记)仍在设置页子 tab | 2026-08-29 | 生效 | - | R-004 定稿(2026-08-29):Q9/Q10 确认(入口=会话头部 PTC 旁,菜单仅切换激活) |
|
||||||
|
|
||||||
<!-- 示例条目(确认格式后删除):
|
<!-- 示例条目(确认格式后删除):
|
||||||
| 产品约束-001 | 示例:求签功能必须保证抽取结果的不可预测性 | 2026-08-26 | 生效 | - | 讨论确认:为保证公平性,抽取必须不可预测 |
|
| 产品约束-001 | 示例:求签功能必须保证抽取结果的不可预测性 | 2026-08-26 | 生效 | - | 讨论确认:为保证公平性,抽取必须不可预测 |
|
||||||
|
|||||||
@@ -11,11 +11,16 @@
|
|||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
| 技术约束-002 | 分仓管理采用「全量持仓 + 标签」模型:持仓数据为账户真实持仓(来自数据源适配器),标签为逻辑分组元数据(独立于持仓存储,可增删改),聚合视图按标签汇总仓位 | 2026-08-27 | 生效 | - | 讨论确认(R-002):全量持仓为基础,标签体系做逻辑分仓 |
|
| 技术约束-002 | 分仓管理采用「全量持仓 + 标签」模型:持仓数据为账户真实持仓(来自数据源适配器),标签为逻辑分组元数据(独立于持仓存储,可增删改),聚合视图按标签汇总仓位 | 2026-08-27 | 生效 | - | 讨论确认(R-002):全量持仓为基础,标签体系做逻辑分仓 |
|
||||||
| 技术约束-001 | 行情/交易数据源必须通过统一接口抽象(定义统一的数据模型与操作接口),实现层为具体数据源适配器;当前限定实现 QMT Bridge 适配器,未来可新增其他行情/交易接口适配器,业务层不感知具体数据源 | 2026-08-27 | 生效 | 2026-08-27 | 变更(2026-08-27 R-002 讨论):原为「QMT Bridge MCP 适配器」,修正为**直接调用 QMT Bridge RESTful 接口**(http://192.168.3.43:8610),MCP 仅作为 Agent 侧封装层,插件侧直连 REST |
|
| 技术约束-001 | 行情/交易数据源必须通过统一接口抽象(定义统一的数据模型与操作接口),实现层为具体数据源适配器;当前限定实现 QMT Bridge 适配器,未来可新增其他行情/交易接口适配器,业务层不感知具体数据源 | 2026-08-27 | 生效 | 2026-08-27 | 变更(2026-08-27 R-002 讨论):原为「QMT Bridge MCP 适配器」,修正为**直接调用 QMT Bridge RESTful 接口**(http://192.168.3.43:8610),MCP 仅作为 Agent 侧封装层,插件侧直连 REST |
|
||||||
| 技术约束-003 | QMT Bridge 数据源通过其 RESTful HTTP 接口接入(基础地址 http://192.168.3.43:8610,OpenAPI v3 规范),不经过 MCP 层;MCP 是给 Agent 用的封装,插件内部直连 REST | 2026-08-27 | 生效 | - | 讨论确认(R-002 第 7 轮):老师指出 MCP 是给智能体用的,插件应直连同一服务端口的 RESTful 接口 |
|
| 技术约束-003 | QMT Bridge 数据源通过其 RESTful HTTP 接口接入(基础地址 http://192.168.3.43:8610,OpenAPI v3 规范),不经过 MCP 层;MCP 是给 Agent 用的封装,插件内部直连 REST | 2026-08-27 | 生效 | - | 讨论确认(R-002 第 7 轮):老师指出 MCP 是给智能体用的,插件应直连同一服务端口的 RESTful 接口;变更标注(2026-08-29 R-004 定稿):基础地址由固定单一地址改为多配置动态管理(由激活配置决定,见技术约束-008),直连 REST 原则不变 |
|
||||||
| 技术约束-004 | 插件服务端 API 用 webServer 自开路由(如 /odl/api/*),**不直接使用 connection.rpc.intercept('/api')** —— DSH 的 /api 通道只能一个 interceptor(api-gateway 已占用),重复 intercept 会抛错导致插件 apply 失败 | 2026-08-28 | 生效 | - | 迭代 01 复盘沉淀:RPC 冲突导致服务端插件 apply 失败、RPC 404 |
|
| 技术约束-004 | 插件服务端 API 用 webServer 自开路由(如 /odl/api/*),**不直接使用 connection.rpc.intercept('/api')** —— DSH 的 /api 通道只能一个 interceptor(api-gateway 已占用),重复 intercept 会抛错导致插件 apply 失败 | 2026-08-28 | 生效 | - | 迭代 01 复盘沉淀:RPC 冲突导致服务端插件 apply 失败、RPC 404 |
|
||||||
| 技术约束-005 | 客户端插件 bundle 必须是 CJS + window.__ModuleLoader__.load({id, factory}) 包装(tsdown 构建 + wrap 脚本),裸 ESM 无法被 DSH 客户端模块系统加载 | 2026-08-28 | 生效 | - | 迭代 01 复盘沉淀:客户端 bundle 未包装导致 loaded without registering via __ModuleLoader__.load |
|
| 技术约束-005 | 客户端插件 bundle 必须是 CJS + window.__ModuleLoader__.load({id, factory}) 包装(tsdown 构建 + wrap 脚本),裸 ESM 无法被 DSH 客户端模块系统加载 | 2026-08-28 | 生效 | - | 迭代 01 复盘沉淀:客户端 bundle 未包装导致 loaded without registering via __ModuleLoader__.load |
|
||||||
| 技术约束-006 | 客户端 slots.register 的 component 必须是第二参数(register({...}, Component));settings schema 必须用 schemastery z.object() 函数式定义 | 2026-08-28 | 生效 | - | 迭代 01 复盘沉淀:component 位置错误致 React #130;普通对象 schema 报 schema is not a function |
|
| 技术约束-006 | 客户端 slots.register 的 component 必须是第二参数(register({...}, Component));settings schema 必须用 schemastery z.object() 函数式定义 | 2026-08-28 | 生效 | - | 迭代 01 复盘沉淀:component 位置错误致 React #130;普通对象 schema 报 schema is not a function |
|
||||||
| 技术约束-007 | 插件安装用 dsh plugin add(自动 reconcile bundles),不直接用 pnpm add;bundle patch 顶层必须是 insert 操作 | 2026-08-28 | 生效 | - | 迭代 01 复盘沉淀:pnpm add 不会更新 dsh.profile.bundles |
|
| 技术约束-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**(JSON data store schema):份额分配存 store.json(策略为中心)、行情快照存 store.market.json、schema 存 store.schema.json;原子写(临时文件+rename);旧 allocations.json 启动自动迁移;DataStore 为数据快速展现存在(重启/刷新首屏有数据) | 2026-09-01 | 生效 | - | R-006 及扩展定稿(2026-08-31/09-01):JSON data store schema 标准化 + 行情持久化 |
|
||||||
|
| 技术约束-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 机制与内置插件注册方式 |
|
||||||
|
|
||||||
<!-- 示例条目(确认格式后删除):
|
<!-- 示例条目(确认格式后删除):
|
||||||
| 技术约束-001 | 示例:技术栈以 Node.js / TypeScript 为准,不引入未讨论的新框架 | 2026-08-26 | 生效 | - | 讨论确认:优先复用 DSH 既有能力,新框架需论证 |
|
| 技术约束-001 | 示例:技术栈以 Node.js / TypeScript 为准,不引入未讨论的新框架 | 2026-08-26 | 生效 | - | 讨论确认:优先复用 DSH 既有能力,新框架需论证 |
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
# 数据存储设计(JSON Data Store Schema)
|
||||||
|
|
||||||
|
> 策略数据集 + 行情缓存的 JSON data store 设计(R-006 及其扩展,2026-08-31 / 2026-09-01 定)
|
||||||
|
> 本文件是数据存储领域的**设计约束文档**,后续数据存储相关迭代以此为依据。
|
||||||
|
|
||||||
|
## 1. 设计目标
|
||||||
|
|
||||||
|
1. **数据快速展现**:DataStore 的核心目标是「数据快速展现」——份额分配、行情价格持久化,重启/刷新后首屏即有数据,不依赖每次实盘查询。
|
||||||
|
2. **标准化描述**:为每个策略定义标准化的字段描述(schema),在描述之上挂数据集。
|
||||||
|
3. **统一 JSON 操作**:数据量很少(无需数据库/Circe),用 JSON 文件 + 类 JSON Schema 统一读写与校验。
|
||||||
|
|
||||||
|
## 2. 总体架构
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.dsh/one-divine-lot/
|
||||||
|
├── store.schema.json # 数据集 schema(类 JSON Schema,随代码发布,自动生成)
|
||||||
|
├── store.json # 份额分配数据(策略为中心,原子写)
|
||||||
|
└── store.market.json # 行情快照缓存(code → 最新行情,原子写)
|
||||||
|
```
|
||||||
|
|
||||||
|
**分层**:
|
||||||
|
- **策略定义**(id/name/visible/order):存 DSH settings(~/.dsh/settings.yaml),设置页交互不变
|
||||||
|
- **份额分配**(策略 → 股票 → 股数):存 store.json(策略为中心)
|
||||||
|
- **行情快照**(code → lastPrice 等):存 store.market.json(持久化缓存)
|
||||||
|
|
||||||
|
## 3. 数据文件结构与 Schema
|
||||||
|
|
||||||
|
### 3.1 store.schema.json(数据集 schema)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"kind": "one-divine-lot-data-store",
|
||||||
|
"strategies": {
|
||||||
|
"description": "策略数据集:每个策略一个 dataset(份额分配)",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"strategyId": { "type": "string", "required": true, "description": "策略 id(与 settings 中的策略一致)" },
|
||||||
|
"dataset": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "该策略的持仓数据集(份额分配)",
|
||||||
|
"items": {
|
||||||
|
"code": { "type": "string", "required": true, "description": "证券代码(含后缀,如 600719.SH)" },
|
||||||
|
"shares": { "type": "number", "required": true, "description": "份额(股数,>0)" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 store.json(份额分配,策略为中心)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"strategies": [
|
||||||
|
{
|
||||||
|
"strategyId": "grid-supermarket",
|
||||||
|
"dataset": [
|
||||||
|
{ "code": "600719.SH", "shares": 800 },
|
||||||
|
{ "code": "300057.SZ", "shares": 1000 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"strategyId": "manual-t",
|
||||||
|
"dataset": [
|
||||||
|
{ "code": "600719.SH", "shares": 1000 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**数据视角**:按策略查持仓(策略为中心),比旧 code 为中心(allocations.json)更贴合业务。
|
||||||
|
|
||||||
|
### 3.3 store.market.json(行情快照缓存)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"savedAt": 1788193491108,
|
||||||
|
"quotes": {
|
||||||
|
"600719.SH": {
|
||||||
|
"time": 1788159604000,
|
||||||
|
"timetag": "20260831 15:00:04",
|
||||||
|
"lastPrice": 7.16,
|
||||||
|
"open": 7.19,
|
||||||
|
"high": 7.24,
|
||||||
|
"low": 7.02,
|
||||||
|
"lastClose": 7.22,
|
||||||
|
"volume": 63523,
|
||||||
|
"amount": 45341300
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**用途**:宿主重启后首屏快速展示上次价格(不依赖实盘订阅);实盘推送/轮询做增量更新并定期写回。
|
||||||
|
|
||||||
|
## 4. 数据流
|
||||||
|
|
||||||
|
```
|
||||||
|
启动时:
|
||||||
|
DataStore.load() → 读 store.json(份额分配)
|
||||||
|
DataStore.loadMarket() → 读 store.market.json(行情缓存,首屏有价)
|
||||||
|
MarketFeed._primePositions() → 主动拉持仓盘口 → 写缓存(服务端启动即预热最新价)
|
||||||
|
|
||||||
|
实盘中:
|
||||||
|
MarketFeed WS 推送 / REST 定时刷新 → MarketDataHub.ingest() → 内存缓存
|
||||||
|
MarketDataHub 防抖 3s → DataStore.setMarketQuotes() → store.market.json
|
||||||
|
|
||||||
|
查询时(market-snapshot):
|
||||||
|
MarketDataHub.getByCodes() → 内存 → 磁盘 → REST 补拉(三级命中)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 关键设计决策
|
||||||
|
|
||||||
|
| 决策 | 结论 | 理由 |
|
||||||
|
|---|---|---|
|
||||||
|
| 存储介质 | JSON 文件(不引入数据库) | 数据量很少,JSON + schema 足够 |
|
||||||
|
| 策略定义位置 | DSH settings(不迁 store) | 设置页交互不变,查询简单 |
|
||||||
|
| 份额分配结构 | 策略为中心(strategies[].dataset) | 贴合业务视角,schema 统一描述 |
|
||||||
|
| dataset 格式 | [{code, shares}] 数组 | 可扩展字段(未来加成本价/备注) |
|
||||||
|
| 行情缓存 | 持久化(store.market.json) | DataStore 为数据快速展现存在,重启不丢价 |
|
||||||
|
| 写入 | 原子写(临时文件 + rename) | 防损坏,多次写安全 |
|
||||||
|
| 测试隔离 | ODL_TEST_DATA_DIR 环境变量或 dataDir 参数 | 防误删真实数据(技术约束-011) |
|
||||||
|
|
||||||
|
## 6. 迁移
|
||||||
|
|
||||||
|
- 旧 allocations.json(code 为中心)→ 新 store.json(策略为中心):**启动时自动迁移**,旧文件备份为 allocations.json.bak
|
||||||
|
- 迁移逻辑在 DataStore._migrateFromLegacy():code 为中心 → 策略为中心聚合
|
||||||
|
|
||||||
|
## 7. 对应实现
|
||||||
|
|
||||||
|
| 模块 | 职责 | 文件 |
|
||||||
|
|---|---|---|
|
||||||
|
| DataStore | 份额分配 + 行情持久化读写、迁移、schema | src/component/DataStore.js |
|
||||||
|
| PositionManager | 份额业务逻辑(依赖 DataStore) | src/component/PositionManager.js |
|
||||||
|
| MarketDataHub | 行情缓存(内存 + 磁盘持久化 + 查询) | src/component/MarketDataHub.js |
|
||||||
|
| MarketFeed | 行情获取(WS + REST 定时刷新 + 启动 prime) | src/component/MarketFeed.js |
|
||||||
|
|
||||||
|
## 8. 约束条目(引用)
|
||||||
|
|
||||||
|
- 技术约束-011:测试/回归脚本禁止在真实数据上执行写操作(独立数据目录)
|
||||||
|
- 本文件为新领域设计约束,后续数据存储变更以本文件为最终依据
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# 迭代 03:技术实现方案
|
||||||
|
|
||||||
|
> 依据:PLAN-003、技术约束-003/008/009、产品约束-005/006、UI约束-001/002
|
||||||
|
|
||||||
|
## 数据模型(settings namespace: one-divine-lot 新增字段 qmtConnections)
|
||||||
|
|
||||||
|
```
|
||||||
|
qmtConnections: {
|
||||||
|
list: [{ id, name, baseUrl, order }], // order 决定列表展示顺序
|
||||||
|
activeId: string, // 当前激活配置 id('' = 无,展示层回退 defaultId/第一条)
|
||||||
|
defaultId: string, // 默认配置 id('' = 无;启动时自动激活的目标)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- id:复用策略 slug 生成逻辑(拼音映射 + 去重),fallback 前缀 qmt-conn;
|
||||||
|
- baseUrl:归一化(trim + 去尾斜杠 + 校验 http(s):// 前缀);
|
||||||
|
- 生效连接解析顺序(getActiveConnection):activeId → defaultId → order 第一条 → null。
|
||||||
|
|
||||||
|
## 关键机制
|
||||||
|
|
||||||
|
1. **启动激活(Q1)**:apply 时 resolveStartupConnection(settings, config)——list 空 → cordis config.qmtBaseUrl 兜底(Q4 不迁移);否则 defaultId → activeId → 第一条;结果直接作为数据源构造参数并打日志。
|
||||||
|
2. **热切换(Q2)**:QmtBridgeRestDataSource 增加 setBaseUrl(url)——baseUrl 为按请求读取的实例字段,改字段即对后续请求生效,无需重建实例、无需重启。
|
||||||
|
3. **删除边界(Q6)**:removeQmtConnection 内处理——被删为激活 → activeId 指向 defaultId(若有效)否则第一条;被删为默认 → defaultId 转移到第一条;删除后为空 → activeId/defaultId 清空;**列表仅剩一条时拒绝删除**(返回约定错误)。
|
||||||
|
4. **测试连接(Q3)**:服务端代理 GET {baseUrl}/health(15s 超时),返回 { ok, latencyMs, status, error };支持传未保存的 baseUrl(表单内先测后存)。
|
||||||
|
5. **头部挂载(Q9)**:client 插件向 slot conversation.session.header.actions 注册 id=odl-qmt-switch、order=-9(内置 PTC 标签 order=-10,并排其后);控件用插件自身 connection RPC 读写配置,不依赖 slot 注入。
|
||||||
|
|
||||||
|
## 服务端 API(POST /odl/api/<method>,webServer 自开路由 · 技术约束-004)
|
||||||
|
|
||||||
|
| 端点 | 入参 | 行为 |
|
||||||
|
|---|---|---|
|
||||||
|
| qmt-connections | - | 返回 { list, activeId, defaultId, active }(active 为生效连接对象) |
|
||||||
|
| qmt-connections/add | { name, baseUrl } | 新增;返回新配置 |
|
||||||
|
| qmt-connections/update | { id, name, baseUrl } | 编辑;若改的是激活配置且 baseUrl 变化 → 编排 setBaseUrl |
|
||||||
|
| qmt-connections/remove | { id } | 删除(含边界处理);生效连接变化 → 编排 setBaseUrl;仅剩一条时报错 |
|
||||||
|
| qmt-connections/activate | { id } | 激活(写 activeId)+ setBaseUrl |
|
||||||
|
| qmt-connections/set-default | { id } | 设默认(写 defaultId) |
|
||||||
|
| qmt-connections/test | { baseUrl } 或 { id } | 代理 /health,返回可达性与延迟 |
|
||||||
|
|
||||||
|
## 客户端
|
||||||
|
|
||||||
|
- **QmtConnectionChip.jsx(新增)**:chip `QMT: <激活配置名> ▾` + 自实现下拉菜单(绝对定位 + 外点关闭;激活项勾选);点选 → activate 端点 → toast 反馈 + 本地状态刷新。
|
||||||
|
- **SettingsSection.jsx(改)**:第三个子 tab「QMT 连接配置」;配置列表(名称/地址/激活与默认徽标)+ 行内操作(激活 / 设为默认 / 编辑 / 测试 / 删除)+ 新增与编辑表单 + 删除确认弹窗(复用 ConfirmDialog)+ Toast 反馈。
|
||||||
|
- **client/index.js(改)**:注册头部 slot;其余 tab 注册不动。
|
||||||
|
|
||||||
|
## 安装与构建
|
||||||
|
|
||||||
|
服务端直接改 src;客户端 bundle 走既有 tsdown + wrap 构建(技术约束-005/006);安装用 dsh plugin add(技术约束-007)。cordis.patch.yml 不改动(Q4)。
|
||||||
|
|
||||||
|
## 实施事实(2026-08-29)
|
||||||
|
|
||||||
|
- **改动文件**:src/settings.js(schema + 9 个 QMT 函数段)、src/data-source/qmt-bridge-rest.js(setBaseUrl)、src/index.js(启动解析 + dataSource 注入 api)、src/api.js(7 个 qmt-connections 端点 + testQmtConnection 代理 + activate/update/remove 热切换编排)、src/client/index.js(头部 slot 注册 odl-qmt-switch order=-9)、src/client/views/QmtConnectionChip.jsx(新增)、src/client/views/SettingsSection.jsx(第三个子 tab QmtConnectionsSettings,追加于文件尾)。cordis.patch.yml 未改动。
|
||||||
|
- **构建**:pnpm run build 一次通过(服务端 ESM 14 文件 + 客户端 CJS bundle 69.60kB + wrap 65907 bytes);node --check 服务端全通过;bundle 内已核对 chip 与端点调用代码。
|
||||||
|
- **安装方式更正**:profile(~/.dsh/profiles/web)中插件为 link 符号链接指向工作区,工作区 lib/ 即宿主加载产物,构建后无需再执行 dsh plugin add(技术约束-007 补充事实:link 安装形态下构建即生效)。
|
||||||
|
- **数据层单测**:14 项断言全部通过(mock settings scope):新增归一化去尾斜杠、order 排序、新增不自动激活、空激活态回退第一条、非法地址拒绝、激活/默认写入、生效连接解析、编辑激活配置地址、删除标记返回、删激活/默认→转移第一条并激活、禁删最后一条、空列表回退 cordis、启动激活默认(覆盖上次激活)、启动解析回写 activeId。
|
||||||
|
- **生效条件**:线上宿主进程(GUI http://127.0.0.1:3080)启动时已加载旧 lib(qmt-connections 端点 404 证实),需重启 DSH Web 或重载插件后新代码生效。
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# 迭代 03 复盘(2026-08-29)
|
||||||
|
|
||||||
|
## 结果
|
||||||
|
|
||||||
|
R-004 全量实现并验收通过(7/7),需求归档至 docs/05-需求池/已完成/R-004.md。
|
||||||
|
|
||||||
|
## 过程事实
|
||||||
|
|
||||||
|
1. 编码按 PLAN-003 七步执行,构建一次通过;数据层 14 项单测全部通过;
|
||||||
|
2. 第一次验收反馈暴露「服务端未重启」版本差(设置页新 UI 可见但端点 404)——服务端模块宿主启动时加载,与客户端 bundle 动态加载行为不同;
|
||||||
|
3. 老师反馈两处 UI 调整并当轮完成:chip 空配置态显示(消除入口死锁)、设置页列表改卡片形式(长文本省略防撑行,UI约束-002 变更);
|
||||||
|
4. 宿主重启后线上实测:热切换闭环(切不可达配置→持仓失败→切回→恢复)、删除边界(删激活自动切回默认)、测试连接(可达 726ms / 不可达 10.1s)全部通过;
|
||||||
|
5. 过程操作失误一次(猜错临时配置 id 导致 404),用真实 id 重跑通过——API 返回的 id 应先查再用;
|
||||||
|
6. 拼音表外中文名 slug 兜底通用名(qmt-conn),不阻塞验收,留作后续增强。
|
||||||
|
|
||||||
|
## 经验(已沉淀至归档文档)
|
||||||
|
|
||||||
|
- 服务端改动需重启宿主 vs 客户端 bundle 刷新即载 —— 版本差排查先看两侧加载时机;
|
||||||
|
- 入口控件空态可见性原则:数据需经该入口创建时,空态应显示并引导;
|
||||||
|
- baseUrl 按请求读取 = 热切换的架构事实依据,先于编码核实成本。
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# 迭代 03:QMT 连接配置(多配置管理 + 会话头部快捷切换)
|
||||||
|
|
||||||
|
> 创建:2026-08-29 | 依据需求:R-004(已定稿 2026-08-29)| 依据计划:PLAN-003(计划-QMT连接配置.md)
|
||||||
|
|
||||||
|
## 迭代目标
|
||||||
|
|
||||||
|
为插件增加 QMT Bridge 连接的多配置管理能力,解决「多环境切换必须进设置页改配置」的效率问题:
|
||||||
|
|
||||||
|
- 设置页新增「QMT 连接配置」子 tab:多配置 CRUD、单选激活、默认标记、测试连接、删除边界处理;
|
||||||
|
- 会话窗口顶栏(PTC 模式标签旁)新增快捷切换控件:一键切换激活配置;
|
||||||
|
- 激活立即生效(数据源热切换,无需重启 DSH);重启后回到默认配置。
|
||||||
|
|
||||||
|
## 目标描述
|
||||||
|
|
||||||
|
插件当前只能通过 cordis.patch.yml 注入单一 qmtBaseUrl,切换环境(本机 QMT / 局域网其他机器)需改宿主配置并重启。本迭代把连接信息升级为「多配置 + 激活态」管理:配置存插件 settings namespace,激活即热切换数据源地址;并提供会话头部常驻切换入口,让切换一次点击完成。
|
||||||
|
|
||||||
|
## 目标的讨论过程
|
||||||
|
|
||||||
|
R-004 三轮讨论(2026-08-29,详见 docs/05-需求池/R-004.md 讨论记录):
|
||||||
|
1. **第一轮**:AI 代码审查(数据源按请求读取地址、/health 可复用、策略配置先例)+ 老师逐条拍板 Q1-Q8(含新增的存储位置问题);
|
||||||
|
2. **第二轮**:老师提出会话头部快捷切换需求;AI 调研 DSH 宿主确认 PTC 标签所在 slot(conversation.session.header.actions)可并排挂载;老师拍板 Q9(入口=会话头部)/Q10(菜单仅切换);
|
||||||
|
3. **第三轮**:老师确认定稿(三要素满足)。
|
||||||
|
|
||||||
|
## 对项目主理人(老师)的配合需求
|
||||||
|
|
||||||
|
- 验收需要第二个真实配置地址(如另一台机器的 QMT Bridge),由老师提供或现场指定;
|
||||||
|
- 构建安装后需要重载 Web 页面,配合验证头部 chip 显示、切换生效与设置页子 tab 功能;
|
||||||
|
- 验收通过后确认归档。
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# 迭代 03:验收标准
|
||||||
|
|
||||||
|
> 依据:PLAN-003 验收标准;产品约束-005/006、UI约束-001/002
|
||||||
|
|
||||||
|
## 验收标准线(全部满足才判定达成)
|
||||||
|
|
||||||
|
1. 设置页出现「QMT 连接配置」子 tab(与通用设置/策略分组并列),配置 CRUD 完整可用(名称 + HTTP 地址);
|
||||||
|
2. 激活某配置后数据源**立即**切到该地址(无需重启 DSH)——切换后用「测试连接」/持仓加载验证目标端点随激活配置变化;
|
||||||
|
3. 默认配置行为正确:启动自动激活默认;重启后回到默认(即使运行中切过其他配置);列表为空时按 cordis qmtBaseUrl 兜底可用;
|
||||||
|
4. 「测试连接」返回可达性 + 延迟;激活不强制先测试;
|
||||||
|
5. 删除边界符合产品约束-005:删激活→自动切默认;删默认→默认转移列表第一条并激活;删最后一条被禁止(有提示);
|
||||||
|
6. 会话头部 PTC 标签旁出现 `QMT: <激活配置名>` chip;点开菜单列出全部配置(激活项勾选);点选即激活并 toast 反馈;头部仅切换(无管理入口);
|
||||||
|
7. 现有功能(分仓/策略/设置现有子 tab)不回归。
|
||||||
|
|
||||||
|
## 验收方法
|
||||||
|
|
||||||
|
- 服务端:构建通过;curl 逐个验证 7 个端点(CRUD / 激活 / 默认 / 删除边界 / test);
|
||||||
|
- 客户端:构建安装后重载页面——设置页走子 tab 全流程;头部 chip 与 PTC 标签并排显示并完成一次真实切换;
|
||||||
|
- 需老师配合:提供第二个真实配置地址,完成「切换 → 测试连接 → 持仓加载」闭环验证与重启回默认验证。
|
||||||
|
|
||||||
|
## 验收目标
|
||||||
|
|
||||||
|
判定本迭代是否达成 R-004 定稿范围(产品约束-005/006 全量落地);通过后 R-004 移入已完成并归档。
|
||||||
|
|
||||||
|
## 验收进度(2026-08-29,服务端侧)
|
||||||
|
|
||||||
|
| 项 | 结果 |
|
||||||
|
|---|---|
|
||||||
|
| 构建(服务端+客户端 bundle) | ✅ 一次通过,bundle 已含新控件与端点代码 |
|
||||||
|
| 数据层逻辑单测(14 项:CRUD/激活/默认/删除边界/启动解析/cordis 兜底) | ✅ 全部通过 |
|
||||||
|
| 线上 API 实测 | ⏳ 待宿主重启/插件重载后进行(当前宿主进程仍运行旧 lib,qmt-connections 404) |
|
||||||
|
| 设置页子 tab / 头部 chip UI 验证 | ⏳ 待重载页面 + 配合提供第二个真实配置地址 |
|
||||||
|
| 重启回到默认 / 删除边界 UI 实测 | ⏳ 同上 |
|
||||||
|
|
||||||
|
**下一步(需老师配合)**:重启 DSH Web(或重载插件)→ 刷新页面 → 按验收标准 1-7 逐条核验。
|
||||||
|
|
||||||
|
### 2026-08-29 第一次验收反馈(老师)
|
||||||
|
|
||||||
|
- 现象:设置页 QMT 子 tab 报 `unknown method: qmt-connections`;会话头部未见 chip。
|
||||||
|
- 定位:① 页面刷新只重载了客户端 bundle(新设置子 tab 已出现),服务端插件代码为宿主进程启动时加载,宿主未重启仍在运行旧 lib —— 端点 404 属预期过渡状态,需重启 DSH Web;② chip 原实现「无配置时隐藏」与 Q4 不迁移组合产生空态不可见问题,已修正为**空配置态也显示 chip**(菜单提示去设置添加,仅服务端未就绪时隐藏),重新构建通过(66127 bytes)。
|
||||||
|
- 待办:老师重启 DSH Web 后按验收标准 1-7 核验。
|
||||||
|
|
||||||
|
## 验收结果(2026-08-29,宿主重启后线上实测)
|
||||||
|
|
||||||
|
> 宿主已重启,新服务端生效;老师已在设置页录入两个真实配置(GEMWIN=http://100.110.38.78:8610 / QMT_CYY=http://192.168.3.43:8610),激活与默认均为 GEMWIN。
|
||||||
|
|
||||||
|
| 验收标准 | 结果 | 证据 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1. 设置页「QMT 连接配置」子 tab + 配置 CRUD | ✅ | 老师经 UI 实录 2 条配置(GEMWIN/QMT_CYY),列表/激活/默认状态正确返回 |
|
||||||
|
| 2. 激活立即切换(无需重启) | ✅ | 激活 QMT_CYY(不可达)→ 持仓立即 fetch failed(证明数据源已切走);切回 GEMWIN → 持仓立即恢复真实数据;全程无重启 |
|
||||||
|
| 3. 默认/启动行为 | ✅(逻辑单测覆盖) | 单测:启动激活默认(覆盖上次激活)并回写 activeId;空列表回退 cordis qmtBaseUrl。线上最终激活=GEMWIN=默认,状态一致;下次重启可复核启动日志 |
|
||||||
|
| 4. 测试连接(可达性+延迟),激活不强制先测 | ✅ | GEMWIN:可达 726ms status=ok;QMT_CYY:不可达 fetch failed(10.1s);激活不可达配置无预检直接成功(符合 Q3) |
|
||||||
|
| 5. 删除边界 | ✅ | 线上实测:新建临时配置→激活→删除,removedWasActive=true、激活自动切回默认 GEMWIN;删默认转移/禁删最后一条由单测覆盖 |
|
||||||
|
| 6. 会话头部 chip(PTC 旁、切换+toast) | ⏳ 待老师目视确认 | bundle 已含 chip 与 slot 注册代码(odl-qmt-switch order=-9);后端切换闭环已通,UI 呈现待确认 |
|
||||||
|
| 7. 现有功能不回归 | ✅ | 持仓/策略等端点正常返回真实数据(大连热电等持仓) |
|
||||||
|
|
||||||
|
**过程发现(已记录)**:拼音映射表外的中文名(如「临时验证」)slug 兜底为通用名 `qmt-conn`(与策略 generateStrategyId 行为一致),后续可在 PINYIN_MAP 扩词或改用时间戳后缀增强区分度——不阻塞验收。
|
||||||
|
|
||||||
|
**遗留待确认**:验收标准 6(头部 chip UI 呈现),由老师刷新页面目视确认。
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# 技术实现方案:04-WS盘中价格实时更新
|
||||||
|
|
||||||
|
> 依据:PLAN-004 | 需求:R-005 | 设计约束:技术约束-001/003/008/010、产品约束-007
|
||||||
|
|
||||||
|
## 技术选型
|
||||||
|
|
||||||
|
- **WebSocket 客户端**:浏览器原生 WebSocket(前端直连,无需 ws 库);
|
||||||
|
- **连接地址**:`ws://<激活配置host>:8610/ws`,host 来自激活 QMT 连接配置(R-004 多配置机制,复用热切换);
|
||||||
|
- **订阅协议**:`POST /data/subscribe` body `{ type: 'whole', codes: ['SH','SZ'] }` → `{ ok, sub_id }`;
|
||||||
|
- **推送消息**:`{ type: 'whole', data: { '<code>': TickSnapshot } }`,TickSnapshot.lastPrice = 现价;
|
||||||
|
- **快照补初值**:连接后先 `GET /data/tick?codes=<关注codes>` 拉一次快照(WS 只推增量,无 prime);
|
||||||
|
- **退订**:`POST /data/unsubscribe` body `{ sub_id }`(断开时清理)。
|
||||||
|
|
||||||
|
## 架构设计
|
||||||
|
|
||||||
|
### 前端行情数据流(事件驱动)
|
||||||
|
|
||||||
|
```
|
||||||
|
QMT Bridge WS (ws://<host>:8610/ws)
|
||||||
|
│ 推送 { type:'whole', data: { code: { lastPrice, ... } } }
|
||||||
|
▼
|
||||||
|
MarketDataProvider (React Context + useReducer)
|
||||||
|
│ 维护 prices: Map<code, { lastPrice, lastClose, stime }>
|
||||||
|
│ 事件:onPrice(codes) → 通知订阅方
|
||||||
|
▼
|
||||||
|
3 个监控表格(AllPositionsTab / StrategyTab)
|
||||||
|
│ 行渲染时读 prices.get(code) 叠加现价
|
||||||
|
▼
|
||||||
|
PriceCell(格式化 + 红涨绿跌 + 变化高亮)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 关注列表(自动汇总)
|
||||||
|
|
||||||
|
- 3 个表格各自渲染时,把出现的 code 上报给 MarketDataProvider(`registerCodes(codes)`);
|
||||||
|
- Provider 合并为关注集合,作为快照拉取与推送过滤的依据;
|
||||||
|
- 简化:全量推送本来就带所有 code,前端只从 prices Map 取自己行需要的 code(无需显式过滤,天然按需)。
|
||||||
|
|
||||||
|
### 生命周期
|
||||||
|
|
||||||
|
- 挂载:获取激活配置 host → 拉快照 → 建 WS → 订阅 whole → 收推送更新 Map;
|
||||||
|
- 配置切换:监听激活配置变化(host 变化)→ 断开旧 WS → 退订 → 重建连接;
|
||||||
|
- 断开:指数退避静默重连 + 状态提示(连接中/已连接/重连中/断开)。
|
||||||
|
|
||||||
|
## 涉及设计约束
|
||||||
|
|
||||||
|
| 约束 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| 技术约束-001 | 统一数据源抽象:行情实时数据仍走 QMT Bridge 适配器口径(现价字段 lastPrice 语义一致) |
|
||||||
|
| 技术约束-003 | 直连 QMT Bridge(前端直连其原生 WS,不经过 MCP) |
|
||||||
|
| 技术约束-008 | 复用激活 QMT 连接配置的 host(多配置热切换联动 WS) |
|
||||||
|
| 技术约束-010(新增) | 前端直连 QMT Bridge WebSocket 订阅行情;服务端不中转 WS |
|
||||||
|
| 产品约束-007(新增) | 3 个监控表格展示盘中现价列:红涨绿跌着色 + 变化高亮;仅现价一项 |
|
||||||
|
|
||||||
|
## 实现步骤
|
||||||
|
|
||||||
|
1. 服务端 API:新增 `qmt-connections/active-host`(返回 { host, baseUrl, wsUrl, name });
|
||||||
|
2. MarketDataProvider.jsx:WS 客户端(连接/订阅/价格 Map/快照/重连/配置切换)+ Context;
|
||||||
|
3. PriceCell.jsx:现价单元格(格式化 + 涨跌色 + 高亮动画);
|
||||||
|
4. 3 表格接入:AllPositionsTab / StrategyTab 加「现价」列;
|
||||||
|
5. 构建安装 + 验收。
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# 迭代 04 复盘:WS 盘中价格实时更新(2026-09-01)
|
||||||
|
|
||||||
|
> 复盘日期:2026-09-01 | 迭代状态:**已完成(验收通过)**
|
||||||
|
> 关联需求:R-005(WS 盘中价格实时更新,已定稿)、R-006(策略数据 JSON data store schema,已定稿)
|
||||||
|
> 关联计划:PLAN-004(WS 盘中价格实时更新)、PLAN-005(策略数据 JSON 标准化)
|
||||||
|
|
||||||
|
## 结果
|
||||||
|
|
||||||
|
迭代 04 达成:3 个监控表格(全部持仓 / 手动做T / 网格超市)实现盘中现价实时显示(服务端缓存 + 前端轮询),数据层标准化为 JSON data store(份额 + 行情持久化),并完成多项架构治理(api 拆分、component 目录、健康检查、膨胀修复)。
|
||||||
|
|
||||||
|
## 过程事实
|
||||||
|
|
||||||
|
1. **R-005 WS 实时价格**:经历多次架构演进——
|
||||||
|
- 初版前端直连 WS(老师拍板)→ 实测发现 QMT Bridge REST 无 CORS 头、前端直连订阅被跨域拦截;
|
||||||
|
- 改服务端代理订阅/快照 → 前端轮询;再演进为「服务端中转 + 行情缓存」(老师定);
|
||||||
|
- 行情持久化(store.market.json)解决首屏无价;MarketFeed 启动 prime 主动拉持仓盘口;
|
||||||
|
2. **R-006 JSON data store**:策略数据集标准化(store.schema.json / store.json / store.market.json),旧 allocations.json 自动迁移(保留 .bak);
|
||||||
|
3. **架构治理**:
|
||||||
|
- src/api.js(377 行)按领域拆分 → src/api/ 目录(positions / strategies / qmt-connections / market / common);
|
||||||
|
- 创建 src/component/ 目录,market-cache 拆分为 MarketDataHub(缓存)+ MarketFeed(获取),业务模块陆续迁入;
|
||||||
|
- 移除冗余端点(subscribe-ws/unsubscribe-ws/tick-snapshot)、前端去重(表格上报 code 替代 Provider 自拉);
|
||||||
|
4. **QMT 健康检查**:服务端定时探测(5 分钟)+ 缓存,前端 QMT chip 右侧圆点(绿/红),点击触发即时探测;
|
||||||
|
5. **数据误删事故**(严重教训):回归测试脚本误在真实数据执行写操作,导致大连热电/万顺新材份额丢失——已恢复 + 建立测试隔离机制(ODL_TEST_DATA_DIR)并沉淀技术约束-011;
|
||||||
|
6. **行情缓存膨胀 bug**(38MB):WS 全市场推送无过滤 + setMarketQuotes 合并残留——三层修复(watchCodes 过滤 / warmup 不认可全市场 / 整体替换),最终 store.market.json 13 条 11KB;
|
||||||
|
7. **WS 推送稳定性**:实测 QMT Bridge WS 推送是会话级/有状态(连接后 wsPushCount=0,纯 REST 轮询供数)——归属 QMT Bridge 工作空间;
|
||||||
|
8. **全市场缓存**:实测 whole 推送 26,751 条中标准 A股仅 2,775 只(其余为期权/衍生品代码段)——方案搁置,保持 watchCodes。
|
||||||
|
|
||||||
|
## 经验(已沉淀)
|
||||||
|
|
||||||
|
- **服务端重启 vs 客户端刷新**:服务端模块宿主启动时加载(改动需重启),客户端 bundle 刷新即载——版本差排查先看两侧加载时机(迭代 03 经验复用);
|
||||||
|
- **前端对行情状态应无感**:行情刷新/获取是服务端职责,前端只拿结果——移除状态条,避免误报;
|
||||||
|
- **测试隔离**:回归/测试脚本禁止在真实数据上执行写操作(技术约束-011,数据误删事故沉淀);
|
||||||
|
- **缓存膨胀三要素**:全量数据源(WS 全市场)必须过滤(watchCodes)、加载不认可历史全量(warmup)、写盘必须整体替换(setMarketQuotes);
|
||||||
|
- **WS 推送不可靠**:QMT Bridge WS 为会话级行为(连接后可能 0 推送),实时数据需 REST 轮询兜底;
|
||||||
|
- **代码段过滤**:QMT whole 推送含大量非 A股品种(89%),全市场类操作需按标准股票代码过滤。
|
||||||
|
|
||||||
|
## 遗留/后续
|
||||||
|
|
||||||
|
1. **WS 推送稳定性**:归属 QMT Bridge 工作空间(老师另开筛选解决);
|
||||||
|
2. **全市场缓存**:搁置(需先解决 WS 稳定 + 只缓存标准 A股);
|
||||||
|
3. **临时诊断端点 market-stats**:可保留观测或后续移除;
|
||||||
|
4. **数据存储设计文档**:docs/03-设计约束/数据存储设计.md(已沉淀,后续存储变更以此为据)。
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# 迭代目标:04-WS盘中价格实时更新
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
在 3 个监控表格(全部持仓 / 手动做T / 网格超市)中实现**盘中价格实时更新**:前端直连 QMT Bridge WebSocket 订阅全市场 tick,事件驱动更新关注股票的现价(lastPrice)。
|
||||||
|
|
||||||
|
## 目标描述
|
||||||
|
|
||||||
|
- 范围:R-005(已定稿 2026-08-31);不新增其他功能域;
|
||||||
|
- 要解决的问题:交易时段表格价格**不会自动更新**(R-003 复盘遗留 O4 问题),需实时反映盘中价格;
|
||||||
|
- 引用需求:**R-005(已定稿,2026-08-31)**;
|
||||||
|
- 实现方式:前端直连 QMT Bridge WS(老师确认拓扑),订阅 whole 全市场,前端按关注列表过滤,现价叠加层更新。
|
||||||
|
|
||||||
|
## 目标讨论过程
|
||||||
|
|
||||||
|
- 2026-08-28 迭代 01 复盘提出 T-005(WS 数据监控)方向,放草稿;
|
||||||
|
- 2026-08-31 老师提出本轮需求:前端直连 ws 全量订阅 + 事件驱动更新 3 个表格的盘中价格(仅现价一项);
|
||||||
|
- 2026-08-31 讨论确认 4 项决策:
|
||||||
|
1. 连接拓扑:**前端直连 QMT Bridge WS**(非插件服务端中转);
|
||||||
|
2. 订阅范围:**订阅全市场行情流**(type=whole,codes=SH/SZ),前端按关注列表过滤;
|
||||||
|
3. 关注列表来源:**3 表格自动汇总**;
|
||||||
|
4. 现价展示:**现价列 + 红涨绿跌 + 变化高亮**,刷新机制为**价格叠加层**(不整表重拉);
|
||||||
|
- 2026-08-31 协议实测(GEMWIN 100.110.38.78:8610):订阅/退订/推送/快照格式全部确认。
|
||||||
|
|
||||||
|
## 对老师的配合需求
|
||||||
|
|
||||||
|
- 验收需要 GEMWIN(Tailscale 100.110.38.78:8610)可达(已确认可达);
|
||||||
|
- 构建安装后重载页面配合验证;
|
||||||
|
- 验收通过后确认归档。
|
||||||
@@ -0,0 +1,482 @@
|
|||||||
|
# 迭代说明:04-WS盘中价格实时更新
|
||||||
|
|
||||||
|
> 本文件为本次迭代的**修改记录**(追加式,只记事实):每完成一处修改,追加一条记录(改了什么 / 为什么 / 涉及文件)。
|
||||||
|
> 创建:2026-08-31
|
||||||
|
|
||||||
|
## 修改记录
|
||||||
|
|
||||||
|
### 2026-08-31 · 建立迭代 04 文档骨架
|
||||||
|
|
||||||
|
- **做了什么**:需求 T-005 草稿转正为 R-005(docs/05-需求池/R-005.md);创建计划 PLAN-004(docs/02-计划/计划-WS盘中价格实时更新.md);创建迭代 04 子目录(docs/04-迭代记录/04-WS盘中价格实时更新/)下的迭代目标 / 技术实现方案 / 验收标准 三件套 + 本说明。
|
||||||
|
- **为什么**:本轮迭代按 divine-lot-dev 流程先落文档(需求定稿 → 计划 → 迭代范围),后续修改全部可回溯。
|
||||||
|
- **涉及文件**:docs/05-需求池/R-005.md、docs/02-计划/计划-WS盘中价格实时更新.md、docs/04-迭代记录/04-WS盘中价格实时更新/(4 个 md)。
|
||||||
|
|
||||||
|
### 2026-08-31 · 服务端:新增 active-host / unsubscribe-ws 端点
|
||||||
|
|
||||||
|
- **做了什么**:src/api.js 新增两个端点:
|
||||||
|
- `qmt-connections/active-host`:返回激活 QMT 连接配置的 `{ name, baseUrl, host, wsUrl, activeId }`(host 含端口,wsUrl = ws://host/ws),供前端据此直连 QMT Bridge WebSocket;连接列表为空时回退数据源当前 baseUrl(cordis 兜底);
|
||||||
|
- `qmt-connections/unsubscribe-ws`:服务端代理 `POST {baseUrl}/data/unsubscribe`(浏览器侧 WS 直连可行,REST 退订统一走服务端避免跨域)。
|
||||||
|
- **为什么**:前端需要激活配置的 host 才能建 WS(技术约束-010);退订走服务端代理更干净。
|
||||||
|
- **涉及文件**:src/api.js。
|
||||||
|
|
||||||
|
### 2026-08-31 · 客户端:MarketDataProvider(WS 行情客户端)
|
||||||
|
|
||||||
|
- **做了什么**:新增 src/client/market/MarketDataProvider.jsx:
|
||||||
|
- 直连 `ws://<激活配置host>:8610/ws`(浏览器原生 WebSocket);
|
||||||
|
- 连接前 REST 订阅 `POST /data/subscribe { type:'whole', codes:['SH','SZ'] }` 拿 sub_id;
|
||||||
|
- 维护 code → { lastPrice, lastClose, stime, open, high, low } 价格 Map(useReducer);
|
||||||
|
- 事件驱动:WS 推送 `{ type:'whole', data: { code: snap } }` → dispatch update → 所有订阅表格重渲染现价;
|
||||||
|
- 连接后拉一次 `GET /data/tick?codes=SH,SZ` 快照补初值(WS 只推增量,无 prime);
|
||||||
|
- 断线指数退避重连(1s→2s→4s…上限 15s)+ 连接状态(connecting/live/reconnecting/disconnected);
|
||||||
|
- 每 5s 轮询 active-host,激活配置 host 变化时自动断开旧 WS 并重建(复用 R-004 多配置热切换)。
|
||||||
|
- **为什么**:实现「前端直连 + 全量订阅 + 事件驱动」的核心(R-005 定稿)。
|
||||||
|
- **涉及文件**:src/client/market/MarketDataProvider.jsx(新增)。
|
||||||
|
|
||||||
|
### 2026-08-31 · 客户端:PriceCell 现价单元格 + 行情状态条
|
||||||
|
|
||||||
|
- **做了什么**:新增 src/client/views/PriceCell.jsx:
|
||||||
|
- `PriceCell`:显示 lastPrice(≥100 用 2 位小数,否则 3 位),红涨绿跌(对比 lastClose,A股习惯:涨红 #d32f2f / 跌绿 #2e7d32 / 平灰);价格变化时 1.2s 金色高亮闪动(CSS keyframes);
|
||||||
|
- `MarketStatusBar`:表格顶部行情连接状态提示(连接中/实时/重连中/未连接,圆点颜色区分)。
|
||||||
|
- **为什么**:产品约束-007(现价列 + 涨跌色 + 变化高亮)。
|
||||||
|
- **涉及文件**:src/client/views/PriceCell.jsx(新增)。
|
||||||
|
|
||||||
|
### 2026-08-31 · 客户端:3 个监控表格接入现价列
|
||||||
|
|
||||||
|
- **做了什么**:
|
||||||
|
- src/client/index.js:withConnection 内包一层 MarketDataProvider,3 个表格共享同一行情实例;
|
||||||
|
- src/client/views/AllPositionsTab.jsx:表头/表体新增「现价」列(名称后),标题下加 MarketStatusBar;
|
||||||
|
- src/client/views/StrategyTab.jsx:表头/表体新增「现价」列(名称后),顶部加 MarketStatusBar(手动做T / 网格超市共用)。
|
||||||
|
- **为什么**:3 表格展示关注股票盘中现价(R-005 目标)。
|
||||||
|
- **涉及文件**:src/client/index.js、src/client/views/AllPositionsTab.jsx、src/client/views/StrategyTab.jsx。
|
||||||
|
|
||||||
|
### 2026-08-31 · 构建验证
|
||||||
|
|
||||||
|
- **做了什么**:`pnpm typecheck`(0 错误)+ `pnpm build` 通过(客户端 bundle 82.54kB / 17.96kB gzip,服务端 14 文件,wrap 完成)。
|
||||||
|
- **端到端链路验证**:Node 模拟前端流程(订阅 whole → WS 连接 → 26 批推送 → 关注 code 过滤 → 提取现价)成功:603028.SH → lastPrice=7.18(lastClose=7.1,涨,UI 红色)。
|
||||||
|
- **为什么**:构建与链路验证通过,可进入宿主安装验收。
|
||||||
|
- **涉及文件**:lib/(构建产物)。
|
||||||
|
|
||||||
|
|
||||||
|
### 2026-08-31 · 问题分析记录:订阅走服务端代理,WS 推送问题归属另一工作空间
|
||||||
|
|
||||||
|
**现象**(老师观察):全部持仓页「行情实时 IP」短暂出现后变「行情重连中」;持仓列表现价始终无数据。
|
||||||
|
|
||||||
|
**分析过程(基于实验证据,非猜测)**:
|
||||||
|
1. **CORS 实证**:QMT Bridge REST(/health、/data/subscribe、/data/tick)响应**不含 Access-Control-Allow-Origin 头**,OPTIONS 预检返回 404 → 浏览器页面(origin=127.0.0.1:3080)直接 fetch QMT Bridge REST 会被**跨域拦截**。这与项目历史一致:REST 一直走 DSH 服务端代理(qmt-connections/test 注释「浏览器跨域规避」)。
|
||||||
|
2. **架构结论**:**订阅是 RESTful 接口(POST /data/subscribe),必须走 DSH 服务端代理转发**,服务端无跨域问题;前端直连 REST 订阅是错误的架构。
|
||||||
|
3. **WS 行为实验**(Node 直连 GEMWIN):
|
||||||
|
- 不订阅直接连 WS → 收到 25 条/5秒 推送(首个连接);
|
||||||
|
- REST 订阅成功(sub_id 有效)→ 连 WS → 0 条;
|
||||||
|
- 全新进程不订阅直连 → 稳定收到 21 条/5秒。
|
||||||
|
- 结论:**QMT Bridge 的 WS 推送语义与 OpenAPI 文档描述不符**(订阅反而可能干扰推送),该问题**归属 QMT Bridge 工作空间**(老师负责,另行解决)。
|
||||||
|
4. **本工作空间策略**:订阅与快照(REST)一律走 DSH 服务端代理;数据显示先以 **tick 快照轮询**(GET /data/tick,稳定可用)为兜底数据源 + WS 推送为增强通道,确保持仓列表实盘价格**先显示出来**。
|
||||||
|
|
||||||
|
**涉及文件**:本记录(docs/04-迭代记录/04-WS盘中价格实时更新/迭代说明.md)。
|
||||||
|
|
||||||
|
|
||||||
|
### 2026-08-31 · 服务端:新增 subscribe-ws / tick-snapshot 代理端点
|
||||||
|
|
||||||
|
- **做了什么**:src/api.js 新增两个端点(均走 DSH 服务端代理,无跨域):
|
||||||
|
- `qmt-connections/subscribe-ws`:代理 `POST {激活baseUrl}/data/subscribe`(body { type, codes }),返回 { ok, sub_id };
|
||||||
|
- `qmt-connections/tick-snapshot`:代理 `GET {激活baseUrl}/data/tick?codes=...`,返回 { ok, data: { code: snapshot } }。
|
||||||
|
- **为什么**:订阅/快照是 RESTful 接口,浏览器直连 QMT Bridge 会跨域(无 CORS 头、OPTIONS 404),统一走服务端代理(与 qmt-connections/test 同模式,项目既有架构约定)。
|
||||||
|
- **涉及文件**:src/api.js。
|
||||||
|
|
||||||
|
### 2026-08-31 · 客户端:MarketDataProvider 重构(服务端代理 + 快照轮询兜底)
|
||||||
|
|
||||||
|
- **做了什么**:重写 src/client/market/MarketDataProvider.jsx:
|
||||||
|
- 订阅 / 快照 / 退订全部改走服务端代理端点(/odl/api/qmt-connections/subscribe-ws、tick-snapshot、unsubscribe-ws),**移除前端直连 QMT Bridge REST**;
|
||||||
|
- **双通道价格源**:① tick 快照轮询(3s 间隔,走服务端代理,稳定兜底,确保现价一定显示);② WS 推送(增强通道,收到即实时覆盖);
|
||||||
|
- WS 连接保持前端直连(WebSocket 不受 CORS 限制),断线重连、激活配置切换联动保留。
|
||||||
|
- **为什么**:实证确认 QMT Bridge REST 无 CORS 头(浏览器直连被拦截);「先实现订阅后的数据显示」——快照轮询保证现价先显示出来,WS 作为增强。
|
||||||
|
- **涉及文件**:src/client/market/MarketDataProvider.jsx。
|
||||||
|
|
||||||
|
### 2026-08-31 · 模拟验证
|
||||||
|
|
||||||
|
- **做了什么**:Node 模拟新版前端流程(tick 快照轮询走服务端代理)→ 一次返回全部关注 code(600719.SH/601117.SH/603028.SH/001330.SZ/002065.SZ)的 lastPrice + lastClose,现价提取完整(如 600719.SH → 7.17,昨收 7.22 → 跌,UI 绿色)。
|
||||||
|
- **为什么**:验证服务端代理 + 快照轮询链路可用,前端渲染 PriceCell 即可显示红涨绿跌。
|
||||||
|
- **涉及文件**:无(验证记录)。
|
||||||
|
|
||||||
|
|
||||||
|
### 2026-08-31 · 端到端验收(宿主链路)
|
||||||
|
|
||||||
|
- **做了什么**:服务端新端点经宿主验证生效(subscribe-ws 订阅成功拿 sub_id;tick-snapshot 返回真实持仓现价);前端 bundle 确认加载新代码(rev=ae40cadd056a,与本地 lib 一致)。
|
||||||
|
- **端到端链路验证**(Node 模拟前端调宿主 /odl/api):
|
||||||
|
- 激活配置 GEMWIN → 订阅 ok(sub_id)→ 13 条持仓 → tick 快照 5/5 命中现价 → 红涨绿跌判定正确(600719.SH 跌→绿、603028.SH 涨→红)→ 退订 ok。
|
||||||
|
- **验证中发现**:/odl/api/positions 端点返回**数组**(value 直接是数组),非 summary 的 {positions:[...]} 结构(测试脚本字段误用,已修正;非产品问题)。
|
||||||
|
- **待老师确认**:浏览器刷新后 3 个表格现价列显示与红涨绿跌效果。
|
||||||
|
- **涉及文件**:无(验证记录)。
|
||||||
|
|
||||||
|
|
||||||
|
### 2026-08-31 · 修复:快照用真实持仓代码(根因定位)
|
||||||
|
|
||||||
|
- **现象**:刷新后订阅成功(sub_id 日志出现),但快照日志与现价均无。
|
||||||
|
- **定位(实证,非猜测)**:给前端加调试日志(订阅/快照/prices 三处)→ 只见订阅成功,无快照日志 → 审查 fetchSnapshot 传参为 'SH,SZ'(交易所代码)→ 宿主实测:tick-snapshot 传 'SH,SZ' 返回 **data 为空** {},传真实股票代码(600719.SH)才返回数据。
|
||||||
|
- **根因**:QMT Bridge 的 /data/tick 需要**真实股票代码**,前端快照却传了交易所代码 'SH,SZ',导致 data 为空、现价无数据。
|
||||||
|
- **修复**:MarketDataProvider 新增 fetchWatchCodes()(从 /odl/api/positions 获取持仓 code 集合),快照改用真实持仓代码。
|
||||||
|
- **验证**:Node 模拟修复后流程 → 13 只持仓全部取到现价(002347.SZ → 6.25 昨收 6.14 红涨;600719.SH → 7.17 昨收 7.22 绿跌)。
|
||||||
|
- **涉及文件**:src/client/market/MarketDataProvider.jsx。
|
||||||
|
|
||||||
|
|
||||||
|
### 2026-08-31 · 架构升级:服务端中转 + 行情缓存(老师定)
|
||||||
|
|
||||||
|
- **老师决策**:DSH 服务端做「一次 WS 订阅 + QMT 桥整合」,维护一份实时行情缓存(内存 Map);页面统一 5s 轮询读 DSH 接口(按 code 查询),不做前端直连。
|
||||||
|
- **实现**:
|
||||||
|
- 新增 src/market-cache.js:MarketCache 类(服务端连 ws://<激活host>:8610/ws 收全市场推送 → 写缓存 Map;断线指数退避重连;setBaseUrl 热切换;getByCodes 按 code 查询);
|
||||||
|
- src/index.js:创建 MarketCache 并启动,插件释放时 stop;激活热切换同步 setBaseUrl;
|
||||||
|
- src/api.js:新增 `market-snapshot` 端点(按 codes 查询缓存);激活/更新/删除连接时同步 marketCache;
|
||||||
|
- src/client/market/MarketDataProvider.jsx:**移除前端直连 WS**,改为 5s 轮询 /odl/api/market-snapshot(先取持仓 code,再按 code 查询)。
|
||||||
|
- **WS 推送稳定性实验**(Node 直连 GEMWIN,2026-08-31):
|
||||||
|
- 不订阅直连 10s → 0 条;REST 订阅后连 10s → 0 条;退订后再连 10s → 8 条;
|
||||||
|
- 结论:**QMT Bridge WS 推送是会话级/有状态行为**,仅首个连接或特定订阅窗口会推送——该问题归属 QMT Bridge 工作空间(老师负责,另行解决)。
|
||||||
|
- **缓存兜底**:MarketCache 以 WS 推送更新缓存为主,**辅以 REST tick 快照补数据**(REST 稳定可用,13 只持仓全部可取),确保缓存有值、前端轮询能拿到现价。
|
||||||
|
- **涉及文件**:src/market-cache.js(新增)、src/index.js、src/api.js、src/client/market/MarketDataProvider.jsx。
|
||||||
|
|
||||||
|
|
||||||
|
### 2026-08-31 · 缓存兜底:market-snapshot 按需补 + REST 定时刷新
|
||||||
|
|
||||||
|
- **做了什么**:
|
||||||
|
- src/market-cache.js:MarketCache 加 REST tick 快照兜底(每 5s 对缓存已有 code 用 /data/tick 刷新);
|
||||||
|
- src/api.js:market-snapshot 端点加「miss 按需补」——查询的 code 不在缓存时,现场用 REST /data/tick 拉取补入缓存并返回(**不依赖 WS 推送**,REST 稳定可用)。
|
||||||
|
- **为什么**:2026-08-31 实验证实 QMT Bridge WS 推送是会话级/有状态行为(仅首个连接或特定订阅窗口推送),不能作为唯一数据源;REST tick 稳定可取(13 只持仓全取到),作为兜底确保前端轮询必有数据。
|
||||||
|
- **验证**:Node 模拟 market-snapshot(缓存空→按需补→5/5 命中→缓存 5 条→再次查询全命中)。
|
||||||
|
- **涉及文件**:src/market-cache.js、src/api.js。
|
||||||
|
|
||||||
|
|
||||||
|
### 2026-08-31 · 结构调整:src/api.js 按领域拆分
|
||||||
|
|
||||||
|
- **做了什么**(老师要求,审视结构后清理):
|
||||||
|
- 原 src/api.js(377 行,聚合 5 类端点)拆分为:
|
||||||
|
- src/api/index.js:路由入口(合并各领域方法表 + 405/404/500 公共处理 + 领域分发);
|
||||||
|
- src/api/common.js:公共工具(writeJson / readJsonBody / resolveActiveBaseUrl);
|
||||||
|
- src/api/positions.js:持仓域(positions);
|
||||||
|
- src/api/strategies.js:策略/份额/tabs 域(14 端点);
|
||||||
|
- src/api/qmt-connections.js:QMT 连接域(11 端点,含热切换编排 setBaseUrl + marketCache 同步);
|
||||||
|
- src/api/market.js:行情域(market-snapshot,含 miss 按需补)。
|
||||||
|
- 更新 src/index.js import(./api.js → ./api/index.js)与 tsdown.config.ts 入口。
|
||||||
|
- **验证**:typecheck 0 错误;构建通过(lib/api/index.js + api chunk 正常);27 个端点方法表核对完整(positions 1 + strategies 14 + qmt 11 + market 1);构建产物模块加载正常(registerApi 正确导出)。
|
||||||
|
- **涉及文件**:src/api/*(6 个新文件)、src/index.js、tsdown.config.ts;删除 src/api.js。
|
||||||
|
|
||||||
|
|
||||||
|
### 2026-08-31 · 拆分验收(宿主重启后全端点回归)
|
||||||
|
|
||||||
|
- **做了什么**:宿主重启后对拆分后的 API 做全端点回归测试。
|
||||||
|
- **结果**:
|
||||||
|
- 27 端点全部可用(15 项首轮通过;3 项「失败」为测试参数错误——shares=0 触发业务校验、无未分配份额触发 no-unallocated,属正确业务行为);
|
||||||
|
- 合法参数重测 add-shares / remove-shares / move-all-shares 全部 OK(600719.SH 100 股往返,清理干净);
|
||||||
|
- 核心端点:market-snapshot 13/13、active-host GEMWIN、QMT CRUD、subscribe-ws/unsubscribe-ws、tick-snapshot(lastPrice=7.15)全部通过。
|
||||||
|
- **结论**:api.js 按领域拆分无功能回归,验收通过。
|
||||||
|
- **涉及文件**:无(验证记录)。
|
||||||
|
|
||||||
|
|
||||||
|
### 2026-08-31 · 结构调整:创建 src/component/ 目录 + market-cache 拆分为 Hub/Feed
|
||||||
|
|
||||||
|
- **老师决策**:在 src/ 下统一创建 component/ 目录收纳业务模块(便于统一位置管理);market-cache 拆成多个模块各一个文件。
|
||||||
|
- **实现**:
|
||||||
|
- 新建 src/component/ 目录:
|
||||||
|
- MarketDataHub.js:行情缓存服务(缓存 Map + getByCodes 查询 + **miss 自动补拉收敛在内部** + ingest 写入入口);
|
||||||
|
- MarketFeed.js:行情数据获取(WS 订阅连接 + REST 定时刷新兜底 + 断线重连 + setBaseUrl 热切换),写入 Hub;
|
||||||
|
- src/index.js:装配改为 marketHub + marketFeed(替代 marketCache);
|
||||||
|
- src/api/market.js:**移除 miss 补拉代码**,只调 hub.getByCodes(HTTP 编排纯净);
|
||||||
|
- src/api/qmt-connections.js:热切换 marketCache?.setBaseUrl → marketFeed?.setBaseUrl;
|
||||||
|
- src/api/index.js:runtime 命名 marketCache → marketHub/marketFeed;
|
||||||
|
- 删除 src/market-cache.js。
|
||||||
|
- **职责划分**(解决「职责混乱」):
|
||||||
|
- MarketDataHub = 缓存 + 查询(单一职责,查询保证有值);
|
||||||
|
- MarketFeed = 取数(WS + REST 刷新),不对外查询;
|
||||||
|
- api/market.js = 纯 HTTP 编排,不碰缓存内部。
|
||||||
|
- **验证**:typecheck 0 错误;构建通过(chunk 打包正常,无动态导入残留);Node 模拟 hub.getByCodes 三场景(空缓存 miss 补拉 / 命中缓存 / 重复全命中)全部通过。
|
||||||
|
- **涉及文件**:src/component/MarketDataHub.js(新增)、src/component/MarketFeed.js(新增)、src/index.js、src/api/market.js、src/api/qmt-connections.js、src/api/index.js;删除 src/market-cache.js。
|
||||||
|
|
||||||
|
|
||||||
|
### 2026-08-31 · 拆分验收(宿主重启后全端点回归)
|
||||||
|
|
||||||
|
- **结果**:宿主重启后全端点回归全部通过:
|
||||||
|
- market-snapshot 13/13(miss 补拉正常,600719.SH → 7.18);二次查询命中缓存 13 条;
|
||||||
|
- summary 13 只;qmt-connections / active-host(GEMWIN)/ activate 热切换 / subscribe-ws+unsubscribe-ws / tick-snapshot(7.18)/ strategies(2 个)全部 OK。
|
||||||
|
- **结论**:MarketDataHub + MarketFeed 拆分无功能回归,component/ 目录结构落地,验收通过。
|
||||||
|
- **涉及文件**:无(验证记录)。
|
||||||
|
|
||||||
|
|
||||||
|
### 2026-08-31 · 优化:移除冗余端点 + 前端去重 + 模块迁入 component/
|
||||||
|
|
||||||
|
**#1 移除冗余端点**(死代码清理):
|
||||||
|
- 移除 src/api/qmt-connections.js 的 subscribe-ws / unsubscribe-ws / tick-snapshot 三端点(架构演进后无人调用:前端无引用、MarketFeed 直接 new WebSocket 不依赖它们)。
|
||||||
|
- 涉及文件:src/api/qmt-connections.js。
|
||||||
|
|
||||||
|
**#2 前端去重**(消除重复 positions 调用):
|
||||||
|
- MarketDataProvider 不再自拉 positions 获取关注 code,改为暴露 registerCodes(codes),由表格组件渲染后上报(AllPositionsTab 上报 summary 持仓 code、StrategyTab 上报策略持仓 code);Provider 只轮询 market-snapshot。
|
||||||
|
- 收益:减少一次网络往返;Provider 与表格数据天然同步(同一份 code 来源)。
|
||||||
|
- 涉及文件:src/client/market/MarketDataProvider.jsx、src/client/views/AllPositionsTab.jsx、src/client/views/StrategyTab.jsx。
|
||||||
|
|
||||||
|
**#3 业务模块迁入 src/component/**(结构治理延续):
|
||||||
|
- 迁移:storage.js → component/AllocationStorage.js;position/manager.js → component/PositionManager.js;data-source/qmt-bridge-rest.js → component/QmtBridgeRestDataSource.js;data-source/types.js → component/data-source-types.js。
|
||||||
|
- 删除空的 src/position/、src/data-source/ 目录;更新 index.js import、tsdown 入口(src/component/*.js)、JSDoc 类型引用。
|
||||||
|
- 收益:lib/component/ 每个模块独立编译文件,结构统一(component = 业务模块,api = 路由,client = 前端)。
|
||||||
|
- 涉及文件:迁移 4 文件 + src/index.js + tsdown.config.ts + 2 处 JSDoc。
|
||||||
|
|
||||||
|
**验证**:typecheck 0 错误;构建通过(lib/component/ 6 个模块独立文件);构建产物模块加载全部正常。
|
||||||
|
|
||||||
|
|
||||||
|
### 2026-08-31 · 优化验收(宿主重启后)
|
||||||
|
|
||||||
|
- **全端点回归通过**:positions 13 条、market-snapshot 13/13、summary 13 只、strategy-positions 12 只、qmt-connections/active-host GEMWIN、已移除端点 tick-snapshot 返回 404(确认移除生效)、test 可达 82ms、二次 market-snapshot 命中缓存。
|
||||||
|
- **备注**:回归中出现一次 positions/summary fetch failed,排查为 QMT Bridge 瞬时网络抖动(Tailscale relay 切换,ping 通 + health ok 后恢复),非代码问题。
|
||||||
|
- **结论**:#1 移除冗余端点 + #2 前端去重 + #3 模块迁入 component/ 三项优化全部完成,无功能回归,验收通过。
|
||||||
|
- **涉及文件**:无(验证记录)。
|
||||||
|
|
||||||
|
|
||||||
|
### 2026-08-31 · 数据误删事故分析与防再发机制
|
||||||
|
|
||||||
|
**事故**:大连热电(600719.SH)与万顺新材(300057.SZ)的份额分配数据丢失。
|
||||||
|
|
||||||
|
**根因(非业务代码 bug,是测试脚本误操作)**:
|
||||||
|
- 我在回归测试脚本(regression.mjs / retest-shares.mjs)中在**真实生产数据**上执行了写操作:
|
||||||
|
- regression.mjs 的 remove-all-shares(清空某策略全部份额);
|
||||||
|
- retest-shares.mjs 对 600719.SH 的 move-all-shares + remove-shares 清理。
|
||||||
|
- 这些操作本应只读或使用临时数据,误在真实 allocations.json 上执行,导致 600719 的 grid/manual-t 份额、300057 的 manual-t 份额被清空。
|
||||||
|
|
||||||
|
**恢复**:按老师提供的原值恢复(600719 = grid 800 + manual-t 1000;300057 = grid 1000 + manual-t 1000),已确认 13 只全部正确。
|
||||||
|
|
||||||
|
**防再发机制(老师定:测试用独立数据目录)**:
|
||||||
|
- AllocationStorage 新增测试模式:环境变量 ODL_TEST_DATA_DIR 或构造参数 dataDir 指向独立目录时,存储隔离,不影响真实 ~/.dsh/one-divine-lot/allocations.json;
|
||||||
|
- 已验证:临时目录实例执行 set/removeStrategyShares 写操作,真实数据不变;
|
||||||
|
- 测试规范:回归/测试脚本必须用独立数据目录,**禁止在真实数据上执行写操作**(只读端点可直连,写操作一律走测试实例)。
|
||||||
|
|
||||||
|
**涉及文件**:src/component/AllocationStorage.js(测试模式支持)。
|
||||||
|
|
||||||
|
|
||||||
|
### 2026-08-31 · R-006:策略数据 JSON data store schema 标准化
|
||||||
|
|
||||||
|
**需求定稿**(讨论确认):
|
||||||
|
- 范围:策略数据集(份额分配)标准化;策略定义仍留 DSH settings(设置页交互不变);
|
||||||
|
- 形态:store.schema.json(类 JSON Schema 描述 dataset)+ store.json(数据,策略为中心);
|
||||||
|
- dataset 格式:[{code, shares}] 数组(可扩展);
|
||||||
|
- 旧 allocations.json 启动时自动迁移(保留 .bak 备份);
|
||||||
|
- 删除策略时 API 层联动删 store dataset(同现状联清份额模式)。
|
||||||
|
|
||||||
|
**实现**:
|
||||||
|
- 新增 src/component/DataStore.js:STORE_SCHEMA 定义 + 读写 store.json(原子写)+ 迁移 + dataset CRUD(getDataset/setDataset/removeDataset/getAllDatasets);
|
||||||
|
- 重写 src/component/PositionManager.js:存储改接 DataStore(策略为中心),对外方法签名不变(addToStrategy/removeFromStrategy/getSummary 等);
|
||||||
|
- src/index.js:DataStore 替代 AllocationStorage 装配。
|
||||||
|
|
||||||
|
**验证**(测试隔离目录):
|
||||||
|
- 迁移:allocations.json(code 为中心)→ store.json(策略为中心 strategies[].dataset=[{code,shares}]),旧文件备份 .bak ✅;
|
||||||
|
- schema 生成 ✅;dataset CRUD ✅;原子写多次 save 完整 ✅。
|
||||||
|
|
||||||
|
**涉及文件**:src/component/DataStore.js(新增)、src/component/PositionManager.js(重写)、src/index.js。
|
||||||
|
|
||||||
|
|
||||||
|
### 2026-08-31 · R-006 验收(宿主重启后)
|
||||||
|
|
||||||
|
- **迁移验证**:宿主重启自动迁移真实 allocations.json → store.json(策略为中心),13 只持仓份额完整转入(grid 13 只 + manual-t 2 只),旧文件备份 allocations.json.bak ✅
|
||||||
|
- **数据完整性**:大连热电 grid 800 + manual-t 1000(分配 1800);万顺新材 grid 1000 + manual-t 1000(分配 2000)✅
|
||||||
|
- **端点回归**:summary 13 只、strategy-positions(grid) 13 只、strategies 列表正常;add-shares 超限正确拒绝(业务校验);strategies/remove 联动清 dataset 正常;market-snapshot 行情正常 ✅
|
||||||
|
- **真实数据未污染**:测试用临时策略(add/remove 往返 + 删除联动),600719 份额不变 ✅
|
||||||
|
- **结论**:R-006 JSON data store schema 标准化完成,数据迁移零丢失,无功能回归,验收通过。
|
||||||
|
- **涉及文件**:无(验证记录)。
|
||||||
|
|
||||||
|
|
||||||
|
### 2026-08-31 · 修复:行情状态误显示「无激活配置」
|
||||||
|
|
||||||
|
- **现象**:点击持仓 tab 能加载持仓数据,但状态条显示「行情未连接(无激活配置)」。
|
||||||
|
- **根因**:MarketDataProvider 主循环在 watchCodes 为空(表格尚未上报 code)时设置 DISCONNECTED 状态;DISCONNECTED 文案「无激活配置」是前端直连时代遗留——R-006 后行情走服务端中转,前端对激活配置应**无感**。
|
||||||
|
- **修复**:
|
||||||
|
- Provider 主循环:watchCodes 为空时保持 IDLE(等待表格上报),不再设 DISCONNECTED;用 statusRef 同步最新状态避免闭包过期;
|
||||||
|
- PriceCell 状态文案:disconnected 改为「行情未连接」(去掉「无激活配置」);
|
||||||
|
- IDLE 时不显示状态条。
|
||||||
|
- **涉及文件**:src/client/market/MarketDataProvider.jsx、src/client/views/PriceCell.jsx。
|
||||||
|
|
||||||
|
|
||||||
|
### 2026-08-31 · 行情持久化(store.market.json,解决首屏无价格)
|
||||||
|
|
||||||
|
- **问题**:行情缓存仅内存(MarketDataHub.cache Map),宿主重启后为空;持仓 tab 首次刷新时价格 miss,若 REST 补拉慢/失败则显示「—」。
|
||||||
|
- **根因**:行情未持久化——重启后无上次价格,需每次实盘订阅/拉取。
|
||||||
|
- **方案(老师确认:DataStore 就是为数据快速展现存在)**:
|
||||||
|
- DataStore 新增行情持久化:store.market.json(code → snapshot,原子写);
|
||||||
|
- MarketDataHub 接入:启动 warmup 读盘 → 内存缓存首屏即有价;ingest 增量更新后防抖写盘(3s);查询命中顺序:内存 → 磁盘 → REST 补拉;
|
||||||
|
- index.js:dataStore 注入 Hub + 启动预热。
|
||||||
|
- **验证**(测试隔离目录):实盘 ingest → 写盘 2 条;模拟重启 warmup 加载 2 条;首次查询 2/2 命中(600719 lastPrice=7.16)——重启后首屏即有价格 ✅。
|
||||||
|
- **涉及文件**:src/component/DataStore.js、src/component/MarketDataHub.js、src/index.js。
|
||||||
|
|
||||||
|
|
||||||
|
### 2026-08-31 · 修复:启动报错 storage 未初始化(TDZ)
|
||||||
|
|
||||||
|
- **现象**:宿主重启后插件加载失败,报 `Cannot access 'storage' before initialization`。
|
||||||
|
- **根因**:index.js 装配时序错误——marketHub 创建时引用 storage(dataStore: storage),但 storage 在其后才创建(const 暂时性死区 TDZ)。
|
||||||
|
- **修复**:将 storage 创建移到 marketHub 之前(55 行),顺序改为 storage → marketHub → marketFeed。
|
||||||
|
- **验证**:装配顺序正确,构建通过。
|
||||||
|
- **涉及文件**:src/index.js。
|
||||||
|
|
||||||
|
|
||||||
|
### 2026-09-01 · 行情持久化验收(宿主重启后)
|
||||||
|
|
||||||
|
- **启动修复**:storage 时序问题(TDZ)修复后宿主正常启动。
|
||||||
|
- **持久化验证**:
|
||||||
|
- store.market.json 已生成(13 条行情快照,600719 大连热电 lastPrice=7.16 等完整字段);
|
||||||
|
- market-snapshot 查询 13/13 命中(内存→磁盘→REST 三级);
|
||||||
|
- 即使 QMT 断网,磁盘缓存也能提供上次价格(首屏有价,不依赖实盘订阅)。
|
||||||
|
- **结论**:行情持久化闭环完成,验收通过。
|
||||||
|
- **涉及文件**:无(验证记录)。
|
||||||
|
|
||||||
|
|
||||||
|
### 2026-09-01 · 修复:持仓 tab 价格要等很久才出现(时序)
|
||||||
|
|
||||||
|
- **现象**:打开全部持仓,价格要等很久(约 5s)才刷新出来。
|
||||||
|
- **根因**:MarketDataProvider 主循环是 5s 定时器;页面加载时 poll() 立即跑一次但 watchCodes 为空(表格未上报)跳过;表格加载后 registerCodes 上报 code,但**不触发立即拉取**,只能等下一次定时器(最多 5s)才有价格。
|
||||||
|
- **修复**:registerCodes 上报新 code 后**立即触发一次 pollMarket**(通过 pollMarketRef 引用),首屏价格快速显示;定时器继续 5s 增量更新。
|
||||||
|
- **验证**:构建通过。服务端 market-snapshot 本就 13/13 有价(已实测),修复后前端上报即拉取,不再等定时器。
|
||||||
|
- **涉及文件**:src/client/market/MarketDataProvider.jsx。
|
||||||
|
|
||||||
|
|
||||||
|
### 2026-09-01 · MarketFeed 启动主动拉取持仓盘口(prime)
|
||||||
|
|
||||||
|
- **问题**(老师指出):前端打开页面只有持仓数据、价格要等行情刷新才出现;但行情刷新是服务端职责,前端应直接拿到最新最终价格。
|
||||||
|
- **根因查证**:插件启动时 MarketFeed 只做了 ①warmup 读磁盘(可能旧/空)②连 WS(被动等推送,QMT 推送会话级不可靠)③定时刷新(**空缓存时跳过**)——**没有任何「启动主动拉一次最新盘口」的逻辑**。
|
||||||
|
- **修复**:MarketFeed.start() 新增 _primePositions():启动即从 dataSource 拿持仓 code → REST /data/tick → 写入缓存;setBaseUrl 热切换时也主动拉一次(新地址盘口)。
|
||||||
|
- **验证**:Node 模拟启动 → prime 拉取 13 条盘口,缓存 13 条,查询 600719 → 7.16 ✅。
|
||||||
|
- **效果**:服务端启动即有最新盘口价,前端任何时候打开页面都能从缓存拿到价格(不依赖前端轮询触发)。
|
||||||
|
- **涉及文件**:src/component/MarketFeed.js。
|
||||||
|
|
||||||
|
|
||||||
|
### 2026-09-01 · 需求:移除行情状态条 + 订阅状态灯 + QMT 健康检查
|
||||||
|
|
||||||
|
**需求 1:移除「行情实时」状态条**
|
||||||
|
- 前端表格彻底移除 MarketStatusBar(PriceCell 删除该组件;AllPositionsTab / StrategyTab 移除引用与 status/wsInfo 解构)。
|
||||||
|
- 原则:行情刷新是服务端职责,前端只拿结果,对行情状态彻底无感。
|
||||||
|
- 涉及文件:src/client/views/PriceCell.jsx、AllPositionsTab.jsx、StrategyTab.jsx。
|
||||||
|
|
||||||
|
**需求 3:QMT 连接健康检查**
|
||||||
|
- 新增 market-status 端点(src/api/market.js):探测激活 QMT Bridge /health 可达性(5s 超时)+ 返回订阅状态 + 缓存大小。
|
||||||
|
- MarketFeed 新增 getStatus()(WS 连接态、最近推送时间、推送计数)。
|
||||||
|
|
||||||
|
**需求 2:订阅状态灯(QMT 切换 chip)**
|
||||||
|
- QmtConnectionChip 文字前加圆点状态灯:
|
||||||
|
- 绿:订阅存在 + 数据正常接收 + QMT 健康;
|
||||||
|
- 黄:订阅存在但数据停滞(最近 30s 无推送);
|
||||||
|
- 红:QMT 连接不健康;
|
||||||
|
- 灰:无订阅。
|
||||||
|
- 每 5s 轮询 market-status;下拉菜单显示订阅明细(推送次数)+ QMT 健康(延迟)。
|
||||||
|
- 涉及文件:src/client/views/QmtConnectionChip.jsx。
|
||||||
|
|
||||||
|
**验证**:typecheck 0 错误;构建通过;本地模拟 market-status(QMT 健康 162ms、订阅 active+dataHealthy、状态灯绿)逻辑正确。
|
||||||
|
|
||||||
|
|
||||||
|
### 2026-09-01 · 修正:market-status 数据健康判断(WS 不可靠时结合缓存)
|
||||||
|
|
||||||
|
- **问题**:宿主重启后 market-status 返回 wsConnected=true 但 tickCount=0、lastTickAt=0 → dataHealthy=false → 状态灯黄(停滞)。但缓存实际有 13 条(REST prime/轮询兜底)。
|
||||||
|
- **根因**:数据健康只以「WS 收到推送」为标准,而 QMT WS 推送是会话级/有状态(连接后可能 0 推送)。
|
||||||
|
- **修正**:dataHealthy = WS 推送新鲜(30s 内)**或** 缓存有数据(cacheSize > 0)——REST 兜底保证缓存更新时数据仍是好的。
|
||||||
|
- **涉及文件**:src/api/market.js。
|
||||||
|
|
||||||
|
|
||||||
|
### 2026-09-01 · 状态灯不稳定分析(黄/灰波动)
|
||||||
|
|
||||||
|
- **实测**(连续 5 次 market-status):qmt.healthy=true、wsConnected=true(WS 稳定)、tickCount=0、cache=13、dataHealthy=false → 灯黄。
|
||||||
|
- **根因**:
|
||||||
|
1. 服务端 dataHealthy 修正(wsFresh || cacheSize)**未生效**——宿主加载的是上一轮构建前的旧代码(构建在重启后完成);
|
||||||
|
2. 前端首次加载 marketStatus=null 显示灰,轮询成功后变黄;「一会儿又变灰」可能与前端某次请求失败/时序有关(已加调试日志待确认)。
|
||||||
|
- **修复**:
|
||||||
|
- 服务端:dataHealthy = WS 推送新鲜 || 缓存有数据(WS 不可靠时缓存兜底);
|
||||||
|
- 前端:loadStatus 失败保留上次状态(不误降灰);加调试日志定位。
|
||||||
|
- **待办**:重启宿主让服务端修正生效,观察状态灯应为绿(订阅+缓存正常)。
|
||||||
|
|
||||||
|
|
||||||
|
### 2026-09-01 · 数据存储设计文档(设计约束)
|
||||||
|
|
||||||
|
- **做了什么**:整理 DataStore 设计为独立设计约束文档 docs/03-设计约束/数据存储设计.md:
|
||||||
|
- 设计目标(数据快速展现、标准化描述、统一 JSON 操作);
|
||||||
|
- 总体架构(store.schema.json / store.json / store.market.json 三层);
|
||||||
|
- 数据文件结构与 Schema(策略为中心份额分配 + 行情快照缓存);
|
||||||
|
- 数据流(启动读盘 → 实盘更新 → 防抖写盘 → 三级查询命中);
|
||||||
|
- 关键设计决策表(JSON 存储、策略定义留 settings、dataset 数组、行情持久化、原子写、测试隔离);
|
||||||
|
- 迁移(allocations.json 自动迁移 + 备份)。
|
||||||
|
- **同步更新**:技术方案约束.md —— 技术约束-010 更新为「服务端中转 + 缓存」(原前端直连已废弃);新增技术约束-012(数据存储设计引用)。
|
||||||
|
- **涉及文件**:docs/03-设计约束/数据存储设计.md(新增)、docs/03-设计约束/技术方案约束.md。
|
||||||
|
|
||||||
|
|
||||||
|
### 2026-09-01 · 重做:移除全盘订阅状态标记,独立 QMT health 检查
|
||||||
|
|
||||||
|
**移除**(全盘订阅状态暂缓,老师重开筛选做):
|
||||||
|
- market-status 端点的 subscriptions 部分(订阅状态);
|
||||||
|
- MarketFeed.getStatus()(订阅状态);
|
||||||
|
- QmtConnectionChip 的订阅状态灯 + market-status 轮询。
|
||||||
|
|
||||||
|
**保留并重做**(QMT health 检查,需求 3):
|
||||||
|
- 服务端:market-status → 精简为 qmt-health 端点(仅 QMT /health 可达性 + 延迟);
|
||||||
|
- 前端:QmtConnectionChip 下拉框**右侧**新增独立 QmtHealthIndicator 控件(小圆点:绿灯=健康 / 红灯=异常;悬停显示延迟/错误;每 5s 轮询 qmt-health)。
|
||||||
|
|
||||||
|
**涉及文件**:src/api/market.js、src/component/MarketFeed.js、src/client/views/QmtConnectionChip.jsx。
|
||||||
|
|
||||||
|
|
||||||
|
### 2026-09-01 · QMT 健康检查改为服务端定时 + 缓存机制
|
||||||
|
|
||||||
|
**机制(老师定)**:
|
||||||
|
- 服务端 QmtHealthMonitor 每 **5 分钟**探测一次激活 QMT /health,结果缓存内存;
|
||||||
|
- 前端打开页面读缓存(**秒回**,不等实时探测);
|
||||||
|
- 前端点击圆点触发即时探测(refresh=true)刷新缓存;
|
||||||
|
- 激活配置切换时立即补探(热切换联动)。
|
||||||
|
|
||||||
|
**实现**:
|
||||||
|
- 新增 src/component/QmtHealthMonitor.js:定时探测(5min)+ 缓存 + getStatus(读缓存)+ probe(即时探测,防并发);
|
||||||
|
- src/api/market.js:qmt-health 默认读缓存;refresh=true 触发即时探测;
|
||||||
|
- src/index.js:创建并启动 QmtHealthMonitor,注入 registerApi,释放时 stop;
|
||||||
|
- src/api/qmt-connections.js:激活/更新/删除连接后 qmtHealthMonitor.probe() 联动;
|
||||||
|
- 前端 QmtHealthIndicator:打开读缓存、30s 低频同步、点击触发即时探测(悬停显示检测时间/延迟/错误)。
|
||||||
|
|
||||||
|
**验证**:typecheck 0 错误;构建通过;QmtHealthMonitor 模块加载正常。
|
||||||
|
|
||||||
|
|
||||||
|
### 2026-09-01 · QMT 健康检查验收(宿主重启后)
|
||||||
|
|
||||||
|
- **验证**:
|
||||||
|
- 读缓存(默认):healthy=true、fromCache=true,耗时 23ms(秒回);
|
||||||
|
- 即时探测(refresh=true):healthy=true、latencyMs=745ms;
|
||||||
|
- 再读缓存:fromCache=true(命中探测结果),耗时 3ms。
|
||||||
|
- **结论**:服务端定时探测(5 分钟)+ 缓存机制生效;前端读缓存秒回,点击触发即时探测;验收通过。
|
||||||
|
- **涉及文件**:无(验证记录)。
|
||||||
|
|
||||||
|
|
||||||
|
### 2026-09-01 · 修复:store.market.json 膨胀(38MB,全市场行情入库)
|
||||||
|
|
||||||
|
- **问题**:store.market.json 膨胀到 38MB(50910 只股票行情)。
|
||||||
|
- **根因**:MarketFeed WS 推送是全市场(whole),MarketDataHub.ingest 不过滤,把全市场 5 万+ code 写进缓存并持久化。
|
||||||
|
- **修复**:MarketDataHub 增加 watchCodes 关注集合(前端查询 code + 持仓 code 自动加入);ingest 只保留关注集合内 code;写盘也只写关注集合(防抖)。MarketFeed prime 把持仓 code 加入 watch。
|
||||||
|
- **清理**:删除膨胀的 store.market.json(修复后自动重建,只含持仓行情)。
|
||||||
|
- **验证**:Node 模拟 WS 推 100 只 → 缓存只 3 只持仓 → store.market.json 只 3 条 ✅。
|
||||||
|
- **涉及文件**:src/component/MarketDataHub.js、src/component/MarketFeed.js。
|
||||||
|
|
||||||
|
|
||||||
|
### 2026-09-01 · 修复 v2:warmup 认可全市场导致 watchCodes 过滤失效
|
||||||
|
|
||||||
|
- **问题**:重启后 store.market.json 又膨胀到 38MB(50910 键)——watchCodes 过滤未生效。
|
||||||
|
- **根因**:MarketDataHub.warmup 从磁盘加载旧数据时,把**所有 code 都加入 watchCodes**(this.watch(code))——磁盘历史认可了全市场,后续 WS 推送全部保留。
|
||||||
|
- **修复**:warmup 只加载数据到内存 cache,**不加入 watchCodes**(watchCodes 只由「持仓 + 前端查询」驱动)。
|
||||||
|
- **清理**:再次删除膨胀文件(重建后只含持仓 13 条)。
|
||||||
|
- **涉及文件**:src/component/MarketDataHub.js。
|
||||||
|
|
||||||
|
|
||||||
|
### 2026-09-01 · 修复 v3:setMarketQuotes 整体替换(清除旧键残留)
|
||||||
|
|
||||||
|
- **问题**:v2 后 store.market.json 仍含旧全量数据(100 条旧 + 新)。
|
||||||
|
- **根因**:DataStore.setMarketQuotes 是**增量合并**(this.market[code]=snap),旧键永不清除——磁盘旧数据(如 50910 条)残留,每次合并越积越多。
|
||||||
|
- **修复**:setMarketQuotes 改为**整体替换**(this.market = next),传入完整集合,清除不在其中的旧键。
|
||||||
|
- **验证**:磁盘预置 100 旧 → warmup → 持仓查询 → WS 推送 → 写盘只 2 条(600719/300057),旧 100 条清除 ✅。
|
||||||
|
- **清理**:删除真实膨胀文件(重启重建只含持仓)。
|
||||||
|
- **涉及文件**:src/component/DataStore.js。
|
||||||
|
|
||||||
|
|
||||||
|
### 2026-09-01 · 膨胀修复验收(宿主重启后)
|
||||||
|
|
||||||
|
- **结果**:store.market.json 从 38MB(50910 键)恢复为 **11KB(13 键 = 13 只持仓)**,无多余键。
|
||||||
|
- **结论**:watchCodes 过滤 + warmup 不认可全市场 + setMarketQuotes 整体替换 三层修复全部生效,膨胀 bug 彻底解决,验收通过。
|
||||||
|
- **涉及文件**:无(验证记录)。
|
||||||
|
|
||||||
|
|
||||||
|
### 2026-09-01 · 决策:全市场缓存方案搁置(保持 watchCodes)
|
||||||
|
|
||||||
|
- **讨论**:老师考虑「全市场缓存」(前端任意 A股实盘查询秒回),vs 当前 watchCodes(持仓 + 前端查询自动累积)。
|
||||||
|
- **实测发现**:QMT whole 推送 26,751 条中,**标准 A股仅 2,775 只(10.4%)**,其余 23,976 条为期权/衍生品/其他品种代码段(23x/10x/19x/16x 等)——A股真实约 5,400 只,推送全市场含大量非股票。
|
||||||
|
- **结论(老师定)**:全市场缓存方案**搁置**,保持当前 watchCodes 方案(持仓 + 前端查询过的 code 自动累积,内存稳定 ~13 条);
|
||||||
|
- **记录**:若未来启用全市场缓存,需只缓存标准 A股代码(正则过滤 60/688/000/001/002/003/300/301),并先解决 WS 推送稳定性(归属 QMT Bridge 工作空间)。
|
||||||
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# 验收标准:04-WS盘中价格实时更新
|
||||||
|
|
||||||
|
## 验收标准线
|
||||||
|
|
||||||
|
1. 3 个监控表格(全部持仓 / 手动做T / 网格超市)出现「现价」列,显示关注股票的 lastPrice(无数据时显示 —);
|
||||||
|
2. 盘中价格变化**自动更新**(无需手动刷新)—— 观察价格列随时间变化;
|
||||||
|
3. 红涨绿跌着色正确(现价 > 昨收 红色,< 昨收 绿色);价格变化时有轻微高亮提示;
|
||||||
|
4. WS 连接与激活 QMT 配置联动:切换激活配置(host 变化)→ WS 自动切换到新 host;断线自动重连;
|
||||||
|
5. 现有功能不回归:份额分配 / 策略管理 / 连接配置管理照常工作。
|
||||||
|
|
||||||
|
## 验收方法
|
||||||
|
|
||||||
|
- 打开 3 个表格,确认现价列出现且显示真实价格(对照 QMT 端行情);
|
||||||
|
- 观察一段时间(约 30s),确认价格随推送自动变化;
|
||||||
|
- 临时断网/杀 Bridge 进程 → 确认重连提示与恢复;
|
||||||
|
- 切换激活 QMT 配置(GEMWIN ↔ 其他)→ 确认 WS 随之切换;
|
||||||
|
- 操作份额添加/移出,确认表格功能正常。
|
||||||
|
|
||||||
|
## 验收目标
|
||||||
|
|
||||||
|
- 现价实时更新闭环完整:WS 推送 → 价格 Map → 表格行更新;
|
||||||
|
- 关注列表自动覆盖 3 个表格出现的所有股票;
|
||||||
|
- 交互(着色/高亮/重连提示)符合预期。
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# 技术实现方案:05-交易记录接入(订单与成交 + 日期导航)
|
||||||
|
|
||||||
|
> 依据:PLAN-006 | 需求:R-007 | 设计约束:技术约束-001/003/004/010、产品约束-007 方向语义
|
||||||
|
|
||||||
|
## 技术选型
|
||||||
|
|
||||||
|
- **数据源**:复用 QmtBridgeRestDataSource(REST 直连,技术约束-003),新增 3 方法;
|
||||||
|
- **服务端 API**:webServer 自开路由(技术约束-004),新增 api/trades.js 领域;
|
||||||
|
- **前端数据流**:服务端中转 + 前端轮询(技术约束-010 思路,复用 market-snapshot 模式);
|
||||||
|
- **UI**:React 组件 + 现有表格风格(LoadState 三态 / 内联样式 / Toast);
|
||||||
|
- **时间段查询**(v3,2026-09-01):RangeSelector 组件(今日/本周/上周快捷 + 手动起止日期),替代单日导航。
|
||||||
|
|
||||||
|
## 架构设计
|
||||||
|
|
||||||
|
### 服务端
|
||||||
|
|
||||||
|
```
|
||||||
|
QmtBridgeRestDataSource.getOrders({code,status}) → GET /trade/orders → 语义化映射 order 列表
|
||||||
|
QmtBridgeRestDataSource.getTrades() → GET /trade/trades → 语义化映射 trade 列表
|
||||||
|
QmtBridgeRestDataSource.getTradingDates({s,e}) → GET /data/calendar/trading_dates → dates[]
|
||||||
|
|
||||||
|
api/trades.js(新增领域):
|
||||||
|
orders → dataSource.getOrders(args)
|
||||||
|
trades → dataSource.getTrades()
|
||||||
|
trading-dates → dataSource.getTradingDates(args)
|
||||||
|
(registerApi 合并 TRADE_METHODS + HANDLERS)
|
||||||
|
```
|
||||||
|
|
||||||
|
**语义化映射(核心)**:
|
||||||
|
|
||||||
|
```
|
||||||
|
order: { orderId, code, name, exchange, direction(48买/49卖), status, orderVolume,
|
||||||
|
tradedVolume, totalVolume, limitPrice, tradedPrice, amount, insertTime }
|
||||||
|
trade: { tradeId, orderId, code, name, exchange, direction, price, volume, amount,
|
||||||
|
commission, tradeTime }
|
||||||
|
```
|
||||||
|
|
||||||
|
**方向判定**:m_nOffsetFlag(48=买入/49=卖出)为主 + m_strOptName 交叉验证(R-007 Q3 闭环)。
|
||||||
|
|
||||||
|
### 客户端(单表合并)
|
||||||
|
|
||||||
|
```
|
||||||
|
TradeRecordsTab(标题 + 时间段同一行 + 单表)
|
||||||
|
├─ RangeSelector:trading-dates → 快捷范围(今日/本周/上周)+ 手动起止日期
|
||||||
|
│ 今日=最近交易日单日(完整互斥时间段);本周=所在自然周周一~周五(不考虑周六日);本月=所在自然月1号~月末
|
||||||
|
├─ 合并逻辑:fetch orders+trades(带 start/end)→ 按 orderId 分组 → 聚合
|
||||||
|
│ 聚合:成交量=Σ、成交均价=加权、成交额=Σ、手续费=Σ
|
||||||
|
├─ OrderRow:委托主行(聚合成交),点击展开/折叠
|
||||||
|
└─ TradeDetailRows:展开的每笔成交明细
|
||||||
|
```
|
||||||
|
|
||||||
|
**合并规则**:
|
||||||
|
- 主行 = 一笔委托(orderId 唯一);
|
||||||
|
- 成交聚合:m_strOrderSysID 匹配的 trades 聚合进主行(加权均价 + 求和);
|
||||||
|
- 无成交(待报/已撤/废单):成交量/均价/金额/手续费显示 —;
|
||||||
|
- 展开:逐笔渲染成交(时间/价/量/额/手续费/成交号)。
|
||||||
|
|
||||||
|
**排序**:委托主行默认按时间降序(最新在前);表头点击排序(时间/代码/方向/状态/委托量/成交量/金额),再次点击切换升/降序。
|
||||||
|
|
||||||
|
**轮询**:仅「今日」范围(最近交易日单日)3-5s 轮询;切换时间段取消轮询、按 range 一次性查询(历史接口就绪后接入 start/end 实际过滤)。
|
||||||
|
|
||||||
|
**时间段查询(v3 定稿)**:
|
||||||
|
- 服务端 orders/trades 端点接收 start/end(透传,QMT Bridge 历史接口就绪后生效);
|
||||||
|
- 前端 RangeSelector:三个快捷按钮 + 两个 date input;
|
||||||
|
- 历史范围(非今日)本期占位「历史数据接口开发中」。
|
||||||
|
|
||||||
|
## 涉及设计约束
|
||||||
|
|
||||||
|
| 约束 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| 技术约束-001 | 统一数据源抽象:交易数据走 QmtBridgeRestDataSource 适配器 |
|
||||||
|
| 技术约束-003 | 直连 QMT Bridge REST(不经过 MCP) |
|
||||||
|
| 技术约束-004 | webServer 自开路由 /odl/api/*(不占 /api interceptor) |
|
||||||
|
| 技术约束-010 | 服务端中转 + 前端轮询(复用市场模式) |
|
||||||
|
| 产品约束-007 | 方向着色沿用红/绿习惯语义(交易:红买绿卖) |
|
||||||
|
|
||||||
|
## 实现步骤
|
||||||
|
|
||||||
|
1. 文档骨架:PLAN-006 + 迭代 05(本步);
|
||||||
|
2. 数据源:getOrders/getTrades/getTradingDates + 语义化映射;
|
||||||
|
3. 服务端 API:api/trades.js 三端点 + 领域分发;
|
||||||
|
4. 客户端合并逻辑:fetch 两接口 → 分组 → 聚合;
|
||||||
|
5. UI:DateNav + OrderRow + TradeDetailRows + TradeRecordsTab + 排序;
|
||||||
|
6. 构建安装:pnpm run build + 刷新/重启;
|
||||||
|
7. 验收 + 复盘。
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# 迭代 05 复盘:交易记录接入(订单与成交 + 日期导航)(2026-09-01)
|
||||||
|
|
||||||
|
> 复盘日期:2026-09-01 | 迭代状态:**实现完成,待老师验收确认**
|
||||||
|
> 关联需求:R-007(交易记录接入,范围已定稿)
|
||||||
|
> 关联计划:PLAN-006(计划-交易记录接入.md)
|
||||||
|
|
||||||
|
## 结果
|
||||||
|
|
||||||
|
迭代 05 达成:交易记录 tab 接入当日委托(/trade/orders)+ 当日成交(/trade/trades),单表合并展示(委托主行 + 按 m_strOrderSysID 展开成交明细),日期导航(交易日历驱动,历史接口占位),排序默认降序可手动。
|
||||||
|
|
||||||
|
## 过程事实
|
||||||
|
|
||||||
|
1. **需求定稿(R-007)**:老师指令接入订单与成交 → 技术研究(API 字段全表 / 方向判定澄清 / 数据时效研究 / sample data)→ 范围定稿(当日订单+成交 + 日期导航)→ 展示设计定稿(单表合并 + 排序降序);
|
||||||
|
2. **方向判定闭环**:m_nDirection=48 恒为操作标记(官方 innerApi 字典),真实方向在 m_nOffsetFlag(48买/49卖)+ m_strOptName 交叉验证;
|
||||||
|
3. **数据时效确认**:/trade/orders 与 /trade/trades 仅当日数据(get_trade_detail_data 读客户端缓存),历史查询需老师完善 QMT Bridge 接口;
|
||||||
|
4. **服务端实现**:QmtBridgeRestDataSource 新增 getOrders/getTrades/getTradingDates(语义化映射)+ api/trades.js 领域(orders/trades/trading-dates 端点);
|
||||||
|
5. **客户端实现**:TradeRecordsTab(单表合并 + 排序 + 轮询 4s)+ DateNav(交易日历导航,当日默认,历史占位);
|
||||||
|
6. **验证**:typecheck 通过、构建成功、数据源三方法端到端实测通过(博纳影业卖出 200 股 @6.00,手续费 0.852)、合并逻辑多笔成交场景验证通过(加权均价 6.03);
|
||||||
|
7. **未做**:历史数据查询实现(等老师 QMT Bridge 接口)、本地持久化(Q5 本期不做)、按策略过滤(Q10 后续)。
|
||||||
|
|
||||||
|
## 经验(待沉淀)
|
||||||
|
|
||||||
|
- **方向判定**:大 QMT m_nDirection 恒 48 是操作标记,m_nOffsetFlag 才是方向(48买/49卖)——实现时勿踩坑;
|
||||||
|
- **手续费字段**:m_dCommission 与 m_dComssion(拼写变体)同值,取值优先 m_dCommission;
|
||||||
|
- **前端轮询**:静默刷新不闪 loading(setOrders 直接更新,不置 loading=true);日期切换时清理轮询定时器;
|
||||||
|
- **服务端/客户端加载时机**:服务端改动需宿主重启,客户端 bundle 刷新即载(沿用迭代 03/04 经验)。
|
||||||
|
|
||||||
|
## 遗留/后续
|
||||||
|
|
||||||
|
1. **历史成交接口**:老师完善 QMT Bridge 后,DateNav 历史日期从占位切换为真实查询(trading-dates 已就绪,orders/trades 加日期参数即可);
|
||||||
|
2. **本地持久化**:跨日复盘需按日落盘(Q5),待历史接口就绪后评估;
|
||||||
|
3. **按策略过滤**:Q10 后续迭代(strategy_name 与插件策略体系打通)。
|
||||||
|
|
||||||
|
## 验收状态
|
||||||
|
|
||||||
|
- 服务端/数据源端到端验证:✅(真实数据实测通过)
|
||||||
|
- 前端 UI:待老师页面验收(客户端 bundle 已构建,刷新页面即载)
|
||||||
|
- 完整验收:对照 验收标准.md 逐条核验(待老师确认)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# 迭代目标:05-交易记录接入(订单与成交 + 日期导航)
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
在「交易记录」tab 接入 QMT Bridge **当日委托(/trade/orders)与当日成交(/trade/trades)**:**单表合并**展示(委托主行 + 按 m_strOrderSysID 展开成交明细)+ **基于交易日历的日期导航**(当日默认选中,历史接口占位)。
|
||||||
|
|
||||||
|
## 目标描述
|
||||||
|
|
||||||
|
- 范围:R-007(范围已定稿 2026-09-01);不新增其他功能域;
|
||||||
|
- 要解决的问题:交易记录 tab 当前为占位(PlaceholderTab),无当日订单/成交数据展示,无法支撑交易复盘(目标-006);
|
||||||
|
- 引用需求:**R-007(范围定稿,2026-09-01)**;
|
||||||
|
- 实现方式:服务端 REST 直连 QMT Bridge(技术约束-003)+ 前端轮询(技术约束-010 思路)+ 单表合并 UI;
|
||||||
|
- 日期导航:复用 /data/calendar/trading_dates,为老师完善历史成交接口预留。
|
||||||
|
|
||||||
|
## 目标讨论过程
|
||||||
|
|
||||||
|
- 2026-09-01 老师提出需求:接入订单与成交数据,开始需求和技术研究(R-007 入池);
|
||||||
|
- 2026-09-01 技术研究:API 面实测(orders/trades 字段全表)、方向判定澄清(m_nOffsetFlag + m_strOptName)、数据时效研究(仅当日)、sample data 沉淀;
|
||||||
|
- 2026-09-01 范围定稿:当日订单+成交接入 + 日期导航(老师将完善历史接口);
|
||||||
|
- 2026-09-01 展示设计定稿:**单表合并**(委托主行 + 展开成交明细)+ **排序默认降序可手动调整**;
|
||||||
|
- 关键决策:Q1-Q11 全部有结论(两块合一、3-5s 轮询、m_nOffsetFlag 判方向、本期不持久化、红买绿卖、P1 等)。
|
||||||
|
|
||||||
|
## 对老师的配合需求
|
||||||
|
|
||||||
|
- QMT Bridge(激活配置)可达,验证当日订单/成交返回真实数据;
|
||||||
|
- 构建安装后重载页面配合验证;
|
||||||
|
- 历史成交接口完善后接入日期导航历史查询(本期仅占位)。
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# 验收标准:05-交易记录接入(订单与成交 + 日期导航)
|
||||||
|
|
||||||
|
## 验收标准线
|
||||||
|
|
||||||
|
1. 「交易记录」tab 显示**当日委托**(单表合并视图):每行一笔委托,展示时间/代码/名称/方向/状态/委托量/成交量/委托价/成交均价/成交额/手续费;
|
||||||
|
2. **合并正确**:委托行聚合成交(按 m_strOrderSysID);有成交的委托行可**展开**查看每笔成交明细(成交时间/价/量/额/手续费/成交号);聚合金额与逐笔合计一致;
|
||||||
|
3. **未成交委托**(待报/已报/已撤/废单):成交量/均价/金额/手续费显示 —;状态列中文映射正确(未报/待报/已报/部成/已成/已撤/废单等);
|
||||||
|
4. **方向**:红买绿卖(红=买、绿=卖);方向判定正确(对照 m_strOptName);
|
||||||
|
5. **时间段查询**:标题 + 时间段控件同一行;快捷按钮 今日/本周/本月 可快速切换(基于交易日历最近交易日计算);手动起止日期可用;非今日范围展示「历史数据接口开发中」占位;
|
||||||
|
6. **排序**:委托主行默认降序(最新在前);点击表头可手动排序(升/降切换);
|
||||||
|
7. **刷新**:当日数据 3-5s 自动刷新(无需手动);切换日期取消轮询;
|
||||||
|
8. **不回归**:持仓/策略/行情/连接配置等现有功能正常。
|
||||||
|
|
||||||
|
## 验收方法
|
||||||
|
|
||||||
|
- 打开交易记录 tab,对照 QMT 端确认当日委托/成交数据真实显示(实测博纳影业 001330.SZ 限价卖出 200 股);
|
||||||
|
- 点击有成交的委托行,确认展开显示每笔成交明细;
|
||||||
|
- 检查未成交委托(若有)成交列显示 —;
|
||||||
|
- 点击 今日/本周/上周 快捷按钮,确认时间段切换正确(今日=最近交易日单日、本周=周一~周五(交易日,不考虑周六日)、本月=完整自然月(1号~月末);三范围互斥,点哪个激活哪个);手动改起止日期确认生效;历史范围显示占位提示;
|
||||||
|
- 点击表头排序,确认升/降序切换与默认降序;
|
||||||
|
- 观察 3-5s 轮询刷新(数据变化自动更新);
|
||||||
|
- 回归:全部持仓 / 策略 / 行情 / QMT 连接配置正常。
|
||||||
|
|
||||||
|
## 验收目标
|
||||||
|
|
||||||
|
- 交易记录闭环完整:REST 查询 → 语义化映射 → 单表合并 → 展开明细 → 日期导航;
|
||||||
|
- 支撑交易复盘(目标-006):当日委托/成交全貌可见;
|
||||||
|
- 交互(合并/展开/排序/导航/着色)符合定稿设计。
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# R-004 待确认问题(单独讨论)
|
|
||||||
|
|
||||||
> 状态:**全部未确认**(逐条讨论、逐条收敛)| 创建:2026-08-28
|
|
||||||
> 关联:docs/05-需求池/R-004.md(需求草稿)、docs/05-需求池/需求池索引.md(R-004)
|
|
||||||
> 用途:R-004 的需求草稿只保留概述 / 现状分析 / 功能范围;**待确认问题单独在此记录并逐条讨论**。每确认一条即更新下方状态并写入「讨论记录」。
|
|
||||||
|
|
||||||
## 待确认问题清单
|
|
||||||
|
|
||||||
| # | 问题 | 状态 | 确认日期 | 结论摘要 |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| Q1 | **「默认」的语义**:默认 = 插件启动时自动激活的配置?还是仅作为列表中的默认选中项?手动激活非默认配置后,下次启动是否回到默认? | 未确认 | - | - |
|
|
||||||
| Q2 | **生效机制**:激活配置后**立即切换**(动态重建数据源,无需重启 DSH)?还是保存后**重启生效**?(当前数据源在插件 apply 时创建一次,改为动态切换需调整架构) | 未确认 | - | - |
|
|
||||||
| Q3 | **是否提供「测试连接」按钮**:验证地址可达(请求 /health 检查 status=ok),帮助确认配置正确? | 未确认 | - | - |
|
|
||||||
| Q4 | **现有宿主配置迁移**:cordis.patch.yml 中的 `qmtBaseUrl`(192.168.3.43:8610)如何处理 —— 首次启动时作为**初始默认配置**自动迁入设置页? | 未确认 | - | - |
|
|
||||||
| Q5 | **超时是否纳入配置项**:timeoutMs 是否需要出现在配置表单(可选字段),还是保持代码默认值? | 未确认 | - | - |
|
|
||||||
| Q6 | **删除/切换边界**:删除当前激活配置时如何处理(禁止删除?自动切换到默认?);删除默认配置时默认标记如何转移? | 未确认 | - | - |
|
|
||||||
| Q7 | **优先级**:老师定级(P0-P2?)—— 建议 P1(提升多环境可用性,单地址当前可用) | 未确认 | - | - |
|
|
||||||
|
|
||||||
## 讨论记录
|
|
||||||
|
|
||||||
> 追加式记录:日期 · 轮次 · 结论。确认 Q1 后即追加对应结论,并回填上方表格。
|
|
||||||
|
|
||||||
- (待第一轮讨论)
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
# R-004 QMT 连接配置(多配置管理)· 初步需求草稿
|
|
||||||
|
|
||||||
> 状态:**讨论中**(初步草稿,待老师确认后定稿)| 登记日期:2026-08-28
|
|
||||||
> 来源:老师指令(2026-08-28)
|
|
||||||
> 关联:docs/05-需求池/需求池索引.md(R-004)
|
|
||||||
|
|
||||||
## 需求概述
|
|
||||||
|
|
||||||
在神之一手的**设置页面**新增一个子 tab「QMT 连接配置」,用于管理多个 QMT Bridge 连接配置:
|
|
||||||
|
|
||||||
- **多配置管理**:可维护多个连接配置(增 / 删 / 改 / 查);
|
|
||||||
- **一次只能激活一个**:从多个配置中单选激活一个,被激活的配置为当前生效连接;
|
|
||||||
- **可设置默认**:标记某配置为默认(语义待确认,见「待确认问题」)。
|
|
||||||
|
|
||||||
## 现状分析:插件当前使用的 QMT 连接信息
|
|
||||||
|
|
||||||
> 通过代码审查得出(src/data-source/qmt-bridge-rest.js、src/index.js、cordis.patch.yml):
|
|
||||||
|
|
||||||
| 配置项 | 当前值 | 是否必配 | 说明 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| **HTTP 基础地址(baseUrl)** | `http://192.168.3.43:8610` | **是(唯一必配)** | 由宿主 cordis.patch.yml 注入 `config.qmtBaseUrl`;数据源所有 REST 请求基于此地址 |
|
|
||||||
| **请求超时(timeoutMs)** | 15000ms | 否(有默认值) | 代码常量 REQUEST_TIMEOUT_MS=15000,当前未暴露到配置,使用默认值 |
|
|
||||||
|
|
||||||
**分析结论**:
|
|
||||||
1. **核心只需配置一个 HTTP 地址**(baseUrl)—— 插件与 QMT Bridge 的所有通信都基于它(/health、/trade/*、/data/* 等 REST 端点);
|
|
||||||
2. **可选配置一个请求超时**(timeoutMs)—— 仅在需要调整超时时才需配置,可提供默认值;
|
|
||||||
3. **当前不需要其他连接参数**:无认证(QMT Bridge REST 端点无鉴权)、无账户/密码、无额外 header —— 未来若 QMT Bridge 增加鉴权,再扩展配置字段。
|
|
||||||
|
|
||||||
## 功能范围(初步)
|
|
||||||
|
|
||||||
**做**:
|
|
||||||
1. 设置页新增「QMT 连接配置」子 tab(与现有「通用设置 / 策略分组」并列);
|
|
||||||
2. **配置 CRUD**:
|
|
||||||
- 新增:名称(识别用,如「本机 QMT」)+ HTTP 地址(必填)+ 请求超时(可选,默认 15000ms);
|
|
||||||
- 编辑 / 删除 / 重命名;
|
|
||||||
3. **激活**:单选激活,**一次只能激活一个**;激活的配置为当前生效连接;
|
|
||||||
4. **默认**:标记某配置为默认(待确认语义);
|
|
||||||
5. **生效**:激活/修改配置后,插件数据源切换(生效机制待确认,见「待确认问题」)。
|
|
||||||
|
|
||||||
**不做(初步)**:
|
|
||||||
- 认证/多账户支持(QMT Bridge 当前无鉴权,未来扩展);
|
|
||||||
- 连接状态监控(如断线重连策略)—— 后续。
|
|
||||||
|
|
||||||
## 待确认问题(单独讨论)
|
|
||||||
|
|
||||||
> 7 个待确认问题已单独记录于 **docs/05-需求池/R-004-待确认问题.md**,逐条讨论、逐条收敛,不在此文件内讨论。
|
|
||||||
|
|
||||||
- [ ] 待确认问题清单:见 `R-004-待确认问题.md`(Q1-Q7,全部未确认)
|
|
||||||
@@ -0,0 +1,543 @@
|
|||||||
|
# R-007 交易记录接入(订单与成交数据)· 需求与技术研究
|
||||||
|
|
||||||
|
> 需求状态:**起草**(2026-09-01 入池,需求与技术研究进行中;AI 提议提为「讨论中」,待老师确认)
|
||||||
|
> 更新日期:2026-09-01 | 来源:老师指令(2026-09-01)
|
||||||
|
> 关联:docs/05-需求池/需求池索引.md(R-007)、docs/01-终极目标/终极目标.md(目标-006 交易复盘、目标-003 按策略监控市场)
|
||||||
|
> 前置研究:本文件已含第一轮技术研究(QMT Bridge 订单/成交 API 实测 + 现有架构集成分析),待确认问题 Q1-Q10 待老师逐条拍板
|
||||||
|
|
||||||
|
## 需求概述
|
||||||
|
|
||||||
|
在神之一手的**「交易记录」tab**(当前为占位)中接入 **QMT Bridge 的订单(当日委托)与成交(当日成交)数据**,实时展示当日委托与成交明细,支撑**交易复盘**(目标-006)与**按策略监控交易**(目标-003)。
|
||||||
|
|
||||||
|
- **订单数据**:当日委托列表(含委托状态、委托量、成交量、限价、时间等);
|
||||||
|
- **成交数据**:当日成交列表(含成交价、成交量、手续费、成交额、时间等);
|
||||||
|
- **展示位置**:现有「交易记录」tab(conversation.view,id=odl-trade-records,tradeRecords 显隐开关已预置,当前渲染 PlaceholderTab 占位);
|
||||||
|
- **数据来源**:插件服务端直连 QMT Bridge REST(技术约束-003,不经过 MCP 层),复用现有统一数据源抽象(技术约束-001)。
|
||||||
|
|
||||||
|
## 现状分析
|
||||||
|
|
||||||
|
### 1. QMT Bridge 已具备订单/成交查询能力(API 面实测)
|
||||||
|
|
||||||
|
| 端点 | 方法 | 说明 | 过滤参数 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| /trade/orders | GET | 当日委托列表(m_* 原始字段),账号为桥绑定账号(单账号策略) | code(客户端过滤,600519 或 600519.SH)、status(active=排除已撤54/废单57,或数字状态码 48-57)、strategy_name(QMT 服务端按 passorder 策略名过滤) |
|
||||||
|
| /trade/trades | GET | 当日成交列表(m_* 原始字段) | 无(返回全部当日成交) |
|
||||||
|
|
||||||
|
**数据特性**:两类数据均由 QMT 策略线程(adjust)每 **300ms 缓存刷新**,HTTP 线程只读缓存——即数据本身是准实时的(约 300ms 延迟),服务端无需自建订阅,REST 轮询即可。
|
||||||
|
|
||||||
|
### 2. 数据结构(OpenAPI 规范 + 线上实测样本)
|
||||||
|
|
||||||
|
**Order(当日委托)核心字段**:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| m_strOrderSysID | string | 合同编号/委托号(撤单凭此号) |
|
||||||
|
| m_strInstrumentID / m_strInstrumentName | string | 证券代码(不带后缀)/ 名称 |
|
||||||
|
| m_nDirection | int | 操作标记(EEntrustBS:**股票买卖永远是 48**,不表达方向,官方已注明;见 Q3 研究) |
|
||||||
|
| m_nOrderStatus | int | 委托状态码:48=未报 49=待报 50=已报(待成交) 51=已报待撤 52=部成待撤 53=部撤 54=已撤 55=部成 56=已成 57=废单 |
|
||||||
|
| m_nVolumeTotalOriginal / m_nVolumeTraded / m_nVolumeTotal | int | 委托数量(最初)/ 已成交量 / 剩余量 |
|
||||||
|
| m_dLimitPrice | number | 委托价格(限价单限价) |
|
||||||
|
| m_strInsertDate / m_strInsertTime | string | 委托日期(YYYYMMDD)/ 时间(HHMMSS) |
|
||||||
|
| m_dTradedPrice | number | 成交均价(股票) |
|
||||||
|
| m_strErrorMsg / m_strCancelInfo | string | 状态信息 / 废单原因 |
|
||||||
|
| m_strOptName | string | **操作名称(实测「限价卖出」,含方向语义,方向判定关键字段)** |
|
||||||
|
|
||||||
|
**Trade(当日成交)核心字段**:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| m_strTradeID | string | 成交编号 |
|
||||||
|
| m_strOrderSysID | string | 合同编号/委托号(与委托表同号,**用于关联委托↔成交**) |
|
||||||
|
| m_strInstrumentID / m_strInstrumentName | string | 证券代码(不带后缀)/ 名称 |
|
||||||
|
| m_nDirection | int | 操作标记(同订单,股票恒为 48,不判方向) |
|
||||||
|
| m_dPrice | number | 成交均价 |
|
||||||
|
| m_nVolume | int | 成交量(股) |
|
||||||
|
| m_strTradeDate / m_strTradeTime | string | 成交日期(YYYYMMDD)/ 时间(HHMMSS) |
|
||||||
|
| m_dCommission | number | 手续费 |
|
||||||
|
| m_dTradeAmount | number | 成交额 |
|
||||||
|
| m_strOptName | string | 操作名称(含方向语义) |
|
||||||
|
|
||||||
|
**线上实测样本**(2026-09-01):当日 1 笔委托 + 1 笔成交(博纳影业 001330.SZ,限价卖出 200 股 @6.00,已成 m_nOrderStatus=56,手续费 0.852 元)。方向判定结论(官方 innerApi 字典核实):m_nDirection=48 是 EEntrustBS 的「买卖」操作标记(官方注明股票买卖永远是 48),**不表达方向**;真实方向在 m_nOffsetFlag(EOffset_Flag_Type:48=买入开仓/49=卖出平仓,实测 49 与「限价卖出」吻合);m_strOptName 为买卖标记中文展示。**方向判定应以 m_nOffsetFlag 为准 + m_strOptName 交叉验证(Q3 已解决)**。
|
||||||
|
|
||||||
|
### 2.1 QMT 桥返回字段全表(实测 2026-09-01)
|
||||||
|
|
||||||
|
> 实调 QMT 桥 /trade/orders 与 /trade/trades 得到的完整字段。标注 ★ 的字段为**语义化映射应保留的核心字段**(股票场景),其余为期权/期货/人民币换算/融资融券等场景字段(股票时为空或默认值)。
|
||||||
|
> 字段体系:大 QMT 内置 API(innerApi),枚举见 https://dict.thinktrader.net/innerApi/enum_constants.html
|
||||||
|
|
||||||
|
**当日委托(/trade/orders)字段表**:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 实测值 | 说明 | 映射 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| m_strOrderSysID | string | 645009273 | 合同编号/委托号(唯一标识,撤单凭此号) | ★ |
|
||||||
|
| m_strOrderRef | string | SZ_645009273 | 委托引用号(带交易所前缀) | ★ |
|
||||||
|
| m_strAccountID | string | 8882874667 | 资金账号 | |
|
||||||
|
| m_strAccountKey | string | 2____10125____… | 账号组合键(内部用) | |
|
||||||
|
| m_strAccountName | string | (空) | 账号名称 | |
|
||||||
|
| m_strAccountRemark | string | (空) | 账号备注 | |
|
||||||
|
| m_strInstrumentID | string | 001330 | 证券代码(不带后缀) | ★ |
|
||||||
|
| m_strInstrumentName | string | 博纳影业 | 证券名称 | ★ |
|
||||||
|
| m_strExchangeID | string | SZ | 交易所(SH/SZ) | ★ |
|
||||||
|
| m_strExchangeName | string | 深交所 | 交易所中文名 | |
|
||||||
|
| m_strUnderCode | string | (空) | 标的代码(衍生品用) | |
|
||||||
|
| m_strProductID / ProductName | string | (空) | 产品代码/名称(股票为空) | |
|
||||||
|
| m_nDirection | int | 48 | EEntrustBS 操作标记:**股票买卖恒为 48**(非方向) | |
|
||||||
|
| m_nOffsetFlag | int | 49 | **EOffset_Flag_Type:48=买入开仓 / 49=卖出平仓(方向判定关键)** | ★ |
|
||||||
|
| m_nHedgeFlag | int | 49 | 投保标记(投机=49) | |
|
||||||
|
| m_nOrderPriceType | int | 50 | 价格类型(50=限价,enum_EBrokerPriceType) | |
|
||||||
|
| m_eEntrustType | int | 48 | 委托类型(48=普通委托) | |
|
||||||
|
| m_strOptName | string | 限价卖出 | **买卖标记中文(方向展示/交叉验证)** | ★ |
|
||||||
|
| m_strOption / OrderParam / OrderStrategyType | string | (空) | 期权属性/委托参数/策略类型名 | |
|
||||||
|
| m_nOrderStrategyType / m_nOpType | int | 异常值 | 策略类型码/操作类型(无实际意义) | |
|
||||||
|
| m_nVolumeTotalOriginal | int | 200 | 委托数量(最初委托量) | ★ |
|
||||||
|
| m_nVolumeTraded | int | 200 | 已成交量 | ★ |
|
||||||
|
| m_nVolumeTotal | int | 0 | 剩余量(总委托-成交) | ★ |
|
||||||
|
| m_dLimitPrice | number | 6 | 委托价格(限价) | ★ |
|
||||||
|
| m_dTradedPrice | number | 6 | 成交均价 | ★ |
|
||||||
|
| m_dTradeAmount | number | 1200 | 成交金额 | ★ |
|
||||||
|
| m_dCancelAmount | number | 0 | 已撤数量 | |
|
||||||
|
| m_dOrderPriceRMB / TradeAmountRMB / ReferenceRate | number | 0 | 人民币换算/参考汇率(外币标的) | |
|
||||||
|
| m_dFrozenCommission / FrozenMargin / ShortOccupedMargin | number | 0 | 冻结手续费/保证金/卖空占用保证金 | |
|
||||||
|
| m_eCashgroupProp / m_eCoveredFlag | int | 48 / 0 | 资金组属性/遮盖标记 | |
|
||||||
|
| m_nOrderStatus | int | 56 | 委托状态:48未报 49待报 50已报 51已报待撤 52部成待撤 53部撤 54已撤 55部成 56已成 57废单 | ★ |
|
||||||
|
| m_nOrderSubmitStatus | int | 51 | 委托提交状态(51=已提交) | |
|
||||||
|
| m_strErrorMsg / m_strCancelInfo | string | (空) | 状态信息/废单原因 | |
|
||||||
|
| m_nErrorID | int | 2147483647 | 错误码(默认值=无错误) | |
|
||||||
|
| m_strInsertDate / m_strInsertTime | string | 20260901 / 101333 | 委托日期(YYYYMMDD)/时间(HHMMSS) | ★ |
|
||||||
|
| m_nStrategyID | int | 4322211943619263000 | 策略 ID(passorder 所属) | |
|
||||||
|
| m_strSource / m_strXTTrade | string | (空)/其他终端 | 委托来源/交易终端 | |
|
||||||
|
| m_nTaskId / FrontID / SessionID / Ref / GroupId | int | -1/2147483647 等 | 任务号/前置机/会话/引用/组号(无实际意义) | |
|
||||||
|
| m_strCompactNo / BrokerName / LocalInfo / Remark | string | (空) | 合约编号/券商/本地信息/备注 | |
|
||||||
|
| m_bEnable | bool | true | 是否有效 | |
|
||||||
|
| m_xtTag | null | null | 扩展标签 | |
|
||||||
|
|
||||||
|
**当日成交(/trade/trades)字段表**:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 实测值 | 说明 | 映射 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| m_strTradeID | string | 0103000030812084 | 成交编号(唯一标识) | ★ |
|
||||||
|
| m_strOrderSysID | string | 645009273 | **合同编号/委托号(与委托表同号,关联委托↔成交)** | ★ |
|
||||||
|
| m_strOrderRef | string | SZ_645009273 | 委托引用号(带交易所前缀) | |
|
||||||
|
| m_strAccountID | string | 8882874667 | 资金账号 | |
|
||||||
|
| m_strAccountKey / AccountRemark | string | (空) | 账号组合键/备注 | |
|
||||||
|
| m_strInstrumentID | string | 001330 | 证券代码(不带后缀) | ★ |
|
||||||
|
| m_strInstrumentName | string | 博纳影业 | 证券名称 | ★ |
|
||||||
|
| m_strExchangeID / ExchangeName | string | SZ / 深交所 | 交易所 | ★ |
|
||||||
|
| m_strProductID / ProductName | string | (空) | 产品代码/名称(股票为空) | |
|
||||||
|
| m_nDirection | int | 48 | EEntrustBS 操作标记:**股票买卖恒为 48**(非方向) | |
|
||||||
|
| m_nOffsetFlag | int | 49 | **EOffset_Flag_Type:48=买入开仓 / 49=卖出平仓(方向判定关键)** | ★ |
|
||||||
|
| m_nRealOffsetFlag | int | -1 | 实际操作类型(-1=未用) | |
|
||||||
|
| m_nHedgeFlag | int | 49 | 投保标记(投机=49) | |
|
||||||
|
| m_nOrderPriceType / m_eEntrustType | int | 50 / 48 | 价格类型/委托类型 | |
|
||||||
|
| m_eCoveredFlag / m_eFutureTradeType | int | 48 | 遮盖标记/期货交易类型 | |
|
||||||
|
| m_strOptName | string | 限价卖出 | **买卖标记中文(方向展示/交叉验证)** | ★ |
|
||||||
|
| m_strOperation | string | (空) | 操作描述 | |
|
||||||
|
| m_dPrice | number | 6 | 成交均价 | ★ |
|
||||||
|
| m_nVolume | int | 200 | 成交量(股) | ★ |
|
||||||
|
| m_dTradeAmount | number | 1200 | 成交金额 | ★ |
|
||||||
|
| m_dCommission | number | 0.852 | **佣金费率(万分之,2026-09-01 老师定)**:0.852 = 万分之0.852 = 0.0000852(非金额);另有 m_dComssion 拼写变体同值 | ★ |
|
||||||
|
| m_dComssion | number | 0.852 | 手续费(拼写变体) | |
|
||||||
|
| m_dCloseProfit / m_nCloseTodayVolume | number/int | 0 | 平仓盈亏/平今量(期货用) | |
|
||||||
|
| m_dOrderPriceRMB / TradeAmountRMB / PriceRMB / ReferenceRate | number | 0 | 人民币换算/参考汇率 | |
|
||||||
|
| m_strTradeDate / m_strTradeTime | string | 20260901 / 101333 | 成交日期(YYYYMMDD)/时间(HHMMSS) | ★ |
|
||||||
|
| m_nStrategyID | int | 4322211943619263000 | 策略 ID(passorder 所属) | |
|
||||||
|
| m_nOrderStrategyType / m_strOrderStrategyType | int/string | 异常值/(空) | 委托策略类型(无实际意义) | |
|
||||||
|
| m_nTaskId / m_nGroupId / m_nRef | int | -1/2147483647/0 | 任务号/组号/引用(无实际意义) | |
|
||||||
|
| m_strSource / m_strXTTrade | string | (空)/其他终端 | 成交来源/交易终端 | |
|
||||||
|
| m_strCompactNo / LocalInfo / Remark | string | (空) | 合约编号/本地信息/备注 | |
|
||||||
|
| m_xtTag | null | null | 扩展标签 | |
|
||||||
|
|
||||||
|
**映射要点**:★ 字段共约 15 个/表,构成语义化模型(orderId/tradeId/code/name/exchange/direction/status/volumes/prices/amount/commission/time);m_nOffsetFlag+m_strOptName 双字段定方向;m_strOrderSysID 关联两表;**m_dCommission = 佣金费率(万分之),非金额(费用计算见「费用计算规则」)**。
|
||||||
|
|
||||||
|
**费用计算规则(2026-09-01 老师定,已实现 calcTradeFees)**:
|
||||||
|
| 费用项 | 计算 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| 佣金 | 成交额 × (m_dCommission ÷ 10000),**不足 5 元按 5 元** | 账号没免五,费率=万分之 m_dCommission(如 0.852 = 万0.852) |
|
||||||
|
| 印花税 | 成交额 × 万分之5(0.05%) | **仅卖出**收取(2023-08-28 起标准,单边) |
|
||||||
|
| 过户费 | 成交额 × 万分之0.1(0.01‰) | **双向**收取,无最低限额(2022-04-29 起标准,中登收取,老师确认) |
|
||||||
|
| 总费用 | 佣金 + 印花税 + 过户费 | 逐笔计算后聚合展示 |
|
||||||
|
|
||||||
|
### 2.2 Sample Data(真实数据样本,2026-09-01 实调)
|
||||||
|
|
||||||
|
> 实调 QMT 桥 /trade/orders 与 /trade/trades 获取的**当日真实数据**(账号 8882874667,交易日期 20260901)。
|
||||||
|
> 当日共 1 笔委托 + 1 笔成交(博纳影业 001330.SZ 限价卖出 200 股 @6.00,已成,手续费 0.852 元)。
|
||||||
|
> 以下为**完整原始返回**(含空/默认字段),与 2.1 字段表一一对应。
|
||||||
|
|
||||||
|
**订单 Sample(/trade/orders 原始返回)**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"m_bEnable": true,
|
||||||
|
"m_dCancelAmount": 0,
|
||||||
|
"m_dFrozenCommission": 0,
|
||||||
|
"m_dFrozenMargin": 0,
|
||||||
|
"m_dLimitPrice": 6,
|
||||||
|
"m_dOrderPriceRMB": 0,
|
||||||
|
"m_dReferenceRate": 0,
|
||||||
|
"m_dShortOccupedMargin": 0,
|
||||||
|
"m_dTradeAmount": 1200,
|
||||||
|
"m_dTradeAmountRMB": 0,
|
||||||
|
"m_dTradedPrice": 6,
|
||||||
|
"m_eCashgroupProp": 48,
|
||||||
|
"m_eCoveredFlag": 0,
|
||||||
|
"m_eEntrustType": 48,
|
||||||
|
"m_nDirection": 48,
|
||||||
|
"m_nErrorID": 2147483647,
|
||||||
|
"m_nFrontID": -1,
|
||||||
|
"m_nGroupId": 2147483647,
|
||||||
|
"m_nHedgeFlag": 49,
|
||||||
|
"m_nOffsetFlag": 49,
|
||||||
|
"m_nOpType": 2147483647,
|
||||||
|
"m_nOrderPriceType": 50,
|
||||||
|
"m_nOrderStatus": 56,
|
||||||
|
"m_nOrderStrategyType": -1645423200,
|
||||||
|
"m_nOrderSubmitStatus": 51,
|
||||||
|
"m_nRef": 0,
|
||||||
|
"m_nSessionID": -1,
|
||||||
|
"m_nStrategyID": 4322211943619263000,
|
||||||
|
"m_nTaskId": -1,
|
||||||
|
"m_nVolumeTotal": 0,
|
||||||
|
"m_nVolumeTotalOriginal": 200,
|
||||||
|
"m_nVolumeTraded": 200,
|
||||||
|
"m_strAccountID": "8882874667",
|
||||||
|
"m_strAccountKey": "2____10125____105214____49____8882874667____",
|
||||||
|
"m_strAccountName": "",
|
||||||
|
"m_strAccountRemark": "",
|
||||||
|
"m_strBrokerName": "",
|
||||||
|
"m_strCancelInfo": "",
|
||||||
|
"m_strCompactNo": "",
|
||||||
|
"m_strErrorMsg": "",
|
||||||
|
"m_strExchangeID": "SZ",
|
||||||
|
"m_strExchangeName": "深交所",
|
||||||
|
"m_strInsertDate": "20260901",
|
||||||
|
"m_strInsertTime": "101333",
|
||||||
|
"m_strInstrumentID": "001330",
|
||||||
|
"m_strInstrumentName": "博纳影业",
|
||||||
|
"m_strLocalInfo": "",
|
||||||
|
"m_strOptName": "限价卖出",
|
||||||
|
"m_strOption": "",
|
||||||
|
"m_strOrderParam": "",
|
||||||
|
"m_strOrderRef": "SZ_645009273",
|
||||||
|
"m_strOrderStrategyType": "",
|
||||||
|
"m_strOrderSysID": "645009273",
|
||||||
|
"m_strProductID": "",
|
||||||
|
"m_strProductName": "",
|
||||||
|
"m_strRemark": "",
|
||||||
|
"m_strSource": "",
|
||||||
|
"m_strUnderCode": "",
|
||||||
|
"m_strXTTrade": "其他终端",
|
||||||
|
"m_xtTag": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**成交 Sample(/trade/trades 原始返回)**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"m_dCloseProfit": 0,
|
||||||
|
"m_dCommission": 0.852,
|
||||||
|
"m_dComssion": 0.852,
|
||||||
|
"m_dOrderPriceRMB": 0,
|
||||||
|
"m_dPrice": 6,
|
||||||
|
"m_dPriceRMB": 0,
|
||||||
|
"m_dReferenceRate": 0,
|
||||||
|
"m_dTradeAmount": 1200,
|
||||||
|
"m_dTradeAmountRMB": 0,
|
||||||
|
"m_eCoveredFlag": 48,
|
||||||
|
"m_eEntrustType": 48,
|
||||||
|
"m_eFutureTradeType": 48,
|
||||||
|
"m_nCloseTodayVolume": 0,
|
||||||
|
"m_nDirection": 48,
|
||||||
|
"m_nGroupId": 2147483647,
|
||||||
|
"m_nHedgeFlag": 49,
|
||||||
|
"m_nOffsetFlag": 49,
|
||||||
|
"m_nOrderPriceType": 50,
|
||||||
|
"m_nOrderStrategyType": -296943616,
|
||||||
|
"m_nRealOffsetFlag": -1,
|
||||||
|
"m_nRef": 0,
|
||||||
|
"m_nStrategyID": 4322211943619263000,
|
||||||
|
"m_nTaskId": -1,
|
||||||
|
"m_nVolume": 200,
|
||||||
|
"m_strAccountID": "8882874667",
|
||||||
|
"m_strAccountKey": "2____10125____105214____49____8882874667____",
|
||||||
|
"m_strAccountRemark": "",
|
||||||
|
"m_strCompactNo": "",
|
||||||
|
"m_strExchangeID": "SZ",
|
||||||
|
"m_strExchangeName": "深交所",
|
||||||
|
"m_strInstrumentID": "001330",
|
||||||
|
"m_strInstrumentName": "博纳影业",
|
||||||
|
"m_strLocalInfo": "",
|
||||||
|
"m_strOperation": "",
|
||||||
|
"m_strOptName": "限价卖出",
|
||||||
|
"m_strOrderRef": "SZ_645009273",
|
||||||
|
"m_strOrderStrategyType": "",
|
||||||
|
"m_strOrderSysID": "645009273",
|
||||||
|
"m_strProductID": "",
|
||||||
|
"m_strProductName": "",
|
||||||
|
"m_strRemark": "",
|
||||||
|
"m_strSource": "",
|
||||||
|
"m_strTradeDate": "20260901",
|
||||||
|
"m_strTradeID": "0103000030812084",
|
||||||
|
"m_strTradeTime": "101333",
|
||||||
|
"m_strXTTrade": "其他终端",
|
||||||
|
"m_xtTag": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**语义化映射结果(供数据模型参考)**:
|
||||||
|
|
||||||
|
| 语义字段 | 订单值 | 成交值 | 来源字段 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| orderId | 645009273 | 645009273 | m_strOrderSysID |
|
||||||
|
| tradeId | - | 0103000030812084 | m_strTradeID |
|
||||||
|
| code | 001330 | 001330 | m_strInstrumentID |
|
||||||
|
| name | 博纳影业 | 博纳影业 | m_strInstrumentName |
|
||||||
|
| exchange | SZ | SZ | m_strExchangeID |
|
||||||
|
| direction | 卖 | 卖 | m_nOffsetFlag=49 + m_strOptName=限价卖出 |
|
||||||
|
| status | 56 已成 | 已成 | m_nOrderStatus |
|
||||||
|
| orderVolume | 200 | - | m_nVolumeTotalOriginal |
|
||||||
|
| tradedVolume | 200 | 200 | m_nVolumeTraded / m_nVolume |
|
||||||
|
| limitPrice | 6 | - | m_dLimitPrice |
|
||||||
|
| price | 6 | 6 | m_dTradedPrice / m_dPrice |
|
||||||
|
| amount | 1200 | 1200 | m_dTradeAmount |
|
||||||
|
| commission | - | 0.852 | m_dCommission |
|
||||||
|
| date | 20260901 | 20260901 | m_strInsertDate / m_strTradeDate |
|
||||||
|
| time | 101333 | 101333 | m_strInsertTime / m_strTradeTime |
|
||||||
|
| accountId | 8882874667 | 8882874667 | m_strAccountID |
|
||||||
|
|
||||||
|
### 2.3 数据时效研究(能查多少天?)
|
||||||
|
|
||||||
|
> 核实日期:2026-09-01 | 结论来源:QMT 桥 OpenAPI 规范 + 大 QMT 内置 API 官方文档(dict.thinktrader.net/innerApi/)
|
||||||
|
|
||||||
|
**结论:/trade/orders 与 /trade/trades 只能返回当日(当天交易日)的委托与成交,无法查历史。**
|
||||||
|
|
||||||
|
**证据链**:
|
||||||
|
|
||||||
|
1. **QMT 桥 OpenAPI 规范**:两个端点的 summary 与 description 均明确「**当日**委托查询」「**当日**成交查询」,且参数只有 code/status/strategy_name(orders)或无参数(trades)——**没有日期范围参数**;
|
||||||
|
2. **底层函数**:QMT 桥数据由策略线程(adjust)调用 `get_trade_detail_data(account, 'stock', 'order'/'deal')` 缓存刷新(官方 code_examples 确认),官方示例全部是 3 参数调用,**无日期参数**;
|
||||||
|
3. **官方 Q&A(数据来源本质)**:`get_trade_detail_data` 从**客户端本地缓存**读取(非调用时查柜台),缓存是「当前状态」——即当日委托/成交的实时快照;官方明确「不能认为查到的状态与柜台完全一致」;
|
||||||
|
4. **行情 vs 交易差异**:历史查询能力只存在于**行情**域(down_history_data / get_market_data_ex 可查历史 K线),**交易域(order/deal)没有历史查询接口**。
|
||||||
|
|
||||||
|
**对本需求的含义**:
|
||||||
|
|
||||||
|
| 影响面 | 结论 |
|
||||||
|
|---|---|
|
||||||
|
| 「交易记录」tab 展示范围 | 只能展示**当日**委托与成交(当日快照) |
|
||||||
|
| 交易复盘(目标-006) | 当日复盘可完整支撑;跨日复盘需**每日落盘存档**(Q5 关联:若要做历史复盘,必须本地持久化) |
|
||||||
|
| Q5 是否持久化 | **由「可选」升级为「必要前提」**:不做持久化则复盘能力只有当日,重启即丢 |
|
||||||
|
| 数据刷新 | 当日数据准实时(300ms 缓存 + 柜台推送 50ms~6s),轮询即可 |
|
||||||
|
|
||||||
|
**结论映射**:R-007 功能范围「不做」中「历史(非当日)交易查询」一项**确认成立**(QMT Bridge 仅提供当日缓存,无法查历史);但「不做持久化」需要重新审视——若老师要支撑跨日复盘,Q5 应改为「做持久化(store.trades.json,按日存档)」或明确接受「仅当日复盘」。
|
||||||
|
|
||||||
|
### 3. 现有架构可复用点(代码审查)
|
||||||
|
|
||||||
|
| 现有能力 | 位置 | 复用方式 |
|
||||||
|
|---|---|---|
|
||||||
|
| 统一数据源抽象 + REST 直连 | src/component/QmtBridgeRestDataSource.js | 新增 getOrders()/getTrades() 方法(参照 getAsset/getPositions,m_ 字段→语义字段映射) |
|
||||||
|
| 服务端 HTTP API 领域分发 | src/api/index.js + 领域文件 | 新增 api/trades.js(订单/成交域),注入 METHODS + HANDLERS |
|
||||||
|
| 前端 tab 框架 | src/client/index.js(GENERAL_TABS) | 交易记录 tab 的 render 从 PlaceholderTab 换成真实组件 |
|
||||||
|
| 服务端缓存 + 前端轮询模式 | MarketDataHub + MarketFeed(market-snapshot) | 交易数据可复用轮询模式(Q5 待定) |
|
||||||
|
| 设置显隐开关 | settings.js tabs.tradeRecords | 已预置,无需新增 |
|
||||||
|
| 涨跌着色/高亮组件 | PriceCell.jsx | 交易方向着色可复用(Q7 待定) |
|
||||||
|
|
||||||
|
## 功能范围(定稿,2026-09-01 老师拍板)
|
||||||
|
|
||||||
|
> 老师指令(2026-09-01):「先把当日订单与成交记录接进来,数据放到交易记录这个 tab 下;我会完善 QMT Bridge 历史成交的接口,所以请添加一个日期导航,根据交易日历来做一个日期导航。」
|
||||||
|
|
||||||
|
**做(本期)**:
|
||||||
|
1. **数据源接入**:QmtBridgeRestDataSource 新增当日委托/当日成交查询(getOrders/getTrades,语义化映射,核心字段见 2.1/2.2);
|
||||||
|
2. **服务端 API**:新增 orders / trades 端点(支持 code/status 过滤),纳入领域分发(新 api/trades.js);
|
||||||
|
3. **交易记录 tab 落地**:占位 tab(odl-trade-records)替换为真实交易视图——**单表合并**:以委托行为主行,按 m_strOrderSysID 合并成交信息(聚合展示 + 行展开查看每笔成交明细);
|
||||||
|
4. **数据刷新**:服务端中转 + 前端轮询(准实时,QMT Bridge 自身 300ms 缓存;前端 3-5s 轮询);
|
||||||
|
5. **状态展示**:委托状态中文映射(未报/已报/部成/已成/已撤/废单等)、买卖方向识别(m_nOffsetFlag + m_strOptName)、成交金额/手续费展示;
|
||||||
|
6. **时间段查询(核心,v3 定稿)**:交易记录 tab 采用**起止时间段查询**(匹配历史接口按时间范围查询的形态)——标题 + 时间段控件**同一行**;控件含三个快捷按钮(**今日 / 本周 / 本月**)+ 手动起止日期(input type=date);快捷范围基于交易日历最近交易日计算且为**完整互斥时间段**(今日=最近交易日单日、本周=所在自然周**周一~周五**(不考虑周六日)、本月=所在自然月1号~月末);**交互单向模型**:preset 意图状态记录用户点的按钮,高亮=preset 匹配,不做「范围反推按钮」(2026-09-01 老师纠正:先有操作后有范围,不是反向推断);手动改日期清除 preset;「今日」范围 3-5s 轮询,其他范围一次性查询;非今日范围展示「历史数据接口开发中」占位(老师完善 QMT Bridge 历史接口后接入,服务端已预留 start/end 透传);
|
||||||
|
7. **委托↔成交合并**:按 m_strOrderSysID 关联——委托行为主行,聚合展示成交(成交量/成交价/成交额/手续费),展开后逐笔展示成交明细(2026-09-01 老师拍板)。
|
||||||
|
|
||||||
|
**不做(本期)**:
|
||||||
|
- 下单/撤单等交易操作(QMT Bridge 当前为查询接口,无下单端点;目标-007 人机合一的关键决策控制权另议);
|
||||||
|
- 历史(非当日)交易查询实现(**接口由老师完善 QMT Bridge 后接入**,本期仅做日期导航占位);
|
||||||
|
- 交易数据本地持久化(Q5 本期不做,跨日复盘待历史接口就绪后评估);
|
||||||
|
- 按策略过滤交易(strategy_name 服务端过滤依赖 QMT passorder 策略名,与插件策略体系不同源,Q10 后续迭代)。
|
||||||
|
|
||||||
|
## 技术研究结论(第一轮)
|
||||||
|
|
||||||
|
1. **API 能力确认**:QMT Bridge v2.0.0 已提供 /trade/orders(支持 code/status/strategy_name 过滤)+ /trade/trades(全量当日成交),数据由策略线程 300ms 缓存刷新、HTTP 线程只读缓存,**REST 轮询即可达准实时**,无需 WS 订阅(规避迭代 04 的 WS 不稳定教训);**时效边界:仅当日数据,无法查历史(2.3 数据时效研究)**;
|
||||||
|
2. **字段语义澄清(核心,官方 innerApi 字典核实)**:大 QMT 内置 API(dict.thinktrader.net/innerApi/)中,m_nDirection 对应 EEntrustBS 枚举(48=买卖/49=查询/50=撤单),官方注明「股票买卖永远是 48」——**它是操作标记不是方向**;真实买卖方向在 m_nOffsetFlag(EOffset_Flag_Type:48=买入开仓/49=卖出平仓,实测 49=限价卖出 完全吻合)+ m_strOptName(买卖标记中文);**方向判定规则已确定:m_nOffsetFlag 为主 + m_strOptName 交叉验证(Q3 有结论)**;
|
||||||
|
3. **字段模型清晰**:已沉淀两张字段全表(2.1 小节),语义化映射核心字段约 15 个/表(orderId/tradeId/code/name/exchange/direction/status/volumes/prices/amount/commission/time),数据模型可直接据此设计;
|
||||||
|
4. **集成路径清晰**:现有数据源抽象 / API 领域分发 / tab 框架 / 服务端缓存模式全部可复用,新增代码为增量式(1 个 API 领域文件 + 数据源 2 方法 + 前端 1 个组件);
|
||||||
|
5. **架构约束沿用**:技术约束-001/003(统一抽象 + REST 直连)、技术约束-004(webServer 自开路由)、技术约束-010 思路(服务端中转 + 缓存,前端轮询);
|
||||||
|
6. **测试隔离**:订单/成交为只读查询端点,回归测试可直连生产 API(符合技术约束-011 只读豁免),但禁止测试写操作;
|
||||||
|
7. **日期导航可行性(2026-09-01 实测)**:QMT Bridge /data/calendar/trading_dates 可用(GET,支持 start/end 过滤,返回 { market, count, dates[] },dates 为 YYYYMMDD 数组)——取近 N 日交易日列表做前后切换导航即可;当日由 trading_dates 返回的最后一个交易日确定(或客户端本地日期取整)。
|
||||||
|
|
||||||
|
## 待确认问题(Q1-Q10,待老师拍板)
|
||||||
|
|
||||||
|
| # | 问题 | AI 建议 |
|
||||||
|
|---|---|---|
|
||||||
|
| # | 问题 | 结论(2026-09-01) |
|
||||||
|
|---|---|---|
|
||||||
|
| Q1 | **展示范围**:交易记录 tab 展示「当日委托 + 当日成交」两块,还是仅成交? | ✅ **两块都展示**:委托表(含状态)+ 成交表(含费用),tab 内分区块 |
|
||||||
|
| Q2 | **数据刷新机制**:服务端中转 + 前端轮询?周期? | ✅ 服务端中转 + 前端 3-5s 轮询(复用 market-snapshot 模式);QMT 自身 300ms 缓存 |
|
||||||
|
| Q3 | **买卖方向判定**:m_nDirection 恒 48 不判方向,方向取哪个字段? | ✅ **m_nOffsetFlag 为主(48买/49卖)+ m_strOptName 展示交叉验证**(官方枚举核实,Q3 闭环) |
|
||||||
|
| Q4 | **委托状态展示**:默认过滤已撤/废单?还是全量? | ✅ 全量展示 + 状态列 + 可选筛选(active/全部),默认全量(首版简化:全量展示即可) |
|
||||||
|
| Q5 | **是否持久化**:跨日复盘的前提是落盘? | ✅ **本期不做持久化**(当日实时查询);跨日复盘待历史接口就绪后评估(老师将完善 QMT Bridge 历史成交接口) |
|
||||||
|
| Q6 | **委托↔成交关联形态**:两表还是单表合并? | ✅ **单表合并**:委托行为主行 + 展开/折叠查看每笔成交(老师拍板,2026-09-01);聚合成交信息在委托行展示,展开后逐笔明细 |
|
||||||
|
| Q7 | **方向着色**:买/卖如何着色? | ✅ 遵循 A 股习惯:**红买绿卖**(与行情红涨绿跌相反语义;前端实现时用色值区分) |
|
||||||
|
| Q8 | **手续费/成交额精度**:展示位与单位? | ✅ 成交额元(2 位小数)、手续费元(3 位小数)、成交量股 |
|
||||||
|
| Q9 | **优先级**:老师定级? | ✅ **P1**(交易复盘是目标-006 核心输入,tab 已占位) |
|
||||||
|
| Q10 | **与策略联动**:按插件策略过滤交易? | ✅ 本期不做(strategy_name 是 QMT passorder 概念,与插件策略不同源;后续迭代) |
|
||||||
|
| Q11 | **日期导航 → 时间段查询**(2026-09-01 演进):基于交易日历的日期导航如何做? | ✅ **起止时间段查询**(老师反馈 v3):放弃单日前后导航,改为时间段(start~end);标题 + 时间段控件同一行;快捷按钮 今日/本周/本月 + 手动起止日期;匹配历史接口按时间范围查询的形态;服务端 orders/trades 预留 start/end 透传 |
|
||||||
|
|
||||||
|
## 展示设计:单表合并(委托为主,成交并入)
|
||||||
|
|
||||||
|
> 2026-09-01 老师拍板:**不要两个表,一个表**——当日委托信息为主行,根据 m_strOrderSysID 合并成交信息。
|
||||||
|
|
||||||
|
### 合并规则
|
||||||
|
|
||||||
|
- **主行 = 一笔委托**(按 m_strOrderSysID 唯一);
|
||||||
|
- **成交聚合**:该委托的全部成交(/trade/trades 中 m_strOrderSysID 匹配)聚合进主行;
|
||||||
|
- **行展开**:点击展开/折叠,展开后逐笔展示每笔成交明细(成交时间/价格/数量/金额/手续费)。
|
||||||
|
|
||||||
|
### 委托主行列(聚合态)
|
||||||
|
|
||||||
|
| 列 | 来源 | 显示逻辑 |
|
||||||
|
|---|---|---|
|
||||||
|
| 展开箭头 | - | ▸/▾ 切换(有成交才可展开;无成交置灰/隐藏) |
|
||||||
|
| 时间 | m_strInsertTime | HH:MM:SS(委托时间) |
|
||||||
|
| 代码 | m_strInstrumentID | 6 位代码 |
|
||||||
|
| 名称 | m_strInstrumentName | 中文名 |
|
||||||
|
| 方向 | m_nOffsetFlag(48买/49卖) + m_strOptName | **红买绿卖**(Q7);显示「买入/卖出」,悬停显示完整操作名(限价卖出) |
|
||||||
|
| 状态 | m_nOrderStatus | 中文映射:未报/待报/已报/已报待撤/部成待撤/部撤/已撤/部成/已成/废单 |
|
||||||
|
| 委托量 | m_nVolumeTotalOriginal | 整数 + 股 |
|
||||||
|
| 成交量 | Σ成交 m_nVolume(优先)或 m_nVolumeTraded | 聚合成交总量;无成交显示 — |
|
||||||
|
| 委托价 | m_dLimitPrice | 2 位小数;市价单显示「市价」 |
|
||||||
|
| **成交均价** | Σ(成交价×量)/Σ量 加权 | 聚合加权均价;无成交显示 — |
|
||||||
|
| **成交额** | Σ成交 m_dTradeAmount | 聚合求和;无成交显示 — |
|
||||||
|
| **手续费** | Σ成交 m_dCommission | 聚合求和(3 位小数);无成交显示 — |
|
||||||
|
|
||||||
|
### 展开明细行(每笔成交)
|
||||||
|
|
||||||
|
| 列 | 来源 | 显示逻辑 |
|
||||||
|
|---|---|---|
|
||||||
|
| 成交时间 | m_strTradeTime | HH:MM:SS |
|
||||||
|
| 成交价 | m_dPrice | 2 位小数 |
|
||||||
|
| 成交量 | m_nVolume | 整数 + 股 |
|
||||||
|
| 成交额 | m_dTradeAmount | 2 位小数 |
|
||||||
|
| 手续费 | m_dCommission | 3 位小数 |
|
||||||
|
| 成交号 | m_strTradeID | 次要,悬停/弱显示 |
|
||||||
|
|
||||||
|
### 显示逻辑要点
|
||||||
|
|
||||||
|
1. **未成交委托**(待报/已报/已撤/废单):成交量/成交均价/成交额/手续费列显示 —,状态列已明确表达,无需其他冗余;
|
||||||
|
2. **多笔成交**:主行聚合(加权均价 + 求和),展开逐笔;聚合金额与逐笔合计一致;
|
||||||
|
3. **排序**:委托主行**默认按时间降序**(最新在前,老师拍板 2026-09-01);**支持手动调整**——点击表头列排序(时间/代码/方向/状态/委托量/成交量/金额等可排序列),再次点击切换升/降序,默认回到降序;
|
||||||
|
4. **方向着色**:红买绿卖(表头或图例标注「红=买 绿=卖」,避免与行情红涨绿跌混淆);
|
||||||
|
5. **空态**:无委托时显示「今日暂无委托记录」;
|
||||||
|
6. **数量格式**:整数(股),不强制千分位(A股习惯)。
|
||||||
|
|
||||||
|
## 技术实现方案(草案,2026-09-01)
|
||||||
|
|
||||||
|
### 程序结构(改造后)
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── component/
|
||||||
|
│ └── QmtBridgeRestDataSource.js # 改:新增 getOrders()/getTrades()(语义化映射)
|
||||||
|
├── api/
|
||||||
|
│ └── trades.js # 新增:订单/成交域(orders / trades / trading-dates 端点)
|
||||||
|
│ └── index.js # 改:合并 TRADE_METHODS
|
||||||
|
├── client/
|
||||||
|
│ └── views/
|
||||||
|
│ ├── TradeRecordsTab.jsx # 新增:交易记录 tab(日期导航 + 单表合并视图)
|
||||||
|
│ ├── OrderRow.jsx # 新增:委托主行(聚合成交,可展开)
|
||||||
|
│ ├── TradeDetailRows.jsx # 新增:展开的成交明细行(每笔成交)
|
||||||
|
│ └── DateNav.jsx # 新增:日期导航组件(交易日历驱动)
|
||||||
|
│ └── index.js # 改:tradeRecords tab 渲染 TradeRecordsTab
|
||||||
|
```
|
||||||
|
|
||||||
|
### 服务端
|
||||||
|
|
||||||
|
1. **数据源**:QmtBridgeRestDataSource 新增
|
||||||
|
- `getOrders({ code, status })` → 调 `/trade/orders`,语义化映射(orderId/status/direction/volumes/prices/amount/time/...,见 2.2 映射表);
|
||||||
|
- `getTrades()` → 调 `/trade/trades`,语义化映射(tradeId/orderId/code/price/volume/amount/commission/time);
|
||||||
|
- `getTradingDates({ start, end })` → 调 `/data/calendar/trading_dates`(支持 start/end,返回 dates[])。
|
||||||
|
2. **API 域**:新建 api/trades.js,端点:
|
||||||
|
- `orders`(args: code/status)→ dataSource.getOrders;
|
||||||
|
- `trades` → dataSource.getTrades;
|
||||||
|
- `trading-dates`(args: start/end)→ dataSource.getTradingDates;
|
||||||
|
- 纳入 registerApi 的 METHODS + HANDLERS。
|
||||||
|
3. **日期解析**:服务端提供「当日交易日」解析(trading_dates 最后一个日期,或本地日期取整),前端导航初始化用。
|
||||||
|
|
||||||
|
### 客户端
|
||||||
|
|
||||||
|
1. **TradeRecordsTab**:顶部日期导航(DateNav)+ 单表合并视图(委托主行 + 可展开成交明细);
|
||||||
|
2. **DateNav**:加载交易日列表(近 N 日,如 -30~+7),前后箭头切换,当日高亮;选中日期状态提升;非当日显示「历史数据接口开发中」提示(老师完善 QMT Bridge 历史接口后启用);
|
||||||
|
3. **数据加载**:useRpc 调 orders / trades;当日 3-5s 轮询;切换日期时取消轮询、加载所选日期数据(历史接口就绪后);
|
||||||
|
4. **合并逻辑(前端)**:fetch 两接口 → 按 m_strOrderSysID 分组成交 → 委托主行聚合(加权均价/求和金额/求和手续费)→ 展开时逐笔渲染成交明细;
|
||||||
|
5. **委托主行列**:展开箭头/时间/代码/名称/方向/状态/委托量/成交量/委托价/成交均价/成交额/手续费;
|
||||||
|
6. **展开明细列**:成交时间/成交价/成交量/成交额/手续费/成交号;
|
||||||
|
7. **着色**:红买绿卖(Q7 已定);方向列红色=买、绿色=卖,表头图例标注。
|
||||||
|
|
||||||
|
### 构建与验证
|
||||||
|
|
||||||
|
- pnpm run build + 页面刷新(客户端 bundle)或宿主重启(服务端模块);
|
||||||
|
- 回归:现有持仓/策略/行情端点不受影响(技术约束-011 只读豁免,可直连生产 API 验证 orders/trades/trading-dates 返回)。
|
||||||
|
|
||||||
|
## 讨论记录
|
||||||
|
|
||||||
|
> 追加式记录:日期 · 轮次 · 结论。
|
||||||
|
|
||||||
|
- **2026-09-01 · 第一轮(需求登记 + 技术研究)**:
|
||||||
|
- 老师指令:添加一个新的需求,接入订单与成交数据,开始需求和技术研究。
|
||||||
|
- AI 完成:需求登记 R-007(本文件);QMT Bridge API 面探索(/trade/orders + /trade/trades 端点确认);线上实测真实数据样本(博纳影业 001330.SZ 委托+成交);现有架构代码审查(数据源/API 领域/tab 框架/缓存模式可复用);形成 Q1-Q10 待确认问题。
|
||||||
|
- 状态建议:**起草 → 讨论中**(已开始需求与技术研究),待老师确认后推进。
|
||||||
|
- **2026-09-01 · 第二轮(官方字典核实,方向字段澄清)**:
|
||||||
|
- 老师指正:QMT 桥转接的是**大 QMT 服务**,字段值应参考内置 API(innerApi)字典:https://dict.thinktrader.net/innerApi/start_now.html?id=gdJsE0 (而非 nativeApi/xtquant)。
|
||||||
|
- AI 核实(dict.thinktrader.net/innerApi/enum_constants.html + data_structure.html):
|
||||||
|
① m_nDirection 对应 enum_EEntrustBS(48=买卖/49=查询/50=撤单),官方注明「股票买卖永远是 48」——**操作标记,非方向**;
|
||||||
|
② 真实方向在 m_nOffsetFlag(enum_EOffset_Flag_Type:48=买入开仓/49=卖出平仓/50=强平/51=平今/52=平昨/53=强减/54=本地强平);
|
||||||
|
③ m_strOptName = 买卖标记,展示委托属性的中文(实测「限价卖出」)。
|
||||||
|
- 线上实测闭环验证:博纳影业订单 m_nDirection=48、**m_nOffsetFlag=49**、m_strOptName=限价卖出 → 49=卖出平仓 与「限价卖出」完全吻合。
|
||||||
|
- 结论:**方向判定规则确定 = m_nOffsetFlag 为主(48买/49卖)+ m_strOptName 交叉验证**;Q3 由「待确认」变为「有结论,待老师确认后固化」。
|
||||||
|
- 文档同步修正:字段表 m_nDirection 释义、实测样本说明、技术研究结论第 2 条、Q3 描述。
|
||||||
|
- **2026-09-01 · 第三轮(字段全表沉淀)**:
|
||||||
|
- 老师指示「看看 QMT 桥返回了哪些字段,列一个表」「要的要的」(确认沉淀)。
|
||||||
|
- AI 实调 /trade/orders 与 /trade/trades 提取完整原始字段,整理为两张字段全表(见 2.1 小节),标注 ★ 语义化映射核心字段(约 15 个/表)。
|
||||||
|
- 关键点:① m_nOffsetFlag+m_strOptName 双字段定方向(Q3 闭环);② m_strOrderSysID 关联委托↔成交;③ 手续费字段有拼写变体 m_dComssion(取值优先 m_dCommission);④ 大量期权/期货/人民币换算字段在股票场景为空/默认值,映射时裁剪。
|
||||||
|
- **2026-09-01 · 第四轮(Sample Data 沉淀)**:
|
||||||
|
- 老师指示「今天有成交数据,帮我在文档中完善 sample data」。
|
||||||
|
- AI 实调最新 /trade/orders + /trade/trades(当日 1 笔委托 + 1 笔成交:博纳影业 001330.SZ 限价卖出 200 股 @6.00,已成,手续费 0.852 元),完整原始 JSON 沉淀为 2.2 小节 Sample Data。
|
||||||
|
- 同时给出语义化映射结果表(orderId/tradeId/code/name/exchange/direction/status/volumes/prices/amount/commission/time/accountId 共 16 字段),作为数据模型直接参考。
|
||||||
|
- 注:实调确认今日数据仅此一笔(订单 645009273 = 成交 0103000030812084 同号关联),sample 覆盖委托↔成交完整链路。
|
||||||
|
- **2026-09-01 · 第五轮(数据时效研究)**:
|
||||||
|
- 老师提问:这两个接口可以返回多少天以内的成交记录?
|
||||||
|
- AI 核实(QMT 桥 OpenAPI + 大 QMT 官方文档 code_examples/question_answer):/trade/orders 与 /trade/trades **只能返回当日数据**——规范明确「当日」且无日期参数;底层 get_trade_detail_data 从客户端本地缓存读取(当前状态快照),交易域无历史查询接口(历史能力仅限行情域)。
|
||||||
|
- 沉淀 2.3 数据时效研究小节;Q5 持久化由「可选」升级为「跨日复盘的必要前提」,待老师拍板。
|
||||||
|
- **2026-09-01 · 第六轮(范围定稿 + 日期导航)**:
|
||||||
|
- 老师拍板:① 先把**当日订单与成交记录接进来**,数据放到**交易记录 tab** 下;② 老师将完善 QMT Bridge **历史成交接口**;③ 请添加一个**日期导航**,**根据交易日历**做一个日期导航。
|
||||||
|
- AI 更新:功能范围定稿(当日委托+成交 + 日期导航占位);Q1-Q10 全部有结论(Q5 本期不做持久化);新增 Q11 日期导航结论;技术实现方案草案(数据源 3 方法 / api/trades.js / TradeRecordsTab + DateNav);实测 trading_dates 接口可用。
|
||||||
|
- 状态:R-007 从「讨论中」推进,功能范围已定稿,待老师确认定稿后进入计划/迭代。
|
||||||
|
- **2026-09-01 · 第七轮(单表合并展示设计)**:
|
||||||
|
- 老师指示讨论委托/成交的显示字段与显示逻辑;AI 提出两表方案;老师拍板:**不要两个表,要一个表**——当日委托信息为主行,根据 m_strOrderSysID 合并成交信息。
|
||||||
|
- 三个决策点老师逐条确认:① **委托行展开/折叠**——主行显示订单状态 + 聚合成交信息,展开后逐笔成交明细;② 委托未成交(已撤/废单/待报)——状态列已明确,成交列显示 —;③ 聚合展示在委托行,展开后分别展示。
|
||||||
|
- AI 沉淀「展示设计:单表合并」章节:委托主行列(时间/代码/名称/方向/状态/委托量/成交量/委托价/成交均价/成交额/手续费)+ 展开明细列(成交时间/价格/量/额/手续费/成交号)+ 合并规则(按 m_strOrderSysID 分组、加权均价、求和、未成交显示 —)。
|
||||||
|
- 技术方案同步:组件改为 TradeRecordsTab + OrderRow(委托主行)+ TradeDetailRows(展开明细)+ DateNav;前端合并逻辑明确(fetch 两接口 → 分组 → 聚合)。
|
||||||
|
- **2026-09-01 · 第九轮(日期导航改版 v2)**:
|
||||||
|
- 老师反馈:顶部日期按钮没有美感——不要做成一个个按钮、不要加边框;以当前**最后一个交易日**为基准,先是**前面 7 个日期**;做成**一行**,把前一日/后一日的导航箭头也放进同一行;每个日期用**小竖线**分隔。
|
||||||
|
- AI 重写 DateNav v2:一行式布局(◀ 日期|日期|...|日期 ▶),日期为纯文本无边框,竖线 | 分隔,选中绿色加粗;基准 = 交易日历最后一个交易日(最近交易日),显示最近 + 前 7 个共 8 个;箭头 ◀ ▶ 同在一行(上一个/下一个交易日);TradeRecordsTab 的「今日」基准同步改为最近交易日(onLastDate 通知)。
|
||||||
|
- **2026-09-01 · 第十轮(时间段查询重构 v3)**:
|
||||||
|
- 老师反馈:看了历史交易接口,是按**传入时间范围**确定查询哪些日期的记录——现有单日导航逻辑不适合;倾向**按日期查询但能指定起止时间**;同时布局浪费空间(标题一行、日期导航一行)——① 日期导航改为**起止时间段**,不再用前一日/后一日;② 时间段控件与**标题同一行**;③ 新增三个功能键:**今日 / 本周 / 上周**快速查询近期数据,其余用户手动指定日期。
|
||||||
|
- AI 重构:删除 DateNav,新增 RangeSelector(今日/本周/上周快捷 + 起止日期 input);TradeRecordsTab 标题 + 时间段同一行;今日范围 4s 轮询、其他范围一次性查询;服务端 orders/trades 预留 start/end 透传(匹配历史接口形态);历史范围占位「历史数据接口开发中」。
|
||||||
|
- 决策:今日=最近交易日单日;本周=所在自然周周一~周日(完整周);本月=所在自然月1号~月末(完整月);均基于交易日历最近交易日计算。
|
||||||
|
- 2026-09-01 修正一:快捷按钮由「今日/本周/上周」改为「**今日/本周/本月**」(老师反馈,去掉上周、新增本月)。
|
||||||
|
- 2026-09-01 修正二(老师纠正):快捷范围为**完整时间段**(本月=9/1~9/30、本周=周一~周日),不做「截至今天」截断——否则月初第一天点「本月」会与「今日」重合、无法操作;这是操作逻辑问题,不是数据问题。
|
||||||
|
- 2026-09-01 · 第十一轮(费用计算规则,老师定):m_dCommission 的值是**佣金费率(万分之)**——如 0.852 = 万分之0.852 = 0.0000852(不是金额 0.852 元)。费用规则:佣金 = 成交额 × 万0.852,不足 5 元按 5 元(账号没免五);卖出加印花税(万分之5,2023-08-28 起)和过户费(万分之0.1,双向,无最低,2022-04-29 起,老师确认费率);过户费收费方中登、ETF/LOF/可转债/REITs 免收。已实现 calcTradeFees 逐笔计算 + 前端聚合展示(总费用/佣金/印花税/过户费四列)。
|
||||||
|
- 2026-09-01 修正四(本周范围,老师纠正):本周 = **周一~周五**(不考虑周六日,不是往前推 7 天/自然周周日);实现 weekSunday→weekFriday(周一+4)。
|
||||||
|
- 2026-09-01 修正三(交互模型,老师纠正):**交互是单向的**——用户先有操作(点「本月」),才有时间范围;不能反过来从「搜索范围落在哪」反向推断用户交互的是哪个按钮。实现改为 **preset 意图状态**:点按钮 → 记录 preset → 按钮高亮(单向);手动改日期 → 清除 preset(不再是任何快捷操作);轮询由 preset==='today' 决定,而非范围反推。
|
||||||
|
- **2026-09-01 · 第八轮(排序逻辑定稿)**:
|
||||||
|
- 老师拍板:委托主行排序**默认降序**(最新委托在前),**支持手动调整**(点击表头排序,可切换升/降序)。
|
||||||
|
- AI 更新:显示逻辑要点第 3 条改为「默认降序 + 表头点击排序」;可排序列包含时间/代码/方向/状态/委托量/成交量/金额等。
|
||||||
|
|
||||||
|
## 关联文档(随讨论补充)
|
||||||
|
|
||||||
|
- 计划:待 R-007 定稿后派生(符合入范围门槛)
|
||||||
|
- 迭代记录:待实现后建立
|
||||||
|
- 设计约束:待定稿后沉淀(方向判定规则、交易数据模型等)
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# R-004 QMT 连接配置(多配置管理 + 会话头部快捷切换)· 已完成
|
||||||
|
|
||||||
|
> 归档日期:2026-08-29 | 需求状态:**已完成**
|
||||||
|
> 原索引:docs/05-需求池/需求池索引.md(主索引保留 R-004 条目,指向本归档)
|
||||||
|
> 定稿记录:2026-08-29 老师确认定稿(三要素满足:边界清楚 + 核心逻辑明确 + 老师确认)
|
||||||
|
> 来源:老师指令(2026-08-28)
|
||||||
|
> 关联:docs/05-需求池/需求池索引.md(R-004)
|
||||||
|
> 说明:待确认问题清单与讨论记录原独立记录于 R-004-待确认问题.md,2026-08-29 经老师要求整合入本文档(保留一份),原独立文件已删除。
|
||||||
|
|
||||||
|
## 需求概述
|
||||||
|
|
||||||
|
在神之一手的**设置页面**新增一个子 tab「QMT 连接配置」,用于管理多个 QMT Bridge 连接配置:
|
||||||
|
|
||||||
|
- **多配置管理**:可维护多个连接配置(增 / 删 / 改 / 查);
|
||||||
|
- **一次只能激活一个**:从多个配置中单选激活一个,被激活的配置为当前生效连接;
|
||||||
|
- **可设置默认**:标记某配置为默认(默认 = 启动时自动激活的配置,见 Q1);
|
||||||
|
- **快捷切换**:在会话窗口顶栏(PTC 模式标签旁)提供常驻下拉控件,一键切换当前激活的连接,无需进入设置页(见 Q9/Q10)。
|
||||||
|
|
||||||
|
## 现状分析:插件当前使用的 QMT 连接信息
|
||||||
|
|
||||||
|
> 通过代码审查得出(src/data-source/qmt-bridge-rest.js、src/index.js、cordis.patch.yml):
|
||||||
|
|
||||||
|
| 配置项 | 当前值 | 是否必配 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **HTTP 基础地址(baseUrl)** | `http://192.168.3.43:8610` | **是(唯一必配)** | 由宿主 cordis.patch.yml 注入 `config.qmtBaseUrl`;数据源所有 REST 请求基于此地址 |
|
||||||
|
| **请求超时(timeoutMs)** | 15000ms | 否(有默认值) | 代码常量 REQUEST_TIMEOUT_MS=15000,当前未暴露到配置,使用默认值 |
|
||||||
|
|
||||||
|
**分析结论**:
|
||||||
|
1. **核心只需配置一个 HTTP 地址**(baseUrl)—— 插件与 QMT Bridge 的所有通信都基于它(/health、/trade/*、/data/* 等 REST 端点);
|
||||||
|
2. **可选配置一个请求超时**(timeoutMs)—— 仅在需要调整超时时才需配置,可提供默认值;
|
||||||
|
3. **当前不需要其他连接参数**:无认证(QMT Bridge REST 端点无鉴权)、无账户/密码、无额外 header —— 未来若 QMT Bridge 增加鉴权,再扩展配置字段。
|
||||||
|
|
||||||
|
**2026-08-29 讨论前代码审查补充**(支撑决策):
|
||||||
|
- 数据源的 baseUrl/timeoutMs 为**按请求读取**(qmt-bridge-rest.js),激活切换只需更新数据源实例字段,无需重建实例,「立即切换」实现成本低;
|
||||||
|
- QMT Bridge 已有 `/health` 端点(插件启动检查在用),「测试连接」可直接复用;
|
||||||
|
- 策略配置已有 settings namespace + CRUD + 设置页子 tab 完整先例,连接配置存储可复用该模式。
|
||||||
|
|
||||||
|
## 功能范围(定稿)
|
||||||
|
|
||||||
|
**做**:
|
||||||
|
1. 设置页新增「QMT 连接配置」子 tab(与现有「通用设置 / 策略分组」并列);
|
||||||
|
2. **配置 CRUD**:新增 / 编辑 / 删除 / 重命名;配置字段为名称(识别用)+ HTTP 地址(必填);超时不纳入表单(Q5);
|
||||||
|
3. **激活**:单选激活,**一次只能激活一个**;激活后**立即切换**生效,无需重启 DSH(Q2);
|
||||||
|
4. **默认**:标记某配置为默认;启动时自动激活默认配置,运行中手动激活其他配置立即生效,重启后回到默认(Q1);
|
||||||
|
5. **测试连接**:手动触发,请求 `/health` 返回可达性与延迟;激活不强制先测试(Q3);
|
||||||
|
6. **删除边界**:允许删除激活配置(自动切换到默认);删除默认配置时默认标记转移到列表第一条并激活之;**禁止删除最后一条**(Q6);
|
||||||
|
7. **会话头部快捷切换**:在会话窗口顶栏注册下拉控件(slot `conversation.session.header.actions`,与 DSH 内置 PTC 模式标签并排):chip 形态显示 `QMT: <激活配置名>`,点开列出全部配置(激活项勾选标记),点选即激活并 toast 反馈;头部控件**仅做切换**,配置的增删改/测试连接/默认标记仍在设置页子 tab(Q9/Q10)。
|
||||||
|
|
||||||
|
**存储**:复用现有 one-divine-lot settings namespace(与策略配置同机制),不新增独立存储文件(Q8)。
|
||||||
|
|
||||||
|
**不做**:
|
||||||
|
- 认证/多账户支持(QMT Bridge 当前无鉴权,未来扩展);
|
||||||
|
- 连接状态监控(如断线重连策略)—— 后续;
|
||||||
|
- 超时(timeoutMs)配置化 —— 保持代码默认 15000ms(Q5);
|
||||||
|
- 宿主配置(cordis.patch.yml qmtBaseUrl)自动迁移 —— 首次使用手动录入(Q4);
|
||||||
|
- 激活前强制连通性校验 —— 测试连接为手动操作(Q3)。
|
||||||
|
|
||||||
|
## 待确认问题(Q1-Q10,已确认)
|
||||||
|
|
||||||
|
> 2026-08-29 由老师逐条拍板(AI 提议 + 老师确认)。Q8(存储位置)与 Q9/Q10(快捷切换)为当轮讨论新增。
|
||||||
|
|
||||||
|
| # | 问题 | 状态 | 确认日期 | 结论摘要 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Q1 | **「默认」的语义**:默认 = 插件启动时自动激活的配置?还是仅作为列表中的默认选中项?手动激活非默认配置后,下次启动是否回到默认? | 已确认 | 2026-08-29 | 默认 = 插件启动时自动激活的配置;运行中手动激活其他配置立即生效,重启后回到默认 |
|
||||||
|
| Q2 | **生效机制**:激活配置后**立即切换**(动态重建数据源,无需重启 DSH)?还是保存后**重启生效**?(当前数据源在插件 apply 时创建一次,改为动态切换需调整架构) | 已确认 | 2026-08-29 | 激活后**立即切换**:更新数据源实例的 baseUrl 即可,无需重启 DSH(代码审查已核实:数据源按请求读取地址,改动成本低) |
|
||||||
|
| Q3 | **是否提供「测试连接」按钮**:验证地址可达(请求 /health 检查 status=ok),帮助确认配置正确? | 已确认 | 2026-08-29 | 提供,**手动测**:请求 /health 返回可达性与延迟;激活本身不强制先测试(复用现有 /health 端点) |
|
||||||
|
| Q4 | **现有宿主配置迁移**:cordis.patch.yml 中的 `qmtBaseUrl`(192.168.3.43:8610)如何处理 —— 首次启动时作为**初始默认配置**自动迁入设置页? | 已确认 | 2026-08-29 | **不迁移**:首次使用手动录入配置 |
|
||||||
|
| Q5 | **超时是否纳入配置项**:timeoutMs 是否需要出现在配置表单(可选字段),还是保持代码默认值? | 已确认 | 2026-08-29 | **不纳入**表单:timeoutMs 保持代码默认 15000ms |
|
||||||
|
| Q6 | **删除/切换边界**:删除当前激活配置时如何处理(禁止删除?自动切换到默认?);删除默认配置时默认标记如何转移? | 已确认 | 2026-08-29 | 允许删除激活配置,删除后自动切换到默认配置;删除的若是默认配置,默认标记转移到列表第一条并激活之;**禁止删除最后一条**配置 |
|
||||||
|
| Q7 | **优先级**:老师定级(P0-P2?)—— 建议 P1(提升多环境可用性,单地址当前可用) | 已确认 | 2026-08-29 | **P1** |
|
||||||
|
| Q8 | **配置存储位置**(2026-08-29 讨论新增):连接配置存储于何处? | 已确认 | 2026-08-29 | 复用现有 one-divine-lot settings namespace(与策略配置同机制),不新增独立存储文件 |
|
||||||
|
| Q9 | **快捷切换入口位置**(2026-08-29 讨论新增):会话窗口顶栏(PTC 模式标签旁)?新建会话页?两处都放? | 已确认 | 2026-08-29 | **会话头部,PTC 模式标签旁**(slot `conversation.session.header.actions`,多实例挂载点已核实可并排注册,无需改动宿主) |
|
||||||
|
| Q10 | **头部菜单能力**(2026-08-29 讨论新增):仅切换激活?含每项测试连接?含管理入口? | 已确认 | 2026-08-29 | **仅切换激活**:列出配置、点选激活 + toast 反馈;测试连接与配置管理留在设置页 |
|
||||||
|
|
||||||
|
## 讨论记录
|
||||||
|
|
||||||
|
> 追加式记录:日期 · 轮次 · 结论。
|
||||||
|
|
||||||
|
- **2026-08-29 · 第一轮(代码审查 + 逐条拍板)**:
|
||||||
|
- 讨论前代码审查补充三项事实(见「现状分析」补充节):① 数据源 baseUrl/timeoutMs 按请求读取,「立即切换」成本低;② /health 端点可复用做测试连接;③ 策略配置的 settings namespace + CRUD + 设置页子 tab 模式可复用。
|
||||||
|
- 讨论中新增 Q8(配置存储位置),连同原 Q1-Q7 共 8 项由老师逐条拍板,结论见上表。
|
||||||
|
- 定稿确认环节:老师反馈「还有要调整的」,追问具体调整点未获选择。故 Q1-Q8 结论按已确认记录,**R-004 暂不定稿**,等待老师补充调整点后继续讨论。
|
||||||
|
- 文档整合:待确认问题清单与讨论记录并入本文档(老师要求保留一份),原独立文件 R-004-待确认问题.md 删除;「功能范围」一节同步按已确认决策对齐(超时移入不做、补充测试连接与删除边界、标注 Q 编号)。
|
||||||
|
- **2026-08-29 · 第二轮(新增会话头部快捷切换功能)**:
|
||||||
|
- 老师提出新功能:会话窗口顶栏(PTC 模式标签旁)增加 QMT 连接快捷切换,避免每次进设置页修改。
|
||||||
|
- AI 前置技术调研结论:PTC 标签为 DSH 内置客户端插件 ui-agent-preset 向 slot `conversation.session.header.actions` 注册的会话头部标签;该 slot 为多实例挂载点(按 order 排序、多插件共存),本插件可并排注册控件,无需改动宿主;DSH 预设标签做成只读是因宿主禁止会话中途更换预设,而 QMT 激活是插件自管逻辑(Q2 已定立即切换),头部放真切换控件成立。
|
||||||
|
- 老师拍板:功能**并入 R-004**(不拆独立需求);入口定**会话头部 PTC 标签旁**(Q9);头部菜单**仅切换激活**(Q10)。管理能力(增删改/测试连接/默认标记)仍在设置页子 tab。
|
||||||
|
- **2026-08-29 · 第三轮(定稿)**:
|
||||||
|
- 老师确认需求定稿。三要素核验:① 边界清楚(做/不做清单完整,含快捷切换);② 核心逻辑明确(Q1-Q10 全部有结论,含存储、生效机制、删除边界、入口与菜单能力);③ 老师明确确认。
|
||||||
|
- 决策沉淀:产品约束-005/006、技术约束-008/009(含技术约束-003 关联变更标注)、UI约束-001/002。
|
||||||
|
- 派生计划:PLAN-003(计划-QMT连接配置.md),进入执行范围。
|
||||||
|
|
||||||
|
## 实现记录
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| 实现迭代 | 03-QMT连接配置 |
|
||||||
|
| 实现方式 | 沿用独立插件 one-divine-lot(服务端 + 客户端改造) |
|
||||||
|
| 服务端 | settings.js(qmtConnections schema + CRUD/激活/默认/启动解析 9 函数)、qmt-bridge-rest.js(setBaseUrl 热切换)、index.js(启动激活地址解析 + dataSource 注入)、api.js(7 个 qmt-connections/* 端点 + /health 代理测试 + 激活/编辑/删除热切换编排) |
|
||||||
|
| 客户端 | QmtConnectionChip.jsx(会话头部快捷切换 chip,slot conversation.session.header.actions order=-9)、SettingsSection.jsx(「QMT 连接配置」第三个子 tab,卡片形式)、client/index.js(头部 slot 注册) |
|
||||||
|
| 构建安装 | pnpm run build(服务端 ESM + 客户端 CJS bundle + wrap);profile 为 link 引用,构建即生效(服务端需宿主重启加载) |
|
||||||
|
|
||||||
|
**实现期间调整(2026-08-29,老师反馈)**:① chip 空配置态由「隐藏」改为「显示 + 引导提示」(消除空态不可见问题);② 设置页配置由列表形式改为**卡片形式**(自适应网格 + 长文本单行省略悬停看全文,UI约束-002 变更)。
|
||||||
|
|
||||||
|
## 验收结果(2026-08-29 线上实测,详见 docs/04-迭代记录/03-QMT连接配置/验收标准.md)
|
||||||
|
|
||||||
|
- ✅ 标准 1:设置页子 tab + 配置 CRUD(实录 GEMWIN / QMT_CYY 两条真实配置)
|
||||||
|
- ✅ 标准 2:激活立即切换(切不可达配置 → 持仓立即失败;切回 → 立即恢复;全程无重启)
|
||||||
|
- ✅ 标准 3:默认/启动行为(单测覆盖启动激活默认并回写、cordis 兜底;线上激活=默认一致)
|
||||||
|
- ✅ 标准 4:测试连接(GEMWIN 可达 726ms;QMT_CYY 不可达 10.1s;激活无预检,符合 Q3)
|
||||||
|
- ✅ 标准 5:删除边界(线上实测删激活配置自动切回默认;删默认转移/禁删最后一条由单测覆盖)
|
||||||
|
- ✅ 标准 7:现有功能不回归(持仓/策略端点正常返回真实数据)
|
||||||
|
- ✅ 标准 6:会话头部 chip —— 老师确认完成并标记 R-004 已完成(2026-08-29)
|
||||||
|
|
||||||
|
## 经验沉淀
|
||||||
|
|
||||||
|
- **服务端改动需重启宿主**:客户端 bundle 页面刷新即加载,服务端模块(api.js/settings.js 等)宿主启动时装入内存——"设置页报 unknown method 但页面能看到新 UI" 即两侧版本差的典型症状
|
||||||
|
- **空态可见性**:入口控件「无数据即隐藏」在「数据需要经该入口创建」时形成死锁,空态应显示并给引导
|
||||||
|
- **数据源热切换可行性**:baseUrl 为按请求读取的实例字段,运行期改字段即生效——无需重建实例/重启宿主(技术约束-008 的事实依据)
|
||||||
|
- **profile link 安装形态**:插件以 symlink 指向工作区时,构建产物即宿主加载物,dsh plugin add 无需重复执行
|
||||||
|
- **拼音 slug 兜底**:映射表外中文名会兜底为通用 id(如 qmt-conn),后续可扩词表或时间戳后缀增强区分度
|
||||||
|
|
||||||
|
## 关联文档
|
||||||
|
|
||||||
|
- 迭代记录:docs/04-迭代记录/03-QMT连接配置/(迭代目标 / 技术实现方案 / 验收标准)
|
||||||
|
- 计划:docs/02-计划/计划-QMT连接配置.md(PLAN-003)
|
||||||
|
- 设计约束:产品约束-005/006、技术约束-003(变更标注)/008/009、UI约束-001/002
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# R-005 WS 盘中价格实时更新 · 已完成
|
||||||
|
|
||||||
|
> 归档日期:2026-09-01 | 需求状态:**已完成**
|
||||||
|
> 原索引:docs/05-需求池/需求池索引.md(主索引保留 R-005 条目,指向本归档)
|
||||||
|
> 实现迭代:04-WS盘中价格实时更新(验收通过,迭代复盘见 docs/04-迭代记录/04-WS盘中价格实时更新/迭代复盘.md)
|
||||||
|
> 讨论记录:需求从 T-005 草稿转正,2026-08-31 定稿;实现过程中的架构演进(前端直连→服务端中转+行情缓存)见迭代 04 迭代说明/复盘
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
> 状态:**已定稿**(2026-08-31,老师确认)| 登记日期:2026-08-31
|
||||||
|
> 来源:T-005 草稿转正(docs/05-需求池/草稿/通过ws长连接做市场数据的实时反映.md)
|
||||||
|
> 关联:T-005(ws 实时数据草稿)、R-002/R-003/R-004(已实现)
|
||||||
|
|
||||||
|
## 需求描述
|
||||||
|
|
||||||
|
在现有分仓管理工具(one-divine-lot 插件)中,实现**盘中价格实时更新**:
|
||||||
|
|
||||||
|
- 前端直连 QMT Bridge 原生 WebSocket(`ws://<激活配置host>:8610/ws`),订阅 `whole`(全市场 tick 增量推送);
|
||||||
|
- 事件驱动:收到推送后,自动更新 3 个监控表格(全部持仓 / 手动做T / 网格超市)中**关注股票列表**的**盘中价格**(现价,字段 `lastPrice`);
|
||||||
|
- 本期只更新**现价**一项数据,不做市值/盈亏/五档等扩展。
|
||||||
|
|
||||||
|
## 核心逻辑(定稿版)
|
||||||
|
|
||||||
|
1. **连接拓扑**:前端直连 QMT Bridge WS(老师确认,非插件服务端中转);
|
||||||
|
2. **订阅范围**:订阅全市场行情流(`type: whole`,`codes: ['SH','SZ']`),前端按关注列表过滤(老师确认);
|
||||||
|
3. **关注列表来源**:3 个表格自动汇总(全部持仓 + 手动做T + 网格超市出现过的 code 合集),无需手动维护(老师确认);
|
||||||
|
4. **现价展示**:3 个表格新增「现价」列(显示 `lastPrice`),红涨绿跌着色,价格变化时轻微高亮提示(老师确认);
|
||||||
|
5. **刷新机制**:现价叠加层 —— WS 维护 code→lastPrice 实时价格 Map,表格行渲染时叠加;份额/持仓仍由 REST 拉取,不整表重拉(老师确认);
|
||||||
|
6. **协议事实**(2026-08-31 实测 GEMWIN 100.110.38.78:8610):
|
||||||
|
- WS 端点:`ws://<host>:8610/ws`(与 REST 同端口,OpenAPI servers 注明);
|
||||||
|
- 订阅:`POST /data/subscribe`,body `{ type: 'whole', codes: ['SH','SZ'] }` → 返回 `{ ok, sub_id }`;
|
||||||
|
- 退订:`POST /data/unsubscribe`,body `{ sub_id }`;
|
||||||
|
- 推送:`{ type: 'whole', data: { '<code>': TickSnapshot } }`,TickSnapshot 含 `lastPrice / open / high / low / lastClose / volume / amount / askPrice / bidPrice / askVol / bidVol / stime / time` 等;
|
||||||
|
- 快照:`GET /data/tick?codes=<code1,code2>` 返回 `{ ok, data: { code: TickSnapshot } }`(WS 只推增量,无 prime;前端可先拉一次快照补齐初值)。
|
||||||
|
|
||||||
|
## 边界(不做)
|
||||||
|
|
||||||
|
- 不做市值/盈亏/五档等实时数据的展示(本期仅现价);
|
||||||
|
- 不做插件服务端中转 WS(老师确认前端直连);
|
||||||
|
- 不做断线重连 UI 引导(先做静默重连 + 状态提示);
|
||||||
|
- 不做关注列表手动配置(自动汇总)。
|
||||||
|
|
||||||
|
## 验收要点(详见迭代 04 验收标准)
|
||||||
|
|
||||||
|
1. 3 个表格出现「现价」列,显示关注股票的 lastPrice;
|
||||||
|
2. 价格变化自动更新(无需手动刷新);
|
||||||
|
3. 红涨绿跌着色 + 变化高亮;
|
||||||
|
4. 连接激活配置的 host 变化时,WS 随之切换(复用 QMT 多配置热切换)。
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# 草稿:RSS 订阅管理
|
||||||
|
|
||||||
|
> 状态:起草(未成形想法,不进入计划/迭代范围)| 登记日期:2026-08-28
|
||||||
|
> 来源:老师指令(2026-08-28)
|
||||||
|
|
||||||
|
## 想法描述
|
||||||
|
|
||||||
|
在神之一手中提供 **RSS 订阅管理**能力:管理 RSS 订阅源(增删改查),抓取并聚合订阅内容,作为资讯/消息的来源之一。
|
||||||
|
|
||||||
|
**与终极目标的关联**:
|
||||||
|
- 支撑 目标-003(按策略监控市场):财经资讯/研报/公告类 RSS 可作为市场监控输入源;
|
||||||
|
- 支撑 目标-004(按策略模式管理消息):RSS 内容可作为消息管理对象(过滤、聚合、分发、通知);
|
||||||
|
- 与周边工具候选 T-004(消息/通知工具)同属消息链路,可能是其上游数据源。
|
||||||
|
|
||||||
|
## 待讨论点
|
||||||
|
|
||||||
|
- [ ] 订阅源范围:财经资讯?研报?公告?博客/专栏?具体订阅哪些源?
|
||||||
|
- [ ] 管理界面形态:设置页新增子 tab?还是独立会话 tab 展示订阅内容?
|
||||||
|
- [ ] 抓取机制:定时轮询间隔?抓取后如何存储(本地 JSON / 结构化文档)?
|
||||||
|
- [ ] 与策略的关联:订阅源是否按策略分组(不同策略关注不同源)?
|
||||||
|
- [ ] 与 T-004 消息/通知工具的关系:RSS 是否作为其数据来源,还是独立工具?
|
||||||
|
- [ ] 内容展示形态:列表?摘要+跳转原文?全文阅读?
|
||||||
|
- [ ] 优先级与排期(机制定稿后确定)
|
||||||
|
|
||||||
|
> 成熟后按「三要素」(边界清楚 / 核心逻辑明确 / 老师确认)讨论定稿,再转正到根目录形成正式需求。
|
||||||
@@ -15,7 +15,10 @@
|
|||||||
|---|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|---|
|
||||||
| R-002 | 分仓管理工具(持仓策略标签管理) | 验证 QMT Bridge MCP 连通(资金/持仓)。**核心逻辑(已定稿)**:① 插件设置中管理持仓策略(策略=标签,增删改 + 显示/隐藏开关);② 按设置在主窗口上方 tab 栏(对话/轨迹/上下文后)显示策略标签,每个标签下加载对应持仓数据;③ 插件处理与存储:QMT 读全量真实持仓(**插件直连 QMT Bridge RESTful 接口**,非 MCP),策略分仓数据本地保存管理。**实现方式**:独立插件,作为外部插件挂载到 DSH。第一个落地工具,源自 T-001。**2026-08-27 定稿,2026-08-28 完成,已归档至 已完成/R-002.md** | 老师指令(2026-08-27) | P0 | 已定稿 | 2026-08-28 | 01-分仓管理工具 | **已实现(已归档)** |
|
| R-002 | 分仓管理工具(持仓策略标签管理) | 验证 QMT Bridge MCP 连通(资金/持仓)。**核心逻辑(已定稿)**:① 插件设置中管理持仓策略(策略=标签,增删改 + 显示/隐藏开关);② 按设置在主窗口上方 tab 栏(对话/轨迹/上下文后)显示策略标签,每个标签下加载对应持仓数据;③ 插件处理与存储:QMT 读全量真实持仓(**插件直连 QMT Bridge RESTful 接口**,非 MCP),策略分仓数据本地保存管理。**实现方式**:独立插件,作为外部插件挂载到 DSH。第一个落地工具,源自 T-001。**2026-08-27 定稿,2026-08-28 完成,已归档至 已完成/R-002.md** | 老师指令(2026-08-27) | P0 | 已定稿 | 2026-08-28 | 01-分仓管理工具 | **已实现(已归档)** |
|
||||||
| R-003 | 分仓管理工具完善与优化 | 对 R-002 的完善与优化:O2 策略 tab 动态化、O3 策略 CRUD(删除弹窗确认)、O5 加载失败兜底、O6 份额快捷操作 + 设置页 tab 化(通用设置/策略分组)。**2026-08-28 定稿,2026-08-28 完成,已归档至 已完成/R-003.md** | 迭代 01 复盘 + 老师补充 | P0 | 已定稿 | 2026-08-28 | 02-分仓管理工具完善与优化 | **已实现(已归档)** |
|
| R-003 | 分仓管理工具完善与优化 | 对 R-002 的完善与优化:O2 策略 tab 动态化、O3 策略 CRUD(删除弹窗确认)、O5 加载失败兜底、O6 份额快捷操作 + 设置页 tab 化(通用设置/策略分组)。**2026-08-28 定稿,2026-08-28 完成,已归档至 已完成/R-003.md** | 迭代 01 复盘 + 老师补充 | P0 | 已定稿 | 2026-08-28 | 02-分仓管理工具完善与优化 | **已实现(已归档)** |
|
||||||
| R-004 | QMT 连接配置(多配置管理) | 在神之一手设置页新增「QMT 连接配置」子 tab:管理多个 QMT Bridge 连接配置(CRUD),一次只能激活一个,可设置默认。**现状分析**:插件当前唯一必配项为 HTTP 基础地址(baseUrl,当前 192.168.3.43:8610),可选请求超时(timeoutMs,默认 15000ms),无认证参数。**初步草稿见 R-004.md,待讨论定稿** | 老师指令(2026-08-28) | 待定 | 讨论中 | 2026-08-28 | - | - |
|
| R-006 | 策略数据 JSON data store schema 标准化 | 策略数据集标准化为 JSON data store(store.schema.json / store.json 策略为中心 / store.market.json 行情持久化);旧 allocations.json 自动迁移。**2026-08-31 定稿,2026-09-01 完成(迭代 04 验收通过)** | 老师指令(2026-08-31) | P2 | 已定稿 | 2026-09-01 | 04-WS盘中价格实时更新 | **已实现(迭代 04 完结)** |
|
||||||
|
| R-005 | WS 盘中价格实时更新 | 3 个监控表格(全部持仓/手动做T/网格超市)盘中现价实时显示:服务端中转 + 行情缓存(store.market.json 持久化)+ 前端轮询读缓存;现价列 + 红涨绿跌 + 变化高亮。**2026-08-31 定稿,2026-09-01 完成(迭代 04 验收通过),已归档至 已完成/R-005.md** | 老师指令(2026-08-31,T-005 转正) | P1 | 已定稿 | 2026-09-01 | 04-WS盘中价格实时更新 | **已实现(已归档)** |
|
||||||
|
| 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):**单表合并**(委托主行 + 展开成交明细)+ **日期导航**(交易日历驱动,为历史成交接口预留);排序默认降序可手动。**2026-09-01 定稿(范围+展示设计 Q1-Q11 有结论)**,需求文件:`R-007.md` | 老师指令(2026-09-01) | P1 | 已定稿 | 2026-09-01 | 05-交易记录接入 | 实现中 |
|
||||||
|
|
||||||
## 渐进明细规划素材
|
## 渐进明细规划素材
|
||||||
|
|
||||||
@@ -27,5 +30,6 @@
|
|||||||
| T-002 | 交易与复盘记录工具 | 将交易、复盘沉淀为 DSH 可管理的结构化文档,支撑市场复盘/交易复盘目标 | 起草 |
|
| T-002 | 交易与复盘记录工具 | 将交易、复盘沉淀为 DSH 可管理的结构化文档,支撑市场复盘/交易复盘目标 | 起草 |
|
||||||
| T-003 | 策略配置文件骨架 | 定义策略的数据结构(YAML/JSON),支撑策略定义目标 | 起草 |
|
| T-003 | 策略配置文件骨架 | 定义策略的数据结构(YAML/JSON),支撑策略定义目标 | 起草 |
|
||||||
| T-004 | 消息/通知工具 | DSH 内消息路由与通知能力,支撑按策略模式管理消息目标 | 起草 |
|
| T-004 | 消息/通知工具 | DSH 内消息路由与通知能力,支撑按策略模式管理消息目标 | 起草 |
|
||||||
| T-005 | 通过 ws 长连接做市场数据的实时反映 | WebSocket 长连接实现市场数据(持仓/行情)实时推送与展示;源自迭代 01 复盘。**2026-08-28 老师确认放草稿,不入正式需求池**,草稿文件:`草稿/通过ws长连接做市场数据的实时反映.md` | 起草 |
|
| T-005 | 通过 ws 长连接做市场数据的实时反映 | WebSocket 长连接实现市场数据(持仓/行情)实时推送与展示;源自迭代 01 复盘。**2026-08-31 转正为 R-005(迭代 04 实施中)**,草稿文件:`草稿/通过ws长连接做市场数据的实时反映.md` | 已转需求 R-005 |
|
||||||
| T-006 | 数据池中间层 + 策略表格动态字段配置 | 服务端数据池(数据集中间层:按 code 聚合宽表、多源字段映射、填充器架构)+ 策略持仓表格动态字段配置(列配置×池行渲染)。源自 R003 讨论(2026-08-28)拆分独立,草稿文件:`草稿/数据池中间层与策略表格动态字段配置.md` | 起草 |
|
| T-006 | 数据池中间层 + 策略表格动态字段配置 | 服务端数据池(数据集中间层:按 code 聚合宽表、多源字段映射、填充器架构)+ 策略持仓表格动态字段配置(列配置×池行渲染)。源自 R003 讨论(2026-08-28)拆分独立,草稿文件:`草稿/数据池中间层与策略表格动态字段配置.md` | 起草 |
|
||||||
|
| T-007 | RSS 订阅管理 | 管理 RSS 订阅源(增删改查)+ 抓取聚合订阅内容,作为资讯/消息来源;支撑 目标-003 市场监控、目标-004 消息管理,与 T-004 同属消息链路。**2026-08-28 老师确认先放草稿**,草稿文件:`草稿/RSS订阅管理.md` | 起草 |
|
||||||
-157
@@ -1,157 +0,0 @@
|
|||||||
/**
|
|
||||||
* 服务端 HTTP API:通过 webServer 注册 /odl/api/* 路由
|
|
||||||
*
|
|
||||||
* 为什么不用 RPC(connection.rpc.intercept('/api')):
|
|
||||||
* DSH 的 /api 通道只能有一个 interceptor(api-gateway 已占用),
|
|
||||||
* 再 intercept 会抛 "already has an interceptor" 导致插件 apply 失败。
|
|
||||||
* 因此改用 webServer 自开路由(与 dsh-better-sidebar 的 /sidebar/api 同模式)。
|
|
||||||
*
|
|
||||||
* 端点(POST /odl/api/<method>,body = { args: {...} }):
|
|
||||||
* positions → 全量持仓
|
|
||||||
* strategies → 策略列表(含 visible)
|
|
||||||
* strategies/update → 更新策略列表(整表)
|
|
||||||
* strategies/add → 新增策略 { name }(O3)
|
|
||||||
* strategies/remove → 删除策略 { strategyId },联动清份额(O3)
|
|
||||||
* strategies/move → 排序 { strategyId, dir: up|down }(O3)
|
|
||||||
* strategy-positions → 某策略持仓 { strategyId }
|
|
||||||
* unallocated → 未分配持仓
|
|
||||||
* summary → 分仓摘要
|
|
||||||
* add-shares → 添加份额 { code, strategyId, shares }
|
|
||||||
* move-all-shares → 全部移入 { code, strategyId }(O6)
|
|
||||||
* remove-shares → 移出份额 { code, strategyId, shares }
|
|
||||||
* remove-all-shares → 一键清零 { strategyId }(O6)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { getStrategies, updateStrategies, addStrategy, removeStrategy, moveStrategy, getTabConfig, updateTabConfig } from './settings.js';
|
|
||||||
|
|
||||||
const API_PREFIX = '/odl/api/';
|
|
||||||
|
|
||||||
/** 响应 JSON */
|
|
||||||
function writeJson(res, status, data) {
|
|
||||||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify(data));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 读取 JSON body */
|
|
||||||
async function readJsonBody(req) {
|
|
||||||
const chunks = [];
|
|
||||||
for await (const chunk of req) chunks.push(chunk);
|
|
||||||
const raw = Buffer.concat(chunks).toString('utf8');
|
|
||||||
if (!raw) return {};
|
|
||||||
try {
|
|
||||||
return JSON.parse(raw);
|
|
||||||
} catch {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 端点方法表 */
|
|
||||||
const METHODS = new Set([
|
|
||||||
'positions',
|
|
||||||
'strategies',
|
|
||||||
'strategies/update',
|
|
||||||
'strategies/add',
|
|
||||||
'strategies/remove',
|
|
||||||
'strategies/move',
|
|
||||||
'strategy-positions',
|
|
||||||
'unallocated',
|
|
||||||
'summary',
|
|
||||||
'add-shares',
|
|
||||||
'move-all-shares',
|
|
||||||
'remove-shares',
|
|
||||||
'remove-all-shares',
|
|
||||||
'tabs',
|
|
||||||
'tabs/update',
|
|
||||||
]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 注册 HTTP API
|
|
||||||
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
||||||
* @param {object} runtime { manager, settings }
|
|
||||||
*/
|
|
||||||
export function registerApi(ctx, { manager, settings }) {
|
|
||||||
ctx.effect(() => ctx.webServer.register({
|
|
||||||
kind: 'prefix',
|
|
||||||
path: '/odl/api',
|
|
||||||
handler: async (req, res) => {
|
|
||||||
if (req.method !== 'POST') {
|
|
||||||
writeJson(res, 405, { ok: false, error: { code: 'method-error', message: 'method not allowed' } });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname;
|
|
||||||
const method = pathname.startsWith(API_PREFIX) ? pathname.slice(API_PREFIX.length) : void 0;
|
|
||||||
if (!method || !METHODS.has(method)) {
|
|
||||||
writeJson(res, 404, { ok: false, error: { code: 'not-found', message: 'unknown method: ' + method } });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const body = await readJsonBody(req);
|
|
||||||
const args = body?.args ?? {};
|
|
||||||
let value;
|
|
||||||
switch (method) {
|
|
||||||
case 'positions':
|
|
||||||
value = await manager.getAllPositions();
|
|
||||||
break;
|
|
||||||
case 'strategies':
|
|
||||||
value = getStrategies(settings);
|
|
||||||
break;
|
|
||||||
case 'strategies/update':
|
|
||||||
await updateStrategies(settings, args.strategies);
|
|
||||||
value = getStrategies(settings);
|
|
||||||
break;
|
|
||||||
case 'strategies/add':
|
|
||||||
value = await addStrategy(settings, args.name);
|
|
||||||
break;
|
|
||||||
case 'strategies/remove':
|
|
||||||
// 删除策略 + 联动清空该策略下所有份额(份额回未分配)
|
|
||||||
await removeStrategy(settings, args.strategyId);
|
|
||||||
await manager.clearStrategyShares(args.strategyId);
|
|
||||||
value = getStrategies(settings);
|
|
||||||
break;
|
|
||||||
case 'strategies/move':
|
|
||||||
value = await moveStrategy(settings, args.strategyId, args.dir);
|
|
||||||
break;
|
|
||||||
case 'tabs':
|
|
||||||
value = getTabConfig(settings);
|
|
||||||
break;
|
|
||||||
case 'tabs/update':
|
|
||||||
await updateTabConfig(settings, args.tabs);
|
|
||||||
value = getTabConfig(settings);
|
|
||||||
break;
|
|
||||||
case 'strategy-positions':
|
|
||||||
value = await manager.getStrategyPositions(args.strategyId);
|
|
||||||
break;
|
|
||||||
case 'unallocated':
|
|
||||||
value = await manager.getUnallocatedPositions();
|
|
||||||
break;
|
|
||||||
case 'summary':
|
|
||||||
value = await manager.getSummary();
|
|
||||||
break;
|
|
||||||
case 'add-shares':
|
|
||||||
value = await manager.addToStrategy(args.code, args.strategyId, args.shares);
|
|
||||||
break;
|
|
||||||
case 'move-all-shares':
|
|
||||||
value = await manager.moveAllUnallocatedToStrategy(args.code, args.strategyId);
|
|
||||||
break;
|
|
||||||
case 'remove-shares':
|
|
||||||
value = await manager.removeFromStrategy(args.code, args.strategyId, args.shares);
|
|
||||||
break;
|
|
||||||
case 'remove-all-shares':
|
|
||||||
value = await manager.clearStrategyShares(args.strategyId);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
writeJson(res, 404, { ok: false, error: { code: 'not-found', message: 'unknown method' } });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
writeJson(res, 200, { ok: true, value });
|
|
||||||
} catch (error) {
|
|
||||||
writeJson(res, 500, {
|
|
||||||
ok: false,
|
|
||||||
error: { code: error?.code ?? 'internal', message: error?.message ?? String(error), details: {} },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}), 'one-divine-lot.api');
|
|
||||||
|
|
||||||
ctx.logger?.info('[one-divine-lot] HTTP API 已注册 (/odl/api/*)');
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* 服务端 HTTP API 公共工具
|
||||||
|
*
|
||||||
|
* 为什么不用 RPC(connection.rpc.intercept('/api')):
|
||||||
|
* DSH 的 /api 通道只能有一个 interceptor(api-gateway 已占用),
|
||||||
|
* 再 intercept 会抛 "already has an interceptor" 导致插件 apply 失败。
|
||||||
|
* 因此改用 webServer 自开路由(与 dsh-better-sidebar 的 /sidebar/api 同模式)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const API_PREFIX = '/odl/api/';
|
||||||
|
|
||||||
|
/** 响应 JSON */
|
||||||
|
export function writeJson(res, status, data) {
|
||||||
|
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取 JSON body */
|
||||||
|
export async function readJsonBody(req) {
|
||||||
|
const chunks = [];
|
||||||
|
for await (const chunk of req) chunks.push(chunk);
|
||||||
|
const raw = Buffer.concat(chunks).toString('utf8');
|
||||||
|
if (!raw) return {};
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从激活配置解析 baseUrl(连接列表为空时回退数据源当前 baseUrl) */
|
||||||
|
export function resolveActiveBaseUrl(settings, dataSource) {
|
||||||
|
const state = getQmtConnections(settings);
|
||||||
|
const active = getActiveQmtConnection(state);
|
||||||
|
return String(active?.baseUrl ?? dataSource?.baseUrl ?? '').replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 延迟引入 settings 函数避免循环依赖
|
||||||
|
import {
|
||||||
|
getQmtConnections,
|
||||||
|
getActiveQmtConnection,
|
||||||
|
} from '../settings.js';
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* 服务端 HTTP API 入口(2026-08-31 从 api.js 按领域拆分)
|
||||||
|
*
|
||||||
|
* 结构:
|
||||||
|
* src/api/
|
||||||
|
* ├── index.js # 路由入口:合并各领域 + 公共处理(405/404/500)
|
||||||
|
* ├── common.js # 公共工具(writeJson/readJsonBody/resolveActiveBaseUrl)
|
||||||
|
* ├── positions.js # 持仓域(positions)
|
||||||
|
* ├── strategies.js # 策略/份额/tabs 域
|
||||||
|
* ├── qmt-connections.js # QMT 连接配置域(含热切换编排)
|
||||||
|
* └── market.js # 行情域(market-snapshot)
|
||||||
|
*
|
||||||
|
* 端点(POST /odl/api/<method>,body = { args: {...} }):
|
||||||
|
* positions → 全量持仓
|
||||||
|
* strategies → 策略列表(含 visible)
|
||||||
|
* strategies/update → 更新策略列表(整表)
|
||||||
|
* strategies/add → 新增策略 { name }
|
||||||
|
* strategies/remove → 删除策略 { strategyId },联动清份额
|
||||||
|
* strategies/move → 排序 { strategyId, dir }
|
||||||
|
* strategy-positions → 某策略持仓 { strategyId }
|
||||||
|
* unallocated → 未分配持仓
|
||||||
|
* summary → 分仓摘要
|
||||||
|
* add-shares → 添加份额 { code, strategyId, shares }
|
||||||
|
* move-all-shares → 全部移入 { code, strategyId }
|
||||||
|
* remove-shares → 移出份额 { code, strategyId, shares }
|
||||||
|
* remove-all-shares → 一键清零 { strategyId }
|
||||||
|
* tabs / tabs/update → 通用 tab 显隐配置
|
||||||
|
* qmt-connections → QMT 连接配置(R-004)
|
||||||
|
* qmt-connections/* → 增删改/激活/默认/测试/active-host/订阅代理
|
||||||
|
* market-snapshot → 按 code 查询行情缓存(R-005)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { API_PREFIX, writeJson, readJsonBody } from './common.js';
|
||||||
|
import { POSITION_METHODS, handlePosition } from './positions.js';
|
||||||
|
import { STRATEGY_METHODS, handleStrategy } from './strategies.js';
|
||||||
|
import { QMT_METHODS, handleQmt } from './qmt-connections.js';
|
||||||
|
import { MARKET_METHODS, handleMarket } from './market.js';
|
||||||
|
import { TRADE_METHODS, handleTrade } from './trades.js';
|
||||||
|
|
||||||
|
/** 全部端点方法表(合并各领域) */
|
||||||
|
const METHODS = new Set([
|
||||||
|
...POSITION_METHODS,
|
||||||
|
...STRATEGY_METHODS,
|
||||||
|
...QMT_METHODS,
|
||||||
|
...MARKET_METHODS,
|
||||||
|
...TRADE_METHODS,
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** 领域分发:method → handler */
|
||||||
|
const HANDLERS = [
|
||||||
|
{ set: POSITION_METHODS, handle: handlePosition },
|
||||||
|
{ set: STRATEGY_METHODS, handle: handleStrategy },
|
||||||
|
{ set: QMT_METHODS, handle: handleQmt },
|
||||||
|
{ set: MARKET_METHODS, handle: handleMarket },
|
||||||
|
{ set: TRADE_METHODS, handle: handleTrade },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册 HTTP API
|
||||||
|
* @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 };
|
||||||
|
|
||||||
|
ctx.effect(() => ctx.webServer.register({
|
||||||
|
kind: 'prefix',
|
||||||
|
path: '/odl/api',
|
||||||
|
handler: async (req, res) => {
|
||||||
|
if (req.method !== 'POST') {
|
||||||
|
writeJson(res, 405, { ok: false, error: { code: 'method-error', message: 'method not allowed' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname;
|
||||||
|
const method = pathname.startsWith(API_PREFIX) ? pathname.slice(API_PREFIX.length) : void 0;
|
||||||
|
if (!method || !METHODS.has(method)) {
|
||||||
|
writeJson(res, 404, { ok: false, error: { code: 'not-found', message: 'unknown method: ' + method } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const body = await readJsonBody(req);
|
||||||
|
const args = body?.args ?? {};
|
||||||
|
// 按领域分发
|
||||||
|
let value;
|
||||||
|
for (const { set, handle } of HANDLERS) {
|
||||||
|
if (set.has(method)) {
|
||||||
|
value = await handle(method, args, runtime);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJson(res, 200, { ok: true, value });
|
||||||
|
} catch (error) {
|
||||||
|
writeJson(res, 500, {
|
||||||
|
ok: false,
|
||||||
|
error: { code: error?.code ?? 'internal', message: error?.message ?? String(error), details: {} },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}), 'one-divine-lot.api');
|
||||||
|
|
||||||
|
ctx.logger?.info('[one-divine-lot] HTTP API 已注册 (/odl/api/*)');
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* 服务端 API:行情域(2026-08-31 拆分;2026-09-01 精简)
|
||||||
|
*
|
||||||
|
* 端点:
|
||||||
|
* market-snapshot → 按 code 查询行情缓存(miss 自动补拉在 MarketDataHub 内部,此处只做编排)
|
||||||
|
* qmt-health → QMT 连接健康检查(读服务端缓存;refresh=true 触发即时探测)
|
||||||
|
*
|
||||||
|
* 2026-09-01:QMT health 改为「服务端定时探测(5 分钟)+ 缓存」机制(QmtHealthMonitor),
|
||||||
|
* 前端打开读缓存秒回,点击/悬停传 refresh=true 触发即时探测。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { resolveActiveBaseUrl } from './common.js';
|
||||||
|
|
||||||
|
/** 行情端点方法表 */
|
||||||
|
export const MARKET_METHODS = new Set([
|
||||||
|
'market-snapshot',
|
||||||
|
'qmt-health',
|
||||||
|
'market-stats', // 临时诊断:WS/REST 通道统计
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理行情端点
|
||||||
|
* @param {string} method
|
||||||
|
* @param {object} args
|
||||||
|
* @param {object} runtime { settings, dataSource, marketHub, marketFeed, qmtHealthMonitor }
|
||||||
|
*/
|
||||||
|
export async function handleMarket(method, args, { ctx, settings, dataSource, marketHub, marketFeed, qmtHealthMonitor }) {
|
||||||
|
switch (method) {
|
||||||
|
case 'market-snapshot': {
|
||||||
|
const codes = Array.isArray(args.codes) ? args.codes : [];
|
||||||
|
if (!marketHub) return {};
|
||||||
|
return await marketHub.getByCodes(codes, { settings, dataSource });
|
||||||
|
}
|
||||||
|
case 'qmt-health': {
|
||||||
|
// QMT 连接健康检查(服务端缓存 + 即时探测,2026-09-01)
|
||||||
|
if (!qmtHealthMonitor) {
|
||||||
|
return { healthy: null, baseUrl: resolveActiveBaseUrl(settings, dataSource), fromCache: false };
|
||||||
|
}
|
||||||
|
if (args.refresh) {
|
||||||
|
// 前端点击/悬停触发即时探测(刷新缓存)
|
||||||
|
return await qmtHealthMonitor.probe();
|
||||||
|
}
|
||||||
|
// 默认读缓存(秒回)
|
||||||
|
return qmtHealthMonitor.getStatus();
|
||||||
|
}
|
||||||
|
case 'market-stats': {
|
||||||
|
// 临时诊断:WS 推送 vs REST 拉取 计数(确认 WS 是否生效)
|
||||||
|
return {
|
||||||
|
stats: marketHub?.stats ?? null,
|
||||||
|
cacheSize: marketHub?.size ?? 0,
|
||||||
|
watchSize: marketHub?.watchCodes?.size ?? 0,
|
||||||
|
serverTime: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw Object.assign(new Error('unknown market method: ' + method), { code: 'not-found' });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* 服务端 API:持仓域(从 api.js 拆分,2026-08-31)
|
||||||
|
*
|
||||||
|
* 端点:
|
||||||
|
* positions → 全量持仓
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 持仓端点方法表 */
|
||||||
|
export const POSITION_METHODS = new Set([
|
||||||
|
'positions',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理持仓端点
|
||||||
|
* @param {string} method
|
||||||
|
* @param {object} args
|
||||||
|
* @param {object} runtime { manager }
|
||||||
|
*/
|
||||||
|
export async function handlePosition(method, args, { manager }) {
|
||||||
|
switch (method) {
|
||||||
|
case 'positions':
|
||||||
|
return await manager.getAllPositions();
|
||||||
|
default:
|
||||||
|
throw Object.assign(new Error('unknown position method: ' + method), { code: 'not-found' });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
/**
|
||||||
|
* 服务端 API:QMT 连接配置域(从 api.js 拆分,2026-08-31)
|
||||||
|
*
|
||||||
|
* 端点:
|
||||||
|
* qmt-connections / add / update / remove / activate / set-default / test
|
||||||
|
* active-host
|
||||||
|
*
|
||||||
|
* 热切换编排:激活/更新/删除连接导致生效地址变化时,同步 dataSource.setBaseUrl + marketFeed.setBaseUrl
|
||||||
|
* (技术约束-008:按请求读取 baseUrl,改字段即生效)
|
||||||
|
*
|
||||||
|
* 2026-08-31:移除 subscribe-ws / unsubscribe-ws / tick-snapshot(架构演进后无人调用,
|
||||||
|
* 订阅与快照统一由 MarketFeed/MarketDataHub 内部处理,不再暴露为 HTTP 端点)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
getQmtConnections, getActiveQmtConnection,
|
||||||
|
addQmtConnection, updateQmtConnection, removeQmtConnection,
|
||||||
|
activateQmtConnection, setDefaultQmtConnection,
|
||||||
|
} from '../settings.js';
|
||||||
|
|
||||||
|
/** QMT 连接端点方法表 */
|
||||||
|
export const QMT_METHODS = new Set([
|
||||||
|
'qmt-connections',
|
||||||
|
'qmt-connections/add',
|
||||||
|
'qmt-connections/update',
|
||||||
|
'qmt-connections/remove',
|
||||||
|
'qmt-connections/activate',
|
||||||
|
'qmt-connections/set-default',
|
||||||
|
'qmt-connections/test',
|
||||||
|
'qmt-connections/active-host',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** 服务端代理测试连接:GET {baseUrl}/health(浏览器跨域规避,Q3 手动测试) */
|
||||||
|
async function testQmtConnection(ctx, args) {
|
||||||
|
const baseUrl = String(args?.baseUrl ?? '').trim().replace(/\/+$/, '');
|
||||||
|
if (!/^https?:\/\//i.test(baseUrl)) {
|
||||||
|
throw Object.assign(new Error('HTTP 地址格式无效(需以 http:// 或 https:// 开头)'), { code: 'qmt-invalid-url' });
|
||||||
|
}
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), 15000);
|
||||||
|
const startedAt = Date.now();
|
||||||
|
try {
|
||||||
|
const res = await fetch(baseUrl + '/health', { signal: controller.signal });
|
||||||
|
const latencyMs = Date.now() - startedAt;
|
||||||
|
let status = null;
|
||||||
|
try { status = (await res.json())?.status ?? null; } catch { /* 非 JSON 响应按 status=null */ }
|
||||||
|
return { ok: res.ok, latencyMs, httpStatus: res.status, status, reachable: res.ok };
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
latencyMs: Date.now() - startedAt,
|
||||||
|
reachable: false,
|
||||||
|
error: err.name === 'AbortError' ? '请求超时(15s)' : (err?.message ?? String(err)),
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 QMT 连接端点
|
||||||
|
* @param {string} method
|
||||||
|
* @param {object} args
|
||||||
|
* @param {object} runtime { manager, settings, dataSource, marketCache }
|
||||||
|
*/
|
||||||
|
export async function handleQmt(method, args, { ctx, settings, dataSource, marketFeed, qmtHealthMonitor }) {
|
||||||
|
switch (method) {
|
||||||
|
case 'qmt-connections': {
|
||||||
|
const state = getQmtConnections(settings);
|
||||||
|
return { ...state, active: getActiveQmtConnection(state) };
|
||||||
|
}
|
||||||
|
case 'qmt-connections/add':
|
||||||
|
return await addQmtConnection(settings, { name: args.name, baseUrl: args.baseUrl });
|
||||||
|
case 'qmt-connections/update': {
|
||||||
|
const before = getQmtConnections(settings);
|
||||||
|
const value = await updateQmtConnection(settings, args.id, { name: args.name, baseUrl: args.baseUrl });
|
||||||
|
// 编辑的是激活配置且地址变化 → 立即热切换(技术约束-008)
|
||||||
|
if (before.activeId === args.id && value.baseUrl !== dataSource.baseUrl) {
|
||||||
|
dataSource.setBaseUrl(value.baseUrl);
|
||||||
|
marketFeed?.setBaseUrl(value.baseUrl);
|
||||||
|
qmtHealthMonitor?.probe(); // 切换后立即补探健康
|
||||||
|
ctx?.logger?.info('[one-divine-lot] 激活配置地址已变更,数据源热切换 → ' + value.baseUrl);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
case 'qmt-connections/remove': {
|
||||||
|
const result = await removeQmtConnection(settings, args.id);
|
||||||
|
// 生效连接因删除发生变化 → 立即热切换(产品约束-005 删除边界)
|
||||||
|
const afterState = getQmtConnections(settings);
|
||||||
|
const active = getActiveQmtConnection(afterState);
|
||||||
|
if (active && active.baseUrl !== dataSource.baseUrl) {
|
||||||
|
dataSource.setBaseUrl(active.baseUrl);
|
||||||
|
marketFeed?.setBaseUrl(active.baseUrl);
|
||||||
|
qmtHealthMonitor?.probe(); // 切换后立即补探健康
|
||||||
|
ctx?.logger?.info('[one-divine-lot] 激活配置已删除,自动切换 → ' + active.name + ' (' + active.baseUrl + ')');
|
||||||
|
}
|
||||||
|
return { ...afterState, active, removedWasActive: result.removedWasActive, removedWasDefault: result.removedWasDefault };
|
||||||
|
}
|
||||||
|
case 'qmt-connections/activate': {
|
||||||
|
const state = await activateQmtConnection(settings, args.id);
|
||||||
|
const active = getActiveQmtConnection(state);
|
||||||
|
if (active && active.baseUrl !== dataSource.baseUrl) {
|
||||||
|
dataSource.setBaseUrl(active.baseUrl);
|
||||||
|
marketFeed?.setBaseUrl(active.baseUrl);
|
||||||
|
qmtHealthMonitor?.probe(); // 切换后立即补探健康
|
||||||
|
ctx?.logger?.info('[one-divine-lot] QMT 连接激活: ' + active.name + ' (' + active.baseUrl + ')');
|
||||||
|
}
|
||||||
|
return { ...state, active };
|
||||||
|
}
|
||||||
|
case 'qmt-connections/set-default':
|
||||||
|
return await setDefaultQmtConnection(settings, args.id);
|
||||||
|
case 'qmt-connections/test': {
|
||||||
|
// 支持传未保存的 baseUrl(表单先测后存),或传 id 用已存配置地址
|
||||||
|
let url = args.baseUrl;
|
||||||
|
if (!url && args.id) {
|
||||||
|
const state = getQmtConnections(settings);
|
||||||
|
url = state.list.find((c) => c.id === args.id)?.baseUrl;
|
||||||
|
}
|
||||||
|
return await testQmtConnection(ctx, { baseUrl: url });
|
||||||
|
}
|
||||||
|
case 'qmt-connections/active-host': {
|
||||||
|
// R-005:返回激活配置的 host 与 ws 地址
|
||||||
|
const state = getQmtConnections(settings);
|
||||||
|
const active = getActiveQmtConnection(state) ?? { baseUrl: dataSource.baseUrl, name: '(fallback)' };
|
||||||
|
const baseUrl = String(active.baseUrl ?? '').replace(/\/+$/, '');
|
||||||
|
let host = '';
|
||||||
|
let wsUrl = '';
|
||||||
|
try {
|
||||||
|
const u = new URL(baseUrl);
|
||||||
|
host = u.host; // 含端口,如 100.110.38.78:8610
|
||||||
|
wsUrl = 'ws://' + u.host + '/ws';
|
||||||
|
} catch { /* baseUrl 非法时返回空 */ }
|
||||||
|
return { name: active.name, baseUrl, host, wsUrl, activeId: state.activeId ?? '' };
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw Object.assign(new Error('unknown qmt method: ' + method), { code: 'not-found' });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* 服务端 API:策略 / 份额 / tabs 域(从 api.js 拆分,2026-08-31)
|
||||||
|
*
|
||||||
|
* 端点:
|
||||||
|
* strategies / strategies/update / strategies/add / strategies/remove / strategies/move
|
||||||
|
* strategy-positions / unallocated / summary
|
||||||
|
* add-shares / move-all-shares / remove-shares / remove-all-shares
|
||||||
|
* tabs / tabs/update
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
getStrategies, updateStrategies, addStrategy, removeStrategy, moveStrategy,
|
||||||
|
getTabConfig, updateTabConfig,
|
||||||
|
} from '../settings.js';
|
||||||
|
|
||||||
|
/** 策略/份额/tabs 端点方法表 */
|
||||||
|
export const STRATEGY_METHODS = new Set([
|
||||||
|
'strategies',
|
||||||
|
'strategies/update',
|
||||||
|
'strategies/add',
|
||||||
|
'strategies/remove',
|
||||||
|
'strategies/move',
|
||||||
|
'strategy-positions',
|
||||||
|
'unallocated',
|
||||||
|
'summary',
|
||||||
|
'add-shares',
|
||||||
|
'move-all-shares',
|
||||||
|
'remove-shares',
|
||||||
|
'remove-all-shares',
|
||||||
|
'tabs',
|
||||||
|
'tabs/update',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理策略/份额/tabs 端点
|
||||||
|
* @param {string} method
|
||||||
|
* @param {object} args
|
||||||
|
* @param {object} runtime { manager, settings }
|
||||||
|
*/
|
||||||
|
export async function handleStrategy(method, args, { manager, settings }) {
|
||||||
|
switch (method) {
|
||||||
|
case 'strategies':
|
||||||
|
return getStrategies(settings);
|
||||||
|
case 'strategies/update':
|
||||||
|
await updateStrategies(settings, args.strategies);
|
||||||
|
return getStrategies(settings);
|
||||||
|
case 'strategies/add':
|
||||||
|
return await addStrategy(settings, args.name);
|
||||||
|
case 'strategies/remove':
|
||||||
|
// 删除策略 + 联动清空该策略下所有份额(份额回未分配)
|
||||||
|
await removeStrategy(settings, args.strategyId);
|
||||||
|
await manager.clearStrategyShares(args.strategyId);
|
||||||
|
return getStrategies(settings);
|
||||||
|
case 'strategies/move':
|
||||||
|
return await moveStrategy(settings, args.strategyId, args.dir);
|
||||||
|
case 'tabs':
|
||||||
|
return getTabConfig(settings);
|
||||||
|
case 'tabs/update':
|
||||||
|
await updateTabConfig(settings, args.tabs);
|
||||||
|
return getTabConfig(settings);
|
||||||
|
case 'strategy-positions':
|
||||||
|
return await manager.getStrategyPositions(args.strategyId);
|
||||||
|
case 'unallocated':
|
||||||
|
return await manager.getUnallocatedPositions();
|
||||||
|
case 'summary':
|
||||||
|
return await manager.getSummary();
|
||||||
|
case 'add-shares':
|
||||||
|
return await manager.addToStrategy(args.code, args.strategyId, args.shares);
|
||||||
|
case 'move-all-shares':
|
||||||
|
return await manager.moveAllUnallocatedToStrategy(args.code, args.strategyId);
|
||||||
|
case 'remove-shares':
|
||||||
|
return await manager.removeFromStrategy(args.code, args.strategyId, args.shares);
|
||||||
|
case 'remove-all-shares':
|
||||||
|
return await manager.clearStrategyShares(args.strategyId);
|
||||||
|
default:
|
||||||
|
throw Object.assign(new Error('unknown strategy method: ' + method), { code: 'not-found' });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* 服务端 API:交易记录域(R-007,2026-09-01)
|
||||||
|
*
|
||||||
|
* 端点:
|
||||||
|
* orders → 当日委托(支持 code/status 过滤)
|
||||||
|
* trades → 当日成交
|
||||||
|
* trading-dates → 交易日历(日期导航)
|
||||||
|
*
|
||||||
|
* 数据流:前端轮询 → /odl/api/orders|trades → dataSource.getOrders/getTrades
|
||||||
|
* (服务端直连 QMT Bridge REST,技术约束-003)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 交易记录端点方法表 */
|
||||||
|
export const TRADE_METHODS = new Set([
|
||||||
|
'orders',
|
||||||
|
'trades',
|
||||||
|
'trading-dates',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理交易记录端点
|
||||||
|
* @param {string} method
|
||||||
|
* @param {object} args
|
||||||
|
* @param {object} runtime { dataSource }
|
||||||
|
*/
|
||||||
|
export async function handleTrade(method, args, { dataSource }) {
|
||||||
|
switch (method) {
|
||||||
|
case 'orders':
|
||||||
|
return await dataSource.getOrders({
|
||||||
|
code: args.code,
|
||||||
|
status: args.status,
|
||||||
|
start: args.start,
|
||||||
|
end: args.end,
|
||||||
|
});
|
||||||
|
case 'trades':
|
||||||
|
return await dataSource.getTrades({
|
||||||
|
start: args.start,
|
||||||
|
end: args.end,
|
||||||
|
});
|
||||||
|
case 'trading-dates':
|
||||||
|
return await dataSource.getTradingDates({
|
||||||
|
start: args.start,
|
||||||
|
end: args.end,
|
||||||
|
});
|
||||||
|
default:
|
||||||
|
throw Object.assign(new Error('unknown trade method: ' + method), { code: 'not-found' });
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
-3
@@ -15,8 +15,11 @@ import { createElement } from 'react';
|
|||||||
import { AllPositionsTab } from './views/AllPositionsTab.jsx';
|
import { AllPositionsTab } from './views/AllPositionsTab.jsx';
|
||||||
import { StrategyTab } from './views/StrategyTab.jsx';
|
import { StrategyTab } from './views/StrategyTab.jsx';
|
||||||
import { PlaceholderTab } from './views/PlaceholderTab.jsx';
|
import { PlaceholderTab } from './views/PlaceholderTab.jsx';
|
||||||
|
import { TradeRecordsTab } from './views/TradeRecordsTab.jsx';
|
||||||
import { SettingsSection } from './views/SettingsSection.jsx';
|
import { SettingsSection } from './views/SettingsSection.jsx';
|
||||||
|
import { QmtConnectionChip } from './views/QmtConnectionChip.jsx';
|
||||||
import { ConnectionProvider } from './views/connection.jsx';
|
import { ConnectionProvider } from './views/connection.jsx';
|
||||||
|
import { MarketDataProvider } from './market/MarketDataProvider.jsx';
|
||||||
|
|
||||||
export const inject = ['slots', 'connection'];
|
export const inject = ['slots', 'connection'];
|
||||||
|
|
||||||
@@ -61,7 +64,7 @@ const GENERAL_TABS = [
|
|||||||
{ key: 'allPositions', id: 'odl-all-positions', label: '全部持仓', order: 10,
|
{ key: 'allPositions', id: 'odl-all-positions', label: '全部持仓', order: 10,
|
||||||
render: (withConnection) => withConnection((props) => createElement(AllPositionsTab, props)) },
|
render: (withConnection) => withConnection((props) => createElement(AllPositionsTab, props)) },
|
||||||
{ key: 'tradeRecords', id: 'odl-trade-records', label: '交易记录', order: 11,
|
{ key: 'tradeRecords', id: 'odl-trade-records', label: '交易记录', order: 11,
|
||||||
render: (withConnection) => withConnection((props) => createElement(PlaceholderTab, { ...props, title: '交易记录' })) },
|
render: (withConnection) => withConnection((props) => createElement(TradeRecordsTab, props)) },
|
||||||
{ key: 'watchlist', id: 'odl-watchlist', label: '关注列表', order: 12,
|
{ key: 'watchlist', id: 'odl-watchlist', label: '关注列表', order: 12,
|
||||||
render: (withConnection) => withConnection((props) => createElement(PlaceholderTab, { ...props, title: '关注列表' })) },
|
render: (withConnection) => withConnection((props) => createElement(PlaceholderTab, { ...props, title: '关注列表' })) },
|
||||||
];
|
];
|
||||||
@@ -73,7 +76,8 @@ export function apply(ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const withConnection = (render) => (props) =>
|
const withConnection = (render) => (props) =>
|
||||||
createElement(ConnectionProvider, { connection }, render(props));
|
createElement(ConnectionProvider, { connection },
|
||||||
|
createElement(MarketDataProvider, null, render(props)));
|
||||||
|
|
||||||
ctx.slots.register({
|
ctx.slots.register({
|
||||||
name: 'settings.section',
|
name: 'settings.section',
|
||||||
@@ -130,10 +134,17 @@ export function apply(ctx) {
|
|||||||
|
|
||||||
registerAllTabs();
|
registerAllTabs();
|
||||||
|
|
||||||
|
// R-004 Q9:会话头部快捷切换 chip(与 DSH 内置 PTC 标签 order=-10 并排,技术约束-009)
|
||||||
|
ctx.slots.register({
|
||||||
|
name: 'conversation.session.header.actions',
|
||||||
|
id: 'odl-qmt-switch',
|
||||||
|
order: -9,
|
||||||
|
}, withConnection((props) => createElement(QmtConnectionChip, props)));
|
||||||
|
|
||||||
ctx.effect(() => {
|
ctx.effect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
disposeAllTabs();
|
disposeAllTabs();
|
||||||
};
|
};
|
||||||
}, 'one-divine-lot.client');
|
}, 'one-divine-lot.client');
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* MarketDataProvider —— 行情数据提供者(R-005 服务端中转架构,2026-08-31)
|
||||||
|
*
|
||||||
|
* 架构(服务端中转,老师确认):
|
||||||
|
* - DSH 服务端做「一次 WS 订阅 + QMT 桥整合」,维护一份实时行情缓存(MarketDataHub + 持久化);
|
||||||
|
* - 前端**统一轮询** DSH 接口(/odl/api/market-snapshot,按 code 查询),不做前端直连;
|
||||||
|
* - 轮询间隔 5s(老师确认)。
|
||||||
|
*
|
||||||
|
* 关注列表(2026-08-31 去重优化):不再自拉 positions,
|
||||||
|
* 改由表格组件在渲染时通过 registerCodes(codes) 上报,Provider 只轮询行情。
|
||||||
|
*
|
||||||
|
* 2026-09-01 修复:registerCodes 上报新 code 后**立即触发一次行情拉取**(不等 5s 定时器),
|
||||||
|
* 解决「打开持仓 tab 价格要等很久才出现」的时序问题。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createContext, useContext, useEffect, useReducer, useRef, useState, useCallback } from 'react';
|
||||||
|
import { useRpc } from '../views/connection.jsx';
|
||||||
|
|
||||||
|
const MarketContext = createContext(null);
|
||||||
|
|
||||||
|
/** 连接状态(轮询模式) */
|
||||||
|
export const MARKET_STATUS = {
|
||||||
|
IDLE: 'idle',
|
||||||
|
CONNECTING: 'connecting',
|
||||||
|
LIVE: 'live',
|
||||||
|
RECONNECTING: 'reconnecting',
|
||||||
|
DISCONNECTED: 'disconnected',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 价格 reducer:code → snapshot */
|
||||||
|
function pricesReducer(state, action) {
|
||||||
|
if (action.type === 'update') {
|
||||||
|
const next = {};
|
||||||
|
for (const [code, snap] of Object.entries(action.data || {})) {
|
||||||
|
if (!snap) continue;
|
||||||
|
next[code] = { ...state[code], ...snap };
|
||||||
|
}
|
||||||
|
return { ...state, ...next };
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 5000; // 轮询间隔(老师确认 5s)
|
||||||
|
|
||||||
|
export function MarketDataProvider({ children }) {
|
||||||
|
const call = useRpc();
|
||||||
|
|
||||||
|
/** 统一 setStatus:同步 ref(供轮询闭包读取最新值) */
|
||||||
|
const updateStatus = useCallback((s) => {
|
||||||
|
statusRef.current = s;
|
||||||
|
setStatus(s);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [prices, dispatch] = useReducer(pricesReducer, {});
|
||||||
|
const [status, setStatus] = useState(MARKET_STATUS.IDLE);
|
||||||
|
const statusRef = useRef(MARKET_STATUS.IDLE);
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
const watchCodesRef = useRef(new Set());
|
||||||
|
const pollMarketRef = useRef(null); // 供 registerCodes 触发立即拉取
|
||||||
|
|
||||||
|
/** 轮询行情缓存(按关注 code 查询,服务端中转) */
|
||||||
|
const pollMarket = useCallback(async () => {
|
||||||
|
const codes = [...watchCodesRef.current];
|
||||||
|
if (codes.length === 0) return;
|
||||||
|
try {
|
||||||
|
const res = await call('one-divine-lot/market-snapshot', {
|
||||||
|
args: { codes },
|
||||||
|
});
|
||||||
|
if (res?.ok && res.value) {
|
||||||
|
const n = Object.keys(res.value).length;
|
||||||
|
if (n > 0) {
|
||||||
|
dispatch({ type: 'update', data: res.value });
|
||||||
|
updateStatus(MARKET_STATUS.LIVE);
|
||||||
|
} else {
|
||||||
|
updateStatus(MARKET_STATUS.CONNECTING); // 缓存未填充
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('[odl-mkt] market-snapshot 失败:', JSON.stringify(res).slice(0, 200));
|
||||||
|
updateStatus(MARKET_STATUS.RECONNECTING);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[odl-mkt] market-snapshot 异常:', e?.message);
|
||||||
|
updateStatus(MARKET_STATUS.RECONNECTING);
|
||||||
|
}
|
||||||
|
}, [call]);
|
||||||
|
|
||||||
|
/** 表格上报关注代码;上报新 code 后立即拉一次行情(不等定时器) */
|
||||||
|
const registerCodes = useCallback((codes) => {
|
||||||
|
const list = Array.isArray(codes) ? codes : [];
|
||||||
|
const next = new Set(watchCodesRef.current);
|
||||||
|
let changed = false;
|
||||||
|
for (const c of list) {
|
||||||
|
if (c && !next.has(c)) { next.add(c); changed = true; }
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
watchCodesRef.current = next;
|
||||||
|
// 立即触发一次行情拉取(首屏快速显示价格)
|
||||||
|
pollMarketRef.current?.();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 同步 pollMarket 到 ref(供 registerCodes 调用)
|
||||||
|
useEffect(() => {
|
||||||
|
pollMarketRef.current = pollMarket;
|
||||||
|
}, [pollMarket]);
|
||||||
|
|
||||||
|
/** 主循环:定时轮询行情(关注 code 由表格上报) */
|
||||||
|
useEffect(() => {
|
||||||
|
mountedRef.current = true;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const poll = async () => {
|
||||||
|
if (cancelled || !mountedRef.current) return;
|
||||||
|
if (watchCodesRef.current.size === 0) {
|
||||||
|
// 关注代码尚未上报(表格未加载)——保持 IDLE
|
||||||
|
if (statusRef.current !== MARKET_STATUS.IDLE) updateStatus(MARKET_STATUS.IDLE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await pollMarket();
|
||||||
|
};
|
||||||
|
|
||||||
|
poll();
|
||||||
|
const timer = setInterval(poll, POLL_INTERVAL_MS);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
mountedRef.current = false;
|
||||||
|
clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [pollMarket]);
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
prices,
|
||||||
|
status,
|
||||||
|
getPrice: useCallback((code) => prices[code] ?? null, [prices]),
|
||||||
|
registerCodes,
|
||||||
|
wsInfo: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MarketContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</MarketContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 消费价格 Map */
|
||||||
|
export function useMarket() {
|
||||||
|
return useContext(MarketContext);
|
||||||
|
}
|
||||||
@@ -6,20 +6,27 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useRpc } from './connection.jsx';
|
import { useRpc } from './connection.jsx';
|
||||||
import { LoadState } from './LoadState.jsx';
|
import { LoadState } from './LoadState.jsx';
|
||||||
|
import { useMarket } from '../market/MarketDataProvider.jsx';
|
||||||
|
import { PriceCell } from './PriceCell.jsx';
|
||||||
|
|
||||||
export function AllPositionsTab(props) {
|
export function AllPositionsTab(props) {
|
||||||
const [positions, setPositions] = useState(null);
|
const [positions, setPositions] = useState(null);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const call = useRpc();
|
const call = useRpc();
|
||||||
|
const { getPrice, registerCodes } = useMarket();
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const res = await call('one-divine-lot/summary');
|
const res = await call('one-divine-lot/summary');
|
||||||
if (res && res.ok) setPositions(res.value);
|
if (res && res.ok) {
|
||||||
else setError((res && res.error && res.error.message) || '加载失败');
|
setPositions(res.value);
|
||||||
|
// 上报持仓 code 给行情 Provider(去重:不再由 Provider 自拉 positions)
|
||||||
|
const codes = (res.value?.positions ?? []).map((p) => p.code).filter(Boolean);
|
||||||
|
registerCodes(codes);
|
||||||
|
} else setError((res && res.error && res.error.message) || '加载失败');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e.message);
|
setError(e.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -40,6 +47,7 @@ export function AllPositionsTab(props) {
|
|||||||
<tr style={{ textAlign: 'left', borderBottom: '2px solid #ddd' }}>
|
<tr style={{ textAlign: 'left', borderBottom: '2px solid #ddd' }}>
|
||||||
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>代码</th>
|
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>代码</th>
|
||||||
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>名称</th>
|
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>名称</th>
|
||||||
|
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>现价</th>
|
||||||
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>总持仓</th>
|
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>总持仓</th>
|
||||||
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>已分配</th>
|
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>已分配</th>
|
||||||
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>未分配</th>
|
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>未分配</th>
|
||||||
@@ -51,6 +59,7 @@ export function AllPositionsTab(props) {
|
|||||||
<tr key={p.code} style={{ borderBottom: '1px solid #eee' }}>
|
<tr key={p.code} style={{ borderBottom: '1px solid #eee' }}>
|
||||||
<td style={{ padding: '8px 6px', lineHeight: '24px' }}>{p.code}</td>
|
<td style={{ padding: '8px 6px', lineHeight: '24px' }}>{p.code}</td>
|
||||||
<td style={{ padding: '8px 6px', lineHeight: '24px' }}>{p.name}</td>
|
<td style={{ padding: '8px 6px', lineHeight: '24px' }}>{p.name}</td>
|
||||||
|
<PriceCell price={getPrice(p.code)?.lastPrice} lastClose={getPrice(p.code)?.lastClose} />
|
||||||
<td style={{ padding: '8px 6px', lineHeight: '24px' }}>{p.totalVolume}</td>
|
<td style={{ padding: '8px 6px', lineHeight: '24px' }}>{p.totalVolume}</td>
|
||||||
<td style={{ padding: '8px 6px', lineHeight: '24px' }}>{p.allocated}</td>
|
<td style={{ padding: '8px 6px', lineHeight: '24px' }}>{p.allocated}</td>
|
||||||
<td style={{ padding: '8px 6px', lineHeight: '24px', color: p.unallocated > 0 ? '#e65100' : '#2e7d32' }}>{p.unallocated}</td>
|
<td style={{ padding: '8px 6px', lineHeight: '24px', color: p.unallocated > 0 ? '#e65100' : '#2e7d32' }}>{p.unallocated}</td>
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* PriceCell —— 现价单元格(R-005,产品约束-007)
|
||||||
|
*
|
||||||
|
* - 显示 lastPrice(保留 2~3 位小数,按价格幅度自适应)
|
||||||
|
* - 红涨绿跌(对比昨收 lastClose,A股习惯:涨=红 #d32f2f,跌=绿 #2e7d32,平=灰)
|
||||||
|
* - 价格变化时轻微高亮(背景闪一下,1.2s 渐隐)
|
||||||
|
* - 无价格数据显示 —
|
||||||
|
*
|
||||||
|
* 2026-09-01:移除 MarketStatusBar —— 前端表格不再显示行情状态条
|
||||||
|
* (行情刷新是服务端职责,前端只拿结果,对行情状态彻底无感)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
function formatPrice(v) {
|
||||||
|
if (v == null || !isFinite(v)) return '—';
|
||||||
|
// 价格 ≥100 用 2 位,<100 用 3 位(A股常规)
|
||||||
|
const digits = Math.abs(v) >= 100 ? 2 : 3;
|
||||||
|
return v.toFixed(digits);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 变化高亮:props.price 变化时触发一次闪动 */
|
||||||
|
function useFlash(price) {
|
||||||
|
const [flash, setFlash] = useState(0);
|
||||||
|
const prevRef = useRef(price);
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevRef.current !== price && price != null) {
|
||||||
|
prevRef.current = price;
|
||||||
|
setFlash((n) => n + 1);
|
||||||
|
}
|
||||||
|
}, [price]);
|
||||||
|
return flash;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PriceCell({ price, lastClose }) {
|
||||||
|
const flash = useFlash(price);
|
||||||
|
const hasPrice = price != null && isFinite(price);
|
||||||
|
let color = '#999';
|
||||||
|
if (hasPrice && lastClose != null && isFinite(lastClose)) {
|
||||||
|
if (price > lastClose) color = '#d32f2f';
|
||||||
|
else if (price < lastClose) color = '#2e7d32';
|
||||||
|
else color = '#666';
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: '8px 6px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
animation: flash ? 'odl-price-flash 1.2s ease-out' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatPrice(price)}
|
||||||
|
{flash > 0 && <style>{'@keyframes odl-price-flash{0%{background:rgba(255,215,0,.5)}100%{background:transparent}}'}</style>}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
/**
|
||||||
|
* 会话头部 QMT 连接快捷切换 chip + 健康状态控件(R-004 Q9/Q10;2026-09-01 重做)
|
||||||
|
*
|
||||||
|
* - 挂载 slot:conversation.session.header.actions(与 DSH 内置 PTC 模式标签并排)
|
||||||
|
* - 形态:
|
||||||
|
* - chip「QMT: <激活配置名> ▾」:点开下拉列出全部配置(激活项勾选),仅切换激活
|
||||||
|
* - chip 右侧独立「QMT 健康」控件:小圆点(绿=健康 / 红=异常),展示 QMT 连接状态
|
||||||
|
* - 数据:qmt-health 端点(服务端代理 GET 激活配置 /health)
|
||||||
|
*
|
||||||
|
* 2026-09-01:移除全盘订阅状态标记(暂缓,重开筛选做);QMT health 检查独立保留。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { useRpc } from './connection.jsx';
|
||||||
|
import { ToastProvider, useToast } from './Toast.jsx';
|
||||||
|
|
||||||
|
/** 下拉菜单项 */
|
||||||
|
function MenuItem({ item, selected, onSelect }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => onSelect(item)}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8, width: '100%', textAlign: 'left',
|
||||||
|
padding: '8px 12px', border: 'none', cursor: 'pointer', background: selected ? '#e8f5e9' : 'transparent',
|
||||||
|
fontSize: 13, color: '#333',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { if (!selected) e.currentTarget.style.background = '#f5f5f5'; }}
|
||||||
|
onMouseLeave={(e) => { if (!selected) e.currentTarget.style.background = 'transparent'; }}
|
||||||
|
>
|
||||||
|
<span style={{ width: 16, flex: 'none', color: '#2e7d32', fontWeight: 700 }}>{selected ? '✓' : ''}</span>
|
||||||
|
<span style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<span style={{ display: 'block', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{item.name}</span>
|
||||||
|
<span style={{ display: 'block', fontSize: 11, color: '#999', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{item.baseUrl}</span>
|
||||||
|
</span>
|
||||||
|
{item.isDefault && <span style={{ flex: 'none', fontSize: 11, color: '#1565c0', border: '1px solid #90caf9', borderRadius: 8, padding: '0 6px' }}>默认</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* QMT 健康状态控件(2026-09-01)
|
||||||
|
* - 位置:QMT 下拉框右侧
|
||||||
|
* - 显示:绿灯(health OK)/ 红灯(health 异常)
|
||||||
|
* - 每 5s 轮询 qmt-health
|
||||||
|
*/
|
||||||
|
function QmtHealthIndicator() {
|
||||||
|
const [health, setHealth] = useState(null); // { healthy, latencyMs, ... } | null(未获取)
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const call = useRpc();
|
||||||
|
|
||||||
|
// 读服务端缓存(默认,秒回)
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await call('one-divine-lot/qmt-health', {});
|
||||||
|
if (res && res.ok) setHealth(res.value);
|
||||||
|
// 失败保留上次状态
|
||||||
|
} catch (e) { /* 静默 */ }
|
||||||
|
}, [call]);
|
||||||
|
|
||||||
|
// 点击触发即时探测(refresh=true,刷新服务端缓存)
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
if (refreshing) return;
|
||||||
|
setRefreshing(true);
|
||||||
|
try {
|
||||||
|
const res = await call('one-divine-lot/qmt-health', { args: { refresh: true } });
|
||||||
|
if (res && res.ok) setHealth(res.value);
|
||||||
|
} catch (e) { /* 静默 */ }
|
||||||
|
finally { setRefreshing(false); }
|
||||||
|
}, [call, refreshing]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load(); // 打开即读缓存
|
||||||
|
const timer = setInterval(load, 30000); // 30s 轮询缓存(服务端 5 分钟更新,前端低频同步)
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
// 状态推导:null=灰(未知/加载中),healthy=true=绿,false=红
|
||||||
|
const color = health === null ? '#bbb' : (health.healthy ? '#2e7d32' : '#d32f2f');
|
||||||
|
const title = health === null
|
||||||
|
? 'QMT 健康状态获取中…(点击立即检测)'
|
||||||
|
: (health.healthy
|
||||||
|
? 'QMT 连接健康(' + (health.latencyMs ?? '-') + 'ms,检测于 ' + new Date(health.checkedAt ?? Date.now()).toLocaleTimeString() + ',点击重新检测)'
|
||||||
|
: 'QMT 连接异常' + (health.healthError ? ':' + String(health.healthError).slice(0, 40) : '') + '(点击重新检测)');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
title={title}
|
||||||
|
onClick={refresh}
|
||||||
|
role="button"
|
||||||
|
aria-label="QMT 健康状态(点击重新检测)"
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
width: 10, height: 10, borderRadius: '50%', background: color, flex: 'none',
|
||||||
|
boxShadow: color !== '#bbb' ? '0 0 4px ' + color : 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
opacity: refreshing ? 0.5 : 1,
|
||||||
|
transition: 'opacity .2s',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChipInner() {
|
||||||
|
const [state, setState] = useState(null); // { list, activeId, defaultId, active }
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [switching, setSwitching] = useState(false);
|
||||||
|
const rootRef = useRef(null);
|
||||||
|
const call = useRpc();
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await call('one-divine-lot/qmt-connections', {});
|
||||||
|
if (res && res.ok) setState(res.value);
|
||||||
|
} catch (e) { /* 头部控件静默失败,不打扰会话 */ }
|
||||||
|
}, [call]);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
// 外点关闭
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onDocClick = (e) => {
|
||||||
|
if (rootRef.current && !rootRef.current.contains(e.target)) setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', onDocClick);
|
||||||
|
return () => document.removeEventListener('mousedown', onDocClick);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const activate = async (item) => {
|
||||||
|
setOpen(false);
|
||||||
|
if (!item || switching) return;
|
||||||
|
if (state && item.id === state.activeId) return;
|
||||||
|
setSwitching(true);
|
||||||
|
try {
|
||||||
|
const res = await call('one-divine-lot/qmt-connections/activate', { args: { id: item.id } });
|
||||||
|
if (res && res.ok) {
|
||||||
|
setState(res.value);
|
||||||
|
toast.success('QMT 连接已切换:' + item.name);
|
||||||
|
} else {
|
||||||
|
toast.error((res && res.error && res.error.message) || '切换失败');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e.message);
|
||||||
|
} finally {
|
||||||
|
setSwitching(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!state) return null; // 服务端未就绪/加载失败时不显示
|
||||||
|
|
||||||
|
const active = state.active || state.list.find((c) => c.id === state.activeId) || null;
|
||||||
|
const label = active ? active.name : (state.list.length === 0 ? '未配置' : '未激活');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={rootRef} style={{ position: 'relative', display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={open}
|
||||||
|
disabled={switching}
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
title="QMT 连接快捷切换(管理配置请进 设置 → 神之一手 → QMT 连接配置)"
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 4, maxWidth: 180, height: 22,
|
||||||
|
padding: '0 8px', borderRadius: 6, border: 'none', cursor: 'pointer', fontSize: 12,
|
||||||
|
lineHeight: '22px', color: 'var(--dsw-alias-label-secondary, #666)',
|
||||||
|
background: 'var(--dsw-alias-fill-tsp-secondary, rgba(0,0,0,0.05))',
|
||||||
|
whiteSpace: 'nowrap', overflow: 'hidden',
|
||||||
|
opacity: switching ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontWeight: 600 }}>QMT:</span>
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</span>
|
||||||
|
<span style={{ fontSize: 10, flex: 'none' }}>▾</span>
|
||||||
|
</button>
|
||||||
|
{/* QMT 健康状态控件(下拉框右侧,绿灯=健康/红灯=异常) */}
|
||||||
|
<QmtHealthIndicator />
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
role="menu"
|
||||||
|
style={{
|
||||||
|
position: 'absolute', top: 'calc(100% + 4px)', right: 0, zIndex: 10001,
|
||||||
|
minWidth: 220, maxWidth: 300, background: '#fff', border: '1px solid #ddd',
|
||||||
|
borderRadius: 8, boxShadow: '0 4px 16px rgba(0,0,0,0.15)', padding: '4px 0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ padding: '4px 12px', fontSize: 11, color: '#999' }}>切换 QMT 连接</div>
|
||||||
|
{state.list.length === 0 ? (
|
||||||
|
<div style={{ padding: '10px 12px', fontSize: 12, color: '#888' }}>
|
||||||
|
暂无连接配置,请进 设置 → 神之一手 → QMT 连接配置 添加
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
state.list.map((c) => (
|
||||||
|
<MenuItem
|
||||||
|
key={c.id}
|
||||||
|
item={{ ...c, isDefault: c.id === state.defaultId }}
|
||||||
|
selected={c.id === state.activeId}
|
||||||
|
onSelect={activate}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
<div style={{ borderTop: '1px solid #eee', marginTop: 4, padding: '6px 12px', fontSize: 11, color: '#999' }}>
|
||||||
|
管理配置请进 设置 → 神之一手 → QMT 连接配置
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 导出(包 ToastProvider,与 SettingsSection 同模式) */
|
||||||
|
export function QmtConnectionChip(props) {
|
||||||
|
return (
|
||||||
|
<ToastProvider>
|
||||||
|
<ChipInner {...props} />
|
||||||
|
</ToastProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
/**
|
||||||
|
* RangeSelector —— 交易记录时间段选择(R-007 v4,2026-09-01 老师拍板)
|
||||||
|
*
|
||||||
|
* 交互模型(2026-09-01 老师纠正:操作 → 状态 → UI,单向,不做反向推断):
|
||||||
|
* - 用户先有操作(点「本月」/「本周」/「今日」或手动改日期),才有时间范围
|
||||||
|
* - **preset 意图状态**记录用户点了哪个快捷按钮('today' | 'thisWeek' | 'thisMonth' | null)
|
||||||
|
* - 按钮高亮 = preset 匹配(单向:操作 → preset → UI 高亮),不反向从 range 推断
|
||||||
|
* - 手动改起止日期 → 清除 preset(不再是任何快捷按钮的操作)
|
||||||
|
* - 默认加载今日 = preset 'today'
|
||||||
|
*
|
||||||
|
* 快捷范围(完整时间段,互不重叠):
|
||||||
|
* - 今日:lastDate ~ lastDate(单日)
|
||||||
|
* - 本周:lastDate 所在自然周 周一 ~ 周五(交易日,不考虑周六日;2026-09-01 老师纠正)
|
||||||
|
* - 本月:lastDate 所在自然月 1 号 ~ 月末(完整月)
|
||||||
|
*
|
||||||
|
* 数据来源:/odl/api/trading-dates(交易日历)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
import { useRpc } from './connection.jsx';
|
||||||
|
|
||||||
|
const PAST_DAYS = 45; // 向前取多少个自然日
|
||||||
|
const FUTURE_DAYS = 7; // 向后取多少个自然日
|
||||||
|
|
||||||
|
/** 格式化 YYYYMMDD → YYYY-MM-DD(input type=date 需要) */
|
||||||
|
export function fmt(dateStr) {
|
||||||
|
if (!dateStr || dateStr.length !== 8) return dateStr ?? '';
|
||||||
|
return dateStr.slice(0, 4) + '-' + dateStr.slice(4, 6) + '-' + dateStr.slice(6, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** YYYY-MM-DD → YYYYMMDD */
|
||||||
|
function toCompact(dateStr) {
|
||||||
|
if (!dateStr) return '';
|
||||||
|
return dateStr.replace(/-/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前本地日期 → YYYYMMDD */
|
||||||
|
function todayStr() {
|
||||||
|
const d = new Date();
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
return y + m + day;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 日期偏移(日历日):YYYYMMDD ± n 天 */
|
||||||
|
function shiftDate(dateStr, days) {
|
||||||
|
const d = new Date(
|
||||||
|
Number(dateStr.slice(0, 4)),
|
||||||
|
Number(dateStr.slice(4, 6)) - 1,
|
||||||
|
Number(dateStr.slice(6, 8)) + days
|
||||||
|
);
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
return y + m + day;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** YYYYMMDD → 该日所在周的周一(自然周) */
|
||||||
|
function weekMonday(dateStr) {
|
||||||
|
const d = new Date(Number(dateStr.slice(0, 4)), Number(dateStr.slice(4, 6)) - 1, Number(dateStr.slice(6, 8)));
|
||||||
|
const day = d.getDay(); // 0=周日 1=周一 ...
|
||||||
|
const diff = day === 0 ? -6 : 1 - day; // 周一偏移
|
||||||
|
return shiftDate(dateStr, diff);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** YYYYMMDD → 该日所在周的周五(周一+4;本周=周一~周五,不考虑周六日) */
|
||||||
|
function weekFriday(dateStr) {
|
||||||
|
return shiftDate(weekMonday(dateStr), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** YYYYMMDD → 该日所在月的最后一天 */
|
||||||
|
function monthLastDay(dateStr) {
|
||||||
|
const y = Number(dateStr.slice(0, 4));
|
||||||
|
const m = Number(dateStr.slice(4, 6));
|
||||||
|
// 下月 0 号 = 本月最后一天
|
||||||
|
const d = new Date(y, m, 0);
|
||||||
|
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const dd = String(d.getDate()).padStart(2, '0');
|
||||||
|
return String(d.getFullYear()) + mm + dd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} props
|
||||||
|
* @param {string|null} preset 快捷意图:'today' | 'thisWeek' | 'thisMonth' | null(手动自定义)
|
||||||
|
* @param {(p: string|null) => void} onPresetChange 快捷意图变化回调
|
||||||
|
* @param {{start: string, end: string}|null} range 当前时间段(YYYYMMDD)
|
||||||
|
* @param {(r: {start: string, end: string}) => void} onChange 时间段变化回调
|
||||||
|
* @param {(last: string) => void} [onLastDate] 最近交易日通知
|
||||||
|
*/
|
||||||
|
export function RangeSelector({ preset, onPresetChange, range, onChange, onLastDate }) {
|
||||||
|
const [dates, setDates] = useState([]);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const call = useRpc();
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const today = todayStr();
|
||||||
|
const res = await call('one-divine-lot/trading-dates', {
|
||||||
|
args: { start: shiftDate(today, -PAST_DAYS), end: shiftDate(today, FUTURE_DAYS) },
|
||||||
|
});
|
||||||
|
if (res && res.ok && Array.isArray(res.value?.dates)) {
|
||||||
|
setDates(res.value.dates.filter((d) => d >= shiftDate(today, -PAST_DAYS) && d <= shiftDate(today, FUTURE_DAYS)).sort());
|
||||||
|
} else {
|
||||||
|
setError((res && res.error && res.error.message) || '交易日历加载失败');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message);
|
||||||
|
}
|
||||||
|
}, [call]);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
// 最近交易日 = 列表最后一个
|
||||||
|
const lastDate = dates.length ? dates[dates.length - 1] : null;
|
||||||
|
|
||||||
|
// 通知最近交易日
|
||||||
|
useEffect(() => {
|
||||||
|
if (lastDate && onLastDate) onLastDate(lastDate);
|
||||||
|
}, [lastDate, onLastDate]);
|
||||||
|
|
||||||
|
// 快捷范围计算(完整互斥时间段)
|
||||||
|
const quickRanges = useMemo(() => {
|
||||||
|
if (!lastDate) return null;
|
||||||
|
return {
|
||||||
|
today: { start: lastDate, end: lastDate },
|
||||||
|
thisWeek: { start: weekMonday(lastDate), end: weekFriday(lastDate) },
|
||||||
|
thisMonth: { start: lastDate.slice(0, 6) + '01', end: monthLastDay(lastDate) },
|
||||||
|
};
|
||||||
|
}, [lastDate]);
|
||||||
|
|
||||||
|
// 操作 → 意图 + 范围(单向)
|
||||||
|
const applyPreset = (key) => {
|
||||||
|
const q = quickRanges;
|
||||||
|
if (!q) return;
|
||||||
|
onPresetChange(key);
|
||||||
|
onChange({ start: q[key].start, end: q[key].end });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 手动改日期 → 清除意图(单向:手动操作不再是任何快捷按钮)
|
||||||
|
const onStartChange = (e) => {
|
||||||
|
const v = toCompact(e.target.value);
|
||||||
|
if (!v) return;
|
||||||
|
if (preset) onPresetChange(null);
|
||||||
|
onChange({ ...(range ?? {}), start: v });
|
||||||
|
};
|
||||||
|
const onEndChange = (e) => {
|
||||||
|
const v = toCompact(e.target.value);
|
||||||
|
if (!v) return;
|
||||||
|
if (preset) onPresetChange(null);
|
||||||
|
onChange({ ...(range ?? {}), end: v });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error || !lastDate) {
|
||||||
|
return (
|
||||||
|
<div style={{ fontSize: 13, color: '#c62828' }}>
|
||||||
|
{error || '交易日历加载中...'}
|
||||||
|
<button onClick={load} style={{ marginLeft: 8, padding: '2px 10px', cursor: 'pointer' }}>重试</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 高亮 = preset 意图(单向),不是 range 反推
|
||||||
|
const activeToday = preset === 'today';
|
||||||
|
const activeThisWeek = preset === 'thisWeek';
|
||||||
|
const activeThisMonth = preset === 'thisMonth';
|
||||||
|
|
||||||
|
const btnStyle = (active) => ({
|
||||||
|
padding: '2px 10px',
|
||||||
|
fontSize: 12,
|
||||||
|
cursor: 'pointer',
|
||||||
|
border: active ? '1px solid #2e7d32' : '1px solid #ccc',
|
||||||
|
borderRadius: 4,
|
||||||
|
background: active ? '#e8f5e9' : '#fff',
|
||||||
|
color: active ? '#1b5e20' : '#555',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
});
|
||||||
|
|
||||||
|
const inputStyle = {
|
||||||
|
fontSize: 12,
|
||||||
|
padding: '2px 4px',
|
||||||
|
border: '1px solid #ccc',
|
||||||
|
borderRadius: 4,
|
||||||
|
width: 112,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'nowrap', whiteSpace: 'nowrap' }}>
|
||||||
|
<button style={btnStyle(activeToday)} onClick={() => applyPreset('today')} title="最近交易日">今日</button>
|
||||||
|
<button style={btnStyle(activeThisWeek)} onClick={() => applyPreset('thisWeek')} title="本周一至本周五(不考虑周六日)">本周</button>
|
||||||
|
<button style={btnStyle(activeThisMonth)} onClick={() => applyPreset('thisMonth')} title="本月1号至本月最后一天(完整自然月)">本月</button>
|
||||||
|
<span style={{ color: '#999' }}>|</span>
|
||||||
|
<input type="date" value={range ? fmt(range.start) : ''} onChange={onStartChange} style={inputStyle} title="起始日期" />
|
||||||
|
<span style={{ color: '#999' }}>~</span>
|
||||||
|
<input type="date" value={range ? fmt(range.end) : ''} onChange={onEndChange} style={inputStyle} title="结束日期" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { todayStr, shiftDate, weekMonday, weekFriday, monthLastDay };
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
* 神之一手设置 section(迭代 02:设置 tab 化扩展)
|
* 神之一手设置 section(迭代 02:设置 tab 化扩展)
|
||||||
*
|
*
|
||||||
* 结构:
|
* 结构:
|
||||||
* - 两个子 tab:通用设置 / 策略分组
|
* - 三个子 tab:通用设置 / 策略分组 / QMT 连接配置(迭代 03,R-004)
|
||||||
* - 通用设置:三个 switch(全部持仓 / 交易记录 / 关注列表),控制对应会话 tab 显隐,切换后提示「刷新页面后生效」
|
* - 通用设置:三个 switch(全部持仓 / 交易记录 / 关注列表),控制对应会话 tab 显隐,切换后提示「刷新页面后生效」
|
||||||
* - 策略分组:策略 CRUD(新增/重命名/删除确认/排序/显隐)+ 就地提示
|
* - 策略分组:策略 CRUD(新增/重命名/删除确认/排序/显隐)+ 就地提示
|
||||||
*/
|
*/
|
||||||
@@ -352,9 +352,9 @@ function StrategyGroupSettings() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 设置 section 主组件:两个子 tab */
|
/** 设置 section 主组件:三个子 tab */
|
||||||
function SettingsSectionInner(props) {
|
function SettingsSectionInner(props) {
|
||||||
const [activeTab, setActiveTab] = useState('general'); // general | strategies
|
const [activeTab, setActiveTab] = useState('general'); // general | strategies | qmt
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 20, fontFamily: 'system-ui' }}>
|
<div style={{ padding: 20, fontFamily: 'system-ui' }}>
|
||||||
@@ -365,6 +365,7 @@ function SettingsSectionInner(props) {
|
|||||||
{[
|
{[
|
||||||
{ key: 'general', label: '通用设置' },
|
{ key: 'general', label: '通用设置' },
|
||||||
{ key: 'strategies', label: '策略分组' },
|
{ key: 'strategies', label: '策略分组' },
|
||||||
|
{ key: 'qmt', label: 'QMT 连接配置' },
|
||||||
].map((tab) => (
|
].map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
@@ -383,6 +384,7 @@ function SettingsSectionInner(props) {
|
|||||||
// 通用设置变更 → 就地提示(由 GeneralSettings 内 toast 提示,这里可额外提示)
|
// 通用设置变更 → 就地提示(由 GeneralSettings 内 toast 提示,这里可额外提示)
|
||||||
}} />}
|
}} />}
|
||||||
{activeTab === 'strategies' && <StrategyGroupSettings />}
|
{activeTab === 'strategies' && <StrategyGroupSettings />}
|
||||||
|
{activeTab === 'qmt' && <QmtConnectionsSettings />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -394,4 +396,327 @@ export function SettingsSection(props) {
|
|||||||
<SettingsSectionInner {...props} />
|
<SettingsSectionInner {...props} />
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 长文本省略单元格:单行省略 + 悬停显示完整内容(避免错误信息等长文本撑行换行) */
|
||||||
|
function EllipsisText({ text, maxWidth = 220, title, style }) {
|
||||||
|
return (
|
||||||
|
<span title={title || text} style={{
|
||||||
|
display: 'inline-block', maxWidth, overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis', whiteSpace: 'nowrap', verticalAlign: 'bottom',
|
||||||
|
...style,
|
||||||
|
}}>{text}</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** QMT 连接配置卡片(R-004;产品约束-005、UI约束-002;2026-08-29 老师反馈改卡片形式) */
|
||||||
|
function QmtConnectionCard({ conn, isActive, isDefault, busy, testResult, onActivate, onSetDefault, onTest, onEdit, onDelete }) {
|
||||||
|
const btn = { padding: '3px 10px', cursor: 'pointer', border: '1px solid #ccc', borderRadius: 4, background: '#fff', fontSize: 12 };
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
border: isActive ? '1px solid #2e7d32' : '1px solid #ddd',
|
||||||
|
background: isActive ? '#f1f8f2' : '#fff',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '12px 14px',
|
||||||
|
display: 'flex', flexDirection: 'column', gap: 8,
|
||||||
|
minWidth: 0, overflow: 'hidden',
|
||||||
|
boxShadow: '0 1px 2px rgba(0,0,0,0.04)',
|
||||||
|
}}>
|
||||||
|
{/* 标题行:名称 + 徽标 */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, minWidth: 0 }}>
|
||||||
|
<EllipsisText text={conn.name} maxWidth={140} style={{ fontWeight: 600, fontSize: 14 }} />
|
||||||
|
{isActive && <span style={{ flex: 'none', fontSize: 11, color: '#2e7d32', border: '1px solid #2e7d32', borderRadius: 8, padding: '0 6px' }}>生效中</span>}
|
||||||
|
{isDefault && <span style={{ flex: 'none', fontSize: 11, color: '#1565c0', border: '1px solid #90caf9', borderRadius: 8, padding: '0 6px' }}>默认</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 地址行:省略+悬停 */}
|
||||||
|
<div style={{ fontSize: 12, color: '#666', minWidth: 0 }}>
|
||||||
|
<EllipsisText text={conn.baseUrl} maxWidth={240} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 测试结果行:可换行但受卡片约束;超长悬停看全文 */}
|
||||||
|
{testResult && (
|
||||||
|
<div style={{
|
||||||
|
fontSize: 12, lineHeight: '16px', color: testResult.ok ? '#2e7d32' : '#c62828',
|
||||||
|
background: testResult.ok ? '#e8f5e9' : '#fdecea',
|
||||||
|
borderRadius: 4, padding: '4px 8px', minWidth: 0,
|
||||||
|
}}>
|
||||||
|
<EllipsisText text={testResult.text} maxWidth={9999} title={testResult.fullText || testResult.text} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 操作行:按钮自动换行排列,不挤压卡片 */}
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 2 }}>
|
||||||
|
{!isActive && <button onClick={onActivate} disabled={busy} style={{ ...btn, color: '#2e7d32', borderColor: '#2e7d32' }}>激活</button>}
|
||||||
|
{!isDefault && <button onClick={onSetDefault} disabled={busy} style={btn}>设为默认</button>}
|
||||||
|
<button onClick={onTest} disabled={busy}>{busy ? '测试中…' : '测试连接'}</button>
|
||||||
|
<button onClick={onEdit} disabled={busy} style={btn}>编辑</button>
|
||||||
|
<button onClick={onDelete} disabled={busy} style={{ ...btn, color: '#c62828', borderColor: '#c62828' }}>删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** QMT 连接配置子 tab —— 卡片版(R-004;产品约束-005、UI约束-002) */
|
||||||
|
function QmtConnectionsSettings() {
|
||||||
|
const [state, setState] = useState(null); // { list, activeId, defaultId, active }
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [testingId, setTestingId] = useState(null);
|
||||||
|
const [testResult, setTestResult] = useState(null); // { key, ok, text, fullText }
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(null);
|
||||||
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState(null);
|
||||||
|
const [formName, setFormName] = useState('');
|
||||||
|
const [formUrl, setFormUrl] = useState('');
|
||||||
|
const call = useRpc();
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await call('one-divine-lot/qmt-connections', {});
|
||||||
|
if (res && res.ok) setState(res.value);
|
||||||
|
else setError((res && res.error && res.error.message) || '加载失败');
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message);
|
||||||
|
}
|
||||||
|
}, [call]);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const openAdd = () => { setEditingId(null); setFormName(''); setFormUrl(''); setFormOpen(true); setTestResult(null); };
|
||||||
|
const openEdit = (c) => { setEditingId(c.id); setFormName(c.name); setFormUrl(c.baseUrl); setFormOpen(true); setTestResult(null); };
|
||||||
|
const closeForm = () => { setFormOpen(false); setEditingId(null); };
|
||||||
|
|
||||||
|
const validateUrl = (raw) => {
|
||||||
|
const url = String(raw || '').trim().replace(/\/+$/, '');
|
||||||
|
if (!/^https?:\/\//i.test(url) || url.length <= 8) return null;
|
||||||
|
return url;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
const name = formName.trim();
|
||||||
|
const url = validateUrl(formUrl);
|
||||||
|
if (!name) { toast.error('请输入连接名称'); return; }
|
||||||
|
if (!url) { toast.error('HTTP 地址格式无效(需以 http:// 或 https:// 开头)'); return; }
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const res = editingId === null
|
||||||
|
? await call('one-divine-lot/qmt-connections/add', { args: { name, baseUrl: url } })
|
||||||
|
: await call('one-divine-lot/qmt-connections/update', { args: { id: editingId, name, baseUrl: url } });
|
||||||
|
if (res && res.ok) {
|
||||||
|
closeForm();
|
||||||
|
toast.success(editingId === null ? '连接配置已添加' : '连接配置已保存');
|
||||||
|
await load();
|
||||||
|
} else {
|
||||||
|
toast.error((res && res.error && res.error.message) || '保存失败');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleActivate = async (c) => {
|
||||||
|
if (state && c.id === state.activeId) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await call('one-divine-lot/qmt-connections/activate', { args: { id: c.id } });
|
||||||
|
if (res && res.ok) {
|
||||||
|
setState(res.value);
|
||||||
|
toast.success('已激活:' + c.name + '(立即生效)');
|
||||||
|
} else {
|
||||||
|
toast.error((res && res.error && res.error.message) || '激活失败');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSetDefault = async (c) => {
|
||||||
|
if (state && c.id === state.defaultId) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await call('one-divine-lot/qmt-connections/set-default', { args: { id: c.id } });
|
||||||
|
if (res && res.ok) {
|
||||||
|
setState((prev) => prev ? { ...prev, defaultId: c.id } : prev);
|
||||||
|
toast.success('已设为默认:' + c.name + '(启动时自动激活)');
|
||||||
|
} else {
|
||||||
|
toast.error((res && res.error && res.error.message) || '设置默认失败');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const showTest = (key, v) => {
|
||||||
|
const text = v.reachable
|
||||||
|
? '可达 · ' + v.latencyMs + 'ms' + (v.status ? ' · status=' + v.status : '')
|
||||||
|
: '不可达 · ' + (v.error || ('HTTP ' + v.httpStatus)) + ' · ' + v.latencyMs + 'ms';
|
||||||
|
setTestResult({ key, ok: v.reachable === true, text, fullText: text });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTest = async (c) => {
|
||||||
|
setTestingId(c.id);
|
||||||
|
try {
|
||||||
|
const res = await call('one-divine-lot/qmt-connections/test', { args: { id: c.id } });
|
||||||
|
if (res && res.ok) showTest(c.id, res.value);
|
||||||
|
else {
|
||||||
|
const msg = (res && res.error && res.error.message) || '测试失败';
|
||||||
|
setTestResult({ key: c.id, ok: false, text: '不可达 · ' + msg, fullText: msg });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setTestResult({ key: c.id, ok: false, text: '不可达 · ' + e.message, fullText: e.message });
|
||||||
|
} finally {
|
||||||
|
setTestingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTestForm = async () => {
|
||||||
|
const url = validateUrl(formUrl);
|
||||||
|
if (!url) { toast.error('HTTP 地址格式无效(需以 http:// 或 https:// 开头)'); return; }
|
||||||
|
setTestingId('__form__');
|
||||||
|
try {
|
||||||
|
const res = await call('one-divine-lot/qmt-connections/test', { args: { baseUrl: url } });
|
||||||
|
if (res && res.ok) showTest('__form__', res.value);
|
||||||
|
else {
|
||||||
|
const msg = (res && res.error && res.error.message) || '测试失败';
|
||||||
|
setTestResult({ key: '__form__', ok: false, text: '不可达 · ' + msg, fullText: msg });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setTestResult({ key: '__form__', ok: false, text: '不可达 · ' + e.message, fullText: e.message });
|
||||||
|
} finally {
|
||||||
|
setTestingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id) => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await call('one-divine-lot/qmt-connections/remove', { args: { id } });
|
||||||
|
setConfirmDelete(null);
|
||||||
|
if (res && res.ok) {
|
||||||
|
setState(res.value);
|
||||||
|
const msgs = [];
|
||||||
|
if (res.value.removedWasActive) msgs.push('激活已自动切换');
|
||||||
|
if (res.value.removedWasDefault) msgs.push('默认标记已转移');
|
||||||
|
toast.success('连接配置已删除' + (msgs.length ? '(' + msgs.join(';') + ')' : ''));
|
||||||
|
} else {
|
||||||
|
toast.error((res && res.error && res.error.message) || '删除失败');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setConfirmDelete(null);
|
||||||
|
toast.error(e.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, marginBottom: 12 }}>
|
||||||
|
<div style={{ fontSize: 13, color: '#666', minWidth: 0 }}>
|
||||||
|
激活配置为当前生效连接(切换立即生效);默认配置在插件启动时自动激活。
|
||||||
|
</div>
|
||||||
|
<button onClick={openAdd} disabled={saving} style={{ flex: 'none', padding: '6px 16px', cursor: 'pointer', border: 'none', borderRadius: 4, background: '#2e7d32', color: '#fff' }}>
|
||||||
|
+ 新增连接
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div style={{ color: '#c62828', marginBottom: 12 }}><EllipsisText text={error} maxWidth={560} /></div>}
|
||||||
|
{!state && !error && <div style={{ color: '#666' }}>加载中...</div>}
|
||||||
|
|
||||||
|
{state && state.list.length === 0 && (
|
||||||
|
<div style={{ padding: '24px 0', color: '#888', fontSize: 13 }}>
|
||||||
|
暂无连接配置。点击「新增连接」录入第一个 QMT Bridge 地址(如 http://192.168.3.43:8610)。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state && state.list.length > 0 && (
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
|
||||||
|
gap: 12,
|
||||||
|
}}>
|
||||||
|
{state.list.map((c) => (
|
||||||
|
<QmtConnectionCard
|
||||||
|
key={c.id}
|
||||||
|
conn={c}
|
||||||
|
isActive={c.id === state.activeId}
|
||||||
|
isDefault={c.id === state.defaultId}
|
||||||
|
busy={saving || testingId === c.id}
|
||||||
|
testResult={testResult && testResult.key === c.id ? testResult : null}
|
||||||
|
onActivate={() => handleActivate(c)}
|
||||||
|
onSetDefault={() => handleSetDefault(c)}
|
||||||
|
onTest={() => handleTest(c)}
|
||||||
|
onEdit={() => openEdit(c)}
|
||||||
|
onDelete={() => setConfirmDelete({ id: c.id, name: c.name })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{formOpen && (
|
||||||
|
<div style={{ marginTop: 16, padding: 16, border: '1px solid #ddd', borderRadius: 8, background: '#fafafa', maxWidth: 640 }}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 12 }}>{editingId === null ? '新增连接配置' : '编辑连接配置'}</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 10 }}>
|
||||||
|
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: 12, color: '#666' }}>
|
||||||
|
连接名称
|
||||||
|
<input type="text" placeholder="如:本机 QMT" value={formName}
|
||||||
|
onChange={(e) => setFormName(e.target.value)}
|
||||||
|
style={{ padding: '6px 10px', border: '1px solid #ccc', borderRadius: 4, fontSize: 13 }} />
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: 12, color: '#666' }}>
|
||||||
|
HTTP 地址
|
||||||
|
<input type="text" placeholder="如 http://192.168.3.43:8610" value={formUrl}
|
||||||
|
onChange={(e) => setFormUrl(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleSave()}
|
||||||
|
style={{ padding: '6px 10px', border: '1px solid #ccc', borderRadius: 4, fontSize: 13 }} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
|
||||||
|
<button onClick={handleSave} disabled={saving}
|
||||||
|
style={{ padding: '6px 16px', cursor: 'pointer', border: 'none', borderRadius: 4, background: '#2e7d32', color: '#fff' }}>
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
<button onClick={handleTestForm} disabled={testingId !== null}
|
||||||
|
style={{ padding: '3px 10px', cursor: 'pointer', border: '1px solid #ccc', borderRadius: 4, background: '#fff', fontSize: 12 }}>
|
||||||
|
{testingId === '__form__' ? '测试中…' : '测试连接'}
|
||||||
|
</button>
|
||||||
|
<button onClick={closeForm} disabled={saving}
|
||||||
|
style={{ padding: '3px 10px', cursor: 'pointer', border: '1px solid #ccc', borderRadius: 4, background: '#fff', fontSize: 12 }}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
{testResult && testResult.key === '__form__' && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: 12, color: testResult.ok ? '#2e7d32' : '#c62828',
|
||||||
|
background: testResult.ok ? '#e8f5e9' : '#fdecea', borderRadius: 4, padding: '3px 8px',
|
||||||
|
minWidth: 0,
|
||||||
|
}}>
|
||||||
|
<EllipsisText text={testResult.text} maxWidth={360} title={testResult.fullText || testResult.text} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{confirmDelete && (
|
||||||
|
<ConfirmDialog
|
||||||
|
title={'删除连接「' + confirmDelete.name + '」'}
|
||||||
|
message={
|
||||||
|
(state && state.list.length <= 1)
|
||||||
|
? '这是最后一个连接配置,不能删除。'
|
||||||
|
: '删除后:若为激活配置将自动切换到默认配置;若为默认配置,默认标记转移到列表第一条。确定删除?'
|
||||||
|
}
|
||||||
|
onConfirm={() => handleDelete(confirmDelete.id)}
|
||||||
|
onCancel={() => setConfirmDelete(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import { useState, useEffect, useCallback } from 'react';
|
|||||||
import { useRpc } from './connection.jsx';
|
import { useRpc } from './connection.jsx';
|
||||||
import { LoadState } from './LoadState.jsx';
|
import { LoadState } from './LoadState.jsx';
|
||||||
import { ToastProvider, useToast } from './Toast.jsx';
|
import { ToastProvider, useToast } from './Toast.jsx';
|
||||||
|
import { useMarket } from '../market/MarketDataProvider.jsx';
|
||||||
|
import { PriceCell } from './PriceCell.jsx';
|
||||||
|
|
||||||
/** 添加持仓区域响应式样式(内联注入,含媒体查询) */
|
/** 添加持仓区域响应式样式(内联注入,含媒体查询) */
|
||||||
const ADD_FORM_CSS = `
|
const ADD_FORM_CSS = `
|
||||||
@@ -135,6 +137,7 @@ function StrategyTabInner({ strategyId, strategyName }) {
|
|||||||
const [confirmClear, setConfirmClear] = useState(false); // 一键清零确认
|
const [confirmClear, setConfirmClear] = useState(false); // 一键清零确认
|
||||||
const call = useRpc();
|
const call = useRpc();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
const { getPrice, registerCodes } = useMarket();
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -144,7 +147,12 @@ function StrategyTabInner({ strategyId, strategyName }) {
|
|||||||
call('one-divine-lot/strategy-positions', { args: { strategyId } }),
|
call('one-divine-lot/strategy-positions', { args: { strategyId } }),
|
||||||
call('one-divine-lot/unallocated', {}),
|
call('one-divine-lot/unallocated', {}),
|
||||||
]);
|
]);
|
||||||
if (strategyRes && strategyRes.ok) setPositions(strategyRes.value);
|
if (strategyRes && strategyRes.ok) {
|
||||||
|
setPositions(strategyRes.value);
|
||||||
|
// 上报策略持仓 code 给行情 Provider(去重:不再由 Provider 自拉 positions)
|
||||||
|
const codes = (strategyRes.value ?? []).map((p) => p.code).filter(Boolean);
|
||||||
|
registerCodes(codes);
|
||||||
|
}
|
||||||
if (allRes && allRes.ok) setAllPositions(allRes.value);
|
if (allRes && allRes.ok) setAllPositions(allRes.value);
|
||||||
if ((strategyRes && !strategyRes.ok) || (allRes && !allRes.ok)) {
|
if ((strategyRes && !strategyRes.ok) || (allRes && !allRes.ok)) {
|
||||||
setError((strategyRes?.error?.message) || (allRes?.error?.message) || '加载失败');
|
setError((strategyRes?.error?.message) || (allRes?.error?.message) || '加载失败');
|
||||||
@@ -244,6 +252,7 @@ function StrategyTabInner({ strategyId, strategyName }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<LoadState loading={loading} error={error} onRetry={load} autoRetry={2}>
|
<LoadState loading={loading} error={error} onRetry={load} autoRetry={2}>
|
||||||
{showAdd && (
|
{showAdd && (
|
||||||
<div className="odl-add-form">
|
<div className="odl-add-form">
|
||||||
@@ -286,6 +295,7 @@ function StrategyTabInner({ strategyId, strategyName }) {
|
|||||||
<tr style={{ textAlign: 'left', borderBottom: '2px solid #ddd' }}>
|
<tr style={{ textAlign: 'left', borderBottom: '2px solid #ddd' }}>
|
||||||
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>代码</th>
|
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>代码</th>
|
||||||
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>名称</th>
|
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>名称</th>
|
||||||
|
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>现价</th>
|
||||||
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>策略份额</th>
|
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>策略份额</th>
|
||||||
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>总持仓</th>
|
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>总持仓</th>
|
||||||
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>操作</th>
|
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>操作</th>
|
||||||
@@ -296,6 +306,7 @@ function StrategyTabInner({ strategyId, strategyName }) {
|
|||||||
<tr key={p.code} style={{ borderBottom: '1px solid #eee' }}>
|
<tr key={p.code} style={{ borderBottom: '1px solid #eee' }}>
|
||||||
<td style={{ padding: '8px 6px' }}>{p.code}</td>
|
<td style={{ padding: '8px 6px' }}>{p.code}</td>
|
||||||
<td style={{ padding: '8px 6px' }}>{p.name}</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', fontWeight: 'bold' }}>{p.shares}</td>
|
||||||
<td style={{ padding: '8px 6px' }}>{p.volume}</td>
|
<td style={{ padding: '8px 6px' }}>{p.volume}</td>
|
||||||
<td style={{ padding: '8px 6px', whiteSpace: 'nowrap' }}>
|
<td style={{ padding: '8px 6px', whiteSpace: 'nowrap' }}>
|
||||||
|
|||||||
@@ -0,0 +1,323 @@
|
|||||||
|
/**
|
||||||
|
* TradeRecordsTab —— 交易记录 tab(R-007 v3,2026-09-01)
|
||||||
|
*
|
||||||
|
* 单表合并视图:
|
||||||
|
* - 主行 = 一笔委托(按 m_strOrderSysID 唯一),聚合成交信息(成交量/加权均价/金额/手续费)
|
||||||
|
* - 点击展开/折叠 → 逐笔成交明细(成交时间/价/量/额/手续费/成交号)
|
||||||
|
* - 排序:默认按委托时间降序(最新在前);点击表头手动排序
|
||||||
|
* - 时间段查询:标题 + RangeSelector 同一行(今日/本周/上周快捷 + 手动起止日期)
|
||||||
|
* - 轮询:仅「今日」范围 3-5s 自动刷新;其他范围一次性查询
|
||||||
|
*
|
||||||
|
* 方向:红买绿卖(Q7 定稿);状态中文映射;未成交显示 —
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
import { useRpc } from './connection.jsx';
|
||||||
|
import { LoadState } from './LoadState.jsx';
|
||||||
|
import { RangeSelector } from './RangeSelector.jsx';
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 4000; // 3-5s 轮询(取 4s)
|
||||||
|
|
||||||
|
/** 委托状态码 → 中文(官方 ORDER_STATUS 字典) */
|
||||||
|
const ORDER_STATUS = {
|
||||||
|
48: '未报', 49: '待报', 50: '已报', 51: '已报待撤', 52: '部成待撤',
|
||||||
|
53: '部撤', 54: '已撤', 55: '部成', 56: '已成', 57: '废单',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** HHMMSS → HH:MM:SS */
|
||||||
|
function fmtTime(t) {
|
||||||
|
if (!t || t.length !== 6) return t ?? '';
|
||||||
|
return t.slice(0, 2) + ':' + t.slice(2, 4) + ':' + t.slice(4, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 数字格式化 */
|
||||||
|
function fmtNum(v, digits = 2) {
|
||||||
|
if (v == null || !isFinite(v)) return '—';
|
||||||
|
return Number(v).toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 方向样式:红买绿卖 */
|
||||||
|
function directionStyle(direction) {
|
||||||
|
if (direction === 'buy') return { color: '#d32f2f', fontWeight: 'bold' };
|
||||||
|
if (direction === 'sell') return { color: '#2e7d32', fontWeight: 'bold' };
|
||||||
|
return { color: '#999' };
|
||||||
|
}
|
||||||
|
function directionText(direction) {
|
||||||
|
if (direction === 'buy') return '买入';
|
||||||
|
if (direction === 'sell') return '卖出';
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 合并逻辑:按 m_strOrderSysID 把成交聚合进委托 */
|
||||||
|
function mergeOrdersAndTrades(orders, trades) {
|
||||||
|
const tradeByOrder = new Map();
|
||||||
|
for (const t of trades) {
|
||||||
|
if (!tradeByOrder.has(t.orderId)) tradeByOrder.set(t.orderId, []);
|
||||||
|
tradeByOrder.get(t.orderId).push(t);
|
||||||
|
}
|
||||||
|
return orders.map((o) => {
|
||||||
|
const ts = tradeByOrder.get(o.orderId) ?? [];
|
||||||
|
const totalVol = ts.reduce((s, t) => s + t.volume, 0);
|
||||||
|
const totalAmount = ts.reduce((s, t) => s + t.amount, 0);
|
||||||
|
// 费用逐笔计算后聚合(每笔 commission/stampTax/transferFee 已是金额)
|
||||||
|
const aggCommission = ts.reduce((s, t) => s + t.commission, 0);
|
||||||
|
const aggStampTax = ts.reduce((s, t) => s + t.stampTax, 0);
|
||||||
|
const aggTransferFee = ts.reduce((s, t) => s + t.transferFee, 0);
|
||||||
|
const aggFeeTotal = ts.reduce((s, t) => s + t.feeTotal, 0);
|
||||||
|
const weightedPrice = totalVol > 0
|
||||||
|
? ts.reduce((s, t) => s + t.price * t.volume, 0) / totalVol
|
||||||
|
: 0;
|
||||||
|
return {
|
||||||
|
...o,
|
||||||
|
trades: ts,
|
||||||
|
aggVolume: totalVol,
|
||||||
|
aggPrice: weightedPrice,
|
||||||
|
aggAmount: totalAmount,
|
||||||
|
aggCommission,
|
||||||
|
aggStampTax,
|
||||||
|
aggTransferFee,
|
||||||
|
aggFeeTotal,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TradeRecordsTab() {
|
||||||
|
const [orders, setOrders] = useState(null);
|
||||||
|
const [trades, setTrades] = useState(null);
|
||||||
|
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 [lastDate, setLastDate] = useState(null); // 最近交易日
|
||||||
|
|
||||||
|
// 默认加载「今日」(preset='today',单向意图):lastDate 到位且 range 未设置时自动设为今日
|
||||||
|
useEffect(() => {
|
||||||
|
if (lastDate && !range) {
|
||||||
|
setRange({ start: lastDate, end: lastDate });
|
||||||
|
}
|
||||||
|
}, [lastDate, range]);
|
||||||
|
const [expanded, setExpanded] = useState(new Set());
|
||||||
|
const [sortKey, setSortKey] = useState('insertTime');
|
||||||
|
const [sortDir, setSortDir] = useState('desc');
|
||||||
|
const call = useRpc();
|
||||||
|
|
||||||
|
// 是否「今日」意图(preset='today',单向)→ 轮询
|
||||||
|
const isTodayRange = preset === 'today';
|
||||||
|
// 是否历史范围(非今日意图)→ 本期占位(QMT Bridge 历史接口开发中)
|
||||||
|
const isHistoryRange = !!range && preset !== 'today';
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!range) return;
|
||||||
|
if (isHistoryRange) {
|
||||||
|
// 历史范围:本期占位(老师将完善 QMT Bridge 历史接口)
|
||||||
|
setOrders([]);
|
||||||
|
setTrades([]);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const [oRes, tRes] = await Promise.all([
|
||||||
|
call('one-divine-lot/orders', { args: { start: range.start, end: range.end } }),
|
||||||
|
call('one-divine-lot/trades', { args: { start: range.start, end: range.end } }),
|
||||||
|
]);
|
||||||
|
if (oRes?.ok && Array.isArray(oRes.value)) setOrders(oRes.value);
|
||||||
|
else throw new Error((oRes && oRes.error && oRes.error.message) || '委托加载失败');
|
||||||
|
if (tRes?.ok && Array.isArray(tRes.value)) setTrades(tRes.value);
|
||||||
|
else throw new Error((tRes && tRes.error && tRes.error.message) || '成交加载失败');
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message);
|
||||||
|
setOrders([]);
|
||||||
|
setTrades([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [call, range, isHistoryRange]);
|
||||||
|
|
||||||
|
// 时间段变化 → 重新加载(展开重置)
|
||||||
|
useEffect(() => {
|
||||||
|
setExpanded(new Set());
|
||||||
|
load();
|
||||||
|
}, [load, range]);
|
||||||
|
|
||||||
|
// 仅「今日」范围轮询
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isTodayRange) return;
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
Promise.all([
|
||||||
|
call('one-divine-lot/orders', { args: { start: range.start, end: range.end } }),
|
||||||
|
call('one-divine-lot/trades', { args: { start: range.start, end: range.end } }),
|
||||||
|
]).then(([o, t]) => {
|
||||||
|
if (o?.ok && Array.isArray(o.value)) setOrders(o.value);
|
||||||
|
if (t?.ok && Array.isArray(t.value)) setTrades(t.value);
|
||||||
|
}).catch(() => {});
|
||||||
|
}, POLL_INTERVAL_MS);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [call, isTodayRange, range]);
|
||||||
|
|
||||||
|
// 合并 + 排序
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
const merged = mergeOrdersAndTrades(orders ?? [], trades ?? []);
|
||||||
|
const dir = sortDir === 'asc' ? 1 : -1;
|
||||||
|
return merged.slice().sort((a, b) => {
|
||||||
|
const va = a[sortKey];
|
||||||
|
const vb = b[sortKey];
|
||||||
|
if (typeof va === 'string' && typeof vb === 'string') return va.localeCompare(vb) * dir;
|
||||||
|
return (va - vb) * dir;
|
||||||
|
});
|
||||||
|
}, [orders, trades, sortKey, sortDir]);
|
||||||
|
|
||||||
|
const toggleSort = (key) => {
|
||||||
|
if (sortKey === key) {
|
||||||
|
setSortDir((d) => (d === 'desc' ? 'asc' : 'desc'));
|
||||||
|
} else {
|
||||||
|
setSortKey(key);
|
||||||
|
setSortDir('desc');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleExpand = (orderId) => {
|
||||||
|
setExpanded((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(orderId)) next.delete(orderId);
|
||||||
|
else next.add(orderId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const arrow = (key) => (sortKey === key ? (sortDir === 'desc' ? ' ▼' : ' ▲') : '');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 16, fontFamily: 'system-ui' }}>
|
||||||
|
{/* 标题 + 时间段选择同一行(2026-09-01 老师反馈) */}
|
||||||
|
<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>
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 委托主行 + 展开明细 */
|
||||||
|
function OrderRows({ order, expanded, onToggle }) {
|
||||||
|
const hasTrades = order.trades.length > 0;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<tr
|
||||||
|
onClick={onToggle}
|
||||||
|
style={{
|
||||||
|
borderBottom: '1px solid #eee',
|
||||||
|
cursor: hasTrades ? 'pointer' : 'default',
|
||||||
|
background: expanded ? '#f5f5f5' : 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<td style={tdStyle}>{hasTrades ? (expanded ? '▾' : '▸') : <span style={{ color: '#ccc' }}>·</span>}</td>
|
||||||
|
<td style={tdStyle}>{fmtTime(order.insertTime)}</td>
|
||||||
|
<td style={tdStyle}>{order.code}</td>
|
||||||
|
<td style={tdStyle}>{order.name}</td>
|
||||||
|
<td style={{ ...tdStyle, ...directionStyle(order.direction) }} title={order.optName}>{directionText(order.direction)}</td>
|
||||||
|
<td style={tdStyle}>{ORDER_STATUS[order.status] ?? order.status ?? '—'}</td>
|
||||||
|
<td style={{ ...tdStyle, textAlign: 'right' }}>{fmtNum(order.orderVolume, 0)}</td>
|
||||||
|
<td style={{ ...tdStyle, textAlign: 'right' }}>{hasTrades ? fmtNum(order.aggVolume, 0) : '—'}</td>
|
||||||
|
<td style={{ ...tdStyle, textAlign: 'right' }}>{fmtNum(order.limitPrice)}</td>
|
||||||
|
<td style={{ ...tdStyle, textAlign: 'right' }}>{hasTrades ? fmtNum(order.aggPrice) : '—'}</td>
|
||||||
|
<td style={{ ...tdStyle, textAlign: 'right' }}>{hasTrades ? fmtNum(order.aggAmount) : '—'}</td>
|
||||||
|
<td style={{ ...tdStyle, textAlign: 'right' }}>{hasTrades ? fmtNum(order.aggFeeTotal, 2) : '—'}</td>
|
||||||
|
<td style={{ ...tdStyle, textAlign: 'right' }}>{hasTrades ? fmtNum(order.aggCommission, 2) : '—'}</td>
|
||||||
|
<td style={{ ...tdStyle, textAlign: 'right' }}>{hasTrades ? fmtNum(order.aggStampTax, 2) : '—'}</td>
|
||||||
|
<td style={{ ...tdStyle, textAlign: 'right' }}>{hasTrades ? fmtNum(order.aggTransferFee, 2) : '—'}</td>
|
||||||
|
</tr>
|
||||||
|
{expanded && hasTrades && (
|
||||||
|
<tr style={{ borderBottom: '1px solid #eee', background: '#fafafa' }}>
|
||||||
|
<td colSpan={15} style={{ padding: '4px 8px 12px' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12, marginTop: 4 }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: '1px solid #ddd', color: '#666' }}>
|
||||||
|
<th style={{ ...tdStyle, textAlign: 'left' }}>成交时间</th>
|
||||||
|
<th style={{ ...tdStyle, textAlign: 'right' }}>成交价</th>
|
||||||
|
<th style={{ ...tdStyle, textAlign: 'right' }}>成交量</th>
|
||||||
|
<th style={{ ...tdStyle, textAlign: 'right' }}>成交额</th>
|
||||||
|
<th style={{ ...tdStyle, textAlign: 'right' }}>总费用</th>
|
||||||
|
<th style={{ ...tdStyle, textAlign: 'right' }}>佣金</th>
|
||||||
|
<th style={{ ...tdStyle, textAlign: 'right' }}>印花税</th>
|
||||||
|
<th style={{ ...tdStyle, textAlign: 'right' }}>过户费</th>
|
||||||
|
<th style={{ ...tdStyle, textAlign: 'right' }}>成交号</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{order.trades.map((t) => (
|
||||||
|
<tr key={t.tradeId} style={{ borderBottom: '1px solid #f0f0f0' }}>
|
||||||
|
<td style={tdStyle}>{fmtTime(t.tradeTime)}</td>
|
||||||
|
<td style={{ ...tdStyle, textAlign: 'right' }}>{fmtNum(t.price)}</td>
|
||||||
|
<td style={{ ...tdStyle, textAlign: 'right' }}>{fmtNum(t.volume, 0)}</td>
|
||||||
|
<td style={{ ...tdStyle, textAlign: 'right' }}>{fmtNum(t.amount)}</td>
|
||||||
|
<td style={{ ...tdStyle, textAlign: 'right' }}>{fmtNum(t.feeTotal, 2)}</td>
|
||||||
|
<td style={{ ...tdStyle, textAlign: 'right' }}>{fmtNum(t.commission, 2)}</td>
|
||||||
|
<td style={{ ...tdStyle, textAlign: 'right' }}>{fmtNum(t.stampTax, 2)}</td>
|
||||||
|
<td style={{ ...tdStyle, textAlign: 'right' }}>{fmtNum(t.transferFee, 2)}</td>
|
||||||
|
<td style={{ ...tdStyle, textAlign: 'right', color: '#999' }}>{t.tradeId}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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' };
|
||||||
@@ -21,12 +21,19 @@ import { homedir } from 'node:os';
|
|||||||
const DEFAULT_DATA_DIR = join(homedir(), '.dsh', 'one-divine-lot');
|
const DEFAULT_DATA_DIR = join(homedir(), '.dsh', 'one-divine-lot');
|
||||||
const FILE_NAME = 'allocations.json';
|
const FILE_NAME = 'allocations.json';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试模式数据目录(防误删真实数据,2026-08-31 老师定):
|
||||||
|
* - 环境变量 ODL_TEST_DATA_DIR 设置时,存储落到独立测试目录,不影响真实 allocations.json;
|
||||||
|
* - 测试脚本必须显式注入 dataDir 或设置该环境变量,禁止在真实数据上执行写操作。
|
||||||
|
*/
|
||||||
|
const TEST_DATA_DIR = process.env.ODL_TEST_DATA_DIR || '';
|
||||||
|
|
||||||
export class AllocationStorage {
|
export class AllocationStorage {
|
||||||
/**
|
/**
|
||||||
* @param {object} [options]
|
* @param {object} [options]
|
||||||
* @param {string} [options.dataDir] 数据目录(默认 ~/.dsh/one-divine-lot)
|
* @param {string} [options.dataDir] 数据目录(默认 ~/.dsh/one-divine-lot)
|
||||||
*/
|
*/
|
||||||
constructor({ dataDir = DEFAULT_DATA_DIR } = {}) {
|
constructor({ dataDir = TEST_DATA_DIR || DEFAULT_DATA_DIR } = {}) {
|
||||||
this.dataDir = dataDir;
|
this.dataDir = dataDir;
|
||||||
this.filePath = join(dataDir, FILE_NAME);
|
this.filePath = join(dataDir, FILE_NAME);
|
||||||
this.data = { version: 1, allocations: {} };
|
this.data = { version: 1, allocations: {} };
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
/**
|
||||||
|
* DataStore —— 策略数据集 JSON data store(R-006,2026-08-31 老师定)
|
||||||
|
*
|
||||||
|
* 架构(定稿结论):
|
||||||
|
* - 策略定义仍留 DSH settings(设置页交互不变);
|
||||||
|
* - 份额分配(数据集)标准化为 JSON data store:
|
||||||
|
* - store.schema.json:数据集 schema(类 JSON Schema,描述 dataset 字段);
|
||||||
|
* - store.json:实际数据(策略为中心:strategies[].dataset = [{code, shares}]);
|
||||||
|
* - 旧 allocations.json 启动时自动迁移(保留 .bak 备份);
|
||||||
|
* - 删除策略时由 API 层联动删对应 dataset。
|
||||||
|
*
|
||||||
|
* 2026-08-31 扩展:行情持久化(R-006 后续,DataStore 为数据快速展现存在)
|
||||||
|
* - store.market.json:最近一次行情快照(code → { lastPrice, lastClose, ... }),
|
||||||
|
* 供宿主重启后首屏快速展示价格(不依赖实盘订阅),实盘轮询/订阅做增量更新并定期写回。
|
||||||
|
*
|
||||||
|
* 文件位置(默认 ~/.dsh/one-divine-lot/):
|
||||||
|
* store.schema.json # schema 定义(只读,随代码发布)
|
||||||
|
* store.json # 份额分配数据(原子写)
|
||||||
|
* store.market.json # 行情快照缓存(原子写)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { promises as fs } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
|
||||||
|
const DEFAULT_DATA_DIR = join(homedir(), '.dsh', 'one-divine-lot');
|
||||||
|
const TEST_DATA_DIR = process.env.ODL_TEST_DATA_DIR || '';
|
||||||
|
const SCHEMA_FILE = 'store.schema.json';
|
||||||
|
const DATA_FILE = 'store.json';
|
||||||
|
const MARKET_FILE = 'store.market.json';
|
||||||
|
const LEGACY_FILE = 'allocations.json';
|
||||||
|
|
||||||
|
/** 数据集 schema(类 JSON Schema,R-006 定稿) */
|
||||||
|
export const STORE_SCHEMA = {
|
||||||
|
version: 1,
|
||||||
|
kind: 'one-divine-lot-data-store',
|
||||||
|
strategies: {
|
||||||
|
description: '策略数据集:每个策略一个 dataset(份额分配)',
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
strategyId: { type: 'string', required: true, description: '策略 id(与 settings 中的策略一致)' },
|
||||||
|
dataset: {
|
||||||
|
type: 'array',
|
||||||
|
description: '该策略的持仓数据集(份额分配)',
|
||||||
|
items: {
|
||||||
|
code: { type: 'string', required: true, description: '证券代码(含后缀,如 600719.SH)' },
|
||||||
|
shares: { type: 'number', required: true, description: '份额(股数,>0)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export class DataStore {
|
||||||
|
/**
|
||||||
|
* @param {object} [options]
|
||||||
|
* @param {string} [options.dataDir] 数据目录(默认 ~/.dsh/one-divine-lot;测试可用 ODL_TEST_DATA_DIR)
|
||||||
|
*/
|
||||||
|
constructor({ dataDir = TEST_DATA_DIR || DEFAULT_DATA_DIR } = {}) {
|
||||||
|
this.dataDir = dataDir;
|
||||||
|
this.schemaPath = join(dataDir, SCHEMA_FILE);
|
||||||
|
this.dataPath = join(dataDir, DATA_FILE);
|
||||||
|
this.marketPath = join(dataDir, MARKET_FILE);
|
||||||
|
this.legacyPath = join(dataDir, LEGACY_FILE);
|
||||||
|
this.data = { version: 1, strategies: [] };
|
||||||
|
this.market = {}; // code → snapshot
|
||||||
|
this.loaded = false;
|
||||||
|
this.marketLoaded = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 确保目录存在 */
|
||||||
|
async _ensureDir() {
|
||||||
|
await fs.mkdir(this.dataDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 写入 schema 文件(随 store.json 一起确保存在) */
|
||||||
|
async _ensureSchema() {
|
||||||
|
await this._ensureDir();
|
||||||
|
try {
|
||||||
|
await fs.access(this.schemaPath);
|
||||||
|
} catch {
|
||||||
|
await fs.writeFile(this.schemaPath, JSON.stringify(STORE_SCHEMA, null, 2), 'utf8');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 加载份额分配数据(首次从磁盘读,含旧数据迁移) */
|
||||||
|
async load() {
|
||||||
|
if (this.loaded) return this.data;
|
||||||
|
await this._ensureDir();
|
||||||
|
await this._ensureSchema();
|
||||||
|
|
||||||
|
// 1. 若旧 allocations.json 存在且 store.json 不存在 → 迁移
|
||||||
|
const hasLegacy = await this._exists(this.legacyPath);
|
||||||
|
const hasStore = await this._exists(this.dataPath);
|
||||||
|
if (hasLegacy && !hasStore) {
|
||||||
|
await this._migrateFromLegacy();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 读 store.json
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(this.dataPath, 'utf8');
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (parsed && typeof parsed === 'object') {
|
||||||
|
this.data = {
|
||||||
|
version: parsed.version ?? 1,
|
||||||
|
strategies: Array.isArray(parsed.strategies) ? parsed.strategies : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code !== 'ENOENT') {
|
||||||
|
throw new Error(`数据读取失败: ${err.message}`);
|
||||||
|
}
|
||||||
|
// store.json 不存在且无旧数据:空 store
|
||||||
|
await this.save();
|
||||||
|
}
|
||||||
|
this.loaded = true;
|
||||||
|
return this.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存份额分配(原子写:临时文件 + rename) */
|
||||||
|
async save() {
|
||||||
|
await this._ensureDir();
|
||||||
|
const tmpPath = `${this.dataPath}.${process.pid}.tmp`;
|
||||||
|
await fs.writeFile(tmpPath, JSON.stringify(this.data, null, 2), 'utf8');
|
||||||
|
await fs.rename(tmpPath, this.dataPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 迁移:allocations.json(code 为中心)→ store.json(策略为中心) */
|
||||||
|
async _migrateFromLegacy() {
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(this.legacyPath, 'utf8');
|
||||||
|
const legacy = JSON.parse(raw);
|
||||||
|
const alloc = legacy?.allocations ?? {};
|
||||||
|
// code 为中心 → 策略为中心
|
||||||
|
const strategyMap = new Map();
|
||||||
|
for (const [code, shares] of Object.entries(alloc)) {
|
||||||
|
for (const [strategyId, n] of Object.entries(shares ?? {})) {
|
||||||
|
if (n > 0) {
|
||||||
|
if (!strategyMap.has(strategyId)) strategyMap.set(strategyId, []);
|
||||||
|
strategyMap.get(strategyId).push({ code, shares: n });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.data = {
|
||||||
|
version: 1,
|
||||||
|
strategies: [...strategyMap.entries()].map(([strategyId, dataset]) => ({ strategyId, dataset })),
|
||||||
|
};
|
||||||
|
await this.save();
|
||||||
|
// 保留旧文件为备份
|
||||||
|
await fs.rename(this.legacyPath, this.legacyPath + '.bak');
|
||||||
|
console.log('[one-divine-lot] 已迁移 allocations.json → store.json(旧文件备份为 allocations.json.bak)');
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[one-divine-lot] 迁移失败(忽略,使用空 store): ' + err.message);
|
||||||
|
this.data = { version: 1, strategies: [] };
|
||||||
|
await this.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _exists(p) {
|
||||||
|
try { await fs.access(p); return true; } catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 行情持久化(store.market.json,R-006 扩展)=====
|
||||||
|
|
||||||
|
/** 加载行情快照缓存(启动时读盘,供首屏快速展示) */
|
||||||
|
async loadMarket() {
|
||||||
|
if (this.marketLoaded) return this.market;
|
||||||
|
await this._ensureDir();
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(this.marketPath, 'utf8');
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (parsed && typeof parsed === 'object' && parsed.quotes) {
|
||||||
|
this.market = { ...parsed.quotes };
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code !== 'ENOENT') {
|
||||||
|
// 行情缓存损坏不阻塞(回退空缓存)
|
||||||
|
console.warn('[one-divine-lot] 行情缓存读取失败(忽略): ' + err.message);
|
||||||
|
}
|
||||||
|
this.market = {};
|
||||||
|
}
|
||||||
|
this.marketLoaded = true;
|
||||||
|
return this.market;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取行情快照(code → snapshot),无则 undefined */
|
||||||
|
async getMarketQuote(code) {
|
||||||
|
await this.loadMarket();
|
||||||
|
return this.market[code];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量读取行情快照 */
|
||||||
|
async getMarketQuotes(codes) {
|
||||||
|
await this.loadMarket();
|
||||||
|
const out = {};
|
||||||
|
for (const c of codes) {
|
||||||
|
if (this.market[c]) out[c] = this.market[c];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 写入行情快照(整体替换,2026-09-01 修复)——传入完整集合,清除不在其中的旧键 */
|
||||||
|
async setMarketQuotes(quotes) {
|
||||||
|
await this.loadMarket();
|
||||||
|
const next = {};
|
||||||
|
for (const [code, snap] of Object.entries(quotes || {})) {
|
||||||
|
if (snap) next[code] = snap;
|
||||||
|
}
|
||||||
|
this.market = next; // 替换(不是合并),防止旧键残留导致膨胀
|
||||||
|
await this.saveMarket();
|
||||||
|
return this.market;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存行情快照(原子写 store.market.json) */
|
||||||
|
async saveMarket() {
|
||||||
|
await this._ensureDir();
|
||||||
|
const tmpPath = `${this.marketPath}.${process.pid}.tmp`;
|
||||||
|
const payload = { version: 1, savedAt: Date.now(), quotes: this.market };
|
||||||
|
await fs.writeFile(tmpPath, JSON.stringify(payload, null, 2), 'utf8');
|
||||||
|
await fs.rename(tmpPath, this.marketPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 份额分配 CRUD(store.json)=====
|
||||||
|
|
||||||
|
/** 读取某策略的数据集([{code, shares}]) */
|
||||||
|
async getDataset(strategyId) {
|
||||||
|
await this.load();
|
||||||
|
const s = this.data.strategies.find((x) => x.strategyId === strategyId);
|
||||||
|
return s ? [...s.dataset] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设置某策略的数据集(整体替换) */
|
||||||
|
async setDataset(strategyId, dataset) {
|
||||||
|
await this.load();
|
||||||
|
const items = Array.isArray(dataset) ? dataset.filter((x) => x && x.code && x.shares > 0) : [];
|
||||||
|
const idx = this.data.strategies.findIndex((x) => x.strategyId === strategyId);
|
||||||
|
if (idx >= 0) {
|
||||||
|
this.data.strategies[idx].dataset = items;
|
||||||
|
} else {
|
||||||
|
this.data.strategies.push({ strategyId, dataset: items });
|
||||||
|
}
|
||||||
|
await this.save();
|
||||||
|
return this.getDataset(strategyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除某策略的数据集(删除策略时联动) */
|
||||||
|
async removeDataset(strategyId) {
|
||||||
|
await this.load();
|
||||||
|
this.data.strategies = this.data.strategies.filter((x) => x.strategyId !== strategyId);
|
||||||
|
await this.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 全部数据集(策略为中心,供 summary/strategy-positions 使用) */
|
||||||
|
async getAllDatasets() {
|
||||||
|
await this.load();
|
||||||
|
return this.data.strategies.map((s) => ({ strategyId: s.strategyId, dataset: [...s.dataset] }));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
/**
|
||||||
|
* MarketDataHub —— 行情缓存服务(R-005,2026-08-31 从 market-cache.js 拆分;R-006 加持久化)
|
||||||
|
*
|
||||||
|
* 职责(单一):行情缓存的存储与查询。
|
||||||
|
* - 持有行情缓存 Map(code → snapshot);
|
||||||
|
* - getByCodes(codes):按 code 查询;miss 的 code 现场用 REST /data/tick 拉取补入(查询保证有值,调用方无感);
|
||||||
|
* - 写入入口:ingest(code→snapshot 批量写入,由 MarketFeed 调用)。
|
||||||
|
*
|
||||||
|
* 2026-08-31 持久化(DataStore 为数据快速展现存在,老师确认):
|
||||||
|
* - 接入 DataStore 的 store.market.json:启动时读盘填充内存缓存(首屏即有上次价格);
|
||||||
|
* - 实盘增量更新(ingest)后写回磁盘(防抖),重启不丢价格;
|
||||||
|
* - 查询命中顺序:内存 → 磁盘 → REST 补拉。
|
||||||
|
*
|
||||||
|
* 2026-09-01 修复:watchCodes 过滤(缓存膨胀 bug)——
|
||||||
|
* - WS 推送是全市场(whole),若不过滤会把 5 万+ 只股票写进缓存(store.market.json 膨胀到 38MB);
|
||||||
|
* - 只保留「关注集合」(前端查询过的 code + 持仓 code)的行情;
|
||||||
|
* - 关注集合来源:getByCodes 查询的 code 自动加入;warmup/启动时加入持仓 code。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { resolveActiveBaseUrl } from '../api/common.js';
|
||||||
|
|
||||||
|
const REQUEST_TIMEOUT_MS = 10000;
|
||||||
|
const PERSIST_DEBOUNCE_MS = 3000; // 写盘防抖
|
||||||
|
|
||||||
|
export class MarketDataHub {
|
||||||
|
/**
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {object} [opts.logger]
|
||||||
|
* @param {import('./DataStore.js').DataStore} [opts.dataStore] 持久化存储(可选;提供则启用行情落盘)
|
||||||
|
*/
|
||||||
|
constructor({ logger, dataStore } = {}) {
|
||||||
|
this.logger = logger;
|
||||||
|
this.dataStore = dataStore;
|
||||||
|
/** 行情缓存:code → snapshot(内存,启动时从磁盘加载) */
|
||||||
|
this.cache = new Map();
|
||||||
|
/** 关注集合:只缓存/持久化这些 code 的行情(防止全市场推送膨胀) */
|
||||||
|
this.watchCodes = new Set();
|
||||||
|
this.stats = { totalTicks: 0, wsPushCount: 0, restFallbackCount: 0, diskHitCount: 0, lastTickAt: 0 };
|
||||||
|
this._persistTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 加入关注集合(查询的 code / 持仓 code) */
|
||||||
|
watch(codes) {
|
||||||
|
const list = Array.isArray(codes) ? codes : [];
|
||||||
|
for (const c of list) {
|
||||||
|
if (c) this.watchCodes.add(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 启动时从磁盘加载行情缓存 + 关注集合 */
|
||||||
|
async warmup() {
|
||||||
|
if (!this.dataStore) return;
|
||||||
|
try {
|
||||||
|
const all = await this.dataStore.loadMarket();
|
||||||
|
let n = 0;
|
||||||
|
for (const [code, snap] of Object.entries(all)) {
|
||||||
|
// 只加载数据到内存缓存,不加入 watchCodes——
|
||||||
|
// watchCodes 应由「持仓 + 前端查询」驱动,不能从磁盘历史认可(否则全市场又进缓存)
|
||||||
|
if (snap) { this.cache.set(code, snap); n++; }
|
||||||
|
}
|
||||||
|
if (n > 0) this.logger?.info?.('[one-divine-lot] MarketDataHub 从磁盘加载行情: ' + n + ' 条');
|
||||||
|
} catch (e) {
|
||||||
|
this.logger?.debug?.('[one-divine-lot] MarketDataHub 行情预热失败: ' + (e?.message ?? e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 code 列表查询缓存行情;miss 依次查磁盘、REST 补拉(保证返回尽可能全)
|
||||||
|
*/
|
||||||
|
async getByCodes(codes, { settings, dataSource } = {}) {
|
||||||
|
const list = Array.isArray(codes) ? codes : [];
|
||||||
|
// 查询的 code 加入关注集合
|
||||||
|
this.watch(list);
|
||||||
|
const out = {};
|
||||||
|
const miss = [];
|
||||||
|
for (const c of list) {
|
||||||
|
const v = this.cache.get(c);
|
||||||
|
if (v) { out[c] = v; continue; }
|
||||||
|
// 磁盘兜底(持久化缓存)
|
||||||
|
if (this.dataStore) {
|
||||||
|
const dv = await this.dataStore.getMarketQuote(c);
|
||||||
|
if (dv) {
|
||||||
|
this.cache.set(c, dv);
|
||||||
|
out[c] = dv;
|
||||||
|
this.stats.diskHitCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
miss.push(c);
|
||||||
|
}
|
||||||
|
// miss 补拉(REST /data/tick)
|
||||||
|
if (miss.length > 0) {
|
||||||
|
await this._fetchAndIngest(miss, { settings, dataSource });
|
||||||
|
for (const c of miss) {
|
||||||
|
const v = this.cache.get(c);
|
||||||
|
if (v) out[c] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量写入缓存(MarketFeed 调用:WS 推送 / REST 定时刷新)——只保留关注集合内 */
|
||||||
|
ingest(data) {
|
||||||
|
if (!data) return;
|
||||||
|
let dirty = false;
|
||||||
|
for (const [code, snap] of Object.entries(data)) {
|
||||||
|
// 只缓存关注集合内的 code(防止全市场推送膨胀)
|
||||||
|
if (!this.watchCodes.has(code)) continue;
|
||||||
|
if (snap) {
|
||||||
|
this.cache.set(code, { ...this.cache.get(code), ...snap });
|
||||||
|
dirty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (dirty) this._schedulePersist();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量写入并统计(WS 推送专用) */
|
||||||
|
ingestWs(data) {
|
||||||
|
this.ingest(data);
|
||||||
|
this.stats.totalTicks++;
|
||||||
|
this.stats.wsPushCount++;
|
||||||
|
this.stats.lastTickAt = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 缓存大小(诊断用) */
|
||||||
|
get size() {
|
||||||
|
return this.cache.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 定期写盘(防抖) */
|
||||||
|
_schedulePersist() {
|
||||||
|
if (!this.dataStore) return;
|
||||||
|
if (this._persistTimer) clearTimeout(this._persistTimer);
|
||||||
|
this._persistTimer = setTimeout(() => {
|
||||||
|
this._persistTimer = null;
|
||||||
|
const quotes = {};
|
||||||
|
// 只写关注集合内的 code(防止 store.market.json 膨胀)
|
||||||
|
for (const code of this.watchCodes) {
|
||||||
|
const snap = this.cache.get(code);
|
||||||
|
if (snap) quotes[code] = snap;
|
||||||
|
}
|
||||||
|
this.dataStore.setMarketQuotes(quotes).catch((e) => {
|
||||||
|
this.logger?.debug?.('[one-divine-lot] 行情写盘失败: ' + (e?.message ?? e));
|
||||||
|
});
|
||||||
|
}, PERSIST_DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** REST 拉取并写入缓存(miss 补拉 + 定时刷新共用) */
|
||||||
|
async _fetchAndIngest(codes, { settings, dataSource } = {}) {
|
||||||
|
if (!Array.isArray(codes) || codes.length === 0) return;
|
||||||
|
try {
|
||||||
|
const baseUrl = resolveActiveBaseUrl(settings, dataSource);
|
||||||
|
const r = await fetch(baseUrl + '/data/tick?codes=' + encodeURIComponent(codes.join(',')), {
|
||||||
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
const j = await r.json().catch(() => ({}));
|
||||||
|
if (j.ok && j.data) {
|
||||||
|
this.ingest(j.data);
|
||||||
|
this.stats.restFallbackCount++;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.logger?.debug?.('[one-divine-lot] MarketDataHub REST 拉取失败: ' + (e?.message ?? e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
/**
|
||||||
|
* MarketFeed —— 行情数据获取(R-005,2026-08-31 从 market-cache.js 拆分)
|
||||||
|
*
|
||||||
|
* 职责(单一):从 QMT Bridge 获取行情数据,写入 MarketDataHub。
|
||||||
|
* - WS 订阅连接:ws://<激活host>:8610/ws,收全市场推送 → hub.ingestWs();
|
||||||
|
* - REST 定时刷新:每 5s 对 hub 中已有 code 拉 /data/tick 刷新(WS 不可靠时的兜底);
|
||||||
|
* - 启动主动拉取(prime):服务启动即拉一次持仓股票的盘口,保证缓存有最新价
|
||||||
|
* (2026-09-01 老师指出:服务端启动就该自动拿最新盘口,前端任何时候打开都有价,不依赖前端轮询触发);
|
||||||
|
* - 断线指数退避重连;setBaseUrl 热切换(激活配置变化时重建连接)。
|
||||||
|
*
|
||||||
|
* 与 MarketDataHub 的分工:
|
||||||
|
* - MarketFeed 只管「取数据」,不直接对外提供查询;
|
||||||
|
* - 数据写入走 hub.ingest() / hub.ingestWs(),查询走 hub.getByCodes()。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { resolveActiveBaseUrl } from '../api/common.js';
|
||||||
|
|
||||||
|
const RECONNECT_BASE_MS = 1000;
|
||||||
|
const RECONNECT_MAX_MS = 15000;
|
||||||
|
const SNAPSHOT_REFRESH_MS = 5000; // REST 定时刷新间隔
|
||||||
|
const REQUEST_TIMEOUT_MS = 10000;
|
||||||
|
|
||||||
|
/** 从 baseUrl 解析 ws 地址 */
|
||||||
|
function buildWsUrl(baseUrl) {
|
||||||
|
if (!baseUrl) return '';
|
||||||
|
try {
|
||||||
|
const u = new URL(String(baseUrl).replace(/\/+$/, ''));
|
||||||
|
return 'ws://' + u.host + '/ws';
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MarketFeed {
|
||||||
|
/**
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {object} opts.hub MarketDataHub 实例(缓存服务,写入目标)
|
||||||
|
* @param {object} opts.runtime { settings, dataSource } —— 解析激活 baseUrl、取持仓 code 用
|
||||||
|
* @param {object} [opts.logger]
|
||||||
|
*/
|
||||||
|
constructor({ hub, runtime, logger } = {}) {
|
||||||
|
this.hub = hub;
|
||||||
|
this.runtime = runtime;
|
||||||
|
this.logger = logger;
|
||||||
|
this.ws = null;
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
this.reconnectAttempt = 0;
|
||||||
|
this.refreshTimer = null;
|
||||||
|
this.baseUrl = '';
|
||||||
|
this.mounted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 启动:主动拉持仓盘口 + 连接 WS + 启动 REST 定时刷新 */
|
||||||
|
start(baseUrl) {
|
||||||
|
this.baseUrl = String(baseUrl ?? '').replace(/\/+$/, '');
|
||||||
|
if (!this.baseUrl) {
|
||||||
|
this.logger?.warn?.('[one-divine-lot] MarketFeed 无 baseUrl,不启动');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.mounted = true;
|
||||||
|
// 启动主动拉一次持仓盘口(服务端预热,前端打开即有价)
|
||||||
|
this._primePositions();
|
||||||
|
this._connect();
|
||||||
|
this._startRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 热切换:激活配置变化时调用 */
|
||||||
|
setBaseUrl(baseUrl) {
|
||||||
|
const next = String(baseUrl ?? '').replace(/\/+$/, '');
|
||||||
|
if (next === this.baseUrl) return;
|
||||||
|
this.baseUrl = next;
|
||||||
|
if (!this.mounted) return;
|
||||||
|
this._teardownWs();
|
||||||
|
this._connect();
|
||||||
|
// 切换后也主动拉一次(新地址的盘口)
|
||||||
|
this._primePositions();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 停止(插件释放时) */
|
||||||
|
stop() {
|
||||||
|
this.mounted = false;
|
||||||
|
this._teardownWs();
|
||||||
|
if (this.refreshTimer) {
|
||||||
|
clearInterval(this.refreshTimer);
|
||||||
|
this.refreshTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动/切换时主动拉持仓股票的盘口(REST /data/tick)写入缓存。
|
||||||
|
* 保证服务端启动即有最新价,前端任何时候打开页面都能从缓存拿到价格(不依赖前端轮询触发)。
|
||||||
|
*/
|
||||||
|
async _primePositions() {
|
||||||
|
if (!this.mounted || !this.baseUrl) return;
|
||||||
|
try {
|
||||||
|
// 从数据源拿当前持仓 code(QMT 真实持仓)
|
||||||
|
const positions = await this.runtime.dataSource.getPositions();
|
||||||
|
const codes = (positions || []).map((p) => p.code).filter(Boolean);
|
||||||
|
if (codes.length === 0) {
|
||||||
|
this.logger?.debug?.('[one-divine-lot] MarketFeed 无持仓可预热');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 持仓 code 加入关注集合(ingest 只保留这些)
|
||||||
|
this.hub.watch(codes);
|
||||||
|
const baseUrl = resolveActiveBaseUrl(this.runtime.settings, this.runtime.dataSource);
|
||||||
|
for (let i = 0; i < codes.length; i += 50) {
|
||||||
|
const batch = codes.slice(i, i + 50);
|
||||||
|
const res = await fetch(baseUrl + '/data/tick?codes=' + encodeURIComponent(batch.join(',')), {
|
||||||
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
const j = await res.json().catch(() => ({}));
|
||||||
|
if (j.ok && j.data) {
|
||||||
|
this.hub.ingest(j.data);
|
||||||
|
this.logger?.info?.('[one-divine-lot] MarketFeed 启动预热盘口: ' + Object.keys(j.data).length + ' 条');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.logger?.debug?.('[one-divine-lot] MarketFeed 启动预热失败: ' + (e?.message ?? e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** REST 定时刷新:对 hub 已有 code 拉 /data/tick 补数据 */
|
||||||
|
_startRefresh() {
|
||||||
|
if (this.refreshTimer) clearInterval(this.refreshTimer);
|
||||||
|
const run = async () => {
|
||||||
|
if (!this.mounted || !this.baseUrl) return;
|
||||||
|
const knownCodes = [...this.hub.cache.keys()];
|
||||||
|
if (knownCodes.length === 0) return;
|
||||||
|
try {
|
||||||
|
const baseUrl = resolveActiveBaseUrl(this.runtime.settings, this.runtime.dataSource);
|
||||||
|
for (let i = 0; i < knownCodes.length; i += 50) {
|
||||||
|
const batch = knownCodes.slice(i, i + 50);
|
||||||
|
const res = await fetch(baseUrl + '/data/tick?codes=' + encodeURIComponent(batch.join(',')), {
|
||||||
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
const j = await res.json().catch(() => ({}));
|
||||||
|
if (j.ok && j.data) {
|
||||||
|
this.hub.ingest(j.data);
|
||||||
|
if (this.hub.stats) this.hub.stats.restFallbackCount++; // 轮询也计入统计
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.logger?.debug?.('[one-divine-lot] MarketFeed REST 定时刷新失败: ' + (e?.message ?? e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
run();
|
||||||
|
this.refreshTimer = setInterval(run, SNAPSHOT_REFRESH_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
_teardownWs() {
|
||||||
|
if (this.reconnectTimer) {
|
||||||
|
clearTimeout(this.reconnectTimer);
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
}
|
||||||
|
if (this.ws) {
|
||||||
|
try { this.ws.onclose = null; this.ws.close(); } catch { /* ignore */ }
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_connect() {
|
||||||
|
if (!this.mounted || !this.baseUrl) return;
|
||||||
|
const wsUrl = buildWsUrl(this.baseUrl);
|
||||||
|
if (!wsUrl) {
|
||||||
|
this.logger?.warn?.('[one-divine-lot] MarketFeed ws 地址无效: ' + this.baseUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger?.info?.('[one-divine-lot] MarketFeed 连接: ' + wsUrl);
|
||||||
|
const ws = new WebSocket(wsUrl);
|
||||||
|
this.ws = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
this.reconnectAttempt = 0;
|
||||||
|
this.logger?.info?.('[one-divine-lot] MarketFeed WS 已连接: ' + wsUrl);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (ev) => {
|
||||||
|
let msg = null;
|
||||||
|
try { msg = typeof ev.data === 'string' ? JSON.parse(ev.data) : null; } catch { msg = null; }
|
||||||
|
if (!msg || msg.type !== 'whole' || !msg.data) return;
|
||||||
|
this.hub.ingestWs(msg.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
if (!this.mounted) return;
|
||||||
|
this.logger?.warn?.('[one-divine-lot] MarketFeed WS 断开,重连中...');
|
||||||
|
const attempt = this.reconnectAttempt++;
|
||||||
|
const delay = Math.min(RECONNECT_BASE_MS * Math.pow(2, attempt), RECONNECT_MAX_MS);
|
||||||
|
this.reconnectTimer = setTimeout(() => {
|
||||||
|
if (this.mounted && this.baseUrl) this._connect();
|
||||||
|
}, delay);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => { /* onclose 触发重连 */ };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,15 +8,19 @@
|
|||||||
* - Q4 不做统计(无市值/盈亏/占比聚合);
|
* - Q4 不做统计(无市值/盈亏/占比聚合);
|
||||||
* - Q5 UI = 策略 tab 内编辑(添加默认 0 起填 + 指定数量移出)。
|
* - Q5 UI = 策略 tab 内编辑(添加默认 0 起填 + 指定数量移出)。
|
||||||
*
|
*
|
||||||
* 数据存储(S4 storage):{ [code]: { [strategyId]: shares } }
|
* 2026-08-31 R-006:存储改为 DataStore(策略为中心,JSON data store schema)。
|
||||||
* 全部持仓来自数据源(QMT),份额为本地维护元数据。
|
* - 数据集存 store.json:strategies[].dataset = [{code, shares}]
|
||||||
|
* - 对外方法签名保持不变(addToStrategy / removeFromStrategy / getSummary 等),
|
||||||
|
* 上层(api)无感知。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { DataStore } from './DataStore.js';
|
||||||
|
|
||||||
export class PositionManager {
|
export class PositionManager {
|
||||||
/**
|
/**
|
||||||
* @param {object} opts
|
* @param {object} opts
|
||||||
* @param {import('../data-source/types.js').DataSource} opts.dataSource 数据源(QMT REST)
|
* @param {import('./data-source-types.js').DataSource} opts.dataSource 数据源(QMT REST)
|
||||||
* @param {import('../storage.js').AllocationStorage} opts.storage 分仓存储
|
* @param {DataStore} opts.storage 数据集存储(R-006 DataStore)
|
||||||
*/
|
*/
|
||||||
constructor({ dataSource, storage }) {
|
constructor({ dataSource, storage }) {
|
||||||
this.dataSource = dataSource;
|
this.dataSource = dataSource;
|
||||||
@@ -40,20 +44,35 @@ export class PositionManager {
|
|||||||
return pos ? pos.volume : 0;
|
return pos ? pos.volume : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 读取全部份额分配(code 为中心,兼容旧调用方){ code: { strategyId: shares } } */
|
||||||
|
async _getAllAsCodeMap() {
|
||||||
|
const datasets = await this.storage.getAllDatasets();
|
||||||
|
const map = {};
|
||||||
|
for (const { strategyId, dataset } of datasets) {
|
||||||
|
for (const item of dataset) {
|
||||||
|
if (!map[item.code]) map[item.code] = {};
|
||||||
|
map[item.code][strategyId] = item.shares;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
/** 某只票的份额分配 { strategyId: shares } */
|
/** 某只票的份额分配 { strategyId: shares } */
|
||||||
async getShares(code) {
|
async getShares(code) {
|
||||||
return this.storage.get(code);
|
const map = await this._getAllAsCodeMap();
|
||||||
|
return { ...(map[code] ?? {}) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 某策略下的持仓(份额 > 0 的标的) */
|
/** 某策略下的持仓(份额 > 0 的标的) */
|
||||||
async getStrategyPositions(strategyId) {
|
async getStrategyPositions(strategyId) {
|
||||||
const [positions, allocations] = await Promise.all([
|
const [positions, dataset] = await Promise.all([
|
||||||
this.getAllPositions(),
|
this.getAllPositions(),
|
||||||
this.storage.getAll(),
|
this.storage.getDataset(strategyId),
|
||||||
]);
|
]);
|
||||||
|
const shareMap = new Map(dataset.map((d) => [d.code, d.shares]));
|
||||||
return positions
|
return positions
|
||||||
.map((p) => {
|
.map((p) => {
|
||||||
const shares = allocations[p.code]?.[strategyId] ?? 0;
|
const shares = shareMap.get(p.code) ?? 0;
|
||||||
return shares > 0 ? { ...p, shares } : null;
|
return shares > 0 ? { ...p, shares } : null;
|
||||||
})
|
})
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
@@ -61,13 +80,13 @@ export class PositionManager {
|
|||||||
|
|
||||||
/** 未分配持仓(总持仓 - 已分配 > 0 的标的) */
|
/** 未分配持仓(总持仓 - 已分配 > 0 的标的) */
|
||||||
async getUnallocatedPositions() {
|
async getUnallocatedPositions() {
|
||||||
const [positions, allocations] = await Promise.all([
|
const [positions, codeMap] = await Promise.all([
|
||||||
this.getAllPositions(),
|
this.getAllPositions(),
|
||||||
this.storage.getAll(),
|
this._getAllAsCodeMap(),
|
||||||
]);
|
]);
|
||||||
return positions
|
return positions
|
||||||
.map((p) => {
|
.map((p) => {
|
||||||
const alloc = allocations[p.code] ?? {};
|
const alloc = codeMap[p.code] ?? {};
|
||||||
const allocated = Object.values(alloc).reduce((s, n) => s + n, 0);
|
const allocated = Object.values(alloc).reduce((s, n) => s + n, 0);
|
||||||
const unallocated = p.volume - allocated;
|
const unallocated = p.volume - allocated;
|
||||||
return unallocated > 0 ? { ...p, shares: alloc, unallocated } : null;
|
return unallocated > 0 ? { ...p, shares: alloc, unallocated } : null;
|
||||||
@@ -77,9 +96,6 @@ export class PositionManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 添加到策略(Q5:份额从 0 起填,每次只确定分配到当前策略的份额)
|
* 添加到策略(Q5:份额从 0 起填,每次只确定分配到当前策略的份额)
|
||||||
* @param {string} code 证券代码
|
|
||||||
* @param {string} strategyId 策略 id
|
|
||||||
* @param {number} shares 要添加到该策略的股数(> 0)
|
|
||||||
*/
|
*/
|
||||||
async addToStrategy(code, strategyId, shares) {
|
async addToStrategy(code, strategyId, shares) {
|
||||||
const total = await this.getTotalVolume(code);
|
const total = await this.getTotalVolume(code);
|
||||||
@@ -97,15 +113,12 @@ export class PositionManager {
|
|||||||
), { code: 'share-limit' });
|
), { code: 'share-limit' });
|
||||||
}
|
}
|
||||||
const next = { ...current, [strategyId]: num };
|
const next = { ...current, [strategyId]: num };
|
||||||
await this.storage.set(code, next);
|
await this._setShares(code, next);
|
||||||
return this.getShares(code);
|
return this.getShares(code);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从策略移出(Q5:指定数量移出)
|
* 从策略移出(Q5:指定数量移出)
|
||||||
* @param {string} code 证券代码
|
|
||||||
* @param {string} strategyId 策略 id
|
|
||||||
* @param {number} shares 要移出的股数(> 0,且 ≤ 该策略当前份额)
|
|
||||||
*/
|
*/
|
||||||
async removeFromStrategy(code, strategyId, shares) {
|
async removeFromStrategy(code, strategyId, shares) {
|
||||||
const current = await this.getShares(code);
|
const current = await this.getShares(code);
|
||||||
@@ -126,25 +139,43 @@ export class PositionManager {
|
|||||||
} else {
|
} else {
|
||||||
delete next[strategyId];
|
delete next[strategyId];
|
||||||
}
|
}
|
||||||
await this.storage.set(code, next);
|
await this._setShares(code, next);
|
||||||
return this.getShares(code);
|
return this.getShares(code);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 写入某票份额(code 为中心 → 更新各策略 dataset) */
|
||||||
* 分仓摘要(Q4:仅份额,无市值/盈亏统计)
|
async _setShares(code, shares) {
|
||||||
* @returns {{ positions: Array, strategies: Object<string, number>, allocatedTotal: number, unallocated: Array }}
|
const datasets = await this.storage.getAllDatasets();
|
||||||
*/
|
// 当前各策略 dataset(保留不含该 code 的部分,更新含该 code 的部分)
|
||||||
|
const strategyIds = [...new Set([...datasets.map((d) => d.strategyId), ...Object.keys(shares)])];
|
||||||
|
for (const sid of strategyIds) {
|
||||||
|
const existing = (datasets.find((d) => d.strategyId === sid)?.dataset ?? []).filter((x) => x.code !== code);
|
||||||
|
const n = shares[sid];
|
||||||
|
const nextItems = n > 0 ? [...existing, { code, shares: n }] : existing;
|
||||||
|
await this.storage.setDataset(sid, nextItems);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分仓摘要(Q4:仅份额,无市值/盈亏统计) */
|
||||||
async getSummary() {
|
async getSummary() {
|
||||||
const [positions, allocations] = await Promise.all([
|
const [positions, datasets] = await Promise.all([
|
||||||
this.getAllPositions(),
|
this.getAllPositions(),
|
||||||
this.storage.getAll(),
|
this.storage.getAllDatasets(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// 构建 code → { strategyId: shares }
|
||||||
|
const codeMap = {};
|
||||||
|
for (const { strategyId, dataset } of datasets) {
|
||||||
|
for (const item of dataset) {
|
||||||
|
if (!codeMap[item.code]) codeMap[item.code] = {};
|
||||||
|
codeMap[item.code][strategyId] = item.shares;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 每个策略的份额汇总
|
// 每个策略的份额汇总
|
||||||
const strategies = {};
|
const strategies = {};
|
||||||
// 每只票:{ totalVolume, shares: {sid: n}, allocated, unallocated }
|
|
||||||
const positionsDetail = positions.map((p) => {
|
const positionsDetail = positions.map((p) => {
|
||||||
const alloc = allocations[p.code] ?? {};
|
const alloc = codeMap[p.code] ?? {};
|
||||||
const allocated = Object.values(alloc).reduce((s, n) => s + n, 0);
|
const allocated = Object.values(alloc).reduce((s, n) => s + n, 0);
|
||||||
const unallocated = p.volume - allocated;
|
const unallocated = p.volume - allocated;
|
||||||
for (const [sid, n] of Object.entries(alloc)) {
|
for (const [sid, n] of Object.entries(alloc)) {
|
||||||
@@ -162,17 +193,14 @@ export class PositionManager {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
positions: positionsDetail,
|
positions: positionsDetail,
|
||||||
strategies, // { strategyId: 总份额 }
|
strategies,
|
||||||
allocatedTotal: Object.values(strategies).reduce((s, n) => s + n, 0),
|
allocatedTotal: Object.values(strategies).reduce((s, n) => s + n, 0),
|
||||||
unallocatedPositions: positionsDetail.filter((p) => p.unallocated > 0),
|
unallocatedPositions: positionsDetail.filter((p) => p.unallocated > 0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 全部移入(迭代 02 O6):把某只票的全部未分配份额移入指定策略
|
* 全部移入(迭代 02 O6):把某只票的全部未分配份额移入指定策略
|
||||||
* 未分配 = 总持仓 - 已分配;一次 add(shares = 未分配数)
|
|
||||||
* @param {string} code 证券代码
|
|
||||||
* @param {string} strategyId 策略 id
|
|
||||||
* @returns {Promise<Object<string, number>>} 更新后的份额分配
|
|
||||||
*/
|
*/
|
||||||
async moveAllUnallocatedToStrategy(code, strategyId) {
|
async moveAllUnallocatedToStrategy(code, strategyId) {
|
||||||
const total = await this.getTotalVolume(code);
|
const total = await this.getTotalVolume(code);
|
||||||
@@ -184,17 +212,17 @@ export class PositionManager {
|
|||||||
throw Object.assign(new Error(`标的 ${code} 无未分配份额`), { code: 'no-unallocated' });
|
throw Object.assign(new Error(`标的 ${code} 无未分配份额`), { code: 'no-unallocated' });
|
||||||
}
|
}
|
||||||
const next = { ...current, [strategyId]: (current[strategyId] ?? 0) + unallocated };
|
const next = { ...current, [strategyId]: (current[strategyId] ?? 0) + unallocated };
|
||||||
await this.storage.set(code, next);
|
await this._setShares(code, next);
|
||||||
return this.getShares(code);
|
return this.getShares(code);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 一键清零(迭代 02 O6):清空某策略下所有股票的份额(份额回未分配)
|
* 一键清零(迭代 02 O6):清空某策略下所有股票的份额
|
||||||
* @param {string} strategyId 策略 id
|
|
||||||
* @returns {Promise<string[]>} 受影响标的代码列表
|
* @returns {Promise<string[]>} 受影响标的代码列表
|
||||||
*/
|
*/
|
||||||
async clearStrategyShares(strategyId) {
|
async clearStrategyShares(strategyId) {
|
||||||
return this.storage.removeStrategyShares(strategyId);
|
const before = await this.storage.getDataset(strategyId);
|
||||||
|
await this.storage.removeDataset(strategyId);
|
||||||
|
return before.map((d) => d.code);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
/**
|
||||||
|
* QMT Bridge REST 数据源适配器
|
||||||
|
*
|
||||||
|
* 设计约束:
|
||||||
|
* - 技术约束-001:统一数据源抽象(业务层不感知具体数据源)
|
||||||
|
* - 技术约束-003:直连 QMT Bridge RESTful 接口(不经过 MCP)
|
||||||
|
*
|
||||||
|
* 服务地址:http://192.168.3.43:8610(可通过 config 覆盖)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DEFAULT_BASE_URL = 'http://192.168.3.43:8610';
|
||||||
|
const REQUEST_TIMEOUT_MS = 15000;
|
||||||
|
|
||||||
|
export class QmtBridgeRestDataSource {
|
||||||
|
constructor({ baseUrl = DEFAULT_BASE_URL, timeoutMs = REQUEST_TIMEOUT_MS } = {}) {
|
||||||
|
this.name = 'qmt-bridge-rest';
|
||||||
|
this.baseUrl = baseUrl.replace(/\/$/, '');
|
||||||
|
this.timeoutMs = timeoutMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 运行期更新基础地址(R-004 激活热切换,技术约束-008)。
|
||||||
|
* baseUrl 为按请求读取的实例字段,改字段即对后续请求生效,无需重建实例或重启。
|
||||||
|
*/
|
||||||
|
setBaseUrl(url) {
|
||||||
|
this.baseUrl = String(url ?? '').trim().replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 基础 HTTP 请求(fetch + 超时) */
|
||||||
|
async request(path, { method = 'GET', body } = {}) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`QMT Bridge REST ${method} ${path} 失败: HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
return await res.json();
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'AbortError') {
|
||||||
|
throw new Error(`QMT Bridge REST 请求超时: ${path} (${this.timeoutMs}ms)`);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 可用性检查 */
|
||||||
|
async isAvailable() {
|
||||||
|
try {
|
||||||
|
const data = await this.request('/health');
|
||||||
|
return data?.status === 'ok';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 资金摘要(/trade/asset) */
|
||||||
|
async getAsset() {
|
||||||
|
const res = await this.request('/trade/asset');
|
||||||
|
const a = Array.isArray(res?.data) ? res.data[0] : res?.data;
|
||||||
|
if (!a) throw new Error('QMT 资金数据为空');
|
||||||
|
return {
|
||||||
|
accountId: a.m_strAccountID ?? '',
|
||||||
|
status: a.m_strStatus ?? '',
|
||||||
|
tradingDate: a.m_strTradingDate ?? '',
|
||||||
|
totalAsset: a.m_dAssetBalance ?? 0,
|
||||||
|
available: a.m_dAvailable ?? 0,
|
||||||
|
stockValue: a.m_dStockValue ?? 0,
|
||||||
|
balance: a.m_dBalance ?? 0,
|
||||||
|
positionProfit: a.m_dPositionProfit ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 全量持仓(/trade/positions) */
|
||||||
|
async getPositions() {
|
||||||
|
const res = await this.request('/trade/positions');
|
||||||
|
const data = res?.data;
|
||||||
|
const list = Array.isArray(data) ? data : data?.positions;
|
||||||
|
if (!Array.isArray(list)) return [];
|
||||||
|
return list.map((p) => this.mapPosition(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合约基本信息(GET /data/instrument,技术约束-001 统一数据源抽象)
|
||||||
|
* 语义化字段优先,回退 QMT 原始字段(实际返回比 OpenAPI 规范更全,含股本/状态等)
|
||||||
|
* @param {string} code 完整证券代码(含后缀,如 600519.SH)
|
||||||
|
* @returns {Promise<import('./data-source-types.js').Instrument>}
|
||||||
|
*/
|
||||||
|
async getInstrument(code) {
|
||||||
|
if (!code) throw new Error('QMT instrument 查询缺少 code');
|
||||||
|
const res = await this.request('/data/instrument?code=' + encodeURIComponent(code));
|
||||||
|
const d = res?.data;
|
||||||
|
if (!d || !d.InstrumentID) throw new Error('QMT 合约信息为空: ' + code);
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
instrumentId: d.InstrumentID ?? '',
|
||||||
|
name: d.InstrumentName ?? '',
|
||||||
|
exchange: d.ExchangeID ?? '',
|
||||||
|
openDate: d.OpenDate ?? 0,
|
||||||
|
preClose: +(d.PreClose ?? d.preClose ?? 0),
|
||||||
|
upStopPrice: +(d.UpStopPrice ?? d.upStopPrice ?? 0),
|
||||||
|
downStopPrice: +(d.DownStopPrice ?? d.downStopPrice ?? 0),
|
||||||
|
floatVolume: +(d.FloatVolume ?? d.FloatVolumn ?? 0),
|
||||||
|
totalVolume: +(d.TotalVolume ?? d.TotalVolumn ?? 0),
|
||||||
|
priceTick: +(d.PriceTick ?? 0),
|
||||||
|
volumeMultiple: +(d.VolumeMultiple ?? 1),
|
||||||
|
instrumentStatus: +(d.InstrumentStatus ?? 0),
|
||||||
|
tradingDay: +(d.TradingDay ?? 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当日委托(GET /trade/orders,R-007)
|
||||||
|
* 过滤:code(600519 或 600519.SH)、status(active=排除已撤54/废单57,或数字状态码 48-57)
|
||||||
|
* 语义化映射(方向判定:m_nOffsetFlag 为主 48买/49卖 + m_strOptName 交叉验证,R-007 Q3 闭环)
|
||||||
|
* @param {object} [opts] { code, status }
|
||||||
|
* @returns {Promise<Array>} 语义化 order 列表
|
||||||
|
*/
|
||||||
|
async getOrders({ code, status, start, end } = {}) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (code) params.set('code', code);
|
||||||
|
if (status) params.set('status', status);
|
||||||
|
const qs = params.toString();
|
||||||
|
const res = await this.request('/trade/orders' + (qs ? '?' + qs : ''));
|
||||||
|
const list = Array.isArray(res?.data) ? res.data : [];
|
||||||
|
return list.map((o) => this.mapOrder(o));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当日成交(GET /trade/trades,R-007)
|
||||||
|
* 无过滤参数,返回全部当日成交
|
||||||
|
* @returns {Promise<Array>} 语义化 trade 列表
|
||||||
|
*/
|
||||||
|
async getTrades({ start, end } = {}) {
|
||||||
|
// 本期:QMT Bridge 无历史参数(老师将完善);预留 start/end 透传通道
|
||||||
|
void start; void end;
|
||||||
|
const res = await this.request('/trade/trades');
|
||||||
|
const list = Array.isArray(res?.data) ? res.data : [];
|
||||||
|
return list.map((t) => this.mapTrade(t));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 交易日历(GET /data/calendar/trading_dates,R-007 日期导航)
|
||||||
|
* @param {object} [opts] { start, end } YYYYMMDD
|
||||||
|
* @returns {Promise<{ market: string, dates: string[] }>}
|
||||||
|
*/
|
||||||
|
async getTradingDates({ start, end } = {}) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (start) params.set('start', start);
|
||||||
|
if (end) params.set('end', end);
|
||||||
|
const qs = params.toString();
|
||||||
|
const res = await this.request('/data/calendar/trading_dates' + (qs ? '?' + qs : ''));
|
||||||
|
return {
|
||||||
|
market: res?.market ?? '',
|
||||||
|
dates: Array.isArray(res?.dates) ? res.dates : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 委托字段映射:m_ 原始字段 → 语义字段(R-007 2.1/2.2) */
|
||||||
|
mapOrder(o) {
|
||||||
|
const direction = o.m_nOffsetFlag === 48 ? 'buy' : o.m_nOffsetFlag === 49 ? 'sell' : '';
|
||||||
|
return {
|
||||||
|
orderId: o.m_strOrderSysID ?? '',
|
||||||
|
code: o.m_strInstrumentID ?? '',
|
||||||
|
name: o.m_strInstrumentName ?? '',
|
||||||
|
exchange: o.m_strExchangeID ?? '',
|
||||||
|
direction, // 'buy' | 'sell' | ''
|
||||||
|
directionCode: o.m_nOffsetFlag ?? null, // 原始 48/49
|
||||||
|
optName: o.m_strOptName ?? '', // 中文操作名(限价卖出)
|
||||||
|
status: o.m_nOrderStatus ?? null, // 48-57
|
||||||
|
orderVolume: o.m_nVolumeTotalOriginal ?? 0,
|
||||||
|
tradedVolume: o.m_nVolumeTraded ?? 0,
|
||||||
|
totalVolume: o.m_nVolumeTotal ?? 0,
|
||||||
|
limitPrice: +(o.m_dLimitPrice ?? 0),
|
||||||
|
tradedPrice: +(o.m_dTradedPrice ?? 0),
|
||||||
|
amount: +(o.m_dTradeAmount ?? 0),
|
||||||
|
insertDate: o.m_strInsertDate ?? '',
|
||||||
|
insertTime: o.m_strInsertTime ?? '',
|
||||||
|
cancelInfo: o.m_strCancelInfo ?? '',
|
||||||
|
errorMsg: o.m_strErrorMsg ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算单笔成交的费用(2026-09-01 老师定规则)
|
||||||
|
* - 佣金:成交额 × (佣金费率万分之m_dCommission),不足 5 元按 5 元(账号没免五)
|
||||||
|
* - 印花税:卖出单边,成交额 × 万分之5(2023-08-28 起标准)
|
||||||
|
* - 过户费:双向,成交额 × 万分之0.1(2022-04-29 起标准,无最低限额)
|
||||||
|
* @param {number} amount 成交额
|
||||||
|
* @param {number} commissionRateWan 佣金费率(万分之,来自 m_dCommission)
|
||||||
|
* @param {'buy'|'sell'|''} direction 方向
|
||||||
|
* @returns {{ commission: number, stampTax: number, transferFee: number, total: number }}
|
||||||
|
*/
|
||||||
|
calcTradeFees(amount, commissionRateWan, direction) {
|
||||||
|
const a = Math.max(0, +(amount ?? 0));
|
||||||
|
// 佣金:万分之X,最低 5 元(没免五)
|
||||||
|
let commission = a * (commissionRateWan / 10000);
|
||||||
|
if (commission < 5 && commission > 0) commission = 5;
|
||||||
|
// 印花税:卖出单边,万分之5
|
||||||
|
const stampTax = direction === 'sell' ? a * 0.0005 : 0;
|
||||||
|
// 过户费:双向,万分之0.1
|
||||||
|
const transferFee = a * 0.00001;
|
||||||
|
return {
|
||||||
|
commission: +commission.toFixed(4),
|
||||||
|
stampTax: +stampTax.toFixed(4),
|
||||||
|
transferFee: +transferFee.toFixed(4),
|
||||||
|
total: +(commission + stampTax + transferFee).toFixed(4),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 成交字段映射:m_ 原始字段 → 语义字段(R-007 2.1/2.2)
|
||||||
|
* m_dCommission 语义(2026-09-01 老师定):佣金费率(万分之),如 0.852 = 万分之0.852 */
|
||||||
|
mapTrade(t) {
|
||||||
|
const direction = t.m_nOffsetFlag === 48 ? 'buy' : t.m_nOffsetFlag === 49 ? 'sell' : '';
|
||||||
|
const commissionRateWan = +(t.m_dCommission ?? t.m_dComssion ?? 0); // 拼写变体兜底
|
||||||
|
const amount = +(t.m_dTradeAmount ?? 0);
|
||||||
|
const fees = this.calcTradeFees(amount, commissionRateWan, direction);
|
||||||
|
return {
|
||||||
|
tradeId: t.m_strTradeID ?? '',
|
||||||
|
orderId: t.m_strOrderSysID ?? '', // 关联委托
|
||||||
|
code: t.m_strInstrumentID ?? '',
|
||||||
|
name: t.m_strInstrumentName ?? '',
|
||||||
|
exchange: t.m_strExchangeID ?? '',
|
||||||
|
direction, // 'buy' | 'sell' | ''
|
||||||
|
directionCode: t.m_nOffsetFlag ?? null,
|
||||||
|
optName: t.m_strOptName ?? '',
|
||||||
|
price: +(t.m_dPrice ?? 0),
|
||||||
|
volume: t.m_nVolume ?? 0,
|
||||||
|
amount,
|
||||||
|
commissionRateWan, // 佣金费率(万分之)
|
||||||
|
commission: fees.commission, // 佣金金额(最低5元)
|
||||||
|
stampTax: fees.stampTax, // 印花税(卖出)
|
||||||
|
transferFee: fees.transferFee, // 过户费
|
||||||
|
feeTotal: fees.total, // 总费用
|
||||||
|
tradeDate: t.m_strTradeDate ?? '',
|
||||||
|
tradeTime: t.m_strTradeTime ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 持仓字段映射:语义字段优先,回退 m_ 原始字段 */
|
||||||
|
mapPosition(p) {
|
||||||
|
return {
|
||||||
|
code: p.stock_code ?? p.m_strInstrumentID ?? '',
|
||||||
|
name: p.stock_name ?? p.m_strInstrumentName ?? '',
|
||||||
|
exchange: p.m_strExchangeName ?? '',
|
||||||
|
volume: p.volume ?? p.m_nVolume ?? 0,
|
||||||
|
available: p.available ?? p.m_nCanUseVolume ?? 0,
|
||||||
|
frozenVolume: p.frozen_volume ?? p.m_nFrozenVolume ?? 0,
|
||||||
|
avgPrice: +(p.avg_price ?? p.m_dAvgOpenPrice ?? 0),
|
||||||
|
price: +(p.price ?? p.m_dLastPrice ?? 0),
|
||||||
|
marketValue: +(p.market_value ?? p.m_dInstrumentValue ?? 0),
|
||||||
|
profit: +(p.profit ?? p.m_dFloatProfit ?? 0),
|
||||||
|
profitPct: +(p.profit_pct ?? (p.m_dProfitRate != null ? p.m_dProfitRate * 100 : 0)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* QmtHealthMonitor —— QMT 连接健康检查(2026-09-01 老师定)
|
||||||
|
*
|
||||||
|
* 机制(服务端定时 + 缓存):
|
||||||
|
* - DSH 服务端每 5 分钟探测一次激活 QMT Bridge /health,结果缓存在内存;
|
||||||
|
* - 前端打开页面时读缓存(秒回,不等实时探测);
|
||||||
|
* - 激活配置切换时立即补探一次(热切换联动);
|
||||||
|
* - 前端点击/悬停可触发即时探测(on-demand),刷新缓存。
|
||||||
|
*
|
||||||
|
* 职责(单一):维护 QMT 健康状态缓存。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { resolveActiveBaseUrl } from '../api/common.js';
|
||||||
|
|
||||||
|
const HEALTH_TIMEOUT_MS = 5000;
|
||||||
|
const HEALTH_INTERVAL_MS = 5 * 60 * 1000; // 5 分钟定时探测
|
||||||
|
|
||||||
|
export class QmtHealthMonitor {
|
||||||
|
/**
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {object} opts.runtime { settings, dataSource } —— 解析激活 baseUrl
|
||||||
|
* @param {object} [opts.logger]
|
||||||
|
*/
|
||||||
|
constructor({ runtime, logger } = {}) {
|
||||||
|
this.runtime = runtime;
|
||||||
|
this.logger = logger;
|
||||||
|
this.cache = null; // { baseUrl, healthy, latencyMs, httpStatus, healthError, checkedAt }
|
||||||
|
this.timer = null;
|
||||||
|
this.mounted = true;
|
||||||
|
this._inFlight = null; // 防止并发探测
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 启动:立即探测一次 + 每 5 分钟定时 */
|
||||||
|
start() {
|
||||||
|
this.mounted = true;
|
||||||
|
this.probe(); // 启动即探测
|
||||||
|
this.timer = setInterval(() => this.probe(), HEALTH_INTERVAL_MS);
|
||||||
|
this.logger?.info?.('[one-divine-lot] QmtHealthMonitor 已启动(5 分钟定时探测)');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 停止(插件释放时) */
|
||||||
|
stop() {
|
||||||
|
this.mounted = false;
|
||||||
|
if (this.timer) {
|
||||||
|
clearInterval(this.timer);
|
||||||
|
this.timer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取缓存健康状态(前端读这个,秒回) */
|
||||||
|
getStatus() {
|
||||||
|
return this.cache
|
||||||
|
? { ...this.cache, fromCache: true }
|
||||||
|
: { healthy: null, baseUrl: '', latencyMs: 0, healthError: null, checkedAt: 0, fromCache: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 立即探测(启动/定时/前端点击/配置切换共用),结果写缓存 */
|
||||||
|
async probe() {
|
||||||
|
if (!this.mounted) return this.cache;
|
||||||
|
// 防并发:同一时刻只探测一次
|
||||||
|
if (this._inFlight) return this._inFlight;
|
||||||
|
const baseUrl = resolveActiveBaseUrl(this.runtime.settings, this.runtime.dataSource);
|
||||||
|
const startedAt = Date.now();
|
||||||
|
this._inFlight = (async () => {
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
const res = await fetch(baseUrl + '/health', {
|
||||||
|
signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
result = {
|
||||||
|
baseUrl,
|
||||||
|
healthy: res.ok,
|
||||||
|
latencyMs: Date.now() - startedAt,
|
||||||
|
httpStatus: res.status,
|
||||||
|
healthError: null,
|
||||||
|
checkedAt: Date.now(),
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
result = {
|
||||||
|
baseUrl,
|
||||||
|
healthy: false,
|
||||||
|
latencyMs: Date.now() - startedAt,
|
||||||
|
httpStatus: null,
|
||||||
|
healthError: e?.message ?? String(e),
|
||||||
|
checkedAt: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
this.cache = result;
|
||||||
|
return result;
|
||||||
|
})();
|
||||||
|
try {
|
||||||
|
return await this._inFlight;
|
||||||
|
} finally {
|
||||||
|
this._inFlight = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,26 @@
|
|||||||
* @property {number} positionProfit 持仓盈亏(账户级)
|
* @property {number} positionProfit 持仓盈亏(账户级)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} Instrument 统一合约/证券基本信息模型
|
||||||
|
* 来源:GET /data/instrument(QMT ContextInfo.get_instrument_detail)
|
||||||
|
* 语义化字段优先,回退 QMT 原始字段(如 FloatVolumn/TotalVolumn 拼写)
|
||||||
|
* @property {string} code 完整证券代码(含交易所后缀,如 600519.SH)
|
||||||
|
* @property {string} instrumentId 合约代码(不带后缀,如 600519)
|
||||||
|
* @property {string} name 证券名称
|
||||||
|
* @property {string} exchange 交易所(SH/SZ)
|
||||||
|
* @property {number} openDate 上市日期 YYYYMMDD
|
||||||
|
* @property {number} preClose 昨收盘价
|
||||||
|
* @property {number} upStopPrice 涨停价
|
||||||
|
* @property {number} downStopPrice 跌停价
|
||||||
|
* @property {number} floatVolume 流通股本(股)
|
||||||
|
* @property {number} totalVolume 总股本(股)
|
||||||
|
* @property {number} priceTick 最小价格变动单位
|
||||||
|
* @property {number} volumeMultiple 合约乘数(股票=1)
|
||||||
|
* @property {number} instrumentStatus 合约状态(0=正常等)
|
||||||
|
* @property {number} tradingDay 交易日 YYYYMMDD
|
||||||
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 数据源统一接口:一个数据源适配器必须实现这些方法
|
* 数据源统一接口:一个数据源适配器必须实现这些方法
|
||||||
* @typedef {Object} DataSource
|
* @typedef {Object} DataSource
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
/**
|
|
||||||
* QMT Bridge REST 数据源适配器
|
|
||||||
*
|
|
||||||
* 设计约束:
|
|
||||||
* - 技术约束-001:统一数据源抽象(业务层不感知具体数据源)
|
|
||||||
* - 技术约束-003:直连 QMT Bridge RESTful 接口(不经过 MCP)
|
|
||||||
*
|
|
||||||
* 服务地址:http://192.168.3.43:8610(可通过 config 覆盖)
|
|
||||||
*/
|
|
||||||
|
|
||||||
const DEFAULT_BASE_URL = 'http://192.168.3.43:8610';
|
|
||||||
const REQUEST_TIMEOUT_MS = 15000;
|
|
||||||
|
|
||||||
export class QmtBridgeRestDataSource {
|
|
||||||
constructor({ baseUrl = DEFAULT_BASE_URL, timeoutMs = REQUEST_TIMEOUT_MS } = {}) {
|
|
||||||
this.name = 'qmt-bridge-rest';
|
|
||||||
this.baseUrl = baseUrl.replace(/\/$/, '');
|
|
||||||
this.timeoutMs = timeoutMs;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 基础 HTTP 请求(fetch + 超时) */
|
|
||||||
async request(path, { method = 'GET', body } = {}) {
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
||||||
method,
|
|
||||||
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`QMT Bridge REST ${method} ${path} 失败: HTTP ${res.status}`);
|
|
||||||
}
|
|
||||||
return await res.json();
|
|
||||||
} catch (err) {
|
|
||||||
if (err.name === 'AbortError') {
|
|
||||||
throw new Error(`QMT Bridge REST 请求超时: ${path} (${this.timeoutMs}ms)`);
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 可用性检查 */
|
|
||||||
async isAvailable() {
|
|
||||||
try {
|
|
||||||
const data = await this.request('/health');
|
|
||||||
return data?.status === 'ok';
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 资金摘要(/trade/asset) */
|
|
||||||
async getAsset() {
|
|
||||||
const res = await this.request('/trade/asset');
|
|
||||||
const a = Array.isArray(res?.data) ? res.data[0] : res?.data;
|
|
||||||
if (!a) throw new Error('QMT 资金数据为空');
|
|
||||||
return {
|
|
||||||
accountId: a.m_strAccountID ?? '',
|
|
||||||
status: a.m_strStatus ?? '',
|
|
||||||
tradingDate: a.m_strTradingDate ?? '',
|
|
||||||
totalAsset: a.m_dAssetBalance ?? 0,
|
|
||||||
available: a.m_dAvailable ?? 0,
|
|
||||||
stockValue: a.m_dStockValue ?? 0,
|
|
||||||
balance: a.m_dBalance ?? 0,
|
|
||||||
positionProfit: a.m_dPositionProfit ?? 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 全量持仓(/trade/positions) */
|
|
||||||
async getPositions() {
|
|
||||||
const res = await this.request('/trade/positions');
|
|
||||||
const data = res?.data;
|
|
||||||
const list = Array.isArray(data) ? data : data?.positions;
|
|
||||||
if (!Array.isArray(list)) return [];
|
|
||||||
return list.map((p) => this.mapPosition(p));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 持仓字段映射:语义字段优先,回退 m_ 原始字段 */
|
|
||||||
mapPosition(p) {
|
|
||||||
return {
|
|
||||||
code: p.stock_code ?? p.m_strInstrumentID ?? '',
|
|
||||||
name: p.stock_name ?? p.m_strInstrumentName ?? '',
|
|
||||||
exchange: p.m_strExchangeName ?? '',
|
|
||||||
volume: p.volume ?? p.m_nVolume ?? 0,
|
|
||||||
available: p.available ?? p.m_nCanUseVolume ?? 0,
|
|
||||||
frozenVolume: p.frozen_volume ?? p.m_nFrozenVolume ?? 0,
|
|
||||||
avgPrice: +(p.avg_price ?? p.m_dAvgOpenPrice ?? 0),
|
|
||||||
price: +(p.price ?? p.m_dLastPrice ?? 0),
|
|
||||||
marketValue: +(p.market_value ?? p.m_dInstrumentValue ?? 0),
|
|
||||||
profit: +(p.profit ?? p.m_dFloatProfit ?? 0),
|
|
||||||
profitPct: +(p.profit_pct ?? (p.m_dProfitRate != null ? p.m_dProfitRate * 100 : 0)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+45
-16
@@ -13,11 +13,14 @@
|
|||||||
* 设计约束:技术约束-001/003、产品约束-001/002/003
|
* 设计约束:技术约束-001/003、产品约束-001/002/003
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { QmtBridgeRestDataSource } from './data-source/qmt-bridge-rest.js';
|
import { QmtBridgeRestDataSource } from './component/QmtBridgeRestDataSource.js';
|
||||||
import { registerSettings } from './settings.js';
|
import { registerSettings, resolveStartupConnection } from './settings.js';
|
||||||
import { AllocationStorage } from './storage.js';
|
import { DataStore } from './component/DataStore.js';
|
||||||
import { PositionManager } from './position/manager.js';
|
import { PositionManager } from './component/PositionManager.js';
|
||||||
import { registerApi } from './api.js';
|
import { registerApi } from './api/index.js';
|
||||||
|
import { MarketDataHub } from './component/MarketDataHub.js';
|
||||||
|
import { MarketFeed } from './component/MarketFeed.js';
|
||||||
|
import { QmtHealthMonitor } from './component/QmtHealthMonitor.js';
|
||||||
|
|
||||||
const name = 'one-divine-lot';
|
const name = 'one-divine-lot';
|
||||||
|
|
||||||
@@ -27,25 +30,49 @@ const inject = ['settings', 'webServer'];
|
|||||||
async function apply(ctx, config) {
|
async function apply(ctx, config) {
|
||||||
const logger = ctx.logger;
|
const logger = ctx.logger;
|
||||||
|
|
||||||
// S2: 数据源(REST 直连 QMT Bridge)
|
// S3: 设置管理(策略=标签 + 显示开关 + QMT 连接配置)
|
||||||
const dataSource = new QmtBridgeRestDataSource({
|
|
||||||
baseUrl: config?.qmtBaseUrl,
|
|
||||||
timeoutMs: config?.qmtTimeoutMs,
|
|
||||||
});
|
|
||||||
|
|
||||||
// S3: 设置管理(策略=标签 + 显示开关)
|
|
||||||
const settings = registerSettings(ctx, config);
|
const settings = registerSettings(ctx, config);
|
||||||
|
|
||||||
// S4: 分仓数据本地存储
|
// S2: 数据源(REST 直连 QMT Bridge)
|
||||||
const storage = new AllocationStorage({
|
// R-004(技术约束-008):启动激活地址 = 默认配置 → 上次激活 → 第一条;列表为空回退 cordis qmtBaseUrl(Q4 不迁移)
|
||||||
|
const settingsScope = settings;
|
||||||
|
let startup;
|
||||||
|
try {
|
||||||
|
startup = await resolveStartupConnection(settingsScope, config);
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn('[one-divine-lot] 启动连接解析失败,回退 cordis qmtBaseUrl: ' + e.message);
|
||||||
|
startup = { connection: null, baseUrl: config?.qmtBaseUrl ?? null, source: 'cordis-fallback' };
|
||||||
|
}
|
||||||
|
const dataSource = new QmtBridgeRestDataSource({
|
||||||
|
baseUrl: startup.baseUrl ?? config?.qmtBaseUrl,
|
||||||
|
timeoutMs: config?.qmtTimeoutMs,
|
||||||
|
});
|
||||||
|
logger.info(
|
||||||
|
'[one-divine-lot] QMT 连接激活: ' + (startup.connection ? startup.connection.name + ' (' + startup.connection.baseUrl + ')' : String(startup.baseUrl ?? '(未配置)')) +
|
||||||
|
' [来源: ' + startup.source + ']'
|
||||||
|
);
|
||||||
|
|
||||||
|
// S4: 分仓数据本地存储(R-006:JSON data store,策略为中心)
|
||||||
|
const storage = new DataStore({
|
||||||
dataDir: config?.dataDir,
|
dataDir: config?.dataDir,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// R-005:行情数据(MarketDataHub 缓存 + MarketFeed 获取,页面轮询读取)
|
||||||
|
const marketHub = new MarketDataHub({ logger, dataStore: storage });
|
||||||
|
// 启动时从磁盘加载行情缓存(首屏快速展示,不依赖实盘订阅)
|
||||||
|
marketHub.warmup().catch((e) => logger.warn('[one-divine-lot] 行情预热失败: ' + e.message));
|
||||||
|
const marketFeed = new MarketFeed({ hub: marketHub, runtime: { settings, dataSource }, logger });
|
||||||
|
marketFeed.start(startup.baseUrl ?? config?.qmtBaseUrl);
|
||||||
|
|
||||||
|
// QMT 连接健康检查(服务端定时探测 + 缓存,2026-09-01)
|
||||||
|
const qmtHealthMonitor = new QmtHealthMonitor({ runtime: { settings, dataSource }, logger });
|
||||||
|
qmtHealthMonitor.start();
|
||||||
|
|
||||||
// S5: 分仓逻辑(份额分配/查询)
|
// S5: 分仓逻辑(份额分配/查询)
|
||||||
const manager = new PositionManager({ dataSource, storage });
|
const manager = new PositionManager({ dataSource, storage });
|
||||||
|
|
||||||
// S6: 服务端 HTTP API
|
// S6: 服务端 HTTP API(R-004:注入 dataSource 以编排激活热切换)
|
||||||
registerApi(ctx, { manager, settings });
|
registerApi(ctx, { manager, settings, dataSource, marketHub, marketFeed, qmtHealthMonitor });
|
||||||
|
|
||||||
// 启动时可用性检查(日志,不阻塞)
|
// 启动时可用性检查(日志,不阻塞)
|
||||||
dataSource.isAvailable().then((ok) => {
|
dataSource.isAvailable().then((ok) => {
|
||||||
@@ -57,6 +84,8 @@ async function apply(ctx, config) {
|
|||||||
ctx.effect(() => {
|
ctx.effect(() => {
|
||||||
logger.info('[one-divine-lot] 插件已加载(S1-S6 就绪)');
|
logger.info('[one-divine-lot] 插件已加载(S1-S6 就绪)');
|
||||||
return () => {
|
return () => {
|
||||||
|
marketFeed.stop();
|
||||||
|
qmtHealthMonitor.stop();
|
||||||
logger.info('[one-divine-lot] 插件已释放');
|
logger.info('[one-divine-lot] 插件已释放');
|
||||||
};
|
};
|
||||||
}, 'one-divine-lot.dispose');
|
}, 'one-divine-lot.dispose');
|
||||||
|
|||||||
+161
@@ -37,6 +37,17 @@ export const strategySchema = z.object({
|
|||||||
tradeRecords: true,
|
tradeRecords: true,
|
||||||
watchlist: true,
|
watchlist: true,
|
||||||
}),
|
}),
|
||||||
|
// QMT 连接配置(R-004,技术约束-008):list + 激活态 + 默认态
|
||||||
|
qmtConnections: z.object({
|
||||||
|
list: z.array(z.object({
|
||||||
|
id: z.string().required(),
|
||||||
|
name: z.string().required(),
|
||||||
|
baseUrl: z.string().required(),
|
||||||
|
order: z.number().default(0),
|
||||||
|
})).default([]),
|
||||||
|
activeId: z.string().default(''),
|
||||||
|
defaultId: z.string().default(''),
|
||||||
|
}).default({ list: [], activeId: '', defaultId: '' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 默认通用 tab 配置 */
|
/** 默认通用 tab 配置 */
|
||||||
@@ -308,3 +319,153 @@ export async function updateTabConfig(scope, tabs) {
|
|||||||
};
|
};
|
||||||
await scope.update(next);
|
await scope.update(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* QMT 连接配置(R-004 已定稿;产品约束-005、技术约束-008)
|
||||||
|
*
|
||||||
|
* 数据结构:qmtConnections: { list: [{id,name,baseUrl,order}], activeId, defaultId }
|
||||||
|
* - 激活:一次只能激活一个(activeId),激活配置为当前生效连接,切换立即生效(Q2)
|
||||||
|
* - 默认:启动时自动激活默认配置(Q1),重启回到默认;运行中手动激活立即生效
|
||||||
|
* - 启动兜底:列表为空时回退 cordis 注入的 qmtBaseUrl(Q4 不迁移)
|
||||||
|
* - 删除边界(Q6):删激活→自动切默认;删默认→默认转移第一条并激活之;禁删最后一条
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 归一化并校验 HTTP 地址:trim + 去尾斜杠 + 校验 http(s):// 前缀 */
|
||||||
|
export function normalizeBaseUrl(raw) {
|
||||||
|
const url = String(raw ?? '').trim().replace(/\/+$/, '');
|
||||||
|
if (!/^https?:\/\//i.test(url) || url.length <= 8) {
|
||||||
|
throw Object.assign(new Error('HTTP 地址格式无效(需以 http:// 或 https:// 开头)'), { code: 'qmt-invalid-url' });
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成连接配置英文 id(复用策略拼音 slug;兜底前缀 qmt-conn) */
|
||||||
|
export function generateQmtConnectionId(name, existingIds = []) {
|
||||||
|
const base = generateStrategyId(name, []);
|
||||||
|
const slug = base && base !== 'strategy' ? base : 'qmt-conn';
|
||||||
|
return dedupe(slug, existingIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取 QMT 连接配置(list 按 order 排序) */
|
||||||
|
export function getQmtConnections(scope) {
|
||||||
|
const value = scope?.get() ?? {};
|
||||||
|
const qc = value.qmtConnections ?? {};
|
||||||
|
const list = Array.isArray(qc.list) ? qc.list : [];
|
||||||
|
return {
|
||||||
|
list: list.slice().sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
|
||||||
|
activeId: typeof qc.activeId === 'string' ? qc.activeId : '',
|
||||||
|
defaultId: typeof qc.defaultId === 'string' ? qc.defaultId : '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析当前生效连接:activeId → defaultId → order 第一条 → null */
|
||||||
|
export function getActiveQmtConnection(state) {
|
||||||
|
const byId = (id) => (id ? state.list.find((c) => c.id === id) ?? null : null);
|
||||||
|
return byId(state.activeId) ?? byId(state.defaultId) ?? state.list[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动激活连接解析(Q1/Q4):默认 → 上次激活 → 第一条;列表为空回退 cordis qmtBaseUrl。
|
||||||
|
* 解析结果与 activeId 不一致时回写(保证运行期状态一致)。
|
||||||
|
* @returns {Promise<{connection: object|null, baseUrl: string|null, source: string}>}
|
||||||
|
*/
|
||||||
|
export async function resolveStartupConnection(scope, config) {
|
||||||
|
const state = getQmtConnections(scope);
|
||||||
|
if (!state.list.length) {
|
||||||
|
const fallback = String(config?.qmtBaseUrl ?? '').trim().replace(/\/+$/, '');
|
||||||
|
return { connection: null, baseUrl: fallback || null, source: fallback ? 'cordis-fallback' : 'none' };
|
||||||
|
}
|
||||||
|
const byId = (id) => (id ? state.list.find((c) => c.id === id) ?? null : null);
|
||||||
|
const defaultConn = byId(state.defaultId);
|
||||||
|
const activeConn = byId(state.activeId);
|
||||||
|
const chosen = defaultConn
|
||||||
|
? { conn: defaultConn, why: 'default' }
|
||||||
|
: activeConn
|
||||||
|
? { conn: activeConn, why: 'last-active' }
|
||||||
|
: { conn: state.list[0], why: 'first' };
|
||||||
|
if (chosen.conn.id !== state.activeId) {
|
||||||
|
await activateQmtConnection(scope, chosen.conn.id);
|
||||||
|
}
|
||||||
|
return { connection: chosen.conn, baseUrl: chosen.conn.baseUrl, source: 'settings:' + chosen.why };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新增连接配置 */
|
||||||
|
export async function addQmtConnection(scope, { name, baseUrl } = {}) {
|
||||||
|
const trimmedName = String(name ?? '').trim();
|
||||||
|
if (!trimmedName) throw Object.assign(new Error('连接名称不能为空'), { code: 'qmt-name-required' });
|
||||||
|
const normalizedUrl = normalizeBaseUrl(baseUrl);
|
||||||
|
const state = getQmtConnections(scope);
|
||||||
|
const id = generateQmtConnectionId(trimmedName, state.list.map((c) => c.id));
|
||||||
|
const conn = {
|
||||||
|
id,
|
||||||
|
name: trimmedName,
|
||||||
|
baseUrl: normalizedUrl,
|
||||||
|
order: state.list.reduce((max, c) => Math.max(max, c.order ?? 0), -1) + 1,
|
||||||
|
};
|
||||||
|
const value = scope?.get() ?? {};
|
||||||
|
await scope.update({ ...value, qmtConnections: { ...state, list: [...state.list, conn] } });
|
||||||
|
return conn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 编辑连接配置(名称 / 地址);返回更新后的配置对象 */
|
||||||
|
export async function updateQmtConnection(scope, id, patch = {}) {
|
||||||
|
const state = getQmtConnections(scope);
|
||||||
|
const target = state.list.find((c) => c.id === id);
|
||||||
|
if (!target) throw Object.assign(new Error('连接配置不存在: ' + id), { code: 'qmt-connection-not-found' });
|
||||||
|
const nextName = patch.name !== undefined ? String(patch.name).trim() : target.name;
|
||||||
|
if (!nextName) throw Object.assign(new Error('连接名称不能为空'), { code: 'qmt-name-required' });
|
||||||
|
const nextUrl = patch.baseUrl !== undefined ? normalizeBaseUrl(patch.baseUrl) : target.baseUrl;
|
||||||
|
const updated = { ...target, name: nextName, baseUrl: nextUrl };
|
||||||
|
const value = scope?.get() ?? {};
|
||||||
|
await scope.update({
|
||||||
|
...value,
|
||||||
|
qmtConnections: { ...state, list: state.list.map((c) => (c.id === id ? updated : c)) },
|
||||||
|
});
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除连接配置(含 Q6 删除边界处理);返回删除后的状态与边界信息 */
|
||||||
|
export async function removeQmtConnection(scope, id) {
|
||||||
|
const state = getQmtConnections(scope);
|
||||||
|
if (!state.list.some((c) => c.id === id)) {
|
||||||
|
throw Object.assign(new Error('连接配置不存在: ' + id), { code: 'qmt-connection-not-found' });
|
||||||
|
}
|
||||||
|
if (state.list.length <= 1) {
|
||||||
|
throw Object.assign(new Error('不能删除最后一个连接配置'), { code: 'qmt-last-connection' });
|
||||||
|
}
|
||||||
|
const removedWasActive = state.activeId === id;
|
||||||
|
const removedWasDefault = state.defaultId === id;
|
||||||
|
const list = state.list.filter((c) => c.id !== id);
|
||||||
|
// 删默认 → 默认标记转移到列表第一条(产品约束-005)
|
||||||
|
let defaultId = removedWasDefault ? (list[0]?.id ?? '') : state.defaultId;
|
||||||
|
// 删激活 → 自动切换到默认(若默认仍有效),否则第一条
|
||||||
|
let activeId = removedWasActive
|
||||||
|
? (defaultId && list.some((c) => c.id === defaultId) ? defaultId : (list[0]?.id ?? ''))
|
||||||
|
: state.activeId;
|
||||||
|
const value = scope?.get() ?? {};
|
||||||
|
await scope.update({ ...value, qmtConnections: { list, activeId, defaultId } });
|
||||||
|
return { list, activeId, defaultId, removedWasActive, removedWasDefault };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 激活连接配置(写 activeId;热切换由 api 层编排 setBaseUrl) */
|
||||||
|
export async function activateQmtConnection(scope, id) {
|
||||||
|
const state = getQmtConnections(scope);
|
||||||
|
if (!state.list.some((c) => c.id === id)) {
|
||||||
|
throw Object.assign(new Error('连接配置不存在: ' + id), { code: 'qmt-connection-not-found' });
|
||||||
|
}
|
||||||
|
const value = scope?.get() ?? {};
|
||||||
|
await scope.update({ ...value, qmtConnections: { ...state, activeId: id } });
|
||||||
|
return { ...state, activeId: id };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设为默认配置(写 defaultId) */
|
||||||
|
export async function setDefaultQmtConnection(scope, id) {
|
||||||
|
const state = getQmtConnections(scope);
|
||||||
|
if (!state.list.some((c) => c.id === id)) {
|
||||||
|
throw Object.assign(new Error('连接配置不存在: ' + id), { code: 'qmt-connection-not-found' });
|
||||||
|
}
|
||||||
|
const value = scope?.get() ?? {};
|
||||||
|
await scope.update({ ...value, qmtConnections: { ...state, defaultId: id } });
|
||||||
|
return { ...state, defaultId: id };
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@ import { defineConfig } from 'tsdown';
|
|||||||
|
|
||||||
export default defineConfig([
|
export default defineConfig([
|
||||||
{
|
{
|
||||||
entry: ['src/index.js', 'src/api.js', 'src/settings.js', 'src/storage.js', 'src/data-source/*.js', 'src/position/*.js'],
|
entry: ['src/index.js', 'src/api/index.js', 'src/settings.js', 'src/component/*.js'],
|
||||||
format: ['esm'],
|
format: ['esm'],
|
||||||
outDir: 'lib',
|
outDir: 'lib',
|
||||||
clean: true,
|
clean: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user