迭代16: 交易关联驱动持仓份额动态调整(R-018)

This commit is contained in:
2026-09-08 11:09:05 +08:00
parent 22ec732607
commit 1223e2e74b
25 changed files with 1659 additions and 405 deletions
@@ -0,0 +1,36 @@
# 计划:交易关联驱动持仓份额动态调整(阶段航点)
> 编号:PLAN-017 | 粒度:阶段航点 创建:2026-09-08 | 状态:**已完成(迭代 16 验收通过 2026-09-08**
> 派生自终极目标:目标-006(交易复盘)/ 目标-007(人机合一)
> 依据需求:**R-018(已定稿,2026-09-08,老师逐项拍板)** —— 符合入范围门槛
> 设计约束:技术约束-017(变更:幽灵清仓条款)、产品约束-011(变更)、数据存储设计.md §11(变更);新增归属分段存储设计
## 目标
从交易记录出发的关联操作升级为**账本写操作**:策略级候选 + 统一份额分配器(一笔委托拆 0..N 段 × 策略)、买卖联动调整 strategy_holdings 份额(买入加仓/建新仓、卖出减仓/归零清仓、仓不足自动截断续分)、撤段逆操作回滚(作废第三态 / 恢复活动仓)、**数据域分界**(幽灵清仓等同步机制不再写账本,账本只由交易关联 + 手动份额操作驱动)。
## 范围
**做**
1. 数据域分界落地:PositionSync 删除幽灵清仓账本写逻辑(同步机制只维护对账单快照)+ 漏关联软提示检测(账本有活动行 + QMT 快照无此 code → 提示,不动账本);
2. 存储地基:strategy_holdings 增 `void_at` 作废第三态(活动 = closed_at IS NULL AND void_at IS NULL,唯一索引收窄)+ 幂等补列;新增归属分段表 `trade_order_attributions`order_id → strategy_id/holding_id/volume/direction/created_at+ 存量归属迁移(trade_orders 两列 → 段表第一段);
3. 归属服务(核心):份额分配器 apply/revoke——判定动作表(买/卖 × 活动仓状态 → open/add/reduce/close/截断/失败)+ 撤段逆操作(撤建仓买入=作废、撤加仓=减回归零作废、撤卖出=恢复活动+份额加回)+ 原子性(改归属 = 撤旧段 + 加新段);
4. 策略级候选 + 分配器 API:候选回归「当前持仓 + 未关联」(R-017 7 天清仓候选退役,holdingId 键控/占位安全逻辑保留);
5. UITradeRecordsTab 归属入口改造为份额分配器(策略下拉 + 段量输入 + 余额提示 + 部分关联);
6. 回归:新脚本(分配器动作/撤段/迁移/软提示)+ 存量回归 + typecheck + build。
**不做**:自动归属推导暴露;全部持仓/未分配 tab 份额联动 UI;未分配余额自动兜底;账本写自动化兜底(软提示不改账本)。
## 实施步骤(阶段划分,供迭代跟踪)
1. 阶段APositionSync 幽灵清仓退役 + 软提示检测基础;
2. 阶段B:存储地基(void_at + 段表 + 迁移);
3. 阶段C:归属服务核心(apply/revoke 动作表 + 原子性);
4. 阶段D:策略级候选 + API(含 R-017 退役);
5. 阶段EUI 份额分配器;
6. 阶段F:回归 + typecheck + build + 存量回归;
7. 迭代复盘 + R-018 索引状态更新 + 需求归档。
## 验收要点
`docs/04-迭代记录/16-交易关联驱动持仓份额动态调整/验收标准.md`
+1 -1
View File
@@ -20,7 +20,7 @@
| 产品约束-008 | 交易记录支持**按策略过滤**:交易记录 tab 提供策略过滤下拉(全部 / 各策略 / 未关联),对今日(QMT 实时)与历史(本地 SQLite)均生效;策略归属 = 委托时间 join 持仓生命周期窗口推导(一码多策略取份额最大,未命中=未关联);历史范围展示本地积累数据(不再「接口开发中」占位) | 2026-09-01 | 生效 | - | 新增(2026-09-01 R-009 定稿 + 迭代 07 实施):策略过滤 + 历史本地展示 |
| 产品约束-010 | 策略自定义字段配置:每个策略可在设置页「策略分组」子 tab 配置自定义字段定义(字段名 / 类型文本·数字·布尔·枚举 / 枚举选项 / 默认值),定义随策略存 settings(不落库);该策略下每个持仓(strategy_holdings 行)按所属策略的定义存取一份字段值(values JSON 列,持仓级键值对,key 对齐定义、允许扩展额外键);旧策略无定义时行为与现状一致(不渲染字段区、不迁移历史值) | 2026-09-02 | 生效 | - | 新增(2026-09-02 R-013 定稿 Q1-Q4 + D6):定义随策略走、值随持仓行,类型化(含枚举),旧策略兼容 |
| 产品约束-009 | 会话 tab 统一由「Tab 设置」管理:设置页「Tab 设置」子 tab 是**所有会话 tab(系统内置 + 策略分组)的唯一顺序与显隐入口**,两类 tab 混排;每行 = 拖动排序 + 显示/隐藏开关;**任何 tab 均不支持重命名与删除**(内置 tab 名称只读,策略命名/删除仍在「策略分组」子 tab);策略改名后 Tab 设置中的名称自动跟随(只存引用);新增策略默认追加到列表末尾;删除策略联动删除 Tab 设置中对应条目;顺序与显隐唯一数据源 = settings.tabs 有序数组 | 2026-09-02 | 生效 | - | R-011 定稿(2026-09-02 Q1-Q5 确认):Tab 设置 = 显示/隐藏 + 拖动排序(落点立即持久化),全 tab 禁重命名/删除 |
| 产品约束-011 | 持仓页数据(策略持仓 / 全部持仓 / 未分配)以服务端 10s 内存快照为准,**接受最多 10s 滞后**(同步时间前端不显示,老师拍板);QMT 抖动/掉线时持仓页面显示**最后一次快照**而非空白;QMT 已卖光的票(连续 3 轮同步确认消失)本地持仓自动转历史(幽灵清仓,不物理删除);部分减持仅表现为「未分配为负」,不做自动修正 | 2026-09-02 | 生效 | - | 新增(2026-09-02 R-014 定稿):老师四问拍板(内存不落库 / 读穿透兜底 / 幽灵自动清仓 / 不显示同步时间) |
| 产品约束-011 | 持仓页数据(策略持仓 / 全部持仓 / 未分配)以服务端 10s 内存快照为准,**接受最多 10s 滞后**(同步时间前端不显示,老师拍板);QMT 抖动/掉线时持仓页面显示**最后一次快照**而非空白;**幽灵自动清仓退役(变更 12026-09-08 R-018 数据域分界)**:同步机制不再把本地策略持仓自动转历史——策略持仓份额只由「交易关联(归属=账本写操作)」与「手动份额操作」驱动,账本转历史唯一途径 = 卖出单关联份额减至 0;对账单码消失仅表现为快照无此行 + **漏关联软提示**(只读提示去关联/移出,不自动写账本);部分减持仅表现为「未分配为负」,不做自动修正 | 2026-09-02 | 生效 | - | 新增(2026-09-02 R-014 定稿);**变更 12026-09-08 R-018/迭代 16 拍板)**:幽灵自动清仓退役(同步机制不写账本),策略持仓由交易关联 + 手动份额操作驱动;漏关联软提示替代自动归档 |
| 产品约束-012 | 行情数据服务形态(R-015):现价/昨收/涨停/跌停统一由盘口内存快照提供(≤5s 更新),价格单一入口;QMT 抖动时页面价格保持旧值不空白;会话头部(QMT 健康灯旁)提供**持仓/盘口同步指示灯**——绿=同步正常(持仓 30s/盘口 15s 内)、黄=同步失败中快照陈旧、灰=从未同步;悬停显示同步时间/快照量/失败数/错误摘要;点击灯 = 立即触发该域同步;不做个股停牌标识(另议) | 2026-09-02 | 生效 | - | 新增(2026-09-02 R-015 定稿):老师提出指示灯,采纳 AI 推荐三态/点击即同步/取消 PriceCell 灰点 |
<!-- 示例条目(确认格式后删除):
| 产品约束-001 | 示例:求签功能必须保证抽取结果的不可预测性 | 2026-08-26 | 生效 | - | 讨论确认:为保证公平性,抽取必须不可预测 |
+1 -1
View File
@@ -26,7 +26,7 @@
| 技术约束-015 | 策略自定义字段存储(R-013,2026-09-02):字段定义随策略定义存 settings.strategies 扩展 configSchema[{key,label,type,enum?,def}]type ∈ text|number|boolean|enum,旧项缺省空数组);字段值落 strategy_holdings 新增 values TEXTJSON 键值对,key 对齐 configSchema.key,允许额外键=可扩展,NULL=未配置);补列用幂等 ALTER(沿用 _ensureTradeAttributionColumns 模式,只读连接容忍);APIstrategy-positions 每行附 values,新增 holdings/values-update {holdingId, values} 写回,服务端按 configSchema 校验(number=有限数、enum=在选项内、boolean=布尔),空值/缺省可写入;持仓生命周期操作(openHolding/addShares/reduceShares/closeHolding)不碰 values 列 | 2026-09-02 | 生效 | - | 新增(2026-09-02 R-013 定稿 + PLAN-012):定义 settings + 值 SQLite 列 + 幂等补列 + 类型校验 |
| 技术约束-014 | 会话 tab 注册与顺序显隐(R-011):客户端注册统一读 **settings.tabs 有序数组**(唯一顺序与显隐来源,内置条目 refKey + 策略条目 refId),按 order 排序、过滤 visible 后注册(builtin 走内置 render、strategy 走 StrategyTab),移除硬编码 order 间隔(原内置 10/11/12、策略 13+);settings.tabs 从布尔对象升级为有序数组,读取时对旧格式(布尔对象 + 策略自带 order/visible)静默归一化迁移(旧隐藏策略迁移后显示),写入即落库;策略定义表收窄为 {id,name}(去除 visible/order);tabs/update 语义改为整表更新(顺序 + 显隐),strategies/add 联动追加 tab 条目(末尾),strategies/remove 联动删除对应 tab 条目,废弃 strategies/move | 2026-09-02 | 生效 | - | R-011 定稿(2026-09-02 Q1-Q5 确认):统一 tabs 有序数组 + 自动迁移 + 联动增删 |
| 技术约束-016 | src 目录按功能域归类(2026-09-02 结构优化):服务端代码**禁止平铺**,按职责域分目录 —— src/data-source/QmtBridgeRestDataSource + data-source-types + QmtHealthMonitor,数据源与连接健康)、src/storage/SqliteStore + DataStore,存储层)、src/position/PositionManager,分仓逻辑)、src/market/MarketDataHub + MarketFeed,行情)、src/trades/TradeSync,交易同步);api/ 按领域拆分子文件(positions/strategies/qmt-connections/market/trades),client/ 仅放 UIviews/ 组件 + market/ provider);文件命名 = 类名(PascalCase+ .js/.jsx;新增服务端模块必须先落对应域目录,无合适域时先讨论补域,不得回退平铺 | 2026-09-02 | 生效 | - | 新增(2026-09-02 结构审查 + 优化落地):component/ 平铺还原为语义分域,删除死代码 AllocationStorage、DataStore.setDataset/removeDataset |
| 技术约束-017 | 持仓内存快照(R-0142026-09-02):全量实盘持仓由服务端 PositionSync **进程内内存快照**管理(启动预热 + 10s 定时全量拉 /trade/positions → 校验 → 整体替换),**不落库**无缓存表、不复用 strategy_holdings——账本与对账单分离,holding_id 交易锚点不掺易变快照;判断标准:外部可一次调用重取全的实时投影不落库);PositionManager.getAllPositions 以快照为准(strategy-positions / unallocated / summary 三接口不再请求时穿透 QMT),快照为空读穿透兜底(当场拉一次并回填);同步失败保留上次快照(不清空不报错);空快照双重确认(/health 可用 + getAsset 账户身份可识别)才接受为真清仓;幽灵清仓:快照连续 3 轮(约 30s)消失的 code 该码全部策略当前持仓自动 closeHolding 转历史(不物理删除),账户身份守卫防误清(accountId 未知当轮跳过、切换当轮重置跳过);syncNow 允许手动调用(mounted 只管定时循环,不拦手动同步) | 2026-09-02 | 生效 | - | 新增(2026-09-02 R-014 定稿 + 迭代 12 实施):AI 初版建缓存表方案经老师质疑反转为内存方案(落库三问:不可再生?重启首屏依赖?读放大/复杂查询?全否 → 内存) |
| 技术约束-017 | 持仓内存快照(R-0142026-09-02**变更 12026-09-08 R-018 数据域分界**):全量实盘持仓由服务端 PositionSync **进程内内存快照**管理(启动预热 + 10s 定时全量拉 /trade/positions → 校验 → 整体替换),**不落库**(账本与对账单分离,holding_id 交易锚点不掺易变快照);PositionManager.getAllPositions 以快照为准(strategy-positions / unallocated / summary 三接口不再请求时穿透 QMT),快照为空读穿透兜底;同步失败保留上次快照;空快照双重确认(/health 可用 + getAsset 账户身份可识别)才接受为真清仓;syncNow 允许手动调用。**幽灵自动清仓**(R-014 原条款:快照连续 3 轮消失 → 本地全部策略当前持仓 closeHolding 转历史)**退役**——同步机制只作用于对账单域,不再写 strategy_holdings 账本(R-018 老师拍板:幽灵清仓本就是一个同步机制,不可以让幽灵把爪子伸太长);账本行转历史唯一途径 = 卖出单关联减至 0 closeHolding(R-018);对账单域「码消失」仅表现为快照无此行 + 漏关联软提示(positions/orphan-hints 只读提示) | 2026-09-02 | 生效 | - | 新增(2026-09-02 R-014 定稿 + 迭代 12 实施);**变更 12026-09-08 R-018/迭代 16 拍板)**:幽灵自动清仓退役(不再 closeHolding 本地账本),PositionSync 只同步对账单快照;漏关联由只读软提示承担 |
| 技术约束-018 | 盘口内存快照(R-0152026-09-02):行情数据由 QuoteSync(取数:启动 prime + 5s REST 定时刷 watch 集合 + 涨停跌停经 /data/instrument 按**交易日**内存缓存)与 QuoteHub(存查:内存快照 Map + watchCodes Set + 读穿透走 dataSource.getTicks + getQuote/getByCodes 对外)管理,**替换并删除 MarketFeed/MarketDataHub**(方案 A,不留兼容壳);**WS 数据通路移除**(ingest 入口带 source 标签留回归口子);**market_quotes_cache 表退役 DROP**(幂等),价格单一入口 = QuoteHubDataStore 行情方法(loadMarket/getMarketQuote(s)/setMarketQuotes)删除;同步失败保留内存旧值;watchCodes 维持只进不出无上限;涨停/跌停/昨收不落库 | 2026-09-02 | 生效 | - | 新增(2026-09-02 R-015 定稿 + 迭代 13 实施):老师五拍板(替换/WS 移除/纯内存 DROP/watch 现状/指示灯);warmup bug 复现(loadMarket return this 残迹)为不落库关键证据 |
| 技术约束-019 | 历史持仓查询(R-0162026-09-07):策略 tab 历史持仓展示走**本地库只读查询**SqliteStore.getHoldingsHistorystrategy_id + closed_at IS NOT NULL + closed_at ≥ sinceMsclosed_at DESC),经独立端点 strategy-holdings/history 暴露;**当前持仓路径(strategy-positions / PositionSync 快照语义)不掺历史数据**(两份结果前端合并渲染);closeHolding 置 shares=0 语义维持不变(Q2 老师拍板:历史行份额显示 0,重点在追溯该持仓的历史操作而非清仓时份额);范围换算服务端做(week=7d/month=30d/quarter=90d/halfYear=182d/year=365d 自然日近似)——**变更 12026-09-07 二轮补充 Q8-Q9 老师拍板)**range 新增 'today' = **本地自然日 00:00 起**(特判零点,不落回溯毫秒档),供前端「今日已清仓默认层」(恒显示、不受历史持仓开关控制);历史范围层语义收窄为**今天之前**;其余不变 | 2026-09-07 | 生效 | - | 变更 12026-09-07 R-016 二轮补充 Q8-Q9 老师拍板):+range=today 自然日边界,历史开关只控今天之前;首轮新增(Q1-Q7):历史行=追溯操作锚点,不动存储写路径 |
+66 -3
View File
@@ -364,7 +364,7 @@ CREATE INDEX IF NOT EXISTS idx_trade_fills_date ON trade_fills (trade_date);
| 失败策略 | 同步失败**保留上次快照**,不清空不报错 | 读方继续消费旧快照,QMT 抖动不再白屏(强于旧的穿透行为) |
| 空快照 | 双重确认(/health 可用 + getAsset 账户身份可识别)才接受为「真清仓」,否则视为异常保留旧快照 | 防一次异常响应清空缓存 |
| 读路径 | `getAllPositions()` 读内存快照;快照为空 → 读穿透当场拉一次并回填;QMT 也挂 → 抛错(前端 LoadState 重试) | 首启兜底;未注入 positionSync 时保持旧行为(兼容) |
| 幽灵持仓自动清仓 | QMT 快照连续 3 轮(约 30s)消失的 code,本地全部策略当前持仓 closeHolding 转历史;**账户身份守卫**:accountId 未知当轮跳过、账户切换当轮重置跳过(防误清);部分减持不触发(负数未分配由 UI 暴露) | 老师选定;清仓转历史不物理删除,历史保留语义不变 |
| ~~幽灵持仓自动清仓~~**退役,2026-09-08 R-018 数据域分界** | ~~QMT 快照连续 3 轮消失 → 本地全部策略当前持仓 closeHolding 转历史~~**不再写 strategy_holdings**:同步机制只作用于对账单域(老师拍板:幽灵清仓本就是一个同步机制,不可以让幽灵把爪子伸太长);账本转历史唯一途径 = 卖出单关联减至 0(R-018);对账单码消失 → 快照无此行 + 漏关联软提示(positions/orphan-hints,只读) | R-014 选定时防本地账本残留;R-018 改由交易关联驱动账本生命周期,幽灵自动归档与「账本只由关联交易改」冲突 → 退役(迭代 16 实施,见 §12) |
| 前端 | 零改动 | 三个接口数据源自动切换 |
### 11.2 数据流(持仓部分,替代原「读取时组装」描述)
@@ -383,8 +383,71 @@ CREATE INDEX IF NOT EXISTS idx_trade_fills_date ON trade_fills (trade_date);
| PositionManager | getAllPositions 改读快照 + 读穿透兜底(未注入 sync 时兼容旧穿透) | src/position/PositionManager.js |
| 回归测试 | 纯内存 mock 34 项(同步/失败保留/空快照/幽灵防抖/账户守卫/读穿透/组装回归) | scripts/test-position-sync.mjs |
## 12. 约束条目(引用
## 12. 归属分段存储设计(R-018 落实,2026-09-08 迭代 16 实施
- 技术约束-012:数据存储遵循本文件(SQLite 存储设计,含交易记录表 §10);
> **变更理由**(R-018 老师拍板):交易归属从「纯标签」(trade_orders 单组 strategy_id/holding_id,不改数量)升级为**账本写操作**——关联即自动调整 strategy_holdings 份额;支持一笔委托拆多段分配多策略;撤段做逆操作。归属真相源从 trade_orders 两列迁移到**分段表**。
### 12.1 strategy_holdings 状态扩展(作废第三态 + 清仓份额快照)
```sql
-- 幂等 ALTER(沿用 _ensureXxxColumn 模式)
ALTER TABLE strategy_holdings ADD COLUMN void_at INTEGER; -- 作废时间(NULL=有效;非 NULL=建仓被撤=从未成立;不进历史层)
ALTER TABLE strategy_holdings ADD COLUMN closed_shares REAL; -- 清仓前份额快照(closeHolding 写入,供撤卖出段恢复活动用)
-- 活动唯一索引收窄(R-018:活动 = closed_at IS NULL AND void_at IS NULL
DROP INDEX IF EXISTS idx_active_holding;
CREATE UNIQUE INDEX IF NOT EXISTS idx_active_holding
ON strategy_holdings (strategy_id, code) WHERE closed_at IS NULL AND void_at IS NULL;
```
- 生命周期操作语义:closeHolding = shares 置 0 + closed_at + **closed_shares=清仓前份额**shares 展示仍 0R-016 Q2 语义不变);voidHolding(新增)= shares 置 0 + void_at(非清仓:建仓被撤 = 从未成立);
- 作废行不进 R-016 历史「已清仓」层(getHoldingsHistory 过滤 void_at IS NULL),保留数据可追溯。
### 12.2 归属分段表 trade_order_attributions
```sql
CREATE TABLE IF NOT EXISTS trade_order_attributions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id TEXT NOT NULL, -- → trade_orders.order_id
strategy_id TEXT NOT NULL, -- 段归属策略
holding_id INTEGER NOT NULL, -- 段锚点持仓(R-010 展开用)
code TEXT NOT NULL, -- 冗余
direction TEXT NOT NULL, -- buy/sell(冗余,撤段判向)
volume REAL NOT NULL, -- 该段已成交量(>0;总额 ≤ order.traded_volume
created_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_attr_order_strategy ON trade_order_attributions (order_id, strategy_id);
CREATE INDEX IF NOT EXISTS idx_attr_holding ON trade_order_attributions (holding_id);
CREATE INDEX IF NOT EXISTS idx_attr_strategy ON trade_order_attributions (strategy_id);
```
- **真相源 = 段表**trade_orders.strategy_id/holding_id 退役为冗余(保留列不删,防外部脚本 break;不再作为查询/过滤源);
- 存量迁移:段表空且 trade_orders 有单组归属 → 迁为第一段(volume = 该单 traded_volume;幂等);
- 查询适配:orders 附加归属、by-holdingR-010)、历史策略过滤(R-009)全部改经段表。
### 12.3 关键设计决策(R-018 迭代 16)
| 决策 | 结论 | 理由 |
|---|---|---|
| 归属真相源 | **分段表 trade_order_attributions**order → 多段 strategyId/holdingId/volume | 一笔委托可拆 N 段关联不同策略(R2/R4);单列两字段无法表达 |
| 数据域分界 | PositionSync/幽灵清仓**只写对账单快照**,不写 strategy_holdings | R-018 老师拍板:同步机制不伸爪子到账本;账本只由交易关联 + 手动份额操作驱动 |
| 关联驱动量 | 段 volume = 该段**已成交量** | Q4:撤单/废单不动账 |
| 撤段逆操作 | 撤 buy 段 → 减回 / 归零作废(void);撤 sell 段 → 加回 / 恢复活动仓(closed_shares | R3/R6/R7:作废 ≠ 清仓(不产生假清仓历史);恢复用 closed_shares 快照 |
| 撤段约束 | 同 holding 内**逆序撤销**(乱序返回 segment-order-conflict);跨 holding 段独立可任意撤 | 账本正确性 > 操作便利(宁可拒绝不写错账) |
| 事务 | setSegments 全量替换在**单事务**内(撤旧段 + 加新段) | 改归属原子性(R3 边界) |
### 12.4 对应实现(追加)
| 模块 | 职责 | 文件 |
|---|---|---|
| AttributionService | 归属服务:apply/revoke/setSegments(动作表判定 + 逆操作 + 事务) | src/trades/AttributionService.js(新增) |
| SqliteStore | 增列/索引重建/voidHolding/close 快照/段表 CRUD/runInTransaction/查询改段表/存量迁移 | src/storage/SqliteStore.js |
| api/trades.js | attribution-targets / attribution-set / attribution-segmentsorders 附加段 | src/api/trades.js |
| PositionSync | 删除幽灵清仓逻辑(只同步对账单快照) | src/position/PositionSync.js |
| 回归测试 | 分界/动作表/撤段/迁移/软提示 | scripts/test-r018-attribution.mjs(新增) |
## 13. 约束条目(引用)
- 技术约束-012:数据存储遵循本文件(SQLite 存储设计,含交易记录表 §10、归属分段存储 §12);
- 技术约束-011:测试/回归脚本禁止在真实数据上执行写操作(独立数据目录);
- 技术约束-017(变更 1,2026-09-08):幽灵自动清仓退役,PositionSync 只同步对账单快照;
- 本文件为新领域设计约束,后续数据存储变更以本文件为最终依据。
@@ -0,0 +1,141 @@
# 技术实现方案:16-交易关联驱动持仓份额动态调整
> 迭代编号:16 依据:PLAN-017 + R-018(已定稿)
## 0. 核心不变量(数据域分界,R-018 §〇)
- PositionSync(含幽灵清仓)**只写对账单域**(进程内快照),永不写 strategy_holdings
- strategy_holdings 写操作只来自:**归属服务(交易关联)** + **手动份额操作**add-shares/remove-shares/move-all-shares/策略删除清空,现状保留);
- 账本行转历史(closed_at)唯一途径:卖出段关联把份额减到 0(closeHolding);**幽灵清仓代码删除**。
## 1. 存储地基(阶段B
### 1.1 strategy_holdings 增列(幂等 ALTER,沿用 _ensureXxxColumn 模式)
```sql
ALTER TABLE strategy_holdings ADD COLUMN void_at INTEGER; -- 作废时间(NULL=有效;非 NULL=建仓被撤=从未成立)
ALTER TABLE strategy_holdings ADD COLUMN closed_shares REAL; -- 清仓前份额快照(closeHolding 写入;供撤卖出段恢复活动用)
```
- 活动判定收敛:`closed_at IS NULL AND void_at IS NULL`
- 唯一索引重建(活动唯一收窄至两态皆空):
```sql
DROP INDEX IF EXISTS idx_active_holding;
CREATE UNIQUE INDEX IF NOT EXISTS idx_active_holding
ON strategy_holdings (strategy_id, code) WHERE closed_at IS NULL AND void_at IS NULL;
```
- closeHolding 变更:置 shares=0 + closed_at=now + **closed_shares=清仓前份额**shares 列仍置 0,R-016 Q2 展示语义不变);
- 新增 voidHolding(strategyId, code):置 shares=0 + void_at=now(不改 closed_at);作废行不进 R-016 历史层(getHoldingsHistory 过滤 void_at IS NULL);
- _mapHolding 增 voidAt / closedShares 字段(读方法 select 补列)。
### 1.2 归属分段表 trade_order_attributions
```sql
CREATE TABLE IF NOT EXISTS trade_order_attributions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id TEXT NOT NULL, -- → trade_orders.order_id
strategy_id TEXT NOT NULL, -- 段归属策略(冗余,过滤快)
holding_id INTEGER NOT NULL, -- 段锚点持仓(R-010 展开用)
code TEXT NOT NULL, -- 冗余(by-holding 反查/防呆)
direction TEXT NOT NULL, -- buy/sell 冗余(撤段判向)
volume REAL NOT NULL, -- 该段已成交量(>0;总额 ≤ order.traded_volume
created_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_attr_order_strategy ON trade_order_attributions (order_id, strategy_id);
CREATE INDEX IF NOT EXISTS idx_attr_holding ON trade_order_attributions (holding_id);
CREATE INDEX IF NOT EXISTS idx_attr_strategy ON trade_order_attributions (strategy_id);
```
- **真相源 = 段表**trade_orders.strategy_id/holding_id 两列**退役为冗余**(不再作为查询/过滤源;保留列不删,防外部脚本 break;写入不再维护);既有单组归属迁移为第一段(volume=该单 traded_volume,幂等:段表空且 order 有归属才迁);
- 撤销语义支持 R-010 by-holding、R-009 history 策略过滤全部改经段表 join。
## 2. 归属服务(阶段C,新增 src/trades/AttributionService.js
> 命名遵守技术约束-016src/trades/ 交易域)。接口同步(node:sqlite DatabaseSync);归属服务持 `storage.sqlite` + DataStore 门面方法。
### 2.1 段级 apply(一段关联)
输入:{ orderId, code, direction, volume(已成交量) } + 目标 strategyId。判定动作表(R-018 §3.3):
| direction | strategy 活动仓(closed/void 均空) | 动作 |
|---|---|---|
| buy | 有 | addShares(strategyId, code, volume) → holding 取活动仓 |
| buy | 无 | openHolding(strategyId, code, volume) → 新仓(不复活历史/作废行) |
| sell | 有 且 shares ≥ volume | reduceShares;减后 0 → closeHolding |
| sell | 有 且 shares < volume | **拒绝**(报错 code='segment-exceeds',由 UI 自动截断后再提交;服务端不做隐式截断——避免「半生效」状态与部分关联提示脱节) |
| sell | 无 | 拒绝(code='no-active-holding',关联失败提示,R1 |
成功 → 写段表(order_id,strategy_id,holding_id,code,direction,volume,now)。买入建仓时 holding_id=openHolding 返回;买入加仓/卖出取活动仓 holding_id。
### 2.2 段级 revoke(撤一段)
输入:{ order_id, strategy_id }(唯一键)。逆操作(R3,粒度=段):
- **撤 buy 段**:查段 → holding。
- holding 活动(closed/void 空):
- shares ≥ 段量 → reduceShares(段量)**减后 shares=0** → voidHolding(作废,非 closeHoldingR3-1/R6 语义:建仓被撤=从未成立,无真实卖出→作废,不产生假清仓历史);
- shares < 段量(说明该段之后已有卖出段作用于同一 holding)→ **拒绝**code='segment-order-conflict',提示先撤更晚的段);
- holding 已 closed/void → 该 buy 段本就不应撤销成功(段应随原操作撤)→ 拒绝;
- **撤 sell 段**:查段 → holding。
- holding 仍活动(该 sell 只是减仓)→ addShares(段量) 加回;
- holding 已 closed 且 **closed_shares == 段量**(该段就是清仓段,且其后无新仓)→ **恢复活动**closed_at=NULL、shares=closed_shares、closed_shares=NULL
- holding 已 closed 且 closed_shares > 段量(部分减仓后另段清仓)→ 拒绝(先撤更晚段);已 closed 且同 code 新活动仓已存在(close 后又建仓)→ 拒绝(无法恢复,避免撞唯一索引;R-018 §3.4 边界);
- 成功后删段表行。
> 实现约束(写入 R-018 边界):**段撤销按「holding 内逆序」支持**;跨 holding 的段(拆单分给多策略)互相独立可任意撤(老师 R7 场景)。乱序撤同 holding 的段返回 segment-order-conflict,提示先撤更晚段——宁可拒绝不写错账(账本正确性 > 操作便利)。
### 2.3 全量替换 setSegments(改归属 = 撤旧段 + 加新段,原子)
输入:{ orderId, segments: [{ strategyId, volume }] }volume>0;总额 ≤ order.traded_volume,允许 < = 部分关联)。
流程(单事务):
1. 读 ordertrade_orderscode/direction/traded_volume);读段表现状;
2. diff:将被删除段逐个 revoke(同 2.2 冲突规则)→ 冲突则整体回滚并报错;
3. 新增/变更段逐个 apply(同 2.1)→ 任一失败整体回滚;
4. COMMIT 后返回 { segments: 当前全部段 }order 附加)。
> 事务:SqliteStore 暴露 `runInTransaction(fn)`BEGIN/COMMIT/ROLLBACK 包裹;同步 API 直接 exec)。
### 2.4 查询
- getOrderAttributions(orderId):段列表(含 strategyName 由 settings 附名);
- orders 列表附加归属:批量查段表按 order 聚合;
- getOrdersByHolding(holdingId):改经段表 join trade_ordersR-010 展示);
- 历史策略过滤(getOrderHistory/getFillHistory strategyId):改经段表(fill→order→段表 strategy_id 集合;`__unassigned__` = 无任何段);
- getHoldingsHistory:过滤 void_at IS NULL(作废不进历史层)+ closed 逻辑不变。
## 3. 候选与 API(阶段D
- **orders/attribution-candidates 退役 R-017 7 天语义** → 改 `orders/attribution-targets`{ code } → 全部策略(settings strategies+ 每策略该 code 当前活动份额 activityShares0=无仓)+ 该 order 已有关联段数提示(供 UI「指向后判定」的前置展示;**不预筛**,指向后由服务端判定)。策略级(Q1),holdingId 不再作下拉键;
- **orders/attribution-set**{ orderId, segments } → 归属服务 setSegments(替代 set-attributionset-attribution 端点删除或保留为单段包装——删,避免双写源混乱;既有数据迁移已覆盖);
- **orders/attribution-segments**{ orderId } → 读当前段(UI 展开/编辑用);
- 今日 orders 端点:order 行附 `segments: [{strategyId,strategyName,holdingId,volume}]`(取代原单组 strategyId/holdingId 附加;兼容字段保留首段值)。
## 4. UI 份额分配器(阶段ETradeRecordsTab
- AttributionSelect 退役 → **AttributionAllocator**
- 头部:委托已成交量 / 已关联 Σ / 待分配余额(醒目色);
- 一段 = 策略下拉(attribution-targets+ 份额输入(默认=余额全量,可改小;sell 时 max=min(余额, activityShares) 自动钳制)+ 「添加段」;
- 段列表(每段:策略名 + 量 + 「撤」按钮 → 调 attribution-set 去掉该段,即全量重交剩余段;或独立 revoke 端点——为原子性走 attribution-set 全量重交);
- 已关联/部分关联/未关联三态展示 + 保存即生效(每步操作即时 setSegments);
- 余额为 0 全关联提示;余额 >0 显「未分配剩余」提示(允许部分关联);
- 撤销冲突(segment-order-conflict)→ toast 展示后端提示。
- TradeRecordsTab order 行「归属」列 = 段 chips(点开进分配器)。
## 5. 分界 + 软提示(阶段A)
- PositionSync:删除 `_autoCloseGhosts`/ghostRounds/_ghostMiss/_fetchAccountId 调用链、stats.closedGhosts;类注释更新(只同步对账单快照);保留空快照双重确认与读穿透(对账单域行为不变)。test-position-sync.mjs 相应改断言(幽灵清仓→不再写账本:mock 断言 storage.closeHolding 未被调用);
- **漏关联软提示(T3)**:新端点 `positions/orphan-hints`:账本活动行(closed/void 均空)∩ QMT 快照无此 code → 返回 [{strategyId,code,name,shares,holdingId}]**只读**,不动账本);交易记录 tab 顶部横幅提示(N 个持仓对账单已无此票,请去关联卖出单或移出)。阈值:全部孤儿都列(不设 7 天——账本无快照即提示,软提示不自动写)。
## 6. 涉及文件
- src/position/PositionSync.js(删幽灵清仓)、scripts/test-position-sync.mjs(改断言)
- src/storage/SqliteStore.js(增列/索引重建/voidHolding/close 快照/段表 CRUD/runInTransaction/查询改段表/迁移)、src/storage/DataStore.js(门面透传)
- src/trades/AttributionService.js(新增:apply/revoke/setSegments
- src/api/trades.jsattribution-targets/set/segmentsorders 附加段)、src/api/strategies.jsstrategy-holdings/history 过滤 void
- src/client/views/TradeRecordsTab.jsxAttributionAllocator 替换 AttributionSelect
- scripts/test-r018-attribution.mjs(新增回归)
- 设计约束:技术约束-017/产品约束-011/数据存储设计.md §11 变更记录 + §12 追加
## 7. 验证链
test-r018-attribution.mjs(分界/动作表/撤段逆序/迁移/软提示/API+ test-position-sync 更新 + 存量回归(r017 改/ r016 / r013 / quote-sync+ typecheck + build。
@@ -0,0 +1,41 @@
# 迭代复盘:16-交易关联驱动持仓份额动态调整
> 复盘日期:2026-09-08 迭代状态:**验收通过(2026-09-08)** —— 口径:自动化为主体 + 只读走查 + 真实使用顺带确认(老师拍板,见验收标准.md)
> 关联需求:R-018(已定稿)| 关联计划:PLAN-017
## 结果
迭代 16 达成(R-018 全范围):**数据域分界落地**——幽灵清仓退役(PositionSync 不再写 strategy_holdings 账本);**归属升级为账本写操作**——策略级候选 + 统一份额分配器(委托拆 0..N 段 × 策略 × 量,余额默认全量可改小、允许部分关联)+ 买卖联动账本(买入加仓/建新仓不复活旧行、卖出减仓/归零清仓、仓不足拒绝、无仓拒绝)+ 撤段逆操作(撤建仓买入=作废第三态不产生假清仓历史、撤加仓=减回归零作废、撤卖出=恢复活动仓+份额加回,粒度=段、改归属原子)+ 归属分段存储(trade_order_attributions,真相源)+ R-017 7 天候选退役(holdingId 键控/占位安全逻辑由策略级 targets 承担)+ 漏关联软提示(只读)。存量归属(trade_orders 单列)自动迁移为段表第一段。
验证:新回归 test-r018-attribution 29/29test-position-sync 29/29(幽灵退役断言重写);存量回归 r016 24 / r017 12(语义修订)/ r013 21 / r009 14 / r009-api 13(端点更新)/ r009-sync 8 / r009-normalize 11 / r011-api 12 / r011-tabs 23 / quote-sync 27 全绿;typecheck + build 通过。
## 过程事实
1. **文档链先行**:讨论收敛 → R-018 定稿 → PLAN-017 → 迭代 16 三件套 → 设计约束变更(技术约束-017 / 产品约束-011 / 数据存储设计 §11-13);
2. **阶段A 分界**PositionSync 删 `_autoCloseGhosts`/ghostRounds/账户守卫链/stats.closedGhosts,只保留对账单快照同步(含空快照双重确认与读穿透);软提示新端点 positions/orphan-hints(账本活动行 ∩ 快照缺失 code,只读);
3. **阶段B 存储**strategy_holdings 增 void_at/closed_shares(幂等 ALTER + 活动唯一索引重建收窄至两态皆空——索引创建移出 SCHEMA_SQL 到补列之后,防旧库无列建索引崩溃,quote-sync 回归暴露);closeHolding 记 closed_sharesshares 仍置 0R-016 Q2 展示语义不变);voidHolding/reopenHolding/getHoldingById;新建 trade_order_attributions 段表;存量归属迁移段表第一段(幂等);
4. **阶段C 归属服务**src/trades/AttributionService.js 新增):apply(动作表判定)/ revoke(撤段逆操作,同 holding 逆序、跨 holding 独立)/ setSegments(全量替换、单事务原子);setOrderAttribution 兼容封装 = 替换语义(清旧段设新段);
5. **阶段D API**attribution-targets(策略级 + activityShares/ attribution-set / attribution-segmentsorders 与 trades/history 返回附加 segmentsR-017 attribution-candidates/set-attribution 端点退役;strategy-holdings/history name 兜底改段表 join
6. **阶段E UI**AttributionSelect → AttributionCell + AttributionEditor(余额头 + 段 chips 可撤 + 策略下拉/量输入/截断提示 + 全部撤除/完成);交易记录 tab 顶部漏关联软提示横幅;
7. **阶段F 回归**:新脚本 test-r018-attribution;更新 position-sync(幽灵退役断言)、r0177 天候选退役语义)、r009-api(新端点)、r016-history(段表造归属)。
## 经验教训(复盘沉淀)
### 1. 语义迁移要逐调用点核对 + 用旧回归脚本当哨兵
R-018 把「归属真相源」从 trade_orders 单列迁到段表,牵动 r009/r016/r017/verify-real 一堆脚本与 strategies handler。靠跑存量回归暴露了两类坑:① quote-sync 的旧结构库建库路径在 SCHEMA_SQL 含新列索引时崩溃(索引引用尚不存在的列)——列迁移与索引重建必须同序;② 测试里「UPDATE trade_orders SET holding_id」直改冗余列的方式在真相源切换后不再生效(r016 name 兜底失败)——测试自身要改经段表造数据。沉淀:**存储层语义变更时,先跑全部存量回归锁定断裂点,再逐点核对(skill §5 语义迁移核对)**。
### 2. 「作废」与「清仓」是两个生命周期终态,不能用同一标记
撤建仓买入段若走 closeHolding 会造出假的「已清仓」历史行(R-016 历史层会显示、统计会含)——老师 R3-1 拍板新增 void 第三态。沉淀:**「从未成立」与「曾经成立后卖出」在账本语义上必须区分**,UI 历史层只认 close。
### 3. 服务端不做隐式截断,让 UI 截断并提示余额
卖出段 > 活动仓时服务端直接拒绝(segment-exceeds),由 UI 自动截为可容纳量并醒目提示「余额保留」——避免服务端悄悄改用户意图造成「半生效」状态与提示脱节。沉淀:**涉及钱/仓位的写接口要显式,宁可报错回滚也不要隐式修正**。
### 4. 数据域分界用一句话可执行规则落地
「幽灵清仓是一个同步机制,爪子不许伸到账本」落到代码 = PositionSync 构造里不再持有 storage 写能力路径 + 回归断言 closeHolding 零调用。沉淀:**架构性拍板要落到「哪个模块能写哪张表」的显式约束,并用测试钉死**。
## 遗留/后续
1. **r013-columns 测试漂移(既有,非本迭代引入)**:断言「默认 8 列」但 COLUMN_META 自 R-015 加 4 行情列后实际 14 列(4 默认隐藏)——R-015 后未同步,建议另立小修(列显隐功能未破坏,纯测试预期过期);
2. **R-016/R-017 文档同步**:R-017 迭代 15 复盘所述「候选窗口」语义已被 R-018 取代(7 天候选退役),R-017 需求文档待标注退役(R-018 需求文档已含 T2 决策,归档时统一);
3. **幽灵清仓退役的旧数据影响**:存量库中过去被幽灵清仓转历史的行保持 closed 不变(不回溯);新语义下账本只随交易关联/手动操作走;
4. **UI 待老师人工验收项**:多段拆单交互、撤段恢复活动、作废行不显历史层、软提示横幅、策略持仓 tab 不因 QMT 卖光自动消失(须关联卖出单才 close)。
@@ -0,0 +1,29 @@
# 迭代目标:16-交易关联驱动持仓份额动态调整
> 迭代编号:16 | 创建:2026-09-08 状态:实施中
> 依据计划:PLAN-017 需求:R-018(已定稿,2026-09-08 老师逐项拍板)
## 目标描述
把「交易归属」从纯标签升级为**账本写操作**,落实 R-018 的**数据域分界**:幽灵清仓等同步机制只作用于「全部持仓」对账单域,不再写 strategy_holdings 账本;策略持仓份额只由**交易关联分配器 + 手动份额操作**驱动。
范围:策略级候选 + 统一份额分配器(委托已成交量拆 0..N 段 × 策略)+ 买卖联动账本(买入加仓/建新仓、卖出减仓/归零清仓、仓不足截断续分、无仓失败提示)+ 撤段逆操作(撤建仓买入=作废第三态、撤加仓=减回归零作废、撤卖出=恢复活动+份额加回,粒度=段)+ 归属分段存储(trade_order_attributions+ R-017 7 天候选退役 + 漏关联软提示 + PositionSync 删幽灵清仓逻辑。
## 目标分解(阶段)
1. **阶段A 数据域分界**PositionSync 删除幽灵清仓账本写(_autoCloseGhosts 不再 closeHolding),只保留对账单快照同步;漏关联软提示检测基础;
2. **阶段B 存储地基**strategy_holdings 增 `void_at` + `closed_shares`(幂等补列,close 时记录清仓前份额供撤段恢复);新建 `trade_order_attributions` 段表;trade_orders 原两列退役为冗余(读取改经段表);存量单组归属迁移为第一段;
3. **阶段C 归属服务**apply/revoke 判定动作表(买/卖 × 活动仓状态)+ 撤段逆操作 + 原子性(同事务撤旧段+加新段);
4. **阶段D 候选与 API**orders/attribution-candidates 回归「策略级」(R-017 7 天候选退役;holdingId 键控/占位安全逻辑保留);orders/attribution-set(全量替换式)+ attribution-segments(读);
5. **阶段E UI 分配器**TradeRecordsTab 归属入口改策略下拉 + 段量 + 段列表(每段可撤);
6. **阶段F 回归**:新回归脚本(分界/分配器动作/撤段/迁移/软提示)+ 存量回归 + typecheck + build。
## 讨论过程(摘要)
2026-09-08 讨论收敛全部决策:Q1 策略级候选 / Q2 拆段 / Q3 保留建仓逻辑(份额基于 QMT 持仓创建)/ Q4 已成交量 / Q5 新买入=新仓不复活;R1 无仓卖出关联失败 / R2 分段存储 / R3 撤段逆操作(作废第三态+恢复活动)/ R4 统一分配器(不分子买卖、余额默认全量可改小、允许部分关联)/ R5 买入可拆段 / R6 撤加仓归零作废 / R7 段级取消;数据域分界(幽灵清仓不写账本);T1 关闭(无 30s 竞态);T2 R-017 退役;T3 漏关联软提示。
## 对老师/主理人的配合需求
- 重启 DSH web 进程(服务端代码更新)+ 刷新页面人工验收;
- 验收通过后迭代 16 标记「验收通过」,R-018 归档 + 需求池索引更新;
- 迭代 12(R-014 幽灵清仓条款)、迭代 15(R-017 退役)为既有功能变更,人工验收时一并确认新行为符合预期。
@@ -0,0 +1,24 @@
# 验收标准:16-交易关联驱动持仓份额动态调整
> 迭代编号:16 | 依据:PLAN-017 验收要点 + R-018
> 验收状态:**已通过(2026-09-08)** —— 口径修订:自动化为主体 + 只读走查;实盘操作类验证项降为「真实使用顺带确认」(老师拍板:实盘不可为验收而操作,边用边发现问题再修)
## 验收标准线
1. **数据域分界(阶段A**PositionSync 不再写 strategy_holdings——幽灵清仓逻辑删除,同步只维护对账单内存快照;回归脚本断言 storage.closeHolding 不被 PositionSync 调用;
2. **存储地基(阶段B**strategy_holdings 增 void_at/closed_shares 列(幂等);活动唯一索引收窄至两态皆空;closeHolding 记录 closed_sharesshares 仍置 0);voidHolding 置 void_at;段表 trade_order_attributions 建表 + 存量单组归属迁移为第一段(幂等);
3. **份额分配器动作表(阶段C**:买入段→加仓/建新仓(不复活旧行);卖出段→减仓/归零清仓、仓不足拒绝(segment-exceeds)、无仓拒绝(no-active-holding);撤买入段→减回/归零作废(void,不产生假清仓历史);撤卖出段→加回/恢复活动仓(closed_shares 恢复);跨 holding 段可任意撤、同 holding 乱序撤拒绝(segment-order-conflict)——全量替换 setSegments 原子(失败整体回滚);
4. **API(阶段D**attribution-targets(策略级 + activityShares);attribution-setsegments 全量替换);attribution-segments(读);今日 orders / trades/history 附加 segmentsR-017 7 天候选退役(attribution-candidates/set-attribution 端点退役,候选改由 targets 承担);
5. **软提示(T3**positions/orphan-hints 返回账本活动行 ∩ QMT 快照缺失的 code(只读不写);
6. **自动化**test-r018-attribution.mjs 29/29 全绿 + test-position-sync 更新后 29/29 + 存量回归(r016 24 / r017 12 / r013 21 / r009 14 / r009-api 13 / r009-sync 8 / r009-normalize 11 / r011-api 12 / r011-tabs 23 / quote-sync 27)全绿 + typecheck + build 通过。
## 验收方法(2026-09-08 修订,老师拍板)
- **主体 = 自动化回归**:上述标准线 1-6 由回归脚本 + typecheck + build 机器验证(AI 已执行并出具结果);实盘为真实资金,不可为验收做买卖操作;
- **人工只读走查**(老师 2026-09-08 已重启并做一次关联操作确认 OK):分配器界面(余额头/策略下拉带仓数/段 chips)显示正常;仅只读观察,不做实盘写操作;
- **顺带确认项(不主动验收,真实使用自然发生时观察)**:卖出全清后策略持仓行不再 30s 自动消失(幽灵退役);顶部漏关联软提示出现;撤段恢复/作废行为——边用边发现问题再修。
## 验收目标
- 标准线 1-6(自动化)通过 → 迭代 16 标记「验收通过」;R-018 归档 已完成/ + 需求池索引实现状态更新;
- 实盘行为类验证项以「真实使用顺带确认」持续跟进(老师口径:先标记成功,后面边用边发现问题再修)。
+74
View File
@@ -0,0 +1,74 @@
# 需求:R-018 交易关联驱动持仓份额动态调整(归属 = 账本写操作)
> 登记:2026-09-08 来源:老师指令(2026-09-08 新优化需求讨论)| 需求状态:**已完成**
> 归档日期:2026-09-08 | 实现迭代:16-交易关联驱动持仓份额动态调整(验收通过 2026-09-08
> 讨论记录索引:R-018.md 正文(Q1-Q5/R1-R7 + 数据域分界 + T2/T3);实现细节见 04-迭代记录/16-交易关联驱动持仓份额动态调整/
> 归档路径:本文件已移入 05-需求池/已完成/
> 迭代状态标记:已定稿(2026-09-08)→ 已实现(迭代 16)→ **已归档(验收通过 2026-09-08,口径=自动化为主体 + 只读走查 + 真实使用顺带确认)**
## 需求描述(一句话)
从**交易记录出发**给委托关联策略/持仓时,**自动同步调整该策略下持仓的份额**(关联即账本写操作),支持一笔委托拆多段分配给不同策略;取消/改归属时做逆操作回滚份额;账本生命周期只由关联交易 + 手动份额操作驱动,同步机制不写账本。
## 〇、数据域分界(最根本前提,2026-09-08 老师拍板)
**「全部持仓」= 对账单域**:唯一数据源 = PositionSyncQMT 快照,10s 内存同步,整体替换)。**包括幽灵清仓在内的一切同步机制只作用于对账单域**,QMT 说了算(老师原话:幽灵清仓本就是一个同步机制,不可以让幽灵把爪子伸太长)。
**「策略持仓」= 账本域(strategy_holdings**:写操作只来自两条通道——
1. **交易关联分配器**(本需求新增):建仓 / 加仓 / 减仓 / 清仓 / 作废;
2. **手动份额操作**(+ 添加持仓 / 移出 / 全部移入):无交易来源时的兜底通道(Q3:保留现在的建仓逻辑;份额以 QMT 持仓信息为基础创建,受「已分配 ≤ 总持仓」校验)。
**既有行为变更**:R-014 定稿的「幽灵自动清仓」(连续 3 轮消失 → closeHolding 本地全部策略当前持仓)**不再写 strategy_holdings**;账本行转历史只由「卖出单关联 → reduceShares 归 0 → closeHolding」产生。影响既有定稿:技术约束-017(幽灵清仓条款)、产品约束-011、R-014、迭代 12(已实现待验收)需修订;PositionSync 删减幽灵清仓逻辑(变更理由 = 本次讨论,写入变更记录)。
## 背景与现状
1. **QMT 实盘持仓**(对账单):PositionSync 10s 内存快照,真实 volume/price(技术约束-017);
2. **本地策略账本** strategy_holdingsshares + 生命周期(open/add/reduce/close),此前靠手动份额操作维护;账本 ≠ 对账单(R-014:holding_id 是交易锚点,不掺易变快照);
3. **交易归属** trade_orders.strategy_id/holding_id:手动设置(R-009/R-017),只做过滤/展示/追溯,**不改数量**——本需求改变这一点。
**展示行关系(防误解)**:策略持仓 tab 当前持仓行 = **QMT 快照 ∩ 账本 shares>0**(行由对账单驱动、份额由账本驱动)。卖出全清 → QMT 无此 code → 展示行随快照消失(≤10s,与幽灵清仓无关);此时账本行仍为当前持仓(closed_at NULL),作为**待关联锚点**保留,直到卖出单被关联。
## 讨论记录(2026-09-08
| # | 决策点 | 结论(老师拍板) |
|---|---|---|
| 1 | 归属候选按什么给?(Q1) | **策略级下拉**,不做预先判断(有无持仓/历史持仓),指向动作发生后按目标策略当时的活动持仓状态现场判定 |
| 2 | 驱动量基准?(Q4 | **已成交量**(撤单/废单不动账) |
| 3 | 交互模型?(R4 统一分配器) | 不分买卖两套流程:一笔委托已成交量可拆 0..N 段(策略×量);待分配余额 = 成交量 − 已分配段和;默认输入 = 余额全量可改小;允许部分关联(剩余保留「未关联」醒目提示);买入卖出均可拆多段 |
| 4 | 买入关联动作?(原始第 2 条) | 该策略有活动持仓 → `addShares` 加仓;无活动持仓(含曾有历史/作废行)→ `openHolding` 建**新仓**(不复活旧行;Q5) |
| 5 | 卖出关联动作?(原始第 3 条) | 活动仓 ≥ 段量 → `reduceShares`,归 0 → `closeHolding`;活动仓 < 段量 → **自动截断**段量=可容纳量归零清仓,余额保留继续分配;该策略无活动持仓 → 关联失败提示(R1) |
| 6 | 撤段逆操作?(R3,粒度=段) | 撤建仓买入段 → 持仓**作废**(新增第三态,非 closeHolding,不产生假清仓历史);撤加仓买入段 → `reduceShares` 减回,归 0 且无真实卖出 → 作废;撤卖出段 → **恢复活动仓 + 份额加回**(撤销语义,区别于 Q5 新买入不接旧仓) |
| 7 | 改归属语义? | 撤旧段 + 加新段,**原子**;撤卖出段若该 holding 已被真实卖出(非本单所致)无法恢复 → 提示不可撤 |
| 8 | 数据域分界?(本轮核心) | 幽灵清仓等同步机制只作用于对账单域;策略持仓写操作只来自交易关联 + 手动份额操作 |
| 9 | T1 卖出关联 vs 幽灵清仓时序竞态 | **关闭**:幽灵不写账本 → 卖出后账本行保留为待关联锚点,无 30s 竞态 |
| 10 | T2 R-0177 天清仓候选) | **退役确认**:新时序下清仓发生在卖出单关联之后,不存在「系统先清、用户后补」场景,候选回归「当前持仓 + 未关联」;R-017 的 holdingId 键控/占位安全逻辑保留 |
| 11 | T3 漏关联兜底 | **软提示确认**:检测「账本有活动行 + QMT 快照无此 code」时提示老师去关联/移出;不自动写账本(不违背分界) |
## 核心规则汇总
| 触发 | 判定 | 动作 |
|---|---|---|
| 买入段 | 该策略该 code 有活动持仓 | `addShares`(加仓,量=段量) |
| 买入段 | 无活动持仓(含曾有历史/作废行) | `openHolding` 建新仓(不复活旧行) |
| 卖出段 | 活动仓 ≥ 段量 | `reduceShares`;归 0 → `closeHolding`(账本转历史唯一途径) |
| 卖出段 | 活动仓 < 段量 | 自动截断:段量=可容纳量 → 归零清仓,余额保留继续分配 |
| 卖出段 | 该策略无活动持仓 | 关联失败提示 |
撤段逆操作:撤建仓买入 → 持仓作废(第三态);撤加仓买入 → 减回归 0 作废;撤卖出 → 恢复活动仓 + 份额加回。改归属 = 撤旧段 + 加新段(原子)。
## 存储 / 模型调整方向
1. **holding 第三态**strategy_holdings 增加作废标记(如 `void_at`);活动 = `closed_at IS NULL AND void_at IS NULL`;作废行不进 R-016 历史「已清仓」层,保留数据可追溯;活动唯一索引(同策略同 code)收窄至两态皆空;
2. **归属单列 → 分段表**:新表(如 `trade_order_attributions`order_id → strategy_id/holding_id/volume/direction/created_at),一笔委托 N 段;R-010 by-holding 与 R-009 history 策略过滤改经段表 join(同一单可出现在多个 holding 展开里);trade_orders 原两列退役或降级为「主归属」快照,已有单组归属迁移为第一段;
3. **软提示(T3**:账本有活动行 + QMT 快照无此 code 时的可见性提示(不动账本)。
## 边界
**做**:幽灵清仓账本侧退役(PositionSync 删幽灵清仓逻辑)+ 策略级候选 + 统一份额分配器(拆段/余额)+ 买卖份额联动 + 撤段逆操作(含作废第三态)+ 归属分段存储 + 查询/展示适配 + R-017 7 天候选退役(holdingId 键控/占位保留)+ 漏关联软提示 + 回归。
**不做**:自动归属推导仍不暴露(可另议);全部持仓 tab / 未分配 tab 份额联动 UI;跨策略「未分配余额」自动兜底;账本写操作自动化兜底(软提示不自动改账本)。
## 验收
见规划后的迭代验收标准(本需求涉及既有已实现功能变更:迭代 12 幽灵清仓语义、迭代 15 R-017 退役,验收需含存量回归)。
+2 -1
View File
@@ -28,7 +28,8 @@
| R-014 | 持仓内存快照(服务端 10s 定时同步,请求不再穿透 QMT) | 服务端建全量持仓**内存快照**(不落库):PositionSync 启动预热 + 每 10s 全量拉 QMT → 校验 → 整体替换;同步失败保留上次快照(QMT 抖动不再白屏);空快照双重确认(/health + getAsset 账户身份)才接受为真清仓;快照为空读穿透兜底(当场拉一次并回填);**幽灵持仓自动清仓**:QMT 连续 3 轮(约 30s)消失的 code,本地全部策略当前持仓自动转历史(账户身份守卫:accountId 未知跳过、切换重置跳过;部分减持不触发);strategy-positions / unallocated / summary 三个接口改读快照,前端零改动。**2026-09-02 定稿(内存不落库 / 读穿透兜底 / 幽灵自动清仓 / 同步时间不显示,老师逐项拍板),2026-09-02 完成(回归 34/34 + r013 回归 21/21 + typecheck + build 通过),迭代 12 验收待老师人工确认** | 架构梳理讨论引出 + 老师指令(2026-09-02 | P1 | 已定稿 | 2026-09-02 | 12-持仓内存快照 | 已实现(待验收) |
| R-015 | 盘口数据内存化(QuoteSync/QuoteHub 统一管理)+ 数据同步指示灯 | QuoteSync(取数:prime + 5s REST 刷新 + 涨停跌停按交易日缓存)/ QuoteHub(存查:内存快照 + watch 集合 + 读穿透 + 价格单一入口)替换 MarketFeed/MarketDataHub(方案 A 替换不包壳);WS 通路移除;market_quotes_cache 表退役 DROP;涨停价/跌停价字段(/data/instrument 按交易日缓存不落库);会话头部新增持仓/盘口同步指示灯(绿/黄/灰 + 悬停详情 + 点击即 syncNow+ sync-status 端点(吸收临时诊断 market-stats);策略持仓表行情列扩展(涨停/跌停/今开/最高 4 列,默认隐藏)。**2026-09-02 定稿(五项老师逐项拍板),2026-09-02 完成(test-quote-sync 全绿 + 存量回归 34/34、21/21 + typecheck + build),迭代 13 验收待老师人工确认** | 架构演进讨论引出 + 老师指令(2026-09-02 | P1 | 已定稿 | 2026-09-02 | 13-盘口内存快照 | 已实现(待验收) |
| R-016 | 策略 tab 历史持仓展示(显示/隐藏已清仓持仓 + 清仓时间范围筛选) | 两个策略 tab(做T/网格超市)title 旁添加:① 显示/隐藏已清仓历史持仓的开关;② 显示历史时默认展示近一周清仓的持仓,并提供 近1个月/近3个月/近半年/近1年 范围选项。背景:卖出清仓后幽灵清仓(R-014)把持仓行转历史,策略 tab 当场少行(2026-09-07 大连热电做T卖出实际发生);历史数据一直在库(strategy_holdings closed_at 非空),只缺展示入口;历史行可复用 R-010 行展开做交易复盘。**2026-09-07 定稿(Q1-Q7 老师拍板:Q2 改判「份额显示 0 就好,不改 closeHolding 语义」,其余按 AI 建议;核心目标=追溯该持仓相关的历史操作)**;历史行复用 R-010 展开做交易复盘;新增 strategy-holdings/history 端点,当前持仓路径零改动。**2026-09-07 二轮补充(Q8-Q9 老师拍板):当天(本地自然日 00:00 起)已清仓的持仓默认显示(range='today' 恒显示层),「历史持仓」开关只控制今天之前的历史(历史持仓关≠当天清仓被藏起);层间按 holdingId 去重,计数不含已清仓行维持;零写路径变更维持**。详见 R-016.md | 老师指令(2026-09-07 优化功能需求讨论) | P1 | 已定稿 | 2026-09-07 | 14-策略tab历史持仓展示 | 已实现(待验收) |
| R-017 | 委托归属候选纳入近期清仓持仓 | 交易记录 tab 归属候选只回当前持仓——清仓后当日委托失去下拉锚点无法补关联(R-016 关联观察立项,2026-09-07 大连热电实际发生:盘中归属后被清为未关联,下拉已无做T候选)。**2026-09-07 定稿(老师指令确认方向)**:候选 = 当前持仓 + 近 7 天清仓持仓(closed 标记,选项文本「已清仓」后缀),下拉 value 改 holdingId 键控(同策略多轮不撞值),排序当前在前/清仓按 closed_at DESC;当日数据修复经 set-attribution 通道完成(非固化功能)。详见 R-017.md | 老师指令(2026-09-07 | P1 | 已定稿 | 2026-09-07 | 15-归属候选纳入清仓持仓 | 已实现(待验收 |
| R-017 | 委托归属候选纳入近期清仓持仓 | 交易记录 tab 归属候选只回当前持仓——清仓后当日委托失去下拉锚点无法补关联(R-016 关联观察立项,2026-09-07 大连热电实际发生:盘中归属后被清为未关联,下拉已无做T候选)。**2026-09-07 定稿(老师指令确认方向)**:候选 = 当前持仓 + 近 7 天清仓持仓(closed 标记,选项文本「已清仓」后缀),下拉 value 改 holdingId 键控(同策略多轮不撞值),排序当前在前/清仓按 closed_at DESC;当日数据修复经 set-attribution 通道完成(非固化功能)。**2026-09-08 R-018 取代标注:7 天清仓候选退役(R-018 T2 拍板)**——归属候选改策略级 attribution-targets(迭代 16 实施),本需求「清仓后补关联」痛点根因(幽灵清仓抢先归档)已由数据域分界消除;holdingId 键控/占位安全逻辑保留。详见 R-017.md | 老师指令(2026-09-07 | P1 | 已定稿 | 2026-09-07 | 15-归属候选纳入清仓持仓 | 已实现(被 R-018 语义取代 |
| R-018 | 交易关联驱动持仓份额动态调整(归属 = 账本写操作) | 从交易记录出发给委托关联策略/持仓时自动同步调整该策略下持仓份额(关联即账本写操作):策略级候选 + 统一「份额分配器」(一笔委托拆 0..N 段 × 策略×量,余额默认全量可改小,允许部分关联)+ 买入加仓/建新仓(不复活旧行)、卖出减仓/归零清仓、仓不足自动截断续分、无仓关联失败;撤段逆操作(撤建仓买入=持仓作废第三态不产生假清仓历史、撤加仓=减回归零作废、撤卖出=恢复活动仓+份额加回,粒度=段、改归属原子);**数据域分界(老师拍板):幽灵清仓等同步机制只作用于「全部持仓」对账单域,不再写 strategy_holdings 账本**——账本生命周期只由交易关联 + 手动份额操作驱动,账本转历史唯一途径=卖出单关联 closeHoldingR-017 7 天清仓候选退役(holdingId 键控/占位保留);漏关联软提示(账本有活动行+QMT 无此 code)。影响既有定稿:技术约束-017/产品约束-011/R-014/迭代12 幽灵清仓条款修订。详见 R-018.md | 老师指令(2026-09-08 新优化需求讨论) | P1 | 已定稿 | 2026-09-08 | 16-交易关联驱动持仓份额动态调整 | **已实现(已归档至 已完成/R-018.md2026-09-08 验收通过)** |
## 渐进明细规划素材
+9 -41
View File
@@ -56,11 +56,11 @@ console.log('[1] startup warmup + basic sync');
{
const ds = mockDataSource({ positions: [pos('600519.SH', 100), pos('300057.SZ', 200)] });
const st = mockStorage([]);
const sync = new PositionSync({ runtime: { dataSource: ds, storage: st }, ghostRounds: 3 });
const sync = new PositionSync({ runtime: { dataSource: ds, storage: st } });
const r = await sync.syncNow();
ok(sync.getSnapshot().length === 2, 'snapshot has 2 rows');
ok(sync.getSyncedAt() > 0, 'syncedAt recorded');
ok(r && r.positions === 2 && Array.isArray(r.closed), 'returns {positions, closed}');
ok(r && r.positions === 2 && r.closed === undefined, 'returns {positions} (ghost-close retired, no closed)');
ok(st.calls.closed.length === 0, 'no local holdings -> no close');
}
@@ -116,8 +116,9 @@ console.log('[3] empty snapshot double-confirmation');
ok(s4.getSnapshot().length === 1, '3d empty + identity unknown -> old snapshot kept');
}
console.log('[4] ghost holdings auto-close (debounce + account guard)');
console.log('[4] R-018 data-domain boundary: ghost-close retired (sync never writes ledger)');
{
// 账本有活动行(本地仍记 000001.SZ),QMT 快照无此 code —— 同步机制不再自动 closeHolding(分界拍板)
const holdings = [
{ holdingId: 1, strategyId: 'grid', code: '000001.SZ', shares: 100 },
{ holdingId: 2, strategyId: 'manual-t', code: '000001.SZ', shares: 50 },
@@ -125,44 +126,11 @@ console.log('[4] ghost holdings auto-close (debounce + account guard)');
];
const ds = mockDataSource({ positions: [pos('600519.SH')] });
const st = mockStorage(holdings);
const sync = new PositionSync({ runtime: { dataSource: ds, storage: st }, ghostRounds: 3 });
await sync.syncNow();
ok(st.calls.closed.length === 0, '4a round1 miss: no close');
await sync.syncNow();
ok(st.calls.closed.length === 0, '4a round2 miss: no close');
const r3 = await sync.syncNow();
ok(st.calls.closed.length === 2 && st.calls.closed.every((c) => c.code === '000001.SZ'), '4a round3: 000001.SZ closed across 2 strategies');
ok(r3.closed.length === 1 && r3.closed[0].code === '000001.SZ' && r3.closed[0].strategies.length === 2, '4a close detail returned');
ok(!st.calls.closed.some((c) => c.code === '600519.SH'), '4a code still in snapshot untouched');
const st2 = mockStorage(holdings);
const ds2 = mockDataSource({ positions: [pos('600519.SH')] });
const sync2 = new PositionSync({ runtime: { dataSource: ds2, storage: st2 }, ghostRounds: 3 });
await sync2.syncNow(); await sync2.syncNow();
ds2._positions = [pos('600519.SH'), pos('000001.SZ')];
await sync2.syncNow();
ds2._positions = [pos('600519.SH')];
await sync2.syncNow();
ok(st2.calls.closed.length === 0, '4b reappear resets debounce counter');
const st3 = mockStorage(holdings);
const ds3 = mockDataSource({ positions: [pos('600519.SH')] });
const sync3 = new PositionSync({ runtime: { dataSource: ds3, storage: st3 }, ghostRounds: 3 });
await sync3.syncNow(); await sync3.syncNow();
ds3._accountId = 'ACC-2';
await sync3.syncNow(); await sync3.syncNow(); await sync3.syncNow();
ok(st3.calls.closed.length === 0, '4c accountId change resets debounce (never reaches 3 consecutive)');
const st4 = mockStorage(holdings);
const ds4 = mockDataSource({ positions: [pos('600519.SH')] });
const sync4 = new PositionSync({ runtime: { dataSource: ds4, storage: st4 }, ghostRounds: 3 });
await sync4.syncNow(); await sync4.syncNow(); // miss x2(差 1 轮到阈值)
ds4._accountId = null; // 身份丢失
await sync4.syncNow(); await sync4.syncNow(); // 期间跳过判定,不累计
ok(st4.calls.closed.length === 0, '4d identity unknown -> skip close judgement');
ds4._accountId = 'ACC-1'; // 身份恢复
await sync4.syncNow(); // 第 3 次 miss → 达阈值清仓
ok(st4.calls.closed.length === 2, '4d identity restored -> 3rd miss closes');
const sync = new PositionSync({ runtime: { dataSource: ds, storage: st } });
for (let i = 0; i < 5; i++) await sync.syncNow(); // 远超旧 3 轮阈值
ok(st.calls.closed.length === 0, '4a multiple rounds missing: ledger NOT closed (ghost retired)');
ok(sync.getSnapshot().every((p) => p.code === '600519.SH'), '4a snapshot only reflects QMT (600519.SH)');
ok(sync.stats.closedGhosts === undefined, '4a stats.closedGhosts removed');
}
console.log('[5] PositionManager snapshot read + read-through fallback');
+26 -14
View File
@@ -37,27 +37,39 @@ const dataSource = {
const runtime = { dataSource, storage, settings };
// 端点注册齐全
assert(TRADE_METHODS.has('orders/attribution-candidates'), '端点 orders/attribution-candidates 已注册');
assert(TRADE_METHODS.has('orders/set-attribution'), '端点 orders/set-attribution 已注册');
// 端点注册齐全R-018candidates/set-attribution 退役 → targets/set/segments
assert(TRADE_METHODS.has('orders/attribution-targets'), '端点 orders/attribution-targets 已注册');
assert(TRADE_METHODS.has('orders/attribution-set'), '端点 orders/attribution-set 已注册');
assert(TRADE_METHODS.has('orders/attribution-segments'), '端点 orders/attribution-segments 已注册');
assert(TRADE_METHODS.has('trades/history'), '端点 trades/history 已注册');
assert(!TRADE_METHODS.has('orders/attribution-candidates'), '端点 orders/attribution-candidates 已退役');
assert(!TRADE_METHODS.has('orders/set-attribution'), '端点 orders/set-attribution 已退役');
// 候选端点(含策略名
// attribution-targets 端点(策略级 + 策略名 + activityShares
storage.openHolding('manual-t', '300057.SZ', 1000);
const cands = await handleTrade('orders/attribution-candidates', { code: '300057.SZ' }, runtime);
assert(cands.length === 1 && cands[0].strategyName === '手动做T', '候选含策略名(手动做T');
const tgt = await handleTrade('orders/attribution-targets', { code: '300057.SZ' }, runtime);
assert(tgt.length === 2, 'targets 返回全部策略');
const tRow = tgt.find((x) => x.strategyId === 'manual-t');
assert(tRow && tRow.strategyName === '手动做T' && tRow.activityShares === 1000, 'targets 含策略名 + activityShares(手动做T 1000');
// set-attribution 端点
storage.upsertTradeOrders([{ orderId: 'X-1', tradeDate: '20260901', code: '300057.SZ', insertDate: '20260901', insertTime: '093000' }]);
const h = storage.sqlite.db.prepare('SELECT holding_id FROM strategy_holdings WHERE strategy_id=?').get('manual-t');
const setRes = await handleTrade('orders/set-attribution', { orderId: 'X-1', strategyId: 'manual-t', holdingId: h.holding_id }, runtime);
assert(setRes.ok === true, 'set-attribution 端点返回 ok');
const row = storage.sqlite.db.prepare('SELECT strategy_id FROM trade_orders WHERE order_id=?').get('X-1');
assert(row.strategy_id === 'manual-t', '归属已持久化');
// attribution-set 端点(R-018 账本写操作)
storage.upsertTradeOrders([{ orderId: 'X-1', tradeDate: '20260901', code: '300057.SZ', insertDate: '20260901', insertTime: '093000', tradedVolume: 1000 }]);
const { AttributionService } = await import('../src/trades/AttributionService.js');
const svc = new AttributionService({ store: storage.sqlite });
const rt = { ...runtime, attribution: svc };
const setRes = await handleTrade('orders/attribution-set', { orderId: 'X-1', segments: [{ strategyId: 'manual-t', volume: 1000 }] }, rt);
assert(setRes.ok === true && setRes.segments.length === 1, 'attribution-set 返回 ok + segments');
const row = storage.sqlite.db.prepare('SELECT strategy_id, holding_id FROM trade_orders WHERE order_id=?').get('X-1');
assert(row.strategy_id === 'manual-t' && row.holding_id != null, '归属已持久化(冗余列镜像)');
// trades/history 端点(策略过滤)
// attribution-segments 端点
const segs = await handleTrade('orders/attribution-segments', { orderId: 'X-1' }, rt);
assert(segs.length === 1 && segs[0].strategyName === '手动做T', 'attribution-segments 读段含策略名');
// trades/history 端点(策略过滤经段表)
const hist = await handleTrade('trades/history', { start: '20260901', end: '20260901', strategyId: 'manual-t' }, runtime);
assert(hist.orders.length === 1 && hist.orders[0].orderId === 'X-1', 'history 端点按策略过滤命中');
assert(Array.isArray(hist.orders[0].segments) && hist.orders[0].segments.length === 1, 'history orders 附加 segments');
storage.close();
rmSync(dir, { recursive: true, force: true });
+5 -2
View File
@@ -66,8 +66,11 @@ try {
db.prepare(
"INSERT INTO trade_orders (order_id, trade_date, code, name, direction, insert_ts, fetched_at) VALUES (?,?,?,?,?,?,?)"
).run('T-001', '20260907', '600719.SH', '大连热电', 'sell', Date.now(), Date.now());
// 归属到 s1 的历史行
db.prepare('UPDATE trade_orders SET holding_id=? WHERE order_id=?').run(week[0].holdingId, 'T-001');
// 归属到 s1 的历史行(R-018:真相源 = 段表 —— 段 + 冗余列镜像)
db.prepare(
"INSERT INTO trade_order_attributions (order_id, strategy_id, holding_id, code, direction, volume, created_at) VALUES (?,?,?,?,?,?,?)"
).run('T-001', 's1', week[0].holdingId, '600719.SH', 'sell', 100, Date.now());
db.prepare('UPDATE trade_orders SET strategy_id=?, holding_id=? WHERE order_id=?').run('s1', week[0].holdingId, 'T-001');
const apiWeek = await handleStrategy('strategy-holdings/history', { strategyId: 's1', range: 'week' }, runtime);
assert(apiWeek.length === 1 && apiWeek[0].code === '600719.SH', '端点近一周返回 1 行');
assert(apiWeek[0].name === '大连热电', 'name 兜底取关联委托 name');
+30 -34
View File
@@ -1,7 +1,7 @@
/**
* R-017 / 迭代 15 回归:归属候选纳入近期清仓持仓
* 独立临时数据目录(技术约束-011)。覆盖:当前持仓在前 / 近 7 天清仓行入选(closed 标记)/
* 超 7 天清仓行被滤 / 策略隔离(同 code 多轮多策略/ 空 code / 端点透传
* R-017 / 迭代 15 回归 → R-018 / 迭代 16 语义修订:归属候选退役 7 天清仓窗口
* 新语义(R-018 T2 老师拍板):候选回归「当前活动持仓 + 未关联」;正式候选为策略级 attribution-targets。
* 覆盖:当前活动持仓候选 / 清仓/作废行不进候选 / 多策略 / 空 code / targets 端点。
*/
import { DataStore } from '../src/storage/DataStore.js';
import { handleTrade } from '../src/api/trades.js';
@@ -18,51 +18,47 @@ function assert(cond, msg) {
const DAY = 86400000;
const tmp = mkdtempSync(join(tmpdir(), 'odl-r017-'));
const storage = new DataStore({ dataDir: tmp });
const runtime = { storage, settings: { get: () => ({}) } }; // 策略名缺省 → fallback strategyId
const runtime = {
storage,
settings: { get: () => ({ strategies: [{ id: 's-t', name: '做T' }, { id: 's-g', name: '网格' }] }) },
};
try {
console.log('\n[1] 当前持仓候选(closed=false');
const h1 = await storage.openHolding('s-t', '600719.SH', 1000);
console.log('\n[1] 当前活动持仓候选(closed=false');
await storage.openHolding('s-t', '600719.SH', 1000);
await storage.openHolding('s-g', '600719.SH', 800); // 同码第二策略
let cands = await storage.getTradeAttributionCandidates('600719.SH');
assert(cands.length === 2, '同码两策略当前持仓都在候选');
assert(cands.every((c) => c.closed === false && c.closedAt === null), '当前持仓 closed=false / closedAt=null');
assert(cands[0].shares >= cands[1].shares, '当前持仓按 shares DESC');
console.log('\n[2] 近 7 天清仓行入选(closed=true),当前在前');
await storage.openHolding('s-t', '000001.SZ', 500);
await storage.closeHolding('s-t', '000001.SZ'); // 刚清仓(做T场景
console.log('\n[2] 清仓/作废行不进候选(R-018 退役 7 天窗口)');
const hc = await storage.openHolding('s-t', '000001.SZ', 500);
await storage.closeHolding('s-t', '000001.SZ'); // 刚清仓(R-017 时代会入选,R-018 起不再
cands = await storage.getTradeAttributionCandidates('000001.SZ');
assert(cands.length === 1 && cands[0].closed === true, '刚清仓行进入候选');
assert(cands[0].holdingId != null && cands[0].closedAt > 0, '清仓行带 holdingId + closedAt');
assert(cands[0].strategyId === 's-t', '清仓行策略正确');
console.log('\n[3] 超 7 天清仓行被滤除');
const hOld = await storage.openHolding('s-t', '600000.SH', 300);
await storage.closeHolding('s-t', '600000.SH');
storage.sqlite.db.prepare('UPDATE strategy_holdings SET closed_at=? WHERE holding_id=?')
.run(Date.now() - 8 * DAY, hOld.holdingId);
assert(cands.length === 0, '刚清仓行不进候选(7 天窗口退役)');
const hv = await storage.openHolding('s-t', '600000.SH', 300);
storage.sqlite.voidHolding('s-t', '600000.SH');
cands = await storage.getTradeAttributionCandidates('600000.SH');
assert(cands.length === 0, '8 天前清仓行不候选');
assert(cands.length === 0, '作废行不候选');
console.log('\n[4] 排序:当前持仓在前,清仓行 closed_at DESC');
await storage.openHolding('s-g', '300750.SZ', 100);
await storage.closeHolding('s-g', '300750.SZ');
const hOlder = await storage.openHolding('s-g', '300750.SZ', 200); // 复用同码再建仓再清
await storage.closeHolding('s-g', '300750.SZ');
storage.sqlite.db.prepare('UPDATE strategy_holdings SET closed_at=? WHERE holding_id=?')
.run(Date.now() - 3 * DAY, hOlder.holdingId);
cands = await storage.getTradeAttributionCandidates('300750.SZ');
assert(cands.length === 2 && cands.every((c) => c.closed), '同码两轮清仓都入选');
assert(cands[0].closedAt >= cands[1].closedAt, '清仓行按 closed_at DESC');
console.log('\n[5] 空 code / 无持仓 code');
console.log('\n[3] 空 code / 无持仓 code');
assert((await storage.getTradeAttributionCandidates('')).length === 0, '空 code → []');
assert((await storage.getTradeAttributionCandidates('999999.SH')).length === 0, '无持仓 code → []');
console.log('\n[6] API 端点透传(含 strategyName fallback');
const api = await handleTrade('orders/attribution-candidates', { code: '000001.SZ' }, runtime);
assert(api.length === 1 && api[0].strategyName === 's-t', '端点返回候选且策略名 fallback 到 id');
console.log('\n[4] R-018 attribution-targets(策略级候选端点');
const hc2 = await storage.openHolding('s-t', '000002.SZ', 500);
const tgt = await handleTrade('orders/attribution-targets', { code: '000002.SZ' }, runtime);
assert(Array.isArray(tgt), 'targets 端点返回数组');
assert(tgt.every((x) => x.strategyId && x.activityShares != null), 'targets 每项含 strategyId + activityShares');
const rowT = tgt.find((x) => x.strategyId === 's-t');
assert(rowT && rowT.activityShares === 500 && rowT.hasActiveHolding === true, '有仓策略 activityShares=500 / hasActiveHolding=true');
const rowG = tgt.find((x) => x.strategyId === 's-g');
assert(rowG && rowG.activityShares === 0 && rowG.hasActiveHolding === false, '无仓策略 activityShares=0 / hasActiveHolding=false');
// 关闭的持仓不占 hasActiveHolding
await storage.closeHolding('s-t', '000002.SZ');
const tgt2 = await handleTrade('orders/attribution-targets', { code: '000002.SZ' }, runtime);
assert(tgt2.find((x) => x.strategyId === 's-t').hasActiveHolding === false, '清仓后该策略 hasActiveHolding=false');
} finally {
storage.close();
try { rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
+164
View File
@@ -0,0 +1,164 @@
/**
* R-018 / 迭代 16 回归:交易关联驱动持仓份额动态调整
* 覆盖:数据域分界(PositionSync 不写账本)/ 归属分段表 / 分配器动作表(买建仓·买加仓·卖减仓·卖清仓·仓不足拒绝·无仓拒绝)/
* 撤段逆操作(撤建仓=作废·撤加仓=减回·撤清仓卖出段=恢复活动·乱序拒绝)/ 全量替换原子 / 存量迁移 / API / 软提示只读。
*/
import { DataStore } from '../src/storage/DataStore.js';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
let pass = 0, fail = 0;
function assert(cond, msg) {
if (cond) { pass++; console.log(' ✅ ' + msg); }
else { fail++; console.log(' ❌ ' + msg); }
}
const tmp = mkdtempSync(join(tmpdir(), 'odl-r018-'));
const storage = new DataStore({ dataDir: tmp });
await storage._ensure();
const now = Date.now();
function insOrder(orderId, code, direction, tradedVolume) {
storage.sqlite.db.prepare(
'INSERT INTO trade_orders (order_id, trade_date, code, name, exchange, direction, order_volume, traded_volume, insert_date, insert_time, insert_ts, fetched_at) ' +
'VALUES (?, \'20260908\', ?, \'X\', \'SH\', ?, ?, ?, \'20260908\', \'093000\', ?, ?)'
).run(orderId, code, direction, tradedVolume, tradedVolume, now, now);
}
const { PositionSync } = await import('../src/position/PositionSync.js');
const { AttributionService } = await import('../src/trades/AttributionService.js');
const svc = new AttributionService({ store: storage.sqlite });
console.log('\n[1] 数据域分界:PositionSync 不写账本(幽灵退役)');
{
const ds = {
async getPositions() { return [{ code: '600519.SH', volume: 100 }]; },
async getAsset() { return { accountId: 'ACC' }; },
async isAvailable() { return true; },
};
const holdings = [{ holdingId: 1, strategyId: 'grid', code: '000001.SZ', shares: 100 }];
const calls = { closed: 0 };
const st = { async getCurrentHoldings() { return holdings; }, async closeHolding() { calls.closed++; } };
const sync = new PositionSync({ runtime: { dataSource: ds, storage: st } });
for (let i = 0; i < 5; i++) await sync.syncNow();
assert(calls.closed === 0, '1a 多轮消失后账本不被 closeHolding(幽灵退役)');
assert(sync.stats.closedGhosts === undefined, '1b stats.closedGhosts 移除');
}
console.log('\n[2] 存储地基:作废态 / closed_shares / 段表 / 兼容迁移');
{
const h1 = await storage.openHolding('grid', '600519.SH', 1000);
await storage.addShares('grid', '600519.SH', 500);
const act = storage.sqlite._getActive('grid', '600519.SH');
assert(act.shares === 1500, '2a addShares 累加 1500');
await storage.closeHolding('grid', '600519.SH');
const closedRow = storage.sqlite.getHoldingById(h1.holdingId);
assert(closedRow.closedShares === 1500 && closedRow.shares === 0, '2b closeHolding 记录 closed_shares=1500 且 shares 置 0');
const re = storage.sqlite.reopenHolding(h1.holdingId);
assert(re && re.shares === 1500, '2c reopenHolding 恢复活动 shares=1500');
const hv = await storage.openHolding('grid', '300057.SZ', 300);
storage.sqlite.voidHolding('grid', '300057.SZ');
const vRow = storage.sqlite.getHoldingById(hv.holdingId);
assert(vRow.voidAt != null && vRow.closedAt == null, '2d voidHolding 置 void_at 非 close');
assert(storage.sqlite.getCurrentHoldings('grid').length === 1, '2e 作废行不在当前持仓');
assert(storage.sqlite.getHoldingsHistory('grid').length === 0, '2f 历史层无作废行(600519 已恢复活动、300057 作废不进历史)');
insOrder('M-1', '600519.SH', 'buy', 1000);
storage.sqlite.setOrderAttribution('M-1', 'grid', h1.holdingId);
const segs = storage.sqlite.getOrderAttributions('M-1');
assert(segs.length === 1 && segs[0].volume === 1000, '2g 兼容 setOrderAttribution 同步段表第一段');
}
console.log('\n[3] 分配器动作表:买建仓 / 买加仓 / 卖减仓 / 卖清仓 / 拒绝');
{
insOrder('B-1', '600000.SH', 'buy', 800);
svc.applySegment({ orderId: 'B-1', strategyId: 'grid', volume: 800 });
const a1 = storage.sqlite._getActive('grid', '600000.SH');
assert(a1 && a1.shares === 800, '3a 买入无仓 → openHolding 建仓 800');
insOrder('B-2', '600000.SH', 'buy', 300);
svc.applySegment({ orderId: 'B-2', strategyId: 'grid', volume: 300 });
const a2 = storage.sqlite._getActive('grid', '600000.SH');
assert(a2.shares === 1100, '3b 买入有仓 → addShares 1100');
insOrder('S-1', '600000.SH', 'sell', 600);
svc.applySegment({ orderId: 'S-1', strategyId: 'grid', volume: 600 });
const a3 = storage.sqlite._getActive('grid', '600000.SH');
assert(a3.shares === 500, '3c 卖出减仓 → 500');
insOrder('S-2', '600000.SH', 'sell', 500);
svc.applySegment({ orderId: 'S-2', strategyId: 'grid', volume: 500 });
assert(storage.sqlite._getActive('grid', '600000.SH') === null, '3d 卖出归零 → closeHolding 清仓');
insOrder('S-3', '600000.SH', 'sell', 999);
let err = null; try { svc.applySegment({ orderId: 'S-3', strategyId: 'grid', volume: 999 }); } catch (e) { err = e; }
assert(err && err.code === 'no-active-holding', '3e 卖出无仓 → no-active-holding 拒绝');
insOrder('B-3', '601111.SH', 'buy', 500);
svc.applySegment({ orderId: 'B-3', strategyId: 'grid', volume: 500 });
insOrder('S-4', '601111.SH', 'sell', 800);
err = null; try { svc.applySegment({ orderId: 'S-4', strategyId: 'grid', volume: 800 }); } catch (e) { err = e; }
assert(err && err.code === 'segment-exceeds', '3f 卖出段 > 活动仓 → segment-exceeds 拒绝');
}
console.log('\n[4] 撤段逆操作');
{
insOrder('B-V', '600800.SH', 'buy', 400);
svc.applySegment({ orderId: 'B-V', strategyId: 'grid', volume: 400 });
const hV = storage.sqlite._getActive('grid', '600800.SH');
svc.revokeSegment({ orderId: 'B-V', strategyId: 'grid' });
const vRow = storage.sqlite.getHoldingById(hV.holdingId);
assert(vRow.voidAt != null && vRow.closedAt == null, '4a 撤建仓买入段 → void 作废(无假清仓历史)');
assert(storage.sqlite.getHoldingsHistory('grid').filter((x) => x.code === '600800.SH').length === 0, '4a 作废不进历史层');
insOrder('B-R', '600900.SH', 'buy', 700);
svc.applySegment({ orderId: 'B-R', strategyId: 'grid', volume: 700 });
insOrder('S-R', '600900.SH', 'sell', 700);
svc.applySegment({ orderId: 'S-R', strategyId: 'grid', volume: 700 });
assert(storage.sqlite._getActive('grid', '600900.SH') === null, '4b 前置:已清仓');
svc.revokeSegment({ orderId: 'S-R', strategyId: 'grid' });
const aR = storage.sqlite._getActive('grid', '600900.SH');
assert(aR && aR.shares === 700, '4b 撤清仓卖出段 → 恢复活动 700');
insOrder('B-X1', '600300.SH', 'buy', 300);
svc.applySegment({ orderId: 'B-X1', strategyId: 'grid', volume: 300 });
insOrder('B-X2', '600300.SH', 'buy', 200);
svc.applySegment({ orderId: 'B-X2', strategyId: 'grid', volume: 200 });
svc.revokeSegment({ orderId: 'B-X2', strategyId: 'grid' });
const aX = storage.sqlite._getActive('grid', '600300.SH');
assert(aX.shares === 300, '4c 撤加仓买入段 → 减回 300');
insOrder('S-X', '600300.SH', 'sell', 300);
svc.applySegment({ orderId: 'S-X', strategyId: 'grid', volume: 300 });
let err = null;
try { svc.revokeSegment({ orderId: 'B-X1', strategyId: 'grid' }); } catch (e) { err = e; }
assert(err && err.code === 'segment-order-conflict', '4d 已清仓后撤更早买入段 → 冲突拒绝(逆序约束)');
}
console.log('\n[5] 全量替换 setSegments(多段 + 原子)');
{
await storage.openHolding('grid', '600999.SH', 500);
await storage.openHolding('manual', '600999.SH', 300);
insOrder('S-SPLIT', '600999.SH', 'sell', 800);
const r = svc.setSegments({ orderId: 'S-SPLIT', segments: [{ strategyId: 'grid', volume: 500 }, { strategyId: 'manual', volume: 300 }] });
assert(r.segments.length === 2, '5a 拆成两段');
assert(storage.sqlite._getActive('grid', '600999.SH') === null && storage.sqlite._getActive('manual', '600999.SH') === null, '5b 两段均归零清仓');
svc.revokeSegment({ orderId: 'S-SPLIT', strategyId: 'manual' });
const aM = storage.sqlite._getActive('manual', '600999.SH');
assert(aM && aM.shares === 300, '5c 撤 manual 段 → 该 holding 恢复活动 300(跨 holding 独立撤)');
assert(storage.sqlite._getActive('grid', '600999.SH') === null, '5c grid 段不受影响(保持清仓)');
let err = null;
try { svc.setSegments({ orderId: 'S-SPLIT', segments: [{ strategyId: 'grid', volume: 500 }, { strategyId: 'manual', volume: 600 }] }); } catch (e) { err = e; }
assert(err != null && err.code === 'segment-exceeds-order', '5d 段合计超已成交量 → 事务前拒绝');
const segsAfter = storage.sqlite.getOrderAttributions('S-SPLIT');
assert(segsAfter.length === 1 && segsAfter[0].strategyId === 'grid' && segsAfter[0].volume === 500, '5e 拒绝未改段表(manual 段已在 5c 撤销,grid 段保留)');
}
console.log('\n[6] 漏关联软提示只读');
{
const { handlePosition } = await import('../src/api/positions.js');
const sync = new PositionSync({ runtime: { dataSource: { getPositions: async () => [{ code: '600519.SH' }], getAsset: async () => ({ accountId: 'A' }), isAvailable: async () => true }, storage } });
await sync.syncNow();
const rt = { storage, positionSync: sync };
const hints = await handlePosition('positions/orphan-hints', {}, rt);
assert(Array.isArray(hints), '6a 返回数组');
assert(hints.every((h) => h.code !== '600519.SH'), '6b 快照内的 code 不提示');
}
storage.close();
try { rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
console.log('\n=== 结果: ' + pass + ' 通过 / ' + fail + ' 失败 ===');
process.exitCode = fail > 0 ? 1 : 0;
+2 -2
View File
@@ -60,8 +60,8 @@ const HANDLERS = [
* @param {import('@deepseek-ai/cordis').Context} ctx
* @param {object} runtime { manager, settings, dataSource, marketCache }
*/
export function registerApi(ctx, { manager, settings, dataSource, marketHub, marketFeed, qmtHealthMonitor, storage, tradeSync, positionSync }) {
const runtime = { ctx, manager, settings, dataSource, marketHub, marketFeed, qmtHealthMonitor, storage, tradeSync, positionSync };
export function registerApi(ctx, { manager, settings, dataSource, marketHub, marketFeed, qmtHealthMonitor, storage, tradeSync, positionSync, attribution }) {
const runtime = { ctx, manager, settings, dataSource, marketHub, marketFeed, qmtHealthMonitor, storage, tradeSync, positionSync, attribution };
ctx.effect(() => ctx.webServer.register({
kind: 'prefix',
+24 -3
View File
@@ -2,24 +2,45 @@
* 服务端 API:持仓域(从 api.js 拆分,2026-08-31
*
* 端点:
* positions → 全量持仓
* positions → 全量持仓
* positions/orphan-hints → R-018 漏关联软提示:账本活动行 ∩ QMT 快照缺失的 code(只读,不动账本)
*/
/** 持仓端点方法表 */
export const POSITION_METHODS = new Set([
'positions',
'positions/orphan-hints',
]);
/**
* 处理持仓端点
* @param {string} method
* @param {object} args
* @param {object} runtime { manager }
* @param {object} runtime { manager, storage, positionSync }
*/
export async function handlePosition(method, args, { manager }) {
export async function handlePosition(method, args, { manager, storage, positionSync }) {
switch (method) {
case 'positions':
return await manager.getAllPositions();
case 'positions/orphan-hints': {
// R-018 T3:漏关联软提示。账本有活动行(closed/void 双空)但 QMT 快照无此 code → 提示去关联/移出。
// 只读不写(同步机制与账本分界:软提示不自动改账本)。
const snapshot = positionSync && Array.isArray(positionSync.getSnapshot())
? positionSync.getSnapshot()
: null;
if (!storage) return [];
const holdings = await storage.getCurrentHoldings(); // 全策略当前活动持仓
if (!snapshot) return []; // 快照不可用(从未同步/未注入)→ 不提示(避免误报)
const snapshotCodes = new Set(snapshot.map((p) => p.code).filter(Boolean));
return holdings
.filter((h) => !snapshotCodes.has(h.code))
.map((h) => ({
holdingId: h.holdingId,
strategyId: h.strategyId,
code: h.code,
shares: h.shares,
}));
}
default:
throw Object.assign(new Error('unknown position method: ' + method), { code: 'not-found' });
}
+5 -2
View File
@@ -118,12 +118,15 @@ export async function handleStrategy(method, args, { manager, settings, storage
}
const rows = await storage.getHoldingsHistory(args.strategyId, { sinceMs });
// name 兜底:该 holding 最近一笔关联委托的 name(前端免二次请求)
const ids = rows.map((r) => r.holdingId);
// R-018:真相源 = 段表 —— 历史行关联委托经段表 join(同一单多段也能兜到 name)
const ids = rows.map((r) => r.holdingId).filter((x) => x != null);
const nameMap = new Map();
if (ids.length > 0) {
const ph = ids.map(() => '?').join(',');
const orows = storage.sqlite.db.prepare(
"SELECT holding_id, name FROM trade_orders WHERE holding_id IN (" + ph + ") AND name != '' ORDER BY insert_ts DESC"
'SELECT a.holding_id, t.name FROM trade_order_attributions a ' +
'JOIN trade_orders t ON t.order_id = a.order_id ' +
'WHERE a.holding_id IN (' + ph + ") AND t.name != '' ORDER BY t.insert_ts DESC"
).all(...ids);
for (const o of orows) if (!nameMap.has(o.holding_id)) nameMap.set(o.holding_id, o.name);
}
+78 -34
View File
@@ -1,12 +1,17 @@
/**
* 服务端 API:交易记录域(R-0072026-09-01
* 服务端 API:交易记录域(R-0072026-09-01R-018 迭代 16 归属改造
*
* 端点:
* orders → 当日委托(支持 code/status 过滤)
* trades → 当日成交
* trading-dates → 交易日历(日期导航)
* trades/history → 本地 SQLite 历史查询(R-009时间段/code/策略/方向过滤)
* trades/by-holding → 按 holding_id 查委托汇总(R-010:策略持仓行展开
* orders → 当日委托(code/status 过滤R-018 附加 segments 归属段
* trades → 当日成交
* trading-dates → 交易日历(日期导航)
* trades/history → 本地 SQLite 历史查询(时间段/code/策略/方向过滤;策略过滤经段表
* trades/by-holding → 按 holding_id 查委托汇总(R-010,段表 join
* orders/attribution-targets → R-018 策略级归属目标(候选 = 全部策略 + 该 code 活动份额)
* orders/attribution-set → R-018 全量替换归属段(segments 数组,原子)
* orders/attribution-segments → R-018 读某 order 的归属段
* orders/attribution-candidates / orders/set-attribution 已退役:R-017 7 天候选退役,
* 归属候选改策略级 targets;归属写改 attribution-set
*
* 数据流:前端轮询 → /odl/api/orders|trades → dataSource.getOrders/getTrades
* (服务端直连 QMT Bridge REST,技术约束-003
@@ -20,18 +25,19 @@ export const TRADE_METHODS = new Set([
'trades',
'trading-dates',
'trades/history', // R-009:本地 SQLite 历史查询(策略过滤)
'orders/attribution-candidates', // R-009:委托归属候选列表(该 code 当前持仓策略
'orders/set-attribution', // R-009:手动设置委托归属
'trades/by-holding', // R-010:按 holding_id 查委托汇总(策略持仓行展开
'trades/by-holding', // R-010:按 holding_id 查委托汇总(策略持仓行展开
'orders/attribution-targets', // R-018:策略级归属候选(含 activityShares
'orders/attribution-set', // R-018:归属段全量替换(账本写操作
'orders/attribution-segments', // R-018:读某 order 归属段
]);
/**
* 处理交易记录端点
* @param {string} method
* @param {object} args
* @param {object} runtime { dataSource }
* @param {object} runtime { dataSource, storage, attribution, settings }
*/
export async function handleTrade(method, args, { dataSource, storage, settings }) {
export async function handleTrade(method, args, { dataSource, storage, attribution, settings }) {
switch (method) {
case 'orders': {
const orders = await dataSource.getOrders({
@@ -40,18 +46,25 @@ export async function handleTrade(method, args, { dataSource, storage, settings
start: args.start,
end: args.end,
});
// R-009:今日实时委托附加【持久化归属】(本地库中用户已设置的 strategy_id/holding_id
// 未设置为 null(前端显示「未关联」,用户可手动设置);候选由 orders/attribution-candidates 提供
// R-018:今日实时委托附加【持久化归属】(段表真相源;order 带 segments 数组 + 兼容首段 strategyId/holdingId
if (storage && Array.isArray(orders) && orders.length > 0) {
const ids = orders.map((o) => o.orderId).filter(Boolean);
const ph = ids.map(() => '?').join(',');
const rows = storage.sqlite.db.prepare(
'SELECT order_id, strategy_id, holding_id FROM trade_orders WHERE order_id IN (' + ph + ')'
).all(...ids);
const attr = new Map(rows.map((r) => [r.order_id, { strategyId: r.strategy_id ?? null, holdingId: r.holding_id ?? null }]));
const idList = orders.map((o) => o.orderId).filter(Boolean);
const segMap = storage.getOrderSegmentsByOrderIds(idList); // Map<orderId, segment[]>
const strategyName = (sid) => {
if (!sid) return '';
const found = (getStrategies(settings) || []).find((s) => s.id === sid);
return found ? found.name : sid;
};
return orders.map((o) => {
const a = attr.get(o.orderId) ?? { strategyId: null, holdingId: null };
return { ...o, strategyId: a.strategyId, holdingId: a.holdingId };
const segs = segMap.get(o.orderId) ?? [];
const withName = segs.map((s) => ({ ...s, strategyName: strategyName(s.strategyId) }));
const first = withName[0];
return {
...o,
segments: withName,
strategyId: first ? first.strategyId : null, // 兼容首段(旧 UI 字段)
holdingId: first ? first.holdingId : null,
};
});
}
return orders;
@@ -66,19 +79,37 @@ export async function handleTrade(method, args, { dataSource, storage, settings
start: args.start,
end: args.end,
});
case 'orders/attribution-candidates': {
// R-009:委托归属候选(该 code 当前持仓策略,Q3 全手动选)
case 'orders/attribution-targets': {
// R-018(Q1):候选 = 策略级下拉。全部策略 + 该 code 每策略当前活动份额 activityShares0=无仓)。
// 不预筛(有无持仓/历史持仓由指向后服务端判定);holdingId 不再作下拉键。
if (!storage) throw Object.assign(new Error('本地存储不可用'), { code: 'storage-unavailable' });
const list = await storage.getTradeAttributionCandidates(args.code);
// 附策略名(settings 的策略 name,便于前端展示)
const nameMap = new Map((getStrategies(settings) || []).map((s) => [s.id, s.name]));
return list.map((c) => ({ ...c, strategyName: nameMap.get(c.strategyId) ?? c.strategyId }));
const code = args.code;
const strategies = getStrategies(settings) || [];
// 该 code 当前活动持仓(closed/void 双空)per strategygetCurrentHoldings 无过滤=全策略)
const allHoldings = await storage.getCurrentHoldings();
const holdings = code ? allHoldings.filter((h) => h.code === code) : [];
const shareByStrategy = new Map(holdings.map((h) => [h.strategyId, h.shares]));
return strategies.map((s) => ({
strategyId: s.id,
strategyName: s.name,
activityShares: Number(shareByStrategy.get(s.id) ?? 0),
hasActiveHolding: (shareByStrategy.get(s.id) ?? 0) > 0,
}));
}
case 'orders/set-attribution': {
// R-009:手动设置委托归属(Q4 可随时改,以最终为准)
case 'orders/attribution-set': {
// R-018:全量替换归属段(account写操作)。segments: [{strategyId, volume}]
if (!attribution || !storage) throw Object.assign(new Error('归属服务不可用'), { code: 'storage-unavailable' });
const res = attribution.setSegments({
orderId: args.orderId,
segments: Array.isArray(args.segments) ? args.segments : [],
});
return { ok: true, segments: res.segments };
}
case 'orders/attribution-segments': {
if (!storage) throw Object.assign(new Error('本地存储不可用'), { code: 'storage-unavailable' });
const ok = await storage.setTradeOrderAttribution(args.orderId, args.strategyId ?? null, args.holdingId ?? null);
return { ok };
const segs = storage.getOrderSegments(args.orderId);
const nameMap = new Map((getStrategies(settings) || []).map((s) => [s.id, s.name]));
return segs.map((s) => ({ ...s, strategyName: nameMap.get(s.strategyId) ?? s.strategyId }));
}
case 'trades/by-holding': {
// R-010:按 holding_id 查委托汇总(策略持仓行展开)
@@ -86,7 +117,7 @@ export async function handleTrade(method, args, { dataSource, storage, settings
return await storage.getTradeOrdersByHolding(args.holdingId);
}
case 'trades/history': {
// R-009:本地 SQLite 历史查询(时间段/code/策略/方向过滤)
// R-009:本地 SQLite 历史查询(时间段/code/策略/方向过滤R-018 orders 附加 segments
const opts = {
start: args.start,
end: args.end,
@@ -101,9 +132,22 @@ export async function handleTrade(method, args, { dataSource, storage, settings
storage.getTradeOrderHistory(opts),
storage.getTradeFillHistory(opts),
]);
return { orders, fills };
const idList = (orders ?? []).map((o) => o.orderId).filter(Boolean);
const segMap = storage.getOrderSegmentsByOrderIds(idList);
const strategyName = (sid) => {
if (!sid) return '';
const found = (getStrategies(settings) || []).find((s) => s.id === sid);
return found ? found.name : sid;
};
const withSegs = (orders ?? []).map((o) => {
const segs = segMap.get(o.orderId) ?? [];
const withName = segs.map((s) => ({ ...s, strategyName: strategyName(s.strategyId) }));
const first = withName[0];
return { ...o, segments: withName, strategyId: first ? first.strategyId : null, holdingId: first ? first.holdingId : null };
});
return { orders: withSegs, fills };
}
default:
throw Object.assign(new Error('unknown trade method: ' + method), { code: 'not-found' });
}
}
}
+270 -102
View File
@@ -12,6 +12,12 @@
* - 策略过滤下拉(全部 / 各策略 / 未关联):今日实时按归属过滤(前端),历史走服务端过滤
* - 历史范围从「接口开发中」占位切换为查本地 SQLitetrades/history
*
* R-018(迭代 16):归属 = 账本写操作(份额分配器)
* - 端点:orders/attribution-targets(策略级候选)/ orders/attribution-set(全量替换段,原子)
* - 交互:每笔委托归属列 = 段 chips;点开分配器:策略下拉 + 段量输入(默认余额全量,可改小)
* + 段列表(每段可撤)——每步操作即时 setSegments,买卖份额联动账本
* - 过滤按首段兼容字段(order.strategyId = 主段);多段订单按主段归属展示
*
* 方向:红买绿卖(Q7 定稿);状态中文映射;未成交显示 —
*/
@@ -111,11 +117,13 @@ export function TradeRecordsTab() {
// R-009:策略过滤(''=全部;'__unassigned__'=未关联;其他=strategyId
const [strategies, setStrategies] = useState([]);
const [strategyFilter, setStrategyFilter] = useState('');
const [attributionSaved, setAttributionSaved] = useState(null); // 归属保存提示(R-009
const [attributionCandidates, setAttributionCandidates] = useState({}); // R-009code → 归属候选列表缓存
const attributionCandidatesRef = useRef({}); // 同步缓存(loadCandidates 判重用)
const [attributionSaved, setAttributionSaved] = useState(null); // 归属保存提示(R-009/R-018
const [attributionTargets, setAttributionTargets] = useState({}); // R-018code → 策略级归属目标缓存(含 activityShares
const attributionTargetsRef = useRef({}); // 同步缓存(loadTargets 判重用)
// 同步 ref
useEffect(() => { attributionCandidatesRef.current = attributionCandidates; }, [attributionCandidates]);
useEffect(() => { attributionTargetsRef.current = attributionTargets; }, [attributionTargets]);
// R-018 T3:漏关联软提示(账本有活动行 + QMT 快照无此 code → 提示去关联/移出;只读不写账本)
const [orphanHints, setOrphanHints] = useState([]);
const call = useRpc();
@@ -192,54 +200,61 @@ export function TradeRecordsTab() {
}
}, [call, range, isHistoryRange, strategyFilter]);
// 拉取某 code 的归属候选(该 code 当前持仓策略;Q3 全手动选
const loadCandidates = useCallback(async (code) => {
// R-018拉取某 code 的归属目标(策略级候选 + activityShares;Q1 指向后判定,候选不预筛持仓
const loadTargets = useCallback(async (code) => {
if (!code) return;
// 已有缓存不重复请求
if (attributionCandidatesRef.current[code]) return;
if (attributionTargetsRef.current[code]) return;
try {
const res = await call('one-divine-lot/orders/attribution-candidates', { args: { code } });
const res = await call('one-divine-lot/orders/attribution-targets', { args: { code } });
if (res?.ok && Array.isArray(res.value)) {
setAttributionCandidates((prev) => ({ ...prev, [code]: res.value }));
setAttributionTargets((prev) => ({ ...prev, [code]: res.value }));
}
} catch { /* ignore */ }
}, [call]);
// 手动设置归属(Q4:可随时改,以最终为准)
const setAttribution = useCallback(async (orderId, code, strategyId, holdingId) => {
try {
const res = await call('one-divine-lot/orders/set-attribution', {
args: { orderId, strategyId: strategyId || null, holdingId: holdingId || null },
});
if (res?.ok) {
setAttributionSaved('归属已保存: ' + orderId);
setTimeout(() => setAttributionSaved(null), 2000);
// 立即更新本地 state(避免竞态:不等 load() 重新拉,归属立刻生效)
setOrders((prev) => (prev ?? []).map((o) =>
o.orderId === orderId ? { ...o, strategyId, holdingId } : o
));
setTrades((prev) => (prev ?? []).map((t) =>
t.orderId === orderId ? { ...t, strategyId, holdingId } : t
));
load(); // 后台刷新兜底(同步 TradeSync 可能更新的数据)
} else {
setAttributionSaved('保存失败: ' + ((res && res.error && res.error.message) || 'unknown'));
setTimeout(() => setAttributionSaved(null), 3000);
}
} catch (e) {
setAttributionSaved('保存失败: ' + e.message);
setTimeout(() => setAttributionSaved(null), 3000);
// R-018:全量替换归属段(segments = [{strategyId, volume}];账本写操作,原子)。返回新 segments
const saveSegments = useCallback(async (orderId, segments) => {
const res = await call('one-divine-lot/orders/attribution-set', {
args: { orderId, segments },
});
if (!res?.ok) {
throw new Error((res && res.error && res.error.message) || '保存失败');
}
const nextSegs = Array.isArray(res.value?.segments) ? res.value.segments : [];
// 立即更新本地 state(归属立刻生效;多段 order 主段 = 第一段)
const first = nextSegs[0];
setOrders((prev) => (prev ?? []).map((o) =>
o.orderId === orderId ? { ...o, segments: nextSegs, strategyId: first ? first.strategyId : null, holdingId: first ? first.holdingId : null } : o
));
setTrades((prev) => (prev ?? []).map((t) =>
t.orderId === orderId ? { ...t, segments: nextSegs, strategyId: first ? first.strategyId : null, holdingId: first ? first.holdingId : null } : t
));
load(); // 后台刷新兜底(同步 TradeSync 可能更新的数据)
return nextSegs;
}, [call, load]);
// 归属候选自动预载:数据加载后,为所有委托 code 预取候选(下拉选项齐全,避免只有「未关联」
// 归属目标自动预载:数据加载后,为所有委托 code 预取目标(策略下拉选项齐全)
useEffect(() => {
const codes = [...new Set((orders ?? []).map((o) => o.code).filter(Boolean))];
if (codes.length === 0) return;
for (const code of codes) {
loadCandidates(code);
loadTargets(code);
}
}, [orders, loadCandidates]);
}, [orders, loadTargets]);
// R-018 T3:拉取漏关联软提示(每 20s + 今日数据加载后)
const loadOrphanHints = useCallback(async () => {
try {
const res = await call('one-divine-lot/positions/orphan-hints', { args: {} });
if (res?.ok && Array.isArray(res.value)) setOrphanHints(res.value);
} catch { /* ignore(服务端不可用时静默) */ }
}, [call]);
useEffect(() => {
if (!isTodayRange) { setOrphanHints([]); return; }
loadOrphanHints();
const timer = setInterval(loadOrphanHints, 20000);
return () => clearInterval(timer);
}, [isTodayRange, loadOrphanHints, range]);
// 时间段变化 → 重新加载(展开重置)
useEffect(() => {
@@ -327,6 +342,21 @@ export function TradeRecordsTab() {
{attributionSaved}
</div>
)}
{/* R-018 T3:漏关联软提示(幽灵清仓退役后,账本活动行但对账单已无此票 → 提示去关联卖出单或移出) */}
{Array.isArray(orphanHints) && orphanHints.length > 0 && (
<div
style={{
marginBottom: 8, fontSize: 12, padding: '6px 10px', borderRadius: 4,
border: '1px solid var(--dsw-alias-state-warning-primary, #f9a825)',
background: 'color-mix(in srgb, var(--dsw-alias-state-warning-primary, #f9a825) 10%, var(--dsw-alias-bg-layer-1, #fff))',
color: 'var(--dsw-alias-label-secondary, #555)',
}}
title="幽灵自动清仓已退役:这些持仓对账单已无此票,账本仍记着 —— 请去「策略持仓」tab 移出或为当日卖出委托设置归属"
>
{orphanHints.length} 个持仓对账单已无此票账本待处理{orphanHints.slice(0, 3).map((h) => h.code).join('、')}
{orphanHints.length > 3 ? ' 等' : ''} 请在对应策略 tab 移出或在下方为当日卖出委托设置归属关联后份额自动清零转历史
</div>
)}
<LoadState loading={loading && !orders} error={error} onRetry={load} autoRetry={2}>
{!orders?.length ? (
@@ -339,7 +369,7 @@ export function TradeRecordsTab() {
</div>
</div>
) : (
<TradeTable rows={rows} toggleSort={toggleSort} sortKey={sortKey} sortDir={sortDir} arrow={arrow} toggleExpand={toggleExpand} expanded={expanded} attributionCandidates={attributionCandidates} loadCandidates={loadCandidates} setAttribution={setAttribution} />
<TradeTable rows={rows} toggleSort={toggleSort} sortKey={sortKey} sortDir={sortDir} arrow={arrow} toggleExpand={toggleExpand} expanded={expanded} targets={attributionTargets} loadTargets={loadTargets} saveSegments={saveSegments} onNotice={setAttributionSaved} />
)}
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--dsw-alias-label-tertiary, #999)' }}>
<span style={{ color: 'var(--dsw-alias-state-error-primary, #d32f2f)' }}> </span>
@@ -352,8 +382,8 @@ export function TradeRecordsTab() {
);
}
/** 交易记录表格(主行 + 展开明细 + R-009 手动归属列 */
function TradeTable({ rows, toggleSort, sortKey, sortDir, arrow, toggleExpand, expanded, attributionCandidates, loadCandidates, setAttribution }) {
/** 交易记录表格(主行 + 展开明细 + R-018 归属分配器 */
function TradeTable({ rows, toggleSort, sortKey, sortDir, arrow, toggleExpand, expanded, targets, loadTargets, saveSegments, onNotice }) {
return (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
@@ -383,9 +413,10 @@ function TradeTable({ rows, toggleSort, sortKey, sortDir, arrow, toggleExpand, e
order={o}
expanded={expanded.has(o.orderId)}
onToggle={() => toggleExpand(o.orderId)}
attributionCandidates={attributionCandidates[o.code] ?? []}
onLoadCandidates={() => loadCandidates(o.code)}
setAttribution={setAttribution}
targets={targets[o.code] ?? []}
loadTargets={() => loadTargets(o.code)}
saveSegments={saveSegments}
onNotice={onNotice}
/>
))}
</tbody>
@@ -393,13 +424,11 @@ function TradeTable({ rows, toggleSort, sortKey, sortDir, arrow, toggleExpand, e
);
}
/** 委托主行 + 展开明细(含 R-009 手动归属下拉 */
function OrderRows({ order, expanded, onToggle, attributionCandidates, onLoadCandidates, setAttribution }) {
/** 委托主行 + 展开明细(含 R-018 归属分配器入口 */
function OrderRows({ order, expanded, onToggle, targets, loadTargets, saveSegments, onNotice }) {
const hasTrades = order.trades.length > 0;
const strategyName = (sid) => {
if (!sid) return '未关联';
return order.strategyName || sid;
};
const segs = Array.isArray(order.segments) ? order.segments : [];
const totalSeg = segs.reduce((s, x) => s + Number(x?.volume ?? 0), 0);
return (
<>
<tr
@@ -432,11 +461,14 @@ function OrderRows({ order, expanded, onToggle, attributionCandidates, onLoadCan
<td style={{ ...tdStyle, textAlign: 'right' }}>{hasTrades ? fmtNum(order.aggStampTax, 2) : '—'}</td>
<td style={{ ...tdStyle, textAlign: 'right' }}>{hasTrades ? fmtNum(order.aggTransferFee, 2) : '—'}</td>
<td style={{ ...tdStyle }} onClick={(e) => e.stopPropagation()}>
<AttributionSelect
<AttributionCell
order={order}
candidates={attributionCandidates}
onLoadCandidates={onLoadCandidates}
setAttribution={setAttribution}
segments={segs}
totalSeg={totalSeg}
targets={targets}
loadTargets={loadTargets}
saveSegments={saveSegments}
onNotice={onNotice}
/>
</td>
</tr>
@@ -472,65 +504,201 @@ function OrderRows({ order, expanded, onToggle, attributionCandidates, onLoadCan
);
}
/** 委托归属选择下拉(R-009,Q3 全手动选;Q4 可随时改)
* 候选 = 该 code 当前持仓 + 近 7 天清仓持仓(R-017orders/attribution-candidates);
* 锚点键 = holdingId(同策略可有多轮持仓行,strategyId 会撞值,R-017 定稿);
* 未设置显示「未关联」,用户点选后持久化(orders/set-attribution)。
/**
* R-018 归属单元格:展示段 chips;点开进入份额分配器(AttributionEditor)。
* - 已关联段:策略名 + 量(段 chip);未关联(余额 > 0 且无段)显示「未关联」
* - 点「分配」→ 打开编辑器(加载 targets 惰性)
*/
function AttributionSelect({ order, candidates, onLoadCandidates, setAttribution }) {
const currentKey = order.holdingId != null ? String(order.holdingId) : '';
function AttributionCell({ order, segments, totalSeg, targets, loadTargets, saveSegments, onNotice }) {
const [editing, setEditing] = useState(false);
const hasSeg = (segments ?? []).length > 0;
const aggVolume = Number(order.aggVolume ?? order.tradedVolume ?? 0);
const leftover = Math.max(0, aggVolume - totalSeg); // 未分配余额(今日实时 aggVolume=已成交量;历史 aggVolume 由 fills 聚合并)
const handleSelect = (ev) => {
const val = ev.target.value;
if (val === currentKey) return;
if (val === '__unassigned__') {
setAttribution(order.orderId, order.code, null, null);
return;
}
const cand = (candidates ?? []).find((c) => String(c.holdingId) === val);
setAttribution(order.orderId, order.code, cand ? cand.strategyId : null, cand ? cand.holdingId : null);
const open = () => {
loadTargets();
setEditing(true);
};
// 已归属但候选列表缺失(如清仓超 7 天):显示「当前归属」占位,避免 select 值悬空
const candByKey = new Map((candidates ?? []).map((c) => [String(c.holdingId), c]));
const hasCurrent = currentKey && candByKey.has(currentKey);
const closedSuffix = (c) => (c.closed ? '(已清仓 ' + fmtClosed(c.closedAt) + '' : '');
if (editing) {
return (
<AttributionEditor
order={order}
segments={segments}
leftover={leftover}
targets={targets}
saveSegments={saveSegments}
onDone={() => setEditing(false)}
onNotice={onNotice}
/>
);
}
return (
<select
value={hasCurrent || !currentKey ? (currentKey || '__unassigned__') : currentKey}
onChange={handleSelect}
style={{
fontSize: 12,
padding: '2px 4px',
border: '1px solid ' + (currentKey ? 'var(--dsw-alias-state-success-primary, #2e7d32)' : 'var(--dsw-alias-border-l2, #ccc)'),
borderRadius: 4,
background: currentKey ? 'color-mix(in srgb, var(--dsw-alias-state-success-primary, #2e7d32) 12%, var(--dsw-alias-bg-layer-1, #fff))' : 'var(--dsw-alias-bg-layer-1, #fff)',
color: currentKey ? 'var(--dsw-alias-state-success-primary, #1b5e20)' : 'var(--dsw-alias-label-tertiary, #999)',
maxWidth: 150,
}}
title="设置该委托的策略/持仓归属(可随时修改;清仓 7 天内可补关联)"
>
<option value="__unassigned__">未关联</option>
{!hasCurrent && currentKey && (
<option value={currentKey}>当前归属{order.strategyId || '未知策略'}</option>
<span style={{ display: 'inline-flex', flexDirection: 'column', alignItems: 'flex-start', gap: 2, minWidth: 120 }}>
{hasSeg ? (
<span style={{ display: 'flex', flexWrap: 'wrap', gap: 2 }}>
{(segments ?? []).map((s) => (
<span
key={s.strategyId}
title={s.strategyName || s.strategyId}
style={{
fontSize: 11, padding: '1px 5px', borderRadius: 3,
border: '1px solid var(--dsw-alias-state-success-primary, #2e7d32)',
color: 'var(--dsw-alias-state-success-primary, #1b5e20)',
background: 'color-mix(in srgb, var(--dsw-alias-state-success-primary, #2e7d32) 10%, var(--dsw-alias-bg-layer-1, #fff))',
}}
>
{(s.strategyName || s.strategyId)}·{fmtNum(s.volume, 0)}
</span>
))}
</span>
) : (
<span style={{ color: 'var(--dsw-alias-label-tertiary, #999)', fontSize: 12 }}>未关联</span>
)}
{(candidates ?? []).map((c) => (
<option key={c.holdingId} value={String(c.holdingId)}>
{c.strategyName || c.strategyId}{closedSuffix(c)}
</option>
))}
</select>
{leftover > 1e-9 && (
<span style={{ fontSize: 11, color: 'var(--dsw-alias-state-error-primary, #d32f2f)' }}>
未分配剩余 {fmtNum(leftover, 0)}
</span>
)}
<button
onClick={open}
style={{
fontSize: 11, padding: '1px 8px', cursor: 'pointer', borderRadius: 3,
border: '1px solid var(--dsw-alias-border-l2, #ccc)',
background: 'var(--dsw-alias-bg-layer-1, #fff)', color: 'var(--dsw-alias-label-primary, #333)',
}}
title="分配该委托的归属策略/份额(关联 = 调整对应策略持仓份额)"
>
{hasSeg ? '调整' : '分配'}
</button>
</span>
);
}
/** 清仓日期短格式(MM-DD */
function fmtClosed(ts) {
if (!ts) return '';
const d = new Date(Number(ts));
if (!isFinite(d.getTime())) return '';
const p2 = (n) => String(n).padStart(2, '0');
return (d.getMonth() + 1) + '-' + p2(d.getDate());
/**
* R-018 份额分配器(编辑器):策略下拉 + 段量输入 + 段列表(可撤)。
* - 余额 = 已成交量 − 已分配段和(默认输入 = 余额全量,可改小)
* - 每步操作(加段/撤段)即时 saveSegments(全量替换,原子)
*/
function AttributionEditor({ order, segments, leftover, targets, saveSegments, onDone, onNotice }) {
const list = (targets ?? []).length > 0 ? targets : null;
const [selStrategy, setSelStrategy] = useState('');
const [amount, setAmount] = useState('');
const [busy, setBusy] = useState(false);
const aggVolume = Number(order.aggVolume ?? order.tradedVolume ?? 0);
const segs = Array.isArray(segments) ? segments : [];
const totalSeg = segs.reduce((s, x) => s + Number(x?.volume ?? 0), 0);
const remaining = Math.max(0, aggVolume - totalSeg);
const notice = (msg, isErr) => {
onNotice?.(msg);
if (isErr) setTimeout(() => onNotice?.(null), 3000);
else setTimeout(() => onNotice?.(null), 2000);
};
const run = async (nextSegments) => {
setBusy(true);
try {
await saveSegments(order.orderId, nextSegments);
notice('归属已保存: ' + order.orderId, false);
} catch (e) {
notice('保存失败: ' + (e?.message ?? String(e)), true);
} finally {
setBusy(false);
}
};
const addSegment = async () => {
if (!selStrategy) { notice('请选择策略', true); return; }
const v = Number(amount);
if (!Number.isFinite(v) || v <= 0) { notice('请输入有效份额量', true); return; }
if (v > remaining + 1e-9) { notice('份额量超过未分配余额 ' + fmtNum(remaining, 0), true); return; }
// 卖出场景:若该策略无活动仓(activityShares=0 且方向 sell)→ 服务端会拒绝;这里给前端提示
const t = (targets ?? []).find((x) => x.strategyId === selStrategy);
const isSell = String(order.direction ?? '') === 'sell';
if (isSell && t && !t.hasActiveHolding) {
notice('该策略无此持仓可减(关联失败)', true);
return;
}
// sell 且 activityShares < amount → 自动截断为可容纳量(R4 老师拍板:自动截断续分)
let vol = v;
if (isSell && t && t.hasActiveHolding && Number(t.activityShares) + 1e-9 < v) {
vol = Number(t.activityShares);
notice('超出该策略可容纳,已自动截断为 ' + fmtNum(vol, 0) + '(余额保留)', false);
}
const merged = [...segs.filter((s) => s.strategyId !== selStrategy), { strategyId: selStrategy, volume: vol }];
await run(merged);
};
const removeSegment = async (strategyId) => {
const merged = segs.filter((s) => s.strategyId !== strategyId);
await run(merged);
};
const clearAll = async () => {
await run([]);
};
const amountDefaultHint = remaining > 0 ? String(remaining) : '';
return (
<span style={{ display: 'inline-flex', flexDirection: 'column', gap: 4, alignItems: 'flex-start', minWidth: 190 }}>
<div style={{ fontSize: 11, color: 'var(--dsw-alias-label-secondary, #666)' }}>
已成交量 {fmtNum(aggVolume, 0)} 已分配 {fmtNum(totalSeg, 0)} 余额 <span style={{ color: remaining > 1e-9 ? 'var(--dsw-alias-state-error-primary, #d32f2f)' : 'var(--dsw-alias-state-success-primary, #1b5e20)', fontWeight: 'bold' }}>{fmtNum(remaining, 0)}</span>
</div>
{(segs.length > 0) && (
<span style={{ display: 'flex', flexWrap: 'wrap', gap: 2, alignItems: 'center' }}>
{segs.map((s) => (
<span key={s.strategyId} style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 11, padding: '1px 5px', borderRadius: 3, border: '1px solid var(--dsw-alias-state-success-primary, #2e7d32)', color: 'var(--dsw-alias-state-success-primary, #1b5e20)' }}>
{s.strategyName || s.strategyId}·{fmtNum(s.volume, 0)}
<span
title="撤销该段(逆操作账本)"
onClick={() => !busy && removeSegment(s.strategyId)}
style={{ cursor: busy ? 'wait' : 'pointer', color: 'var(--dsw-alias-state-error-primary, #c62828)' }}
></span>
</span>
))}
</span>
)}
{list && list.length > 0 && remaining > 1e-9 && (
<span style={{ display: 'inline-flex', gap: 3, alignItems: 'center' }}>
<select value={selStrategy} onChange={(e) => setSelStrategy(e.target.value)} style={{ fontSize: 11, padding: '1px 3px' }} title="选择归属策略">
<option value="">策略</option>
{list.map((t) => (
<option key={t.strategyId} value={t.strategyId}>
{t.strategyName || t.strategyId}{t.hasActiveHolding ? '(仓 ' + fmtNum(t.activityShares, 0) + '' : ''}
</option>
))}
</select>
<input
type="number"
min="1"
step={100}
value={amount}
placeholder={amountDefaultHint}
onChange={(e) => setAmount(e.target.value)}
style={{ width: 70, padding: '1px 3px', fontSize: 11 }}
title="分配份额量(默认 = 未分配余额全量,可改小)"
/>
<button onClick={() => !busy && addSegment()} disabled={busy} style={{ fontSize: 11, padding: '1px 6px', cursor: 'pointer' }}>
分配
</button>
</span>
)}
{remaining <= 1e-9 && (
<span style={{ fontSize: 11, color: 'var(--dsw-alias-state-success-primary, #1b5e20)' }}>已全部分配</span>
)}
<span style={{ display: 'inline-flex', gap: 4 }}>
{segs.length > 0 && (
<button onClick={() => !busy && clearAll()} disabled={busy} style={{ fontSize: 11, padding: '1px 6px', cursor: 'pointer', color: 'var(--dsw-alias-state-error-primary, #c62828)' }}>
全部撤除
</button>
)}
<button onClick={onDone} style={{ fontSize: 11, padding: '1px 6px', cursor: 'pointer' }}>完成</button>
</span>
</span>
);
}
const thStyle = { padding: '8px 6px', lineHeight: '24px', whiteSpace: 'nowrap' };
+6 -2
View File
@@ -23,6 +23,7 @@ import { QuoteHub } from './market/QuoteHub.js';
import { QuoteSync } from './market/QuoteSync.js';
import { QmtHealthMonitor } from './data-source/QmtHealthMonitor.js';
import { TradeSync } from './trades/TradeSync.js';
import { AttributionService } from './trades/AttributionService.js';
const name = 'one-divine-lot';
@@ -87,8 +88,11 @@ async function apply(ctx, config) {
const tradeSync = new TradeSync({ runtime: { dataSource, storage }, logger });
tradeSync.start();
// S6: 服务端 HTTP APIR-004:注入 dataSource 以编排激活热切换;R-009:注入 storage 供 trades/history
registerApi(ctx, { manager, settings, dataSource, marketHub, marketFeed, qmtHealthMonitor, storage, tradeSync, positionSync });
// R-018 迭代 16:归属服务(关联 = 账本写操作;经 SqliteStore 段表/生命周期同步接口
const attribution = new AttributionService({ store: storage.sqlite });
// S6: 服务端 HTTP APIR-004:注入 dataSource 以编排激活热切换;R-009:注入 storage 供 trades/historyR-018:注入 attribution
registerApi(ctx, { manager, settings, dataSource, marketHub, marketFeed, qmtHealthMonitor, storage, tradeSync, positionSync, attribution });
// 启动时可用性检查(日志,不阻塞)
dataSource.isAvailable().then((ok) => {
+20 -97
View File
@@ -1,50 +1,43 @@
/**
* PositionSync —— 全量持仓内存快照同步2026-09-02 讨论定稿)
* PositionSync —— 全量持仓(对账单)内存快照同步
*
* 背景:策略持仓 / 全部持仓 / 未分配三个接口原先在请求时穿透 QMT
* PositionManager.getAllPositions 实时拉 /trade/positions),带来两个问题:
* ① QMT 一抖(超时/掉线)所有持仓页面当场空白;
* ② 每次进 tab 都打一次 QMT HTTP。
*
* 方案(老师拍板):服务端建内存快照,以快照为准:
* 方案(老师拍板 R-014):服务端建内存快照,以快照为准:
* - 启动预热一次,之后每 10s 全量拉 QMT 持仓 → 校验 → 整体替换内存快照;
* - 不落库(内存管理,讨论明确:不建缓存表、不复用 strategy_holdings)——
* 持仓快照随时可用一次调用重拿全,落库只会引入「过期数据冒充实时的说谎风险」;
* - 同步失败保留上次快照(不清空、不报错给读方);
* - 空快照需双重确认(/health 可用 + getAsset 账户身份可识别)才接受为「真清仓」,
* 否则视为 QMT 异常(如未登录),保留旧快照;
* - 缓存为空时由 PositionManager 读穿透兜底(当场拉一次 QMT 并回填 backfill
* - 缓存为空时由 PositionManager 读穿透兜底(当场拉一次 QMT 并回填 backfill
*
* 幽灵持仓自动清仓2026-09-02 老师选定):
* - QMT 快照中消失的 code(本地 strategy_holdings 仍有当前持仓),连续 3 轮同步
* (约 30s)仍消失 → 该 code 全部策略的当前持仓 closeHolding 转历史(不物理删除);
* - 防抖护栏:
* a) 账户身份守卫——每轮同步顺带 getAsset 取 accountId;身份未知(接口失败/未登录)
* 当轮跳过清仓判定(不累计计数);accountId 变化(R-004 连接热切换/换账户)时
* 重置防抖计数,防止 A 账户持仓被 B 账户快照误清;
* b) 部分减持不触发(QMT 仍有该 code,只是 volume 变小)——账实差额由前端
* 「未分配为负」暴露,属产品已知行为,不在本模块处理。
*
* 设计对齐:TradeSync 同构(启动预热 + 定时 + 失败容忍);技术约束-011 测试隔离不受影响
* (本模块无持久化,测试无需隔离数据目录)。
* 数据域分界2026-09-08 R-018 老师拍板):
* - 本模块是「全部持仓」= 对账单域的唯一同步机制,**只维护内存快照**;
* - 幽灵持仓自动清仓(R-014 原条款:QMT 快照连续 3 轮消失 → 本地全部策略
* 当前持仓 closeHolding 转历史)**已退役**——同步机制不写 strategy_holdings
* 账本(老师原话:幽灵清仓本就是一个同步机制,不可以让幽灵把爪子伸太长);
* - 账本行转历史唯一途径 = 卖出单关联份额减至 0(R-018 归属服务);
* - 对账单码消失仅表现为:快照无此行 + 前端漏关联软提示(positions/orphan-hints
* 只读检测「账本有活动行 + QMT 快照无此 code」,提示老师去关联/移出,不动账本)。
*/
const SYNC_INTERVAL_MS = 10 * 1000; // 同步间隔(老师定:10 秒)
const GHOST_CLOSE_ROUNDS = 3; // 幽灵防抖:连续 N 轮消失才自动清仓(约 30s)
export class PositionSync {
/**
* @param {object} opts
* @param {object} opts.runtime { dataSource, storage } —— dataSource 拉实盘storage 做幽灵清仓
* @param {object} opts.runtime { dataSource, storage } —— dataSource 拉实盘storage 保留供软提示检测编排,本类不写账本)
* @param {object} [opts.logger]
* @param {number} [opts.intervalMs] 同步间隔(测试可调小)
* @param {number} [opts.ghostRounds] 幽灵清仓防抖轮数(测试可调小)
*/
constructor({ runtime, logger, intervalMs = SYNC_INTERVAL_MS, ghostRounds = GHOST_CLOSE_ROUNDS } = {}) {
constructor({ runtime, logger, intervalMs = SYNC_INTERVAL_MS } = {}) {
this.runtime = runtime;
this.logger = logger;
this.intervalMs = intervalMs;
this.ghostRounds = Math.max(1, ghostRounds);
/** 内存快照:Position[]QmtBridgeRestDataSource.mapPosition 语义化输出;只读约定) */
this.snapshot = [];
/** 最近一次成功同步时间(毫秒;0 = 尚未同步成功过) */
@@ -52,11 +45,7 @@ export class PositionSync {
this.timer = null;
this.mounted = false;
this.syncing = false;
/** 幽灵防抖计数:code → 连续从 QMT 快照消失的轮数 */
this._ghostMiss = new Map();
/** 账户身份守卫:最近一次 getAsset 的 accountId(连接热切换时重置防抖) */
this._lastAccountId = null;
this.stats = { syncCount: 0, failCount: 0, lastError: '', closedGhosts: 0, lastSyncedAt: 0 };
this.stats = { syncCount: 0, failCount: 0, lastError: '', lastSyncedAt: 0 };
}
/** 启动:立即预热一次 + 定时同步 */
@@ -69,7 +58,7 @@ export class PositionSync {
this.timer = setInterval(() => {
this.syncNow().catch(() => { /* syncNow 内部已容错 */ });
}, this.intervalMs);
this.logger?.info?.('[one-divine-lot] PositionSync 启动(10s 内存快照同步 + 幽灵持仓自动清仓防抖 ' + this.ghostRounds + ' 轮');
this.logger?.info?.('[one-divine-lot] PositionSync 启动(10s 内存快照同步;幽灵自动清仓已退役 R-018,不写账本');
}
/** 停止(插件释放时);内存快照保留(读方在停止后仍可消费最后快照) */
@@ -105,9 +94,9 @@ export class PositionSync {
}
/**
* 同步一次:拉 QMT 全量持仓 → 校验 → 整体替换内存快照 → 幽灵清仓判定
* 同步一次:拉 QMT 全量持仓 → 校验 → 整体替换内存快照。
* 任何失败只记统计,不动内存(读方继续消费上次快照)。
* @returns {Promise<{positions:number, closed:Array}|{kept:true, reason:string}|null>}
* @returns {Promise<{positions:number}|{kept:true, reason:string}|null>}
*/
async syncNow() {
// mounted 不拦手动同步(测试/热切换后手动触发均可用);syncing 只防重入
@@ -132,7 +121,7 @@ export class PositionSync {
this.stats.lastSyncedAt = this.syncedAt;
return { kept: true, reason: this.stats.lastError };
}
// 真清仓:接受空快照(幽灵清仓会把本地当前持仓全部转历史
// 真清仓:接受空快照(仅对账单域清空;本地账本不动——账本生命周期由交易关联驱动,R-018
}
// ① 整体替换内存快照(校验通过才动内存)
@@ -140,10 +129,7 @@ export class PositionSync {
this.syncedAt = Date.now();
this.stats.syncCount++;
this.stats.lastSyncedAt = this.syncedAt;
// ② 幽灵持仓自动清仓(带防抖;失败不影响快照)
const closed = await this._autoCloseGhosts(positions);
return { positions: positions.length, closed };
return { positions: positions.length };
} catch (e) {
this.stats.failCount++;
this.stats.lastError = e?.message ?? String(e);
@@ -154,7 +140,7 @@ export class PositionSync {
}
}
/** 取账户身份(getAsset 失败/无 accountId 返回 null */
/** 取账户身份(getAsset 失败/无 accountId 返回 null;空快照双重确认用 */
async _fetchAccountId() {
try {
const a = await this.runtime.dataSource.getAsset();
@@ -163,67 +149,4 @@ export class PositionSync {
return null;
}
}
/**
* 幽灵清仓:快照中消失的 code,连续 N 轮仍消失 → 该 code 全部策略的当前持仓转历史。
* 账户身份守卫见类注释;closeHolding 置 shares=0 + closed_at(不物理删除,历史保留)。
* @param {Array} snapshot 本轮成功的 QMT 快照
* @returns {Promise<Array<{code:string, strategies:string[]}>>} 本轮实际清仓的 code
*/
async _autoCloseGhosts(snapshot) {
const { storage } = this.runtime;
const closed = [];
// 守卫:账户身份未知 → 本轮跳过清仓判定(不累计、不清零,保守)
const accountId = await this._fetchAccountId();
if (!accountId) return closed;
if (this._lastAccountId != null && accountId !== this._lastAccountId) {
// 换账户(连接热切换):旧计数作废,本轮直接跳过判定(连旧账户的快照都不可信)
this._ghostMiss.clear();
this._lastAccountId = accountId;
this.logger?.info?.('[one-divine-lot] PositionSync 检测到账户切换(' + this._lastAccountId + ' → ' + accountId + '),本轮跳过幽灵判定');
return closed;
}
this._lastAccountId = accountId;
let holdings;
try {
holdings = await storage.getCurrentHoldings(); // 全策略当前持仓(closed_at IS NULL
} catch (e) {
this.logger?.debug?.('[one-divine-lot] PositionSync 读本地持仓失败,跳过幽灵判定: ' + (e?.message ?? e));
return closed;
}
const snapshotCodes = new Set(snapshot.map((p) => p.code).filter(Boolean));
const localCodes = [...new Set(holdings.map((h) => h.code).filter(Boolean))];
for (const code of localCodes) {
if (snapshotCodes.has(code)) {
this._ghostMiss.delete(code); // QMT 仍有 → 计数复位(部分减持不在此处理)
continue;
}
const miss = (this._ghostMiss.get(code) ?? 0) + 1;
if (miss < this.ghostRounds) {
this._ghostMiss.set(code, miss);
continue;
}
// 连续 N 轮消失 → 清仓该 code 全部策略的当前持仓(转历史,不物理删除)
try {
const rows = holdings.filter((h) => h.code === code);
for (const h of rows) {
await storage.closeHolding(h.strategyId, code);
}
this._ghostMiss.delete(code);
this.stats.closedGhosts += rows.length;
closed.push({ code, strategies: rows.map((r) => r.strategyId) });
this.logger?.warn?.(
'[one-divine-lot] PositionSync 幽灵清仓: ' + code + ' × ' + rows.length + ' 个策略(QMT 连续 ' + this.ghostRounds + ' 轮无此持仓,自动转历史)'
);
} catch (e) {
// 单码清仓失败:保留计数,下轮重试
this.logger?.debug?.('[one-divine-lot] PositionSync 幽灵清仓失败(下轮重试): ' + code + ' ' + (e?.message ?? e));
}
}
return closed;
}
}
+15
View File
@@ -170,6 +170,21 @@ export class DataStore {
return this.sqlite.getOrdersByHolding(holdingId);
}
/** 按 order_id 读委托(R-018 归属服务用) */
getTradeOrderById(orderId) {
return this.sqlite.getOrderById(orderId);
}
/** 读某 order 的全部归属段(R-018[]=无段) */
getOrderSegments(orderId) {
return this.sqlite.getOrderAttributions(orderId);
}
/** 批量读多 order 归属段(R-018 orders 列表附加) */
getOrderSegmentsByOrderIds(orderIds) {
return this.sqlite.getAttributionsByOrderIds(orderIds);
}
/** 关闭(插件释放时) */
close() {
this.sqlite.close();
+376 -66
View File
@@ -34,15 +34,17 @@ const LEGACY_FILE = 'allocations.json';
const SCHEMA_SQL = `
DROP TABLE IF EXISTS market_quotes_cache; -- R-015:存量库清理(幂等;行情改内存快照)
CREATE TABLE IF NOT EXISTS strategy_holdings (
holding_id INTEGER PRIMARY KEY AUTOINCREMENT,
strategy_id TEXT NOT NULL,
code TEXT NOT NULL,
shares REAL NOT NULL,
created_at INTEGER NOT NULL,
closed_at INTEGER
holding_id INTEGER PRIMARY KEY AUTOINCREMENT,
strategy_id TEXT NOT NULL,
code TEXT NOT NULL,
shares REAL NOT NULL,
created_at INTEGER NOT NULL,
closed_at INTEGER, -- 清仓时间(NULL=未清仓)
void_at INTEGER, -- R-018 作废时间(NULL=有效;非 NULL=建仓被撤=从未成立;不进历史层)
closed_shares REAL -- R-018 清仓前份额快照(closeHolding 写入,供撤卖出段恢复活动)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_active_holding
ON strategy_holdings (strategy_id, code) WHERE closed_at IS NULL;
-- R-018:活动唯一索引收窄至两态皆空 —— 索引在 _ensureHoldingStateColumns(补列后)统一重建,
-- 避免旧库无 void_at 列时 CREATE INDEX 引用不存在的列(SCHEMA_SQL 与补列分离)
-- market_quotes_cache 已退役(R-015/迭代 13:行情改内存快照 QuoteHub,价格单一入口不再落库)
-- 交易委托(R-009 迭代 07:委托主行;order_id 唯一,UPSERT 幂等;零冗余 strategy_id/holding_id
CREATE TABLE IF NOT EXISTS trade_orders (
@@ -91,6 +93,20 @@ CREATE INDEX IF NOT EXISTS idx_trade_orders_date ON trade_orders (trade_date);
CREATE INDEX IF NOT EXISTS idx_trade_orders_ts ON trade_orders (insert_ts);
CREATE INDEX IF NOT EXISTS idx_trade_fills_order ON trade_fills (order_id);
CREATE INDEX IF NOT EXISTS idx_trade_fills_date ON trade_fills (trade_date);
-- R-018:委托归属分段表(真相源 = 段表;一笔委托可拆 N 段关联不同策略/持仓)
CREATE TABLE IF NOT EXISTS trade_order_attributions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id TEXT NOT NULL, -- → trade_orders.order_id
strategy_id TEXT NOT NULL, -- 段归属策略
holding_id INTEGER NOT NULL, -- 段锚点持仓(R-010 展开用)
code TEXT NOT NULL, -- 冗余
direction TEXT NOT NULL, -- buy/sell(冗余,撤段判向)
volume REAL NOT NULL, -- 该段已成交量(>0;总额 ≤ order.traded_volume
created_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_attr_order_strategy ON trade_order_attributions (order_id, strategy_id);
CREATE INDEX IF NOT EXISTS idx_attr_holding ON trade_order_attributions (holding_id);
CREATE INDEX IF NOT EXISTS idx_attr_strategy ON trade_order_attributions (strategy_id);
`;
/** 解析 strategy_holdings."values" JSON 列(容错:空/脏返回 null;模块级,供 .map(this._mapHolding) 裸调用) */
@@ -132,6 +148,10 @@ export class SqliteStore {
this._ensureTradeAttributionColumns();
// 存量库迁移:strategy_holdings 补自定义字段值列(R-013 迭代 11;幂等)
this._ensureHoldingValuesColumn();
// R-018 迭代 16strategy_holdings 补 void_at/closed_shares 列 + 重建活动唯一索引(旧库 ALTER,幂等)
this._ensureHoldingStateColumns();
// R-018 迭代 16:存量 trade_orders 单组归属 → 段表第一段(幂等;真相源迁移)
this._migrateLegacyAttributionToSegments();
return this.db;
}
@@ -179,6 +199,79 @@ export class SqliteStore {
}
}
/**
* R-018 迭代 16:确保 strategy_holdings 存在 void_at / closed_shares 列(旧库 ALTER 补列,幂等)
* + 重建活动唯一索引(活动 = closed_at IS NULL AND void_at IS NULL,两态皆空才唯一)。
* 新库在 SCHEMA_SQL 已含列与新索引定义;旧库(无新列)在此补齐并重建索引。
*/
_ensureHoldingStateColumns() {
try {
const cols = new Set(this.db.prepare('PRAGMA table_info(strategy_holdings)').all().map((c) => c.name));
if (!cols.has('void_at')) {
this.db.exec('ALTER TABLE strategy_holdings ADD COLUMN void_at INTEGER');
}
if (!cols.has('closed_shares')) {
this.db.exec('ALTER TABLE strategy_holdings ADD COLUMN closed_shares REAL');
}
// 列就绪(新库建表已含 / 旧库已补)→ 重建索引(DROP 旧定义 + CREATE 新定义;幂等)
this.db.exec('DROP INDEX IF EXISTS idx_active_holding');
this.db.exec(
'CREATE UNIQUE INDEX IF NOT EXISTS idx_active_holding ' +
'ON strategy_holdings (strategy_id, code) WHERE closed_at IS NULL AND void_at IS NULL'
);
} catch (err) {
if (String(err?.message ?? '').includes('readonly')) {
console.warn('[one-divine-lot] strategy_holdings void_at/closed_shares 迁移跳过(只读连接,插件持锁中)');
} else {
throw err;
}
}
}
/**
* R-018 迭代 16:存量归属迁移 —— trade_orders 单组 strategy_id/holding_id → 段表第一段。
* 幂等:仅当 trade_order_attributions 中该 order 无任何段、且 trade_orders 归属列非空时写入
* volume = 该单已成交量 traded_volume;无法确定段归属时视为已成交量全量)。
* 迁移后归属真相源 = 段表;trade_orders 两列退役为冗余(不删除,防外部脚本 break)。
*/
_migrateLegacyAttributionToSegments() {
try {
const rows = this.db.prepare(
'SELECT order_id, code, direction, traded_volume, strategy_id, holding_id FROM trade_orders ' +
'WHERE strategy_id IS NOT NULL OR holding_id IS NOT NULL'
).all();
if (rows.length === 0) return;
const hasSeg = this.db.prepare(
'SELECT 1 FROM trade_order_attributions WHERE order_id=?'
);
const insert = this.db.prepare(
'INSERT OR IGNORE INTO trade_order_attributions (order_id, strategy_id, holding_id, code, direction, volume, created_at) ' +
'VALUES (?,?,?,?,?,?,?)'
);
const now = Date.now();
this.db.exec('BEGIN');
try {
for (const r of rows) {
if (!r.strategy_id || r.holding_id == null) continue; // 不完整归属不迁
if (hasSeg.get(r.order_id)) continue; // 已有段不重复迁
const vol = Number(r.traded_volume) > 0 ? Number(r.traded_volume) : 0;
if (vol <= 0) continue;
insert.run(r.order_id, r.strategy_id, r.holding_id, r.code, r.direction ?? '', vol, now);
}
this.db.exec('COMMIT');
} catch (err) {
this.db.exec('ROLLBACK');
throw err;
}
} catch (err) {
if (String(err?.message ?? '').includes('readonly')) {
console.warn('[one-divine-lot] 存量归属迁移跳过(只读连接,插件持锁中)');
} else {
throw err;
}
}
}
// ===== 持仓自定义字段值(R-013 迭代 11=====
/**
@@ -309,74 +402,127 @@ export class SqliteStore {
} catch { /* 已备份或不存在 */ }
}
// ===== 持仓生命周期 =====
// ===== 持仓生命周期R-018:活动 = closed_at IS NULL AND void_at IS NULL=====
/** 建仓:创建一笔新持仓(该策略该 code 无当前持仓时) */
/** 建仓:创建一笔新持仓(该策略该 code 无当前/作废持仓时) */
openHolding(strategyId, code, shares) {
this.init();
const now = Date.now();
const r = this.db.prepare(
'INSERT INTO strategy_holdings (strategy_id, code, shares, created_at, closed_at) VALUES (?,?,?,?,NULL)'
).run(strategyId, code, Number(shares), now);
return { holdingId: Number(r.lastInsertRowid), strategyId, code, shares: Number(shares), createdAt: now, closedAt: null, values: null }; // R-013:新持仓无自定义字段值
return { holdingId: Number(r.lastInsertRowid), strategyId, code, shares: Number(shares), createdAt: now, closedAt: null, voidAt: null, closedShares: null, values: null }; // R-013:新持仓无自定义字段值
}
/** 加仓:当前持仓份额累加 */
/** 加仓:当前活动持仓份额累加(活动 = closed/void 双空) */
addShares(strategyId, code, shares) {
this.init();
const r = this.db.prepare(
'UPDATE strategy_holdings SET shares = shares + ? WHERE strategy_id=? AND code=? AND closed_at IS NULL'
'UPDATE strategy_holdings SET shares = shares + ? WHERE strategy_id=? AND code=? AND closed_at IS NULL AND void_at IS NULL'
).run(Number(shares), strategyId, code);
if (r.changes === 0) {
// 无当前持仓(被关过),退化为建仓
// 无当前持仓(被关过/作废),退化为建仓
return this.openHolding(strategyId, code, shares);
}
return this._getActive(strategyId, code);
}
/** 减仓:当前持仓份额减少(需先确认减后 > 0,调用方校验) */
/** 减仓:当前活动持仓份额减少(需先确认减后 0,调用方校验) */
reduceShares(strategyId, code, shares) {
this.init();
this.db.prepare(
'UPDATE strategy_holdings SET shares = shares - ? WHERE strategy_id=? AND code=? AND closed_at IS NULL'
'UPDATE strategy_holdings SET shares = shares - ? WHERE strategy_id=? AND code=? AND closed_at IS NULL AND void_at IS NULL'
).run(Number(shares), strategyId, code);
return this._getActive(strategyId, code);
}
/** 清仓:份额归零,closed_at=now 转历史(不物理删除) */
/**
* 清仓:份额归零,closed_at=now 转历史(不物理删除;R-016 Q2:shares 展示仍 0)。
* R-018:同时记录 closed_shares=清仓前份额快照(供撤卖出段恢复活动用)。
*/
closeHolding(strategyId, code) {
this.init();
const now = Date.now();
// 先读清仓前份额(事务内快照由调用方保证,这里单语句内取值)
const active = this._getActive(strategyId, code);
this.db.prepare(
'UPDATE strategy_holdings SET shares = 0, closed_at = ? WHERE strategy_id=? AND code=? AND closed_at IS NULL'
).run(now, strategyId, code);
return { strategyId, code, closedAt: now };
'UPDATE strategy_holdings SET shares = 0, closed_at = ?, closed_shares = ? WHERE strategy_id=? AND code=? AND closed_at IS NULL AND void_at IS NULL'
).run(now, active ? Number(active.shares) : null, strategyId, code);
return { strategyId, code, closedAt: now, closedShares: active ? Number(active.shares) : null };
}
/** 读取某策略某 code 的当前持仓(可能为 null) */
/**
* 作废(R-018 R3-1/R6):建仓被撤 = 从未成立 —— shares 置 0 + void_at=now(非清仓,不产生假清仓历史)。
* 作废行不进 R-016 历史层(getHoldingsHistory 过滤 void_at IS NULL)。
*/
voidHolding(strategyId, code) {
this.init();
const now = Date.now();
const active = this._getActive(strategyId, code);
this.db.prepare(
'UPDATE strategy_holdings SET shares = 0, void_at = ? WHERE strategy_id=? AND code=? AND closed_at IS NULL AND void_at IS NULL'
).run(now, strategyId, code);
return { strategyId, code, voidAt: now, holdingId: active ? active.holdingId : null };
}
/**
* 恢复活动仓(R-018 R3-2:撤卖出段 → 撤销 closeHolding):
* closed_at=NULL、shares=closed_shares(清仓前份额快照)、closed_shares 清空。
* 调用方须先确认:该 strategy/code 当前无其他活动仓(否则撞唯一索引)且该行 closed_shares 有效。
* @param {number} holdingId
* @returns {object|null} 恢复后的活动行(失败 null)
*/
reopenHolding(holdingId) {
this.init();
const row = this.db.prepare(
'SELECT holding_id, strategy_id, code, closed_shares FROM strategy_holdings WHERE holding_id=? AND closed_at IS NOT NULL AND void_at IS NULL'
).get(holdingId);
if (!row || row.closed_shares == null) return null;
const conflict = this.db.prepare(
'SELECT 1 FROM strategy_holdings WHERE strategy_id=? AND code=? AND holding_id<>? AND closed_at IS NULL AND void_at IS NULL'
).get(row.strategy_id, row.code, holdingId);
if (conflict) return null; // 同 code 已存在新活动仓,无法恢复
this.db.prepare(
'UPDATE strategy_holdings SET closed_at = NULL, shares = ?, closed_shares = NULL WHERE holding_id=?'
).run(Number(row.closed_shares), holdingId);
return this.db.prepare(
'SELECT holding_id, strategy_id, code, shares, created_at, closed_at, void_at, closed_shares, "values" FROM strategy_holdings WHERE holding_id=?'
).get(holdingId);
}
/** 读取某策略某 code 的当前活动持仓(closed/void 双空;可能为 null */
_getActive(strategyId, code) {
this.init();
const row = this.db.prepare(
'SELECT holding_id, strategy_id, code, shares, created_at, closed_at, "values" FROM strategy_holdings WHERE strategy_id=? AND code=? AND closed_at IS NULL'
'SELECT holding_id, strategy_id, code, shares, created_at, closed_at, void_at, closed_shares, "values" FROM strategy_holdings WHERE strategy_id=? AND code=? AND closed_at IS NULL AND void_at IS NULL'
).get(strategyId, code);
return row ? this._mapHolding(row) : null;
}
/** 查询当前持仓(closed_at IS NULL;可加 strategy_id 过滤 */
/** 按 holding_id 取行(含历史/作废;归属服务撤段用 */
getHoldingById(holdingId) {
this.init();
const row = this.db.prepare(
'SELECT holding_id, strategy_id, code, shares, created_at, closed_at, void_at, closed_shares, "values" FROM strategy_holdings WHERE holding_id=?'
).get(holdingId);
return row ? this._mapHolding(row) : null;
}
/** 查询当前活动持仓(closed/void 双空;可加 strategy_id 过滤) */
getCurrentHoldings(strategyId) {
this.init();
if (strategyId) {
return this.db.prepare(
'SELECT holding_id, strategy_id, code, shares, created_at, closed_at, "values" FROM strategy_holdings WHERE strategy_id=? AND closed_at IS NULL ORDER BY holding_id'
'SELECT holding_id, strategy_id, code, shares, created_at, closed_at, void_at, closed_shares, "values" FROM strategy_holdings WHERE strategy_id=? AND closed_at IS NULL AND void_at IS NULL ORDER BY holding_id'
).all(strategyId).map(this._mapHolding);
}
return this.db.prepare(
'SELECT holding_id, strategy_id, code, shares, created_at, closed_at, "values" FROM strategy_holdings WHERE closed_at IS NULL ORDER BY strategy_id, holding_id'
'SELECT holding_id, strategy_id, code, shares, created_at, closed_at, void_at, closed_shares, "values" FROM strategy_holdings WHERE closed_at IS NULL AND void_at IS NULL ORDER BY strategy_id, holding_id'
).all().map(this._mapHolding);
}
/**
* 查询某策略已清仓持仓(closed_at 非空;R-016 迭代 14
* 查询某策略已清仓持仓(closed_at 非空 且 未作废R-016 迭代 14 + R-018 过滤 void
* 只读新增,不动任何写路径(closeHolding 置 shares=0 语义维持——Q2 老师拍板:历史行份额显示 0)。
* @param {string} strategyId 策略 ID
* @param {{sinceMs?: number}} [opts] sinceMsclosed_at 下限(毫秒,含);缺省不过滤
@@ -385,7 +531,7 @@ export class SqliteStore {
getHoldingsHistory(strategyId, { sinceMs } = {}) {
this.init();
const params = [strategyId];
let sql = 'SELECT holding_id, strategy_id, code, shares, created_at, closed_at, "values" FROM strategy_holdings WHERE strategy_id=? AND closed_at IS NOT NULL';
let sql = 'SELECT holding_id, strategy_id, code, shares, created_at, closed_at, void_at, closed_shares, "values" FROM strategy_holdings WHERE strategy_id=? AND closed_at IS NOT NULL AND void_at IS NULL';
if (sinceMs != null && Number.isFinite(Number(sinceMs))) {
sql += ' AND closed_at >= ?';
params.push(Number(sinceMs));
@@ -394,16 +540,16 @@ export class SqliteStore {
return this.db.prepare(sql).all(...params).map(this._mapHolding);
}
/** 查询历史(含当前 + 已清仓;可加 code 过滤) */
/** 查询历史(含当前活动 + 已清仓 + 作废;可加 code 过滤) */
getHoldingHistory(code) {
this.init();
if (code) {
return this.db.prepare(
'SELECT holding_id, strategy_id, code, shares, created_at, closed_at, "values" FROM strategy_holdings WHERE code=? ORDER BY holding_id'
'SELECT holding_id, strategy_id, code, shares, created_at, closed_at, void_at, closed_shares, "values" FROM strategy_holdings WHERE code=? ORDER BY holding_id'
).all(code).map(this._mapHolding);
}
return this.db.prepare(
'SELECT holding_id, strategy_id, code, shares, created_at, closed_at, "values" FROM strategy_holdings ORDER BY holding_id'
'SELECT holding_id, strategy_id, code, shares, created_at, closed_at, void_at, closed_shares, "values" FROM strategy_holdings ORDER BY holding_id'
).all().map(this._mapHolding);
}
@@ -415,6 +561,8 @@ export class SqliteStore {
shares: row.shares,
createdAt: row.created_at,
closedAt: row.closed_at,
voidAt: row.void_at ?? null, // R-018:作废时间(NULL=有效)
closedShares: row.closed_shares ?? null, // R-018:清仓前份额快照
values: parseValues(row.values), // R-013:自定义字段值(未配置 null;模块级函数,.map 裸传不依赖 this)
};
}
@@ -514,6 +662,33 @@ export class SqliteStore {
return list.length;
}
/**
* R-018:事务包裹(同步 API 直接 exec)。fn 内抛错 → ROLLBACK 后重抛。
* 归属服务 setSegments 全量替换(撤旧段 + 加新段)必须整体原子。
* @param {Function} fn 同步回调(内部经本 store 写库)
* @returns {*} fn 返回值
*/
runInTransaction(fn) {
this.init();
this.db.exec('BEGIN');
try {
const result = fn();
this.db.exec('COMMIT');
return result;
} catch (err) {
try { this.db.exec('ROLLBACK'); } catch { /* ignore */ }
throw err;
}
}
/** 按 order_id 读委托(trade_orders;无 → null */
getOrderById(orderId) {
this.init();
if (!orderId) return null;
const row = this.db.prepare('SELECT * FROM trade_orders WHERE order_id=?').get(orderId);
return row ? this._mapOrderRow(row) : null;
}
/**
* 委托时间 → 毫秒时间戳(insert_date YYYYMMDD + insert_time HHMMSS
* 时间字段缺失时回退 fetched_at(同步时间),保证 join 窗口可用
@@ -553,7 +728,7 @@ export class SqliteStore {
if (codes.length === 0) return out;
const placeholders = codes.map(() => '?').join(',');
const rows = this.db.prepare(
'SELECT holding_id, strategy_id, code, shares, created_at, closed_at, "values" FROM strategy_holdings WHERE code IN (' + placeholders + ')'
'SELECT holding_id, strategy_id, code, shares, created_at, closed_at, void_at, "values" FROM strategy_holdings WHERE code IN (' + placeholders + ') AND void_at IS NULL'
).all(...codes);
const holdingsByCode = new Map();
for (const h of rows) {
@@ -562,9 +737,9 @@ export class SqliteStore {
}
for (const o of list) {
const ts = this._orderInsertTs(o);
// 方案 A2026-09-01 老师确认):当前持仓(closed_at IS NULL)只做同 code 匹配
// 方案 A2026-09-01 老师确认):当前活动持仓(closed_at IS NULL AND void_at IS NULL)只做同 code 匹配
// created_at 可能是迁移时间戳,不要求 ≤ 委托时间;现在持有=当日交易可归);
// 已清仓(closed_at 非空)才按时间窗口(created_at ≤ t < closed_at)判断。
// 已清仓(closed_at 非空)才按时间窗口(created_at ≤ t < closed_at)判断;作废行(void_at 非空)不参与
const candidates = (holdingsByCode.get(o.code) ?? []).filter((h) => {
if (h.closed_at == null) return true; // 当前持仓:同 code 即匹配
if (h.created_at > ts) return false;
@@ -614,14 +789,24 @@ export class SqliteStore {
if (where.length) sql += ' WHERE ' + where.join(' AND ');
sql += ' ORDER BY insert_ts DESC';
const rows = this.db.prepare(sql).all(...params);
// 归属 = 用户手动设置的持久化列(Q3 全手动选;未设置为 null=未关联
// R-018:归属真相源 = 段表(strategyId 命中任一策略段即算该策略;__unassigned__ = 无任何段
const mapped = rows.map((r) => this._mapOrderRow(r));
if (!strategyId) return mapped;
const ids = mapped.map((o) => o.orderId).filter(Boolean);
if (ids.length === 0) return [];
const ph = ids.map(() => '?').join(',');
const segRows = this.db.prepare(
'SELECT order_id, strategy_id FROM trade_order_attributions WHERE order_id IN (' + ph + ')'
).all(...ids);
const segByOrder = new Map();
for (const s of segRows) {
if (!segByOrder.has(s.order_id)) segByOrder.set(s.order_id, new Set());
segByOrder.get(s.order_id).add(s.strategy_id);
}
if (strategyId === '__unassigned__') {
return rows.map((r) => this._mapOrderRow(r)).filter((o) => !o.strategyId);
return mapped.filter((o) => !segByOrder.has(o.orderId));
}
if (strategyId) {
return rows.map((r) => this._mapOrderRow(r)).filter((o) => o.strategyId === strategyId);
}
return rows.map((r) => this._mapOrderRow(r));
return mapped.filter((o) => segByOrder.get(o.orderId)?.has(strategyId));
}
/**
@@ -643,22 +828,29 @@ export class SqliteStore {
const rows = this.db.prepare(sql).all(...params);
const fills = rows.map((r) => this._mapFillRow(r));
if (!strategyId) return fills;
// 策略过滤:按所属委托的【持久化归属】strategy_idfill → order 的 strategy_id
// R-018:策略过滤经段表fill → order → 段;命中任一策略段即算
const orderIds = [...new Set(fills.map((f) => f.orderId).filter(Boolean))];
if (orderIds.length === 0) return [];
const ph = orderIds.map(() => '?').join(',');
const orderRows = this.db.prepare(
'SELECT order_id, strategy_id FROM trade_orders WHERE order_id IN (' + ph + ')'
const segRows = this.db.prepare(
'SELECT order_id, strategy_id FROM trade_order_attributions WHERE order_id IN (' + ph + ')'
).all(...orderIds);
const orderStrategy = new Map(orderRows.map((r) => [r.order_id, r.strategy_id ?? null]));
if (strategyId === '__unassigned__') {
return fills.filter((f) => !orderStrategy.get(f.orderId));
const segByOrder = new Map();
for (const s of segRows) {
if (!segByOrder.has(s.order_id)) segByOrder.set(s.order_id, new Set());
segByOrder.get(s.order_id).add(s.strategy_id);
}
return fills.filter((f) => orderStrategy.get(f.orderId) === strategyId);
if (strategyId === '__unassigned__') {
return fills.filter((f) => !segByOrder.has(f.orderId));
}
return fills.filter((f) => segByOrder.get(f.orderId)?.has(strategyId));
}
/**
* 设置委托归属(Q4 老师确认:可随时改,以最终修改为准)
* R-018 兼容封装:段表是真相源 —— 单组归属(strategyId+holdingId)写冗余列,
* 并同步为段表一段(volume = 该 order 已成交量;无 order 记录时仅写冗余列,供旧脚本/一次性修复)。
* 正式 UI 走 AttributionService.setSegments(多段)。
* @param {string} orderId
* @param {string|null} strategyId null=清除归属(未关联)
* @param {number|null} holdingId null=清除归属
@@ -666,49 +858,167 @@ export class SqliteStore {
*/
setOrderAttribution(orderId, strategyId, holdingId) {
this.init();
const r = this.db.prepare(
'UPDATE trade_orders SET strategy_id=?, holding_id=? WHERE order_id=?'
).run(strategyId ?? null, holdingId ?? null, orderId);
return r.changes > 0;
const row = this.db.prepare(
'SELECT code, direction, traded_volume FROM trade_orders WHERE order_id=?'
).get(orderId);
if (!row) {
const r = this.db.prepare(
'UPDATE trade_orders SET strategy_id=?, holding_id=? WHERE order_id=?'
).run(strategyId ?? null, holdingId ?? null, orderId);
return r.changes > 0;
}
if (strategyId && holdingId != null) {
// 单段兼容 = 替换语义(R-018:归属真相源 = 段表;旧通道一次只表达一段 → 清空旧段再设新段)
const vol = Number(row.traded_volume ?? 0);
this.deleteAllOrderSegments(orderId);
this.upsertOrderSegment({
orderId, strategyId, holdingId: Number(holdingId),
code: row.code, direction: row.direction ?? '', volume: vol,
});
} else {
// 清除归属:删全部段(冗余列由镜像清空)
this.deleteAllOrderSegments(orderId);
}
return true;
}
/**
* 按 holding_id 查询委托汇总(R-010 迭代 08:策略持仓行展开用)
* 按 holding_id 查询委托汇总(R-010 迭代 08:策略持仓行展开用;R-018:经段表 join——同一单多段可出现在多个 holding 展开
* @param {number} holdingId 持仓 ID
* @returns {Array} 该 holding 的委托列表(含归属字段,按时间降序)
*/
getOrdersByHolding(holdingId) {
this.init();
if (holdingId == null) return [];
return this.db.prepare(
'SELECT * FROM trade_orders WHERE holding_id=? ORDER BY insert_ts DESC'
).all(holdingId).map((r) => this._mapOrderRow(r));
const rows = this.db.prepare(
'SELECT t.* FROM trade_orders t ' +
'JOIN trade_order_attributions a ON a.order_id = t.order_id ' +
'WHERE a.holding_id = ? ORDER BY t.insert_ts DESC'
).all(holdingId);
return rows.map((r) => this._mapOrderRow(r));
}
/**
* 委托归属候选列表(用户手动设置用;Q3 全手动选)
* R-017 迭代 15:候选 = 该 code 当前持仓 + 近 7 天清仓的持仓(closed 标记)——
* 清仓后(幽灵清仓最快约 30s 转历史)当日委托仍可补关联到对应 holding
* 委托归属候选(存储层兼容方法;R-018 T2 退役 7 天清仓窗口后:候选 = 该 code **当前活动持仓**)。
* R-018 产品语义的正式候选 = 策略级 attribution-targets(阶段 D 新端点);本方法供 DataStore/
* 旧脚本(verify-real)读当前持仓用,不含清仓/作废行
* @param {string} code 证券代码(含后缀)
* @returns {Array<{strategyId: string, holdingId: number, shares: number, closed: boolean, closedAt: number|null}>} 候选列表(可空)
*/
getAttributionCandidates(code) {
this.init();
if (!code) return [];
const weekAgo = Date.now() - 7 * 86400000;
return this.db.prepare(
`SELECT holding_id, strategy_id, shares, closed_at FROM strategy_holdings
WHERE code=? AND (closed_at IS NULL OR closed_at >= ?)
ORDER BY (closed_at IS NULL) DESC, shares DESC, closed_at DESC`
).all(code, weekAgo).map((h) => ({
'SELECT holding_id, strategy_id, shares, closed_at FROM strategy_holdings ' +
'WHERE code=? AND closed_at IS NULL AND void_at IS NULL ' +
'ORDER BY shares DESC'
).all(code).map((h) => ({
strategyId: h.strategy_id,
holdingId: h.holding_id,
shares: h.shares,
closed: h.closed_at != null,
closedAt: h.closed_at ?? null,
closed: false,
closedAt: null,
}));
}
// ===== 归属分段表(R-018 迭代 16:真相源 = 段表;trade_orders 冗余列 = 主段镜像)=====
/**
* 读某 order 的全部归属段(无段 → [])
*/
getOrderAttributions(orderId) {
this.init();
if (!orderId) return [];
return this.db.prepare(
'SELECT id, order_id, strategy_id, holding_id, code, direction, volume, created_at FROM trade_order_attributions WHERE order_id=? ORDER BY id'
).all(orderId).map((r) => this._mapSegmentRow(r));
}
/**
* 批量读多个 order 的归属段(orders 列表附加用):{ orderId: segments[] }
*/
getAttributionsByOrderIds(orderIds) {
this.init();
const out = new Map();
const ids = [...new Set((orderIds ?? []).filter(Boolean))];
if (ids.length === 0) return out;
const ph = ids.map(() => '?').join(',');
const rows = this.db.prepare(
'SELECT id, order_id, strategy_id, holding_id, code, direction, volume, created_at FROM trade_order_attributions WHERE order_id IN (' + ph + ') ORDER BY id'
).all(...ids);
for (const r of rows) {
const s = this._mapSegmentRow(r);
if (!out.has(s.orderId)) out.set(s.orderId, []);
out.get(s.orderId).push(s);
}
return out;
}
/**
* 插入一个归属段(归属服务 apply 后调用)+ 同步冗余列主段镜像。
* 唯一键 (order_id, strategy_id):已存在则更新 volume/holding 不新建(幂等改归属)。
*/
upsertOrderSegment({ orderId, strategyId, holdingId, code, direction, volume }) {
this.init();
const now = Date.now();
this.db.prepare(
'INSERT INTO trade_order_attributions (order_id, strategy_id, holding_id, code, direction, volume, created_at) ' +
'VALUES (?,?,?,?,?,?,?) ' +
'ON CONFLICT(order_id, strategy_id) DO UPDATE SET ' +
'holding_id=excluded.holding_id, code=excluded.code, direction=excluded.direction, ' +
'volume=excluded.volume, created_at=excluded.created_at'
).run(orderId, strategyId, holdingId, code, direction, Number(volume), now);
this._syncOrderMirror(orderId);
return this.db.prepare(
'SELECT id, order_id, strategy_id, holding_id, code, direction, volume, created_at FROM trade_order_attributions WHERE order_id=? AND strategy_id=?'
).get(orderId, strategyId);
}
/**
* 删除一个归属段(归属服务 revoke 后调用)+ 同步冗余列主段镜像。
*/
deleteOrderSegment(orderId, strategyId) {
this.init();
const r = this.db.prepare(
'DELETE FROM trade_order_attributions WHERE order_id=? AND strategy_id=?'
).run(orderId, strategyId);
if (r.changes > 0) this._syncOrderMirror(orderId);
return r.changes > 0;
}
/** 删除某 order 全部段(清空归属用) */
deleteAllOrderSegments(orderId) {
this.init();
const r = this.db.prepare(
'DELETE FROM trade_order_attributions WHERE order_id=?'
).run(orderId);
if (r.changes > 0) this._syncOrderMirror(orderId);
return r.changes > 0;
}
/** 冗余列主段镜像:trade_orders.strategy_id/holding_id = 第一条段(段表真相源,冗余列兼容旧查询/外部脚本) */
_syncOrderMirror(orderId) {
const seg = this.db.prepare(
'SELECT strategy_id, holding_id FROM trade_order_attributions WHERE order_id=? ORDER BY id LIMIT 1'
).get(orderId);
this.db.prepare(
'UPDATE trade_orders SET strategy_id=?, holding_id=? WHERE order_id=?'
).run(seg ? seg.strategy_id : null, seg ? seg.holding_id : null, orderId);
}
_mapSegmentRow(r) {
return {
id: r.id,
orderId: r.order_id,
strategyId: r.strategy_id,
holdingId: r.holding_id,
code: r.code,
direction: r.direction,
volume: r.volume,
createdAt: r.created_at,
};
}
_mapOrderRow(r) {
return {
orderId: r.order_id,
@@ -730,8 +1040,8 @@ export class SqliteStore {
insertTs: r.insert_ts,
cancelInfo: r.cancel_info,
errorMsg: r.error_msg,
strategyId: r.strategy_id ?? null, // 手动归属(Q1 冗余)
holdingId: r.holding_id ?? null, // 手动归属(Q1 冗余)
strategyId: r.strategy_id ?? null, // 冗余主段镜像(R-018:真相源=段表
holdingId: r.holding_id ?? null, // 冗余主段镜像(R-018:真相源=段表
fetchedAt: r.fetched_at,
};
}
+214
View File
@@ -0,0 +1,214 @@
/**
* AttributionService —— 交易关联归属服务(R-018 / 迭代 16)
*
* 语义(R-018 老师拍板):
* - 归属 = 账本写操作:把委托的已成交量拆成 0..N 段(策略 × 量)分配到 strategy_holdings
* - 买入段:策略有活动仓 → addShares(加仓);无 → openHolding(建新仓,不复活旧行);
* - 卖出段:活动仓 ≥ 段量 → reduceShares,归 0 → closeHolding(账本转历史唯一途径);
* 活动仓 < 段量 → 拒绝(segment-exceeds,UI 截断后重提);无活动仓 → 拒绝(no-active-holding);
* - 撤段(revoke,粒度=段):撤 buy 段 → 减回,归 0 且无真实卖出 → voidHolding(作废第三态);
* 撤 sell 段 → 加回;若该段触发清仓且 closed_shares==段量 → reopenHolding 恢复活动仓;
* - 同 holding 内段撤销按逆序支持;乱序 → segment-order-conflict(宁可拒绝不写错账);
* - 改归属 = setSegments(全量替换:撤旧段 + 加新段,单事务原子)。
*
* 真相源 = trade_order_attributions 段表;trade_orders 冗余列由存储层镜像维护。
*/
/** 段级 apply:把一段归属落到账本。返回该段(含最终 holdingId)。 */
export class AttributionService {
/**
* @param {object} opts
* @param {import('../storage/SqliteStore.js').SqliteStore} opts.store SqliteStore(段表 CRUD / 持仓生命周期同步接口)
*/
constructor({ store }) {
this.store = store;
}
/**
* 应用一段归属(外部单段入口也走这里;内部 setSegments diff 复用)
* @param {object} seg { orderId, strategyId, volume }
* @returns {object} { segment, holdingId }
*/
applySegment({ orderId, strategyId, volume }) {
const store = this.store;
const order = store.getOrderById(orderId);
if (!order) throw Object.assign(new Error('委托不存在: ' + orderId), { code: 'order-not-found' });
if (!strategyId) throw Object.assign(new Error('缺少策略'), { code: 'bad-request' });
const v = Number(volume);
if (!Number.isFinite(v) || v <= 0) throw Object.assign(new Error('段量必须为正数'), { code: 'bad-request' });
const tv = Number(order.tradedVolume ?? 0);
if (v > tv) {
throw Object.assign(new Error('段量超过该委托已成交量: ' + v + ' > ' + tv), { code: 'segment-exceeds-order' });
}
// 累计校验:该 order 已有段合计 + 新增 ≤ 已成交量(单段入口不允许一单追加超量)
const existing = this.store.getOrderAttributions(orderId);
const already = existing.filter((s) => s.strategyId !== strategyId).reduce((s, x) => s + Number(x.volume), 0);
if (already + v > tv + 1e-9) {
throw Object.assign(new Error('该委托已关联 ' + already + ',再加 ' + v + ' 超过已成交量 ' + tv), { code: 'segment-exceeds-order' });
}
const direction = String(order.direction ?? '');
const code = order.code;
return this._applyOne({ orderId, code, direction, strategyId, volume: v });
}
/**
* 撤销一段归属(撤段 = 逆操作)。
* @param {object} arg { orderId, strategyId }
* @returns {boolean}
*/
revokeSegment({ orderId, strategyId }) {
const store = this.store;
const seg = store.db.prepare(
'SELECT * FROM trade_order_attributions WHERE order_id=? AND strategy_id=?'
).get(orderId, strategyId);
if (!seg) return false; // 无该段(已撤/不存在)→ 视为成功(幂等)
const holding = store.getHoldingById(seg.holding_id);
if (!holding) {
// 锚点行丢失(异常数据):仅删段(不可逆),账本已不可考
store.deleteOrderSegment(orderId, strategyId);
return true;
}
const segVolume = Number(seg.volume);
if (seg.direction === 'sell') {
this._revokeSellSegment(orderId, strategyId, seg, holding, segVolume);
} else {
// buy(含缺省方向视为 buy 侧处理)
this._revokeBuySegment(orderId, strategyId, seg, holding, segVolume);
}
return true;
}
/**
* 全量替换某 order 的归属段(改归属 = 撤旧段 + 加新段,单事务原子)。
* @param {object} arg { orderId, segments: [{ strategyId, volume }] }
* @returns {object} { segments: 最终段列表 }
*/
setSegments({ orderId, segments }) {
const store = this.store;
const order = store.getOrderById(orderId);
if (!order) throw Object.assign(new Error('委托不存在: ' + orderId), { code: 'order-not-found' });
const list = Array.isArray(segments) ? segments : [];
const tv = Number(order.tradedVolume ?? 0);
const total = list.reduce((s, x) => s + Number(x?.volume ?? 0), 0);
if (total > tv + 1e-9) {
throw Object.assign(new Error('段合计超过委托已成交量: ' + total + ' > ' + tv), { code: 'segment-exceeds-order' });
}
return store.runInTransaction(() => {
// 1) 撤掉「不在新集合」的旧段(或量变化的旧段先撤)
const oldSegs = store.getOrderAttributions(orderId);
const target = new Map();
for (const s of list) {
if (!s?.strategyId || !(Number(s.volume) > 0)) throw Object.assign(new Error('段格式无效(需 strategyId + volume>0'), { code: 'bad-request' });
target.set(s.strategyId, Number(s.volume));
}
// 收集需要撤销的段:新集合没有的 strategy,或 volume 变化需先撤(同 strategy 走 upsert 覆盖即可——
// 但份额变更不能简单覆盖:同 strategy 段从 v1→v2 时账本已 apply 过 v1,须先撤 v1 再 apply v2
for (const old of oldSegs) {
const want = target.get(old.strategyId);
if (want === undefined || Math.abs(Number(old.volume) - want) > 1e-9) {
this._revokeOneInternal(orderId, old); // 撤旧段
}
}
// 2) 应用新集合(含量未变的段:重新 apply 幂等 add 会重复加 → 需判断)
for (const [sid, vol] of target.entries()) {
const old = oldSegs.find((o) => o.strategyId === sid);
if (old && Math.abs(Number(old.volume) - vol) <= 1e-9) continue; // 未变:保留
// 量变或新增:apply(买入加仓/建仓、卖出减/清、失败即回滚)
this._applyOne({ orderId, code: order.code, direction: String(order.direction ?? ''), strategyId: sid, volume: vol });
}
return { segments: store.getOrderAttributions(orderId) };
});
}
/** 单段 apply(内部;含校验与 holding 状态判定) */
_applyOne({ orderId, code, direction, strategyId, volume }) {
const store = this.store;
const active = store._getActive(strategyId, code);
let holdingId;
if (direction === 'sell') {
if (!active) {
throw Object.assign(new Error('该策略无此持仓可减,关联失败: ' + code), { code: 'no-active-holding' });
}
if (Number(active.shares) + 1e-9 < volume) {
throw Object.assign(new Error('卖出段量超过策略持仓(可容纳 ' + active.shares + ',请求 ' + volume + ''), { code: 'segment-exceeds' });
}
const remain = Number(active.shares) - volume;
if (remain <= 1e-9) {
store.closeHolding(strategyId, code); // 归零清仓(closed_shares 快照由存储层记录)
} else {
store.reduceShares(strategyId, code, volume);
}
holdingId = active.holdingId;
} else {
// buy:有活动仓 → 加仓;无 → 建新仓
if (active) {
store.addShares(strategyId, code, volume);
holdingId = active.holdingId;
} else {
const created = store.openHolding(strategyId, code, volume);
holdingId = created.holdingId;
}
}
const seg = store.upsertOrderSegment({ orderId, strategyId, holdingId, code, direction, volume });
return { segment: seg, holdingId };
}
/** 内部撤段(已取段对象;事务内用) */
_revokeOneInternal(orderId, seg) {
const holding = this.store.getHoldingById(seg.holding_id);
const vol = Number(seg.volume);
if (seg.direction === 'sell') {
this._revokeSellSegment(orderId, seg.strategy_id, seg, holding, vol);
} else {
this._revokeBuySegment(orderId, seg.strategy_id, seg, holding, vol);
}
}
/** 撤卖出段 */
_revokeSellSegment(orderId, strategyId, seg, holding, vol) {
const store = this.store;
if (!holding) { store.deleteOrderSegment(orderId, strategyId); return; }
if (holding.closedAt == null && holding.voidAt == null) {
// holding 仍活动(该段只是减仓)→ 加回份额
store.addShares(strategyId, holding.code, vol);
store.deleteOrderSegment(orderId, strategyId);
return;
}
// holding 已清仓:仅当该段是清仓段(closed_shares == 段量)且其后无新仓 → 恢复活动
const closedShares = Number(holding.closedShares ?? 0);
if (holding.closedAt != null && holding.voidAt == null && Math.abs(closedShares - vol) <= 1e-9) {
const reopened = store.reopenHolding(holding.holdingId);
if (!reopened) {
throw Object.assign(new Error('无法恢复该持仓(同 code 已存在新活动仓或份额快照缺失),请先处理新仓'), { code: 'segment-order-conflict' });
}
store.deleteOrderSegment(orderId, strategyId);
return;
}
throw Object.assign(new Error('该卖出段不是最近一笔(请先撤销更晚的段)'), { code: 'segment-order-conflict' });
}
/** 撤买入段 */
_revokeBuySegment(orderId, strategyId, seg, holding, vol) {
const store = this.store;
if (!holding) { store.deleteOrderSegment(orderId, strategyId); return; }
if (holding.voidAt != null) {
// 已作废(之前撤销过建仓):仅删段
store.deleteOrderSegment(orderId, strategyId);
return;
}
if (holding.closedAt != null) {
throw Object.assign(new Error('该买入段对应持仓已清仓(请先处理卖出段或按逆序撤销)'), { code: 'segment-order-conflict' });
}
const shares = Number(holding.shares);
if (shares + 1e-9 < vol) {
throw Object.assign(new Error('该买入段之后已有卖出段作用于同一持仓,无法单独撤销(请先撤销更晚的段)'), { code: 'segment-order-conflict' });
}
const remain = shares - vol;
if (remain <= 1e-9) {
store.voidHolding(strategyId, holding.code); // 归零且无真实卖出 → 作废(非 closeHolding
} else {
store.reduceShares(strategyId, holding.code, vol);
}
store.deleteOrderSegment(orderId, strategyId);
}
}