Files
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

86 lines
5.0 KiB
Markdown
Raw Permalink 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.
# 技术实现方案: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 兼容性证明);
- 老师人工验收(见验收标准)。