Files
one_divine_lot/docs/04-迭代记录/12-持仓内存快照/技术实现方案.md
T
kyugao 245fcad95c 迭代12: 持仓内存快照(PositionSync 10s 定时同步,请求不再穿透 QMT)
- PositionSync: 内存快照 + 10s 定时全量同步 + 失败保留旧快照 + 空快照双重确认
  (/health + getAsset 账户身份)+ 幽灵持仓自动清仓(连续3轮消失 + 账户身份守卫防误清)
- PositionManager.getAllPositions 改读快照,空则读穿透兜底;未注入 sync 时兼容旧穿透
- 回归 test-position-sync.mjs 35 项(纯内存 mock,含 kept 分支刷新 syncedAt 防灯灰)
- 文档链: R-014 + PLAN-013 + 迭代三件套 + 数据存储设计 §11(内存不落库决策与三问标准)

需求: R-014(老师拍板: 内存不落库/读穿透兜底/幽灵自动清仓/不显示同步时间)
2026-09-03 13:25:50 +08:00

5.0 KiB
Raw Blame History

技术实现方案:12-持仓内存快照

迭代编号:12 依据:PLAN-013 + R-014(老师四问拍板)+ 技术约束-010/012/016;新增 技术约束-017

1. PositionSyncsrc/position/PositionSync.js,新增)

1.1 数据结构与生命周期

class PositionSync {
  snapshot = [];          // 内存快照:Position[]mapPosition 语义化输出;只读约定)
  syncedAt = 0;           // 最近成功同步毫秒时间戳(0 = 从未成功)
  _ghostMiss = new Map(); // 幽灵防抖:code → 连续消失轮数
  _lastAccountId = null;  // 账户身份守卫
  stats = { syncCount, failCount, lastError, closedGhosts, lastSyncedAt };
}
  • start():立即预热一次(syncNow,失败仅 warn+ setInterval(10s)stop() 清定时器,内存快照保留stop 后读方仍可消费最后快照);
  • 对外读:getSnapshot()(数组本体,只读约定)/getSyncedAt()/backfill(positions)(读穿透回填入口,仅非空回填);
  • syncNow() 可手动调用(不检查 mounted——测试与热切换后手动刷新均可用;仅 syncing 防重入)。

1.2 同步一轮(syncNow

mounted 不拦手动同步 → syncing 防重入
positions = dataSource.getPositions()          // 全量
非数组 → 抛错(走失败分支)
空数组 → 双重确认:isAvailable() && getAsset().accountId
         双通过 → 接受为「真清仓」(接受空快照)
         否则   → 视为 QMT 异常(未登录/半可用),保留旧快照,failCount++
成功 → snapshot = positions(整体替换);syncedAt = nowstats.syncCount++
     → _autoCloseGhosts(snapshot)(失败不影响快照)
任何异常 → failCount++ / lastError 记录;不动内存(读方继续消费旧快照)

1.3 幽灵持仓自动清仓(_autoCloseGhosts,老师拍板「同步时自动清仓」)

  • 判定对象:本地 getCurrentHoldings()(全部策略、closed_at IS NULL)中 code 不在本轮快照的条目;
  • 防抖:连续 3 轮(ghostRounds=3,约 30s)消失才清仓;快照复现 → 计数复位;
  • 清仓动作:该 code 全部策略的当前持仓 closeHolding(strategyId, code)shares=0 + closed_at=now不物理删除,历史保留语义不变);
  • 账户身份守卫(防误清核心):
    • 每轮顺带 getAsset().accountId;身份未知 → 当轮跳过判定(不累计不清零,保守);
    • accountId 变化(R-004 连接热切换/换账户)→ 清空计数 + 当轮直接跳过(旧账户快照不可信);
  • 部分减持不触发(QMT 仍有该 code)——账实差额由前端「未分配为负」暴露,属产品已知行为;
  • 单码清仓失败:保留计数,下轮重试。

2. 读路径切换(src/position/PositionManager.js

constructor({ dataSource, storage, getStrategySchema, positionSync })  // positionSync 可选注入

async getAllPositions() {
  if (!this.positionSync) return this.dataSource.getPositions();  // 未注入 → 旧穿透(兼容)
  const cached = this.positionSync.getSnapshot();
  if (cached.length > 0) return cached;                            // 主路径:读快照
  const positions = await this.dataSource.getPositions();          // 读穿透:当场拉一次
  this.positionSync.backfill(positions);                           // 回填(仅非空)
  return positions;                                                // QMT 也挂 → 抛错(前端 LoadState 重试)
}
  • strategy-positions / unallocated / summary 三个接口全部经 getAllPositions自动切换,前端零改动
  • 闸门校验(addToStrategy 不超实盘)改用快照总量——与页面显示的未分配数同源同鲜度,自洽。

3. 装配(src/index.js

const positionSync = new PositionSync({ runtime: { dataSource, storage }, logger });
positionSync.start();
const manager = new PositionManager({ dataSource, storage, positionSync, getStrategySchema });
registerApi(ctx, { ..., positionSync });
// disposepositionSync.stop()(在 marketFeed.stop 之后、tradeSync.stop 之前)

4. 回归脚本(scripts/test-position-sync.mjs,纯内存 mock

  • 不落库不碰真实数据目录(内存快照方案下技术约束-011 天然满足);
  • mock:可编程 dataSourcepositions/accountId/healthy 可变状态)+ 内存 storagegetCurrentHoldings/closeHolding 记录调用);
  • 34 项用例:基本流 / 失败保留快照 / 空快照四分支(health 挂、身份未知、双通过、有旧快照)/ 幽灵防抖(3 轮关闭、复现复位、账户切换重置、身份丢失跳过+恢复后关闭)/ 读路径(快照零 QMT 调用、读穿透回填、未注入兼容、QMT 挂抛错)/ getStrategyPositions 组装回归(shares/holdingId/lastTradePrice/values/ 定时器冒烟。

5. 验证

  • typecheck + build 通过;test-position-sync 34/34test-r013-custom-fields 21/21(未注入 positionSync 兼容性证明);
  • 老师人工验收(见验收标准)。