# 技术实现方案:12-持仓内存快照 > 迭代编号:12 | 依据:PLAN-013 + R-014(老师四问拍板)+ 技术约束-010/012/016;新增 技术约束-017 ## 1. PositionSync(src/position/PositionSync.js,新增) ### 1.1 数据结构与生命周期 ```js 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 = now;stats.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) ```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) ```js const positionSync = new PositionSync({ runtime: { dataSource, storage }, logger }); positionSync.start(); const manager = new PositionManager({ dataSource, storage, positionSync, getStrategySchema }); registerApi(ctx, { ..., positionSync }); // dispose:positionSync.stop()(在 marketFeed.stop 之后、tradeSync.stop 之前) ``` ## 4. 回归脚本(scripts/test-position-sync.mjs,纯内存 mock) - **不落库不碰真实数据目录**(内存快照方案下技术约束-011 天然满足); - mock:可编程 dataSource(positions/accountId/healthy 可变状态)+ 内存 storage(getCurrentHoldings/closeHolding 记录调用); - 34 项用例:基本流 / 失败保留快照 / 空快照四分支(health 挂、身份未知、双通过、有旧快照)/ 幽灵防抖(3 轮关闭、复现复位、账户切换重置、身份丢失跳过+恢复后关闭)/ 读路径(快照零 QMT 调用、读穿透回填、未注入兼容、QMT 挂抛错)/ getStrategyPositions 组装回归(shares/holdingId/lastTradePrice/values)/ 定时器冒烟。 ## 5. 验证 - typecheck + build 通过;test-position-sync 34/34;test-r013-custom-fields 21/21(未注入 positionSync 兼容性证明); - 老师人工验收(见验收标准)。