迭代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(老师拍板: 内存不落库/读穿透兜底/幽灵自动清仓/不显示同步时间)
This commit is contained in:
2026-09-03 13:25:50 +08:00
parent 39b135fa36
commit 245fcad95c
11 changed files with 789 additions and 5 deletions
@@ -0,0 +1,85 @@
# 技术实现方案:12-持仓内存快照
> 迭代编号:12 依据:PLAN-013 + R-014(老师四问拍板)+ 技术约束-010/012/016;新增 技术约束-017
## 1. PositionSyncsrc/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 = 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
```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 });
// 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 兼容性证明);
- 老师人工验收(见验收标准)。