Files
one_divine_lot/docs/04-迭代记录/16-交易关联驱动持仓份额动态调整/技术实现方案.md
T

142 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 技术实现方案: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。