迭代16: 交易关联驱动持仓份额动态调整(R-018)
This commit is contained in:
@@ -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)
|
||||
|
||||
> 命名遵守技术约束-016(src/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(作废,非 closeHolding;R3-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. 读 order(trade_orders:code/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_orders(R-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 当前活动份额 activityShares(0=无仓)+ 该 order 已有关联段数提示(供 UI「指向后判定」的前置展示;**不预筛**,指向后由服务端判定)。策略级(Q1),holdingId 不再作下拉键;
|
||||
- **orders/attribution-set**:{ orderId, segments } → 归属服务 setSegments(替代 set-attribution;set-attribution 端点删除或保留为单段包装——删,避免双写源混乱;既有数据迁移已覆盖);
|
||||
- **orders/attribution-segments**:{ orderId } → 读当前段(UI 展开/编辑用);
|
||||
- 今日 orders 端点:order 行附 `segments: [{strategyId,strategyName,holdingId,volume}]`(取代原单组 strategyId/holdingId 附加;兼容字段保留首段值)。
|
||||
|
||||
## 4. UI 份额分配器(阶段E,TradeRecordsTab)
|
||||
|
||||
- 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.js(attribution-targets/set/segments;orders 附加段)、src/api/strategies.js(strategy-holdings/history 过滤 void)
|
||||
- src/client/views/TradeRecordsTab.jsx(AttributionAllocator 替换 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。
|
||||
Reference in New Issue
Block a user