Files

11 KiB
Raw Permalink Blame History

技术实现方案: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 模式)

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
  • 唯一索引重建(活动唯一收窄至两态皆空):
    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 列仍置 0R-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

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(作废,非 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。