245fcad95c
- PositionSync: 内存快照 + 10s 定时全量同步 + 失败保留旧快照 + 空快照双重确认 (/health + getAsset 账户身份)+ 幽灵持仓自动清仓(连续3轮消失 + 账户身份守卫防误清) - PositionManager.getAllPositions 改读快照,空则读穿透兜底;未注入 sync 时兼容旧穿透 - 回归 test-position-sync.mjs 35 项(纯内存 mock,含 kept 分支刷新 syncedAt 防灯灰) - 文档链: R-014 + PLAN-013 + 迭代三件套 + 数据存储设计 §11(内存不落库决策与三问标准) 需求: R-014(老师拍板: 内存不落库/读穿透兜底/幽灵自动清仓/不显示同步时间)
86 lines
5.0 KiB
Markdown
86 lines
5.0 KiB
Markdown
# 技术实现方案: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 兼容性证明);
|
||
- 老师人工验收(见验收标准)。
|