迭代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 兼容性证明);
- 老师人工验收(见验收标准)。
@@ -0,0 +1,44 @@
# 迭代复盘:12-持仓内存快照(服务端 10s 定时同步,请求不再穿透 QMT)
> 复盘日期:2026-09-02 | 迭代状态:**已实施,待老师人工验收**
> 关联需求:R-014(持仓内存快照,已定稿)
> 关联计划:PLAN-013(计划-持仓内存快照)
## 结果
迭代 12 达成:服务端建全量持仓**内存快照**(PositionSync10s 定时全量同步,**不落库**),策略持仓 / 全部持仓 / 未分配三个接口改读快照为准;同步失败保留上次快照(QMT 抖动不再白屏);空快照双重确认(health + 账户身份);快照为空读穿透兜底;**幽灵持仓自动清仓**(连续 3 轮消失 + 账户身份守卫防误清)。前端零改动,SQLite 零 schema 变更。
## 过程事实
1. **起因(架构梳理讨论)**:老师要求梳理持仓/实盘数据存储与同步机制 → 梳理结论「本地数据准确性靠打开 tab 时与实盘当场对账,无对账任务、无修正、无告警」+ 幽灵持仓盲区(QMT 卖光的票本地账本仍记着,UI 隐身)→ 老师提出优化方向(服务端缓存 + 10s 同步 + 策略持仓改读缓存);
2. **方案反转(落库 → 内存)**AI 初版方案建 positions_cache 表(惯性抄 market_quotes_cache / trade_fills 先例);老师质疑是否可复用 strategy_holdings 或放内存 → AI 论证修正:**判断落库的标准是「数据能否随时一次调用重拿全」**,持仓快照满足,落库反引入「过期快照冒充实时的说谎风险」;strategy_holdings 是账本不可掺对账单(holding_id 是交易归属锚点)→ 定稿纯内存方案;
3. **四问拍板**(老师,2026-09-02):① 内存不落库;② 读穿透兜底(首启/QMT 从未连上时当场拉一次并回填);③ 幽灵持仓同步时自动清仓(加防抖护栏);④ 同步时间前端本轮不显示;
4. **实现**PositionSync(快照 + 定时 + 空快照双确认 + 幽灵清仓防抖 + 账户守卫 + stats)、PositionManager.getAllPositions 读快照 + 读穿透 + 未注入兼容、index.js 装配、回归脚本 34 项;
5. **修复两处**(测试驱动发现):syncNow 的 mounted 守卫拦住了手动同步(测试直接调用返回 null)→ 移除该守卫(mounted 只管定时循环,手动同步/热切换后刷新不受限);账户切换守卫从「重置后当轮继续计数」收紧为「当轮直接跳过」(旧账户快照不可信);
6. **验证**typecheck + build 通过;test-position-sync 34/34test-r013-custom-fields 21/21(未注入 positionSync 旧行为兼容证明);
7. **文档链补全**(先实施后补档):R-014 + PLAN-013 + 迭代三件套 + 技术约束-017 + 数据存储设计.md §11;修正一处归档操作(R-014 未验收先写了 已完成/,按规范移回需求池根目录)。
## 经验教训(复盘沉淀)
### 1. 「抄先例」要过适用性检查
- 行情缓存落库(重启首屏有价)与交易落库(QMT 只有当日数据,不存即丢)各有硬理由;持仓快照两个理由都不占。判断标准沉淀:**外部可重取的实时投影不落库**(落库换不来任何能力,只带来说谎风险);
- 数据该放哪一层,先问三个问题:不可再生吗?重启首屏依赖吗?有读放大或复杂查询需求吗?——全否 → 内存。
### 2. 账本与对账单分离
- strategy_holdings(人为分配、生命周期、holding_id 锚点)与实盘持仓快照(外部投影、易变)是两类数据;复用同一张表会污染交易归属链。合并的诱惑来自「都是持仓」,分辨的依据是「谁写、谁信、活了多久」。
### 3. 幽灵清仓的误清防线 = 防抖 × 身份守卫
- 单纯「消失即清仓」在 QMT 半可用(未登录返回空)和账户热切换两个场景都会误清;连续 3 轮 + accountId 守卫(未知跳过 / 切换重置跳过)把误清窗口压到可忽略;
- 沉淀:**对「删/清」类自动化动作,默认加两道独立护栏再上**。
### 4. mounted 守卫的语义边界
- 定时器服务的 mounted 应只表达「定时循环是否运行」,不应拦截手动触发(syncNow/sync 类方法);否则测试、手动刷新、热切换后的即时同步全被误伤。
### 5. 先实施后补档的可行边界
- 本迭代代码在讨论中当场落地、文档事后补全;补档过程顺畅依赖两点:讨论中老师拍板结论明确(四问有记录)+ 实现严格按结论执行无偏移。若讨论结论含糊,不允许先实施(文档先行约束不破)。
## 遗留/后续
1. **部分减持的自动修正**(未分配负数仍靠 UI 暴露,人工「移出」修正):如需「检测账实不符 → 提示一键按实盘修正」可另立需求;
2. **同步时间前端显示**:老师拍板本轮不做;如持仓滞后感知需要,可加「更新于 HH:mm:ss」小标注(后端 syncedAt 已就绪);
3. **既有观察点未动**(另行登记评估):getStrategyPositions 的 N+1 委托查询(可改 IN 一次查);src/api/trades.js orders 端点绕 DataStore 门面直摸 sqlite.db(层级破洞);calcOrderFees 数据源/前端双实现(可收敛);数据存储设计.md §9.1 库文件名(one-divine-lot.db vs 实际 store.db)等历史偏差。
@@ -0,0 +1,26 @@
# 迭代目标:12-持仓内存快照(服务端 10s 定时同步,请求不再穿透 QMT)
> 迭代编号:12 | 创建:2026-09-02 | 状态:已实施,待验收
> 依据计划:PLAN-013 需求:R-014(已定稿,2026-09-02,老师逐项拍板 4 问)
## 目标描述
消除「QMT 抖动 → 持仓页面白屏」:服务端建全量持仓**内存快照**(不落库,老师拍板),PositionSync 每 10 秒与 QMT 全量同步一次,以快照为持仓数据的唯一读取源;策略持仓 / 全部持仓 / 未分配三个接口不再在请求时穿透 QMT;同步失败保留上次快照;快照为空读穿透兜底;幽灵持仓(QMT 已卖光、本地账本仍记着的条目)在同步循环中自动清仓(带防抖护栏)。
## 目标分解
1. **PositionSync**src/position/PositionSync.js):启动预热一次 + setInterval(10s) 全量拉 /trade/positions → 格式校验 → 整体替换内存快照;失败记 stats 不动内存;空快照双重确认(isAvailable + getAsset accountId);
2. **读路径切换**PositionManager.getAllPositions):有注入 positionSync → 读快照(空则读穿透回填);未注入 → 旧穿透行为(兼容既有构造点,如 test-r013);
3. **幽灵自动清仓**(同步循环内):本地当前持仓 code ∉ QMT 快照,连续 3 轮 → 全部策略 closeHolding 转历史;护栏:accountId 未知当轮跳过、账户切换当轮重置跳过、单码清仓失败保留计数下轮重试;
4. **装配**index.js):start + dispose + 注入 manager/api runtime
5. **回归脚本**scripts/test-position-sync.mjs,纯内存 mock 34 项)+ typecheck + build + 既有回归(r013)。
## 背景事实(本迭代为「先实施后补档」)
- 迭代代码于 2026-09-02 架构梳理讨论中当场实施(老师指令「开工吧」),讨论中老师四问拍板(落库质疑改内存 / 读穿透 / 幽灵自动清仓 / 不显示同步时间);
- 文档链(R-014 / PLAN-013 / 本迭代三件套 / 技术约束-017 / 数据存储设计 §11)事后补全,实现与讨论结论一致。
## 对老师的配合需求
- **人工验收**:真实环境重载插件 → QMT 正常时开策略持仓/全部持仓 tab 数据正常 → 停掉 QMT Bridge 刷新页面(数据应保留上次快照而非白屏)→ 恢复 QMT → 在券商端卖出某只票全部持仓,约 30s 后确认本地持仓行自动转历史;
- 验收通过后:R-014 移入 已完成/ 归档、迭代 12 标记验收通过。
@@ -0,0 +1,22 @@
# 验收标准:12-持仓内存快照
> 迭代编号:12 | 依据:PLAN-013 验收要点 + R-014
## 验收标准线
1. **自动化**typecheck + build 通过;回归脚本 test-position-sync.mjs 全绿(34 项:基本流 / 失败保留快照 / 空快照双重确认四分支 / 幽灵清仓防抖与账户守卫 / 读穿透兜底 / 未注入兼容 / 组装回归 / 定时冒烟);既有回归 test-r013-custom-fields.mjs 全绿(21 项,兼容性证明);
2. **读路径**:插件启动日志出现「PositionSync 启动」;QMT 正常时打开策略持仓 / 全部持仓 tab,数据与改造前一致(行集合 / 份额 / 成本价 / 最后一笔成交价 / 自定义字段值);连续切 tab 不产生对 QMT 的 /trade/positions 新调用(服务端日志无新增穿透请求);
3. **抗抖**:停掉 QMT Bridge 后刷新页面,持仓 tab 仍显示**最后一次快照数据**(不再白屏);恢复 QMT 后 ≤10s 快照自动恢复最新;
4. **幽灵自动清仓**:在券商端卖出某只票全部持仓后,约 30s(3 轮同步)内,该票在全部策略下的当前持仓自动转历史(strategy_holdings 出现 closed_at,不物理删除);服务端日志出现「PositionSync 幽灵清仓」warn
5. **防误清**:QMT 短暂返回空(如未登录)时本地持仓不被清空(保留旧快照);连接热切换到另一账户时旧计数重置,无误清;
6. **零 schema 变更**SQLite 无新表、strategy_holdings 结构不变;前端代码零改动。
## 验收方法
- 自动化项由 AI 执行并出具结果(已完成:34/34 + 21/21 + typecheck + build);
- 2~5 项老师真实环境人工验收:重载插件 → 正常浏览持仓 tab → 停 QMT 验证抗抖 → 恢复 → 券商端清仓一票验证幽灵自动转历史;
- 全部通过后:迭代 12 标记「验收通过」,R-014 归档至 已完成/。
## 验收目标
- 6 条验收线通过,迭代 12 标记「验收通过」,R-014 更新实现状态(已实现)并归档。