迭代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,50 @@
# 计划:持仓内存快照(服务端 10s 定时同步,请求不再穿透 QMT)(阶段航点)
> 编号:PLAN-013 | 粒度:阶段航点 创建:2026-09-02 | 状态:**已实施(待验收)**
> 派生自终极目标:目标-008(真实交易系统接入)、目标-001(DSH 插件形态)
> 依据需求:**R-014(已定稿,2026-09-02,老师逐项拍板 4 问)** —— 符合入范围门槛
> 设计约束:技术约束-010(服务端中转 + 缓存模式沿用)、技术约束-012(存储设计)、技术约束-016(分域目录);本次新增 技术约束-017
> 补充说明:本计划为「先实施后补档」——代码随架构梳理讨论当场落地(老师指令),文档链(本计划 + 迭代三件套 + 约束)事后补全,事实以迭代复盘为准
## 目标
服务端建立全量持仓**内存快照**PositionSync10s 定时与 QMT 同步),策略持仓 / 全部持仓 / 未分配三个接口改读快照为准,**消除「QMT 抖动 → 持仓页面白屏」**并去掉每次进 tab 的穿透 HTTP 调用;前端零改动。
## 范围
**做**
1. PositionSyncsrc/position/PositionSync.js):启动预热 + 10s 定时全量拉 QMT → 校验 → 整体替换内存快照;同步失败**保留上次快照**(不清空不报错);
2. 空快照双重确认:/health 可用 + getAsset 账户身份可识别,二者兼备才接受为「真清仓」,否则视为异常保留旧快照;
3. 读穿透兜底:PositionManager.getAllPositions() 快照为空 → 当场拉一次 QMT 并回填;未注入 positionSync 时保持旧穿透行为(兼容);
4. 幽灵持仓自动清仓:QMT 快照连续 3 轮(约 30s)消失的 code,本地全部策略当前持仓 closeHolding 转历史;防抖护栏 = 账户身份守卫(accountId 未知当轮跳过;账户切换当轮重置计数并跳过);部分减持不触发;
5. index.js 装配(start/dispose/注入 manager 与 api runtime);回归脚本(纯内存 mock,技术约束-011 不受影响)。
**不做**
- 不落库(无 positions_cache 表;不建表、不改 SQLite schema)——R-014 讨论 #1 明确;
- 不复用 strategy_holdings(账本与对账单分离,holding_id 锚点语义不掺快照);
- 前端改动(零改动;同步时间显示本轮不做);
- 部分减持的自动修正(负数未分配仍由 UI 暴露,后续可另立需求);
- N+1 委托查询、api/trades.js 绕门面等既有观察点(另行登记)。
## 涉及文件
```
src/
├── position/PositionSync.js # 新增:持仓内存快照同步服务
├── position/PositionManager.js # getAllPositions 改读快照 + 读穿透兜底(兼容旧构造)
└── index.js # 装配 PositionSync + 注入
scripts/
└── test-position-sync.mjs # 回归脚本(纯内存 mock 34 项)
```
## 实现步骤
1. PositionSync 模块(快照 + 定时 + 校验 + 幽灵清仓);
2. PositionManager 读路径切换 + 读穿透 + 兼容分支;
3. index.js 装配;
4. 回归脚本(34 项)+ typecheck + build + r013 回归;
5. 文档链补全(本计划 + 迭代三件套 + 技术约束-017 + 数据存储设计 §11)。
## 验收要点
-`docs/04-迭代记录/12-持仓内存快照/验收标准.md`
+33 -1
View File
@@ -351,7 +351,39 @@ CREATE INDEX IF NOT EXISTS idx_trade_fills_date ON trade_fills (trade_date);
| TradeSync | 服务端定时同步(启动预热 + 60s + UPSERT 幂等) | src/trades/TradeSync.js |
| api/trades.js | +trades/history 端点 | src/api/trades.js |
## 11. 约束条目(引用
## 11. 持仓内存快照(2026-09-02 优化
> **变更理由**(老师拍板讨论):原实盘持仓在每次请求时穿透 QMT`PositionManager.getAllPositions` 实时拉 `/trade/positions`),带来 ① QMT 抖动 → 策略/全部持仓/未分配三个页面当场空白;② 每次进 tab 都打一次 QMT HTTP。改为服务端内存快照为准,10s 定时同步。
### 11.1 决策
| 决策 | 结论 | 理由 |
|---|---|---|
| 存储介质 | **纯内存**PositionSync 进程内快照),**不落库** | 持仓快照随时可用一次调用重拿全,不满足落库的任一正当条件(不可再生/重启首屏依赖);落库反而引入「过期快照冒充实时的说谎风险」;不复用 strategy_holdings(账本 ≠ 对账单,holding_id 是交易归属锚点,不可掺易变快照) |
| 同步 | PositionSync:启动预热 + 10s 定时全量拉取 → 校验 → 整体替换内存 | 与 TradeSync 同构;闸门校验与页面显示同源同鲜度 |
| 失败策略 | 同步失败**保留上次快照**,不清空不报错 | 读方继续消费旧快照,QMT 抖动不再白屏(强于旧的穿透行为) |
| 空快照 | 双重确认(/health 可用 + getAsset 账户身份可识别)才接受为「真清仓」,否则视为异常保留旧快照 | 防一次异常响应清空缓存 |
| 读路径 | `getAllPositions()` 读内存快照;快照为空 → 读穿透当场拉一次并回填;QMT 也挂 → 抛错(前端 LoadState 重试) | 首启兜底;未注入 positionSync 时保持旧行为(兼容) |
| 幽灵持仓自动清仓 | QMT 快照连续 3 轮(约 30s)消失的 code,本地全部策略当前持仓 closeHolding 转历史;**账户身份守卫**:accountId 未知当轮跳过、账户切换当轮重置跳过(防误清);部分减持不触发(负数未分配由 UI 暴露) | 老师选定;清仓转历史不物理删除,历史保留语义不变 |
| 前端 | 零改动 | 三个接口数据源自动切换 |
### 11.2 数据流(持仓部分,替代原「读取时组装」描述)
```
启动:PositionSync 预热一次 → 内存快照
实盘:10s 定时全量同步(失败保留旧快照;空快照双重确认;幽灵清仓判定)
读取:getAllPositions() → 内存快照 →(空)读穿透回填
```
### 11.3 对应实现(追加)
| 模块 | 职责 | 文件 |
|---|---|---|
| PositionSync | 持仓内存快照同步(10s 定时 + 失败保留 + 空快照双重确认 + 幽灵清仓防抖) | src/position/PositionSync.js |
| PositionManager | getAllPositions 改读快照 + 读穿透兜底(未注入 sync 时兼容旧穿透) | src/position/PositionManager.js |
| 回归测试 | 纯内存 mock 34 项(同步/失败保留/空快照/幽灵防抖/账户守卫/读穿透/组装回归) | scripts/test-position-sync.mjs |
## 12. 约束条目(引用)
- 技术约束-012:数据存储遵循本文件(SQLite 存储设计,含交易记录表 §10);
- 技术约束-011:测试/回归脚本禁止在真实数据上执行写操作(独立数据目录);
@@ -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 更新实现状态(已实现)并归档。
+32
View File
@@ -0,0 +1,32 @@
# 需求:R-014 持仓内存快照(服务端 10s 定时同步,请求不再穿透 QMT)
> 登记:2026-09-02 | 来源:架构梳理讨论 + 老师指令 | 状态:**已定稿(老师逐项拍板 4 问)**
> 归属:迭代 12 计划:PLAN-013
## 需求描述
原实盘持仓数据在**每次请求时穿透 QMT**(`PositionManager.getAllPositions()` 实时拉 `/trade/positions`),带来两个问题:
1. **QMT 一抖(超时/掉线),策略持仓 / 全部持仓 / 未分配三个页面当场空白**——因为拼装以 QMT 返回行为驱动表,QMT 返回空则整页无行;
2. **每次进 tab 都打一次 QMT HTTP**,且部分实盘字段(volume/available/frozenVolume/price/marketValue/profit/profitPct 七个)该页面根本不消费,属搭车字段。
老师拍板优化:**服务端建缓存(内存快照),以快照为准,每 10 秒与 QMT 同步一次;策略持仓等接口相信快照,去掉请求时穿透 QMT 的逻辑。**
## 讨论记录(2026-09-02,三问拍板)
| # | 问题 | 结论(老师拍板) |
|---|---|---|
| 1 | 落库还是内存?(AI 初版方案建 SQLite 缓存表,老师质疑) | **纯内存管理,不落库**。理由(老师追问后 AI 论证修正):持仓快照随时可用一次调用重拿全,不满足落库任一正当条件(不可再生历史 / 重启首屏依赖);落库反引入「过期快照冒充实时的说谎风险」;不复用 strategy_holdings(账本 ≠ 对账单,holding_id 是交易归属锚点,不可掺易变快照) |
| 2 | 首启缓存未预热 / QMT 从未连上时,前端请求怎么办? | **读穿透兜底**:缓存为空当场拉一次 QMT 并回填;QMT 也挂才报错(前端既有 LoadState 重试 UI 兜住) |
| 3 | QMT 里卖光的票,本地持仓记录(幽灵条目)怎么办? | **同步时自动清仓**:QMT 快照连续 3 轮(约 30s)消失的 code,本地全部策略当前持仓自动转历史(不物理删除);加防抖护栏(账户身份守卫)防误清 |
| 4 | 前端要不要显示同步时间(数据最多滞后 10s)? | **本轮不加**,前端零改动;10s 级滞后对持仓场景够用 |
## 边界
**做**:内存快照同步服务(PositionSync)、getAllPositions 改读快照 + 读穿透兜底、空快照双重确认、幽灵持仓自动清仓(防抖 + 账户守卫)、回归脚本。
**不做**:不落库(无新表、无 schema 变更);不同步时间前端显示;部分减持(QMT 仍有但变少 → 负数未分配)的自动修正(维持 UI 暴露现状,后续可另立需求);策略份额账本(strategy_holdings)的任何改动。
## 验收
`docs/04-迭代记录/12-持仓内存快照/验收标准.md`
+1
View File
@@ -25,6 +25,7 @@
| R-011 | Tab 设置:统一管理所有 tab(内置 + 策略分组混排,显示/隐藏 + 拖动排序) | 设置页「通用设置」升级为「Tab 设置」,统一管理所有会话 tab(系统内置 + 策略分组)的唯一入口:两类混排、**拖动排序**(原生 HTML5 DnD,落点立即持久化)+ **显隐开关**;**任何 tab 均不支持重命名/删除**(策略命名/删除仍在「策略分组」子 tab);顺序/显隐统一为一份数据源(tabs 有序数组,策略行名 join strategies 自动跟随改名),删除策略联动删除对应条目;新增策略追加末尾;老配置自动迁移(旧隐藏策略迁移后显示)。**2026-09-02 定稿(Q1-Q5 确认)并完成(迭代 09 验收通过),已归档至 已完成/R-011.md** | 老师指令(2026-09-02 | P1 | 已定稿 | 2026-09-02 | 09-Tab设置统一管理 | **已实现(已归档)** |
| R-012 | UI 适配 DSH 主题(浅色 / 深色 / 跟随系统) | 神之一手 UI 适配 DSH 浅色/深色/跟随系统主题:141 处硬编码色值替换为宿主 `--dsw-*` token,随主题自动切换;不自行维护主题偏好(**暂定跟随系统**);涨跌红涨绿跌 → 宿主 state-error/success;仅色值 token 化不动布局。**2026-09-02 定稿(暂定跟随系统)并完成(迭代 10),已归档至 已完成/R-012.md** | 老师指令(2026-09-02 | P1 | 已定稿 | 2026-09-02 | 10-UI主题适配 | **已实现(已归档)** |
| R-013 | 策略自定义字段配置(定义随策略,值落库) | 策略可自定义、可扩展:每个策略在设置页「策略分组」子 tab 配置自定义字段(字段名/类型文本·数字·布尔·枚举/枚举项/单位 unit/默认值,configSchema 随策略定义存 settings);每个持仓行按所属策略的定义存一份键值对值(strategy_holdings 新增 values TEXT(JSON) 列);自定义字段作为持仓表列展示,列显隐/排序每策略独立配置(strategyColumns),单元格点击内联编辑。**2026-09-02 定稿(Q1-Q4 + D6)并完成(迭代 11 验收通过),已归档至 已完成/R-013.md** | 老师指令(2026-09-02 | P1 | 已定稿 | 2026-09-02 | 11-策略自定义字段配置 | **已实现(已归档)** |
| R-014 | 持仓内存快照(服务端 10s 定时同步,请求不再穿透 QMT) | 服务端建全量持仓**内存快照**(不落库):PositionSync 启动预热 + 每 10s 全量拉 QMT → 校验 → 整体替换;同步失败保留上次快照(QMT 抖动不再白屏);空快照双重确认(/health + getAsset 账户身份)才接受为真清仓;快照为空读穿透兜底(当场拉一次并回填);**幽灵持仓自动清仓**:QMT 连续 3 轮(约 30s)消失的 code,本地全部策略当前持仓自动转历史(账户身份守卫:accountId 未知跳过、切换重置跳过;部分减持不触发);strategy-positions / unallocated / summary 三个接口改读快照,前端零改动。**2026-09-02 定稿(内存不落库 / 读穿透兜底 / 幽灵自动清仓 / 同步时间不显示,老师逐项拍板),2026-09-02 完成(回归 34/34 + r013 回归 21/21 + typecheck + build 通过),迭代 12 验收待老师人工确认** | 架构梳理讨论引出 + 老师指令(2026-09-02 | P1 | 已定稿 | 2026-09-02 | 12-持仓内存快照 | 已实现(待验收) |
## 渐进明细规划素材
+243
View File
@@ -0,0 +1,243 @@
/**
* PositionSync / PositionManager 快照化 回归测试(2026-09-02 优化)
*
* 运行:node scripts/test-position-sync.mjs
* 隔离:纯内存 mock(无 dataSource 真实请求、无 SQLite 真实目录),符合技术约束-011。
*/
let passed = 0;
let failed = 0;
function ok(cond, name) {
if (cond) { passed++; console.log(' \u2713 ' + name); }
else { failed++; console.error(' \u2717 ' + name); }
}
/** 构造语义化持仓行 */
function pos(code, volume = 100, extra = {}) {
return { code, name: 'stock-' + code, exchange: 'SH', volume, available: volume, frozenVolume: 0, avgPrice: 10, price: 11, marketValue: volume * 11, profit: volume, profitPct: 1, ...extra };
}
/** 可编程 mock 数据源 */
function mockDataSource({ positions, accountId = 'ACC-1', healthy = true } = {}) {
const ds = {
name: 'mock',
async getPositions() {
const p = ds._positions;
if (p instanceof Error) throw p;
if (typeof p === 'function') return p();
return p;
},
async getAsset() {
if (!ds._accountId) throw new Error('asset unavailable');
return { accountId: ds._accountId };
},
async isAvailable() { return ds._healthy; },
_positions: positions,
_accountId: accountId,
_healthy: healthy,
};
return ds;
}
/** 内存 mock 本地存储(只实现 PositionSync 依赖的方法) */
function mockStorage(holdings = []) {
const calls = { closed: [] };
return {
calls,
async getCurrentHoldings() { return holdings; },
async closeHolding(strategyId, code) { calls.closed.push({ strategyId, code }); },
};
}
const { PositionSync } = await import('../src/position/PositionSync.js');
const { PositionManager } = await import('../src/position/PositionManager.js');
console.log('[1] startup warmup + basic sync');
{
const ds = mockDataSource({ positions: [pos('600519.SH', 100), pos('300057.SZ', 200)] });
const st = mockStorage([]);
const sync = new PositionSync({ runtime: { dataSource: ds, storage: st }, ghostRounds: 3 });
const r = await sync.syncNow();
ok(sync.getSnapshot().length === 2, 'snapshot has 2 rows');
ok(sync.getSyncedAt() > 0, 'syncedAt recorded');
ok(r && r.positions === 2 && Array.isArray(r.closed), 'returns {positions, closed}');
ok(st.calls.closed.length === 0, 'no local holdings -> no close');
}
console.log('[2] sync failure keeps old snapshot');
{
const ds = mockDataSource({});
let boom = false;
ds._positions = () => { if (boom) throw new Error('boom'); return [pos('600519.SH')]; };
const sync = new PositionSync({ runtime: { dataSource: ds, storage: mockStorage([]) } });
await sync.syncNow();
ok(sync.getSnapshot().length === 1, 'first success: snapshot 1 row');
boom = true;
const r2 = await sync.syncNow();
ok(r2 === null, 'failure returns null');
ok(sync.getSnapshot().length === 1, 'old snapshot kept after failure');
ok(sync.stats.failCount === 1, 'failCount 1');
}
console.log('[2b] kept branch still refreshes syncedAt (indicator not gray)');
{
const ds = mockDataSource({ positions: [], accountId: null }); // health ok 但身份未知 → kept
const s2b = new PositionSync({ runtime: { dataSource: ds, storage: mockStorage([]) } });
const r = await s2b.syncNow();
ok(r && r.kept && s2b.getSyncedAt() > 0, '2b kept(空快照不接受) 同步时间已刷新(灯不灰)');
}
console.log('[3] empty snapshot double-confirmation');
{
const ds1 = mockDataSource({ positions: [], healthy: false });
const s1 = new PositionSync({ runtime: { dataSource: ds1, storage: mockStorage([]) } });
await s1.syncNow();
ok(s1.getSnapshot().length === 0 && s1.stats.failCount === 1, '3a empty + health down -> rejected');
const ds2 = mockDataSource({ positions: [pos('600519.SH')] });
const s2 = new PositionSync({ runtime: { dataSource: ds2, storage: mockStorage([]) } });
await s2.syncNow();
ds2._positions = []; ds2._healthy = false;
await s2.syncNow();
ok(s2.getSnapshot().length === 1, '3b empty + health down -> old snapshot kept');
const ds3 = mockDataSource({ positions: [pos('600519.SH')] });
const s3 = new PositionSync({ runtime: { dataSource: ds3, storage: mockStorage([]) } });
await s3.syncNow();
ds3._positions = [];
const r3 = await s3.syncNow();
ok(r3 && r3.positions === 0 && s3.getSnapshot().length === 0, '3c empty + health/identity ok -> genuine empty accepted');
const ds4 = mockDataSource({ positions: [pos('600519.SH')], accountId: null });
const s4 = new PositionSync({ runtime: { dataSource: ds4, storage: mockStorage([]) } });
await s4.syncNow();
ds4._positions = []; ds4._accountId = null;
await s4.syncNow();
ok(s4.getSnapshot().length === 1, '3d empty + identity unknown -> old snapshot kept');
}
console.log('[4] ghost holdings auto-close (debounce + account guard)');
{
const holdings = [
{ holdingId: 1, strategyId: 'grid', code: '000001.SZ', shares: 100 },
{ holdingId: 2, strategyId: 'manual-t', code: '000001.SZ', shares: 50 },
{ holdingId: 3, strategyId: 'grid', code: '600519.SH', shares: 10 },
];
const ds = mockDataSource({ positions: [pos('600519.SH')] });
const st = mockStorage(holdings);
const sync = new PositionSync({ runtime: { dataSource: ds, storage: st }, ghostRounds: 3 });
await sync.syncNow();
ok(st.calls.closed.length === 0, '4a round1 miss: no close');
await sync.syncNow();
ok(st.calls.closed.length === 0, '4a round2 miss: no close');
const r3 = await sync.syncNow();
ok(st.calls.closed.length === 2 && st.calls.closed.every((c) => c.code === '000001.SZ'), '4a round3: 000001.SZ closed across 2 strategies');
ok(r3.closed.length === 1 && r3.closed[0].code === '000001.SZ' && r3.closed[0].strategies.length === 2, '4a close detail returned');
ok(!st.calls.closed.some((c) => c.code === '600519.SH'), '4a code still in snapshot untouched');
const st2 = mockStorage(holdings);
const ds2 = mockDataSource({ positions: [pos('600519.SH')] });
const sync2 = new PositionSync({ runtime: { dataSource: ds2, storage: st2 }, ghostRounds: 3 });
await sync2.syncNow(); await sync2.syncNow();
ds2._positions = [pos('600519.SH'), pos('000001.SZ')];
await sync2.syncNow();
ds2._positions = [pos('600519.SH')];
await sync2.syncNow();
ok(st2.calls.closed.length === 0, '4b reappear resets debounce counter');
const st3 = mockStorage(holdings);
const ds3 = mockDataSource({ positions: [pos('600519.SH')] });
const sync3 = new PositionSync({ runtime: { dataSource: ds3, storage: st3 }, ghostRounds: 3 });
await sync3.syncNow(); await sync3.syncNow();
ds3._accountId = 'ACC-2';
await sync3.syncNow(); await sync3.syncNow(); await sync3.syncNow();
ok(st3.calls.closed.length === 0, '4c accountId change resets debounce (never reaches 3 consecutive)');
const st4 = mockStorage(holdings);
const ds4 = mockDataSource({ positions: [pos('600519.SH')] });
const sync4 = new PositionSync({ runtime: { dataSource: ds4, storage: st4 }, ghostRounds: 3 });
await sync4.syncNow(); await sync4.syncNow(); // miss x2(差 1 轮到阈值)
ds4._accountId = null; // 身份丢失
await sync4.syncNow(); await sync4.syncNow(); // 期间跳过判定,不累计
ok(st4.calls.closed.length === 0, '4d identity unknown -> skip close judgement');
ds4._accountId = 'ACC-1'; // 身份恢复
await sync4.syncNow(); // 第 3 次 miss → 达阈值清仓
ok(st4.calls.closed.length === 2, '4d identity restored -> 3rd miss closes');
}
console.log('[5] PositionManager snapshot read + read-through fallback');
{
const ds = mockDataSource({ positions: [pos('600519.SH')] });
const sync = new PositionSync({ runtime: { dataSource: ds, storage: mockStorage([]) } });
await sync.syncNow();
let qmtCalls = 0;
const countingDs = { name: 'mock-count', getPositions: async () => { qmtCalls++; return ds.getPositions(); }, getAsset: ds.getAsset.bind(ds), isAvailable: ds.isAvailable.bind(ds) };
const mgr = new PositionManager({ dataSource: countingDs, storage: mockStorage([]), positionSync: sync });
const r1 = await mgr.getAllPositions();
const r2 = await mgr.getAllPositions();
ok(r1.length === 1 && r2.length === 1, '5a two reads return data');
ok(qmtCalls === 0, '5a zero QMT calls on read path (cache effective)');
const emptySync = new PositionSync({ runtime: { dataSource: countingDs, storage: mockStorage([]) } });
const mgr2 = new PositionManager({ dataSource: countingDs, storage: mockStorage([]), positionSync: emptySync });
const r3 = await mgr2.getAllPositions();
ok(r3.length === 1 && qmtCalls === 1, '5b empty snapshot -> read-through once');
ok(emptySync.getSnapshot().length === 1, '5b read-through backfilled memory snapshot');
const r4 = await mgr2.getAllPositions();
ok(qmtCalls === 1 && r4.length === 1, '5b second read no more read-through');
const mgr3 = new PositionManager({ dataSource: countingDs, storage: mockStorage([]) });
await mgr3.getAllPositions();
ok(qmtCalls === 2, '5c no positionSync injected -> legacy passthrough behavior');
const badDs = mockDataSource({ positions: new Error('QMT down') });
const mgr4 = new PositionManager({ dataSource: badDs, storage: mockStorage([]), positionSync: new PositionSync({ runtime: { dataSource: badDs, storage: mockStorage([]) } }) });
let threw = false;
try { await mgr4.getAllPositions(); } catch (e) { threw = true; }
ok(threw, '5d empty snapshot + QMT down -> throws (frontend retry UI)');
}
console.log('[6] getStrategyPositions assembly regression (snapshot-driven)');
{
const positions = [pos('600519.SH', 1000), pos('300057.SZ', 500)];
const ds = mockDataSource({ positions });
const sync = new PositionSync({ runtime: { dataSource: ds, storage: mockStorage([]) } });
await sync.syncNow();
const storage = {
async getDataset(strategyId) { return strategyId === 'grid' ? [{ code: '600519.SH', shares: 600 }] : []; },
async getCurrentHoldings(strategyId) {
const all = [
{ holdingId: 11, strategyId: 'grid', code: '600519.SH', shares: 600, values: { stop: 8 } },
];
return strategyId ? all.filter((h) => h.strategyId === strategyId) : all;
},
async getTradeOrdersByHolding(holdingId) {
return holdingId === 11
? [{ orderId: 'o1', tradedVolume: 300, tradedPrice: 9.9, insertTs: 2 }, { orderId: 'o2', tradedVolume: 0, tradedPrice: 0, insertTs: 1 }]
: [];
},
};
const mgr = new PositionManager({ dataSource: ds, storage, positionSync: sync, getStrategySchema: () => [{ key: 'stop' }] });
const rows = await mgr.getStrategyPositions('grid');
ok(rows.length === 1, '6 assembly driven by snapshot rows');
const row1 = rows[0];
ok(row1.shares === 600 && row1.holdingId === 11, '6 shares/holdingId attached');
ok(row1.lastTradePrice === 9.9, '6 lastTradePrice = latest filled (9.9)');
ok(row1.values && row1.values.stop === 8, '6 values passthrough with schema filter');
}
console.log('[7] interval smoke (30ms x 800ms)');
{
let n = 0;
const ds = mockDataSource({ positions: [] });
ds._positions = () => { n++; return [pos('600519.SH', n)]; };
const sync = new PositionSync({ runtime: { dataSource: ds, storage: mockStorage([]) }, intervalMs: 30 });
sync.start();
await new Promise((res) => setTimeout(res, 800));
sync.stop();
ok(sync.stats.syncCount >= 2, '7 interval sync ran (syncCount=' + sync.stats.syncCount + ')');
ok(sync.getSnapshot()[0].volume >= 2, '7 snapshot refreshed per round (volume=' + sync.getSnapshot()[0].volume + ')');
}
console.log('RESULT: passed=' + passed + ' failed=' + failed);
process.exit(failed > 0 ? 1 : 0);
+24 -4
View File
@@ -18,6 +18,13 @@
* openHolding(建仓)/ addShares(加仓)/ reduceShares(减仓)/ closeHolding(清仓转历史);
* - 清仓/清零/删策略后数据转历史保留(closed_at),供交易记录/复盘关联(R-007 铺路);
* - 对外方法签名与返回结构不变,上层 api 无感知。
*
* 2026-09-02 持仓快照内存化(老师拍板优化):
* - getAllPositions 从「请求时穿透 QMT」改为「读 PositionSync 内存快照为准」,
* PositionSync 每 10s 全量同步(失败保留上次快照);
* - 缓存为空(启动未预热成功)→ 读穿透兜底:当场拉一次 QMT 并回填快照;
* - 收益:QMT 抖动不再导致持仓页面空白(读旧快照);策略/全部持仓/未分配
* 三个接口去掉了每次进 tab 的 QMT HTTP 调用;前端零改动。
*/
import { DataStore } from '../storage/DataStore.js';
@@ -25,22 +32,35 @@ import { DataStore } from '../storage/DataStore.js';
export class PositionManager {
/**
* @param {object} opts
* @param {import('../data-source/data-source-types.js').DataSource} opts.dataSource 数据源(QMT REST
* @param {import('../data-source/data-source-types.js').DataSource} opts.dataSource 数据源(QMT REST;读穿透兜底用
* @param {DataStore} opts.storage 数据集存储(R-006 DataStore
* @param {Function} [opts.getStrategySchema] 读策略自定义字段定义的回调 (strategyId) => configSchema[]
* R-013:由 index.js 注入,内部经 settings.getStrategies 取;缺省返回 []
* @param {import('./PositionSync.js').PositionSync} [opts.positionSync] 持仓内存快照同步服务
* 2026-09-02 注入;缺省时退回旧的请求时穿透行为,保证独立可用/测试兼容)
*/
constructor({ dataSource, storage, getStrategySchema }) {
constructor({ dataSource, storage, getStrategySchema, positionSync }) {
this.dataSource = dataSource;
this.storage = storage;
this.positionSync = positionSync ?? null;
this.getStrategySchema = typeof getStrategySchema === 'function'
? getStrategySchema
: () => [];
}
/** 全量持仓(QMT 真实数据) */
/**
* 全量持仓(QMT 真实数据)
* 2026-09-02 起:以 PositionSync 内存快照为准(10s 定时同步,失败保留上次快照);
* 快照为空 → 读穿透当场拉一次 QMT 并回填(首启兜底);QMT 也挂 → 向上抛错(前端 LoadState 重试)。
* 未注入 positionSync 时保持旧行为(穿透),不破坏独立构造场景。
*/
async getAllPositions() {
return this.dataSource.getPositions();
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;
}
/** 单只持仓 */
+229
View File
@@ -0,0 +1,229 @@
/**
* PositionSync —— 全量持仓内存快照同步(2026-09-02 讨论定稿)
*
* 背景:策略持仓 / 全部持仓 / 未分配三个接口原先在请求时穿透 QMT
* PositionManager.getAllPositions 实时拉 /trade/positions),带来两个问题:
* ① QMT 一抖(超时/掉线)所有持仓页面当场空白;
* ② 每次进 tab 都打一次 QMT HTTP。
*
* 方案(老师拍板):服务端建内存快照,以快照为准:
* - 启动预热一次,之后每 10s 全量拉 QMT 持仓 → 校验 → 整体替换内存快照;
* - 不落库(内存管理,讨论明确:不建缓存表、不复用 strategy_holdings)——
* 持仓快照随时可用一次调用重拿全,落库只会引入「过期数据冒充实时的说谎风险」;
* - 同步失败保留上次快照(不清空、不报错给读方);
* - 空快照需双重确认(/health 可用 + getAsset 账户身份可识别)才接受为「真清仓」,
* 否则视为 QMT 异常(如未登录),保留旧快照;
* - 缓存为空时由 PositionManager 读穿透兜底(当场拉一次 QMT 并回填 backfill);
*
* 幽灵持仓自动清仓(2026-09-02 老师选定):
* - QMT 快照中消失的 code(本地 strategy_holdings 仍有当前持仓),连续 3 轮同步
* (约 30s)仍消失 → 该 code 全部策略的当前持仓 closeHolding 转历史(不物理删除);
* - 防抖护栏:
* a) 账户身份守卫——每轮同步顺带 getAsset 取 accountId;身份未知(接口失败/未登录)
* 当轮跳过清仓判定(不累计计数);accountId 变化(R-004 连接热切换/换账户)时
* 重置防抖计数,防止 A 账户持仓被 B 账户快照误清;
* b) 部分减持不触发(QMT 仍有该 code,只是 volume 变小)——账实差额由前端
* 「未分配为负」暴露,属产品已知行为,不在本模块处理。
*
* 设计对齐:TradeSync 同构(启动预热 + 定时 + 失败容忍);技术约束-011 测试隔离不受影响
* (本模块无持久化,测试无需隔离数据目录)。
*/
const SYNC_INTERVAL_MS = 10 * 1000; // 同步间隔(老师定:10 秒)
const GHOST_CLOSE_ROUNDS = 3; // 幽灵防抖:连续 N 轮消失才自动清仓(约 30s)
export class PositionSync {
/**
* @param {object} opts
* @param {object} opts.runtime { dataSource, storage } —— dataSource 拉实盘,storage 做幽灵清仓
* @param {object} [opts.logger]
* @param {number} [opts.intervalMs] 同步间隔(测试可调小)
* @param {number} [opts.ghostRounds] 幽灵清仓防抖轮数(测试可调小)
*/
constructor({ runtime, logger, intervalMs = SYNC_INTERVAL_MS, ghostRounds = GHOST_CLOSE_ROUNDS } = {}) {
this.runtime = runtime;
this.logger = logger;
this.intervalMs = intervalMs;
this.ghostRounds = Math.max(1, ghostRounds);
/** 内存快照:Position[]QmtBridgeRestDataSource.mapPosition 语义化输出;只读约定) */
this.snapshot = [];
/** 最近一次成功同步时间(毫秒;0 = 尚未同步成功过) */
this.syncedAt = 0;
this.timer = null;
this.mounted = false;
this.syncing = false;
/** 幽灵防抖计数:code → 连续从 QMT 快照消失的轮数 */
this._ghostMiss = new Map();
/** 账户身份守卫:最近一次 getAsset 的 accountId(连接热切换时重置防抖) */
this._lastAccountId = null;
this.stats = { syncCount: 0, failCount: 0, lastError: '', closedGhosts: 0, lastSyncedAt: 0 };
}
/** 启动:立即预热一次 + 定时同步 */
start() {
if (this.mounted) return;
this.mounted = true;
this.syncNow().catch((e) => {
this.logger?.warn?.('[one-divine-lot] PositionSync 启动预热失败: ' + (e?.message ?? e));
});
this.timer = setInterval(() => {
this.syncNow().catch(() => { /* syncNow 内部已容错 */ });
}, this.intervalMs);
this.logger?.info?.('[one-divine-lot] PositionSync 启动(10s 内存快照同步 + 幽灵持仓自动清仓防抖 ' + this.ghostRounds + ' 轮)');
}
/** 停止(插件释放时);内存快照保留(读方在停止后仍可消费最后快照) */
stop() {
this.mounted = false;
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
/** 当前内存快照(可能为空数组 = 尚未同步成功过;只读约定,勿改写) */
getSnapshot() {
return this.snapshot;
}
/** 最近同步时间(毫秒;0 = 从未成功) */
getSyncedAt() {
return this.syncedAt;
}
/**
* 读穿透回填(PositionManager 缓存未命中时调用):把当场拉到的持仓填入内存快照。
* 只在非空时回填(空数组无法区分真清仓与异常,交给定时同步的健康确认逻辑)。
* @param {Array} positions 语义化持仓列表
*/
backfill(positions) {
if (Array.isArray(positions) && positions.length > 0) {
this.snapshot = positions;
this.syncedAt = Date.now();
this.stats.lastSyncedAt = this.syncedAt;
}
}
/**
* 同步一次:拉 QMT 全量持仓 → 校验 → 整体替换内存快照 → 幽灵清仓判定。
* 任何失败只记统计,不动内存(读方继续消费上次快照)。
* @returns {Promise<{positions:number, closed:Array}|{kept:true, reason:string}|null>}
*/
async syncNow() {
// mounted 不拦手动同步(测试/热切换后手动触发均可用);syncing 只防重入
if (this.syncing) return null;
const { dataSource } = this.runtime;
if (!dataSource) return null;
this.syncing = true;
try {
const positions = await dataSource.getPositions();
if (!Array.isArray(positions)) throw new Error('持仓响应格式无效(非数组)');
if (positions.length === 0) {
// 空快照:区分「真清仓」与「QMT 异常」。双重确认都通过才接受清空,否则保留旧快照。
const alive = await dataSource.isAvailable().catch(() => false);
const accountId = await this._fetchAccountId().catch(() => null);
if (!alive || !accountId) {
this.stats.failCount++;
this.stats.lastError = !alive ? '空快照且 QMT health 不可用' : '空快照且账户身份不可识别(疑似未登录)';
this.logger?.debug?.('[one-divine-lot] PositionSync 空快照不接受(' + this.stats.lastError + '),保留旧快照 ' + this.snapshot.length + ' 行');
// 同步动作本身成功(QMT 应答了,只是不接受数据)→ 刷新 syncedAt,指示灯不因此灰
this.syncedAt = Date.now();
this.stats.lastSyncedAt = this.syncedAt;
return { kept: true, reason: this.stats.lastError };
}
// 真清仓:接受空快照(幽灵清仓会把本地当前持仓全部转历史)
}
// ① 整体替换内存快照(校验通过才动内存)
this.snapshot = positions;
this.syncedAt = Date.now();
this.stats.syncCount++;
this.stats.lastSyncedAt = this.syncedAt;
// ② 幽灵持仓自动清仓(带防抖;失败不影响快照)
const closed = await this._autoCloseGhosts(positions);
return { positions: positions.length, closed };
} catch (e) {
this.stats.failCount++;
this.stats.lastError = e?.message ?? String(e);
this.logger?.debug?.('[one-divine-lot] PositionSync 同步失败(保留旧快照 ' + this.snapshot.length + ' 行): ' + this.stats.lastError);
return null;
} finally {
this.syncing = false;
}
}
/** 取账户身份(getAsset 失败/无 accountId 返回 null */
async _fetchAccountId() {
try {
const a = await this.runtime.dataSource.getAsset();
return a?.accountId || null;
} catch {
return null;
}
}
/**
* 幽灵清仓:快照中消失的 code,连续 N 轮仍消失 → 该 code 全部策略的当前持仓转历史。
* 账户身份守卫见类注释;closeHolding 置 shares=0 + closed_at(不物理删除,历史保留)。
* @param {Array} snapshot 本轮成功的 QMT 快照
* @returns {Promise<Array<{code:string, strategies:string[]}>>} 本轮实际清仓的 code
*/
async _autoCloseGhosts(snapshot) {
const { storage } = this.runtime;
const closed = [];
// 守卫:账户身份未知 → 本轮跳过清仓判定(不累计、不清零,保守)
const accountId = await this._fetchAccountId();
if (!accountId) return closed;
if (this._lastAccountId != null && accountId !== this._lastAccountId) {
// 换账户(连接热切换):旧计数作废,本轮直接跳过判定(连旧账户的快照都不可信)
this._ghostMiss.clear();
this._lastAccountId = accountId;
this.logger?.info?.('[one-divine-lot] PositionSync 检测到账户切换(' + this._lastAccountId + ' → ' + accountId + '),本轮跳过幽灵判定');
return closed;
}
this._lastAccountId = accountId;
let holdings;
try {
holdings = await storage.getCurrentHoldings(); // 全策略当前持仓(closed_at IS NULL
} catch (e) {
this.logger?.debug?.('[one-divine-lot] PositionSync 读本地持仓失败,跳过幽灵判定: ' + (e?.message ?? e));
return closed;
}
const snapshotCodes = new Set(snapshot.map((p) => p.code).filter(Boolean));
const localCodes = [...new Set(holdings.map((h) => h.code).filter(Boolean))];
for (const code of localCodes) {
if (snapshotCodes.has(code)) {
this._ghostMiss.delete(code); // QMT 仍有 → 计数复位(部分减持不在此处理)
continue;
}
const miss = (this._ghostMiss.get(code) ?? 0) + 1;
if (miss < this.ghostRounds) {
this._ghostMiss.set(code, miss);
continue;
}
// 连续 N 轮消失 → 清仓该 code 全部策略的当前持仓(转历史,不物理删除)
try {
const rows = holdings.filter((h) => h.code === code);
for (const h of rows) {
await storage.closeHolding(h.strategyId, code);
}
this._ghostMiss.delete(code);
this.stats.closedGhosts += rows.length;
closed.push({ code, strategies: rows.map((r) => r.strategyId) });
this.logger?.warn?.(
'[one-divine-lot] PositionSync 幽灵清仓: ' + code + ' × ' + rows.length + ' 个策略(QMT 连续 ' + this.ghostRounds + ' 轮无此持仓,自动转历史)'
);
} catch (e) {
// 单码清仓失败:保留计数,下轮重试
this.logger?.debug?.('[one-divine-lot] PositionSync 幽灵清仓失败(下轮重试): ' + code + ' ' + (e?.message ?? e));
}
}
return closed;
}
}