迭代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:
@@ -0,0 +1,50 @@
|
||||
# 计划:持仓内存快照(服务端 10s 定时同步,请求不再穿透 QMT)(阶段航点)
|
||||
|
||||
> 编号:PLAN-013 | 粒度:阶段航点 | 创建:2026-09-02 | 状态:**已实施(待验收)**
|
||||
> 派生自终极目标:目标-008(真实交易系统接入)、目标-001(DSH 插件形态)
|
||||
> 依据需求:**R-014(已定稿,2026-09-02,老师逐项拍板 4 问)** —— 符合入范围门槛
|
||||
> 设计约束:技术约束-010(服务端中转 + 缓存模式沿用)、技术约束-012(存储设计)、技术约束-016(分域目录);本次新增 技术约束-017
|
||||
> 补充说明:本计划为「先实施后补档」——代码随架构梳理讨论当场落地(老师指令),文档链(本计划 + 迭代三件套 + 约束)事后补全,事实以迭代复盘为准
|
||||
|
||||
## 目标
|
||||
|
||||
服务端建立全量持仓**内存快照**(PositionSync,10s 定时与 QMT 同步),策略持仓 / 全部持仓 / 未分配三个接口改读快照为准,**消除「QMT 抖动 → 持仓页面白屏」**并去掉每次进 tab 的穿透 HTTP 调用;前端零改动。
|
||||
|
||||
## 范围
|
||||
|
||||
**做**:
|
||||
1. PositionSync(src/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
@@ -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. 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 兼容性证明);
|
||||
- 老师人工验收(见验收标准)。
|
||||
@@ -0,0 +1,44 @@
|
||||
# 迭代复盘:12-持仓内存快照(服务端 10s 定时同步,请求不再穿透 QMT)
|
||||
|
||||
> 复盘日期:2026-09-02 | 迭代状态:**已实施,待老师人工验收**
|
||||
> 关联需求:R-014(持仓内存快照,已定稿)
|
||||
> 关联计划:PLAN-013(计划-持仓内存快照)
|
||||
|
||||
## 结果
|
||||
|
||||
迭代 12 达成:服务端建全量持仓**内存快照**(PositionSync,10s 定时全量同步,**不落库**),策略持仓 / 全部持仓 / 未分配三个接口改读快照为准;同步失败保留上次快照(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/34;test-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 更新实现状态(已实现)并归档。
|
||||
@@ -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`。
|
||||
@@ -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-持仓内存快照 | 已实现(待验收) |
|
||||
|
||||
## 渐进明细规划素材
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/** 单只持仓 */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user