diff --git a/docs/02-计划/计划-持仓内存快照.md b/docs/02-计划/计划-持仓内存快照.md new file mode 100644 index 0000000..9ad0741 --- /dev/null +++ b/docs/02-计划/计划-持仓内存快照.md @@ -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`。 diff --git a/docs/03-设计约束/数据存储设计.md b/docs/03-设计约束/数据存储设计.md index f6bdb89..4d40789 100644 --- a/docs/03-设计约束/数据存储设计.md +++ b/docs/03-设计约束/数据存储设计.md @@ -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:测试/回归脚本禁止在真实数据上执行写操作(独立数据目录); diff --git a/docs/04-迭代记录/12-持仓内存快照/技术实现方案.md b/docs/04-迭代记录/12-持仓内存快照/技术实现方案.md new file mode 100644 index 0000000..dc67676 --- /dev/null +++ b/docs/04-迭代记录/12-持仓内存快照/技术实现方案.md @@ -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 兼容性证明); +- 老师人工验收(见验收标准)。 diff --git a/docs/04-迭代记录/12-持仓内存快照/迭代复盘.md b/docs/04-迭代记录/12-持仓内存快照/迭代复盘.md new file mode 100644 index 0000000..4a30c7c --- /dev/null +++ b/docs/04-迭代记录/12-持仓内存快照/迭代复盘.md @@ -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)等历史偏差。 diff --git a/docs/04-迭代记录/12-持仓内存快照/迭代目标.md b/docs/04-迭代记录/12-持仓内存快照/迭代目标.md new file mode 100644 index 0000000..5228bc1 --- /dev/null +++ b/docs/04-迭代记录/12-持仓内存快照/迭代目标.md @@ -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 标记验收通过。 diff --git a/docs/04-迭代记录/12-持仓内存快照/验收标准.md b/docs/04-迭代记录/12-持仓内存快照/验收标准.md new file mode 100644 index 0000000..8c9b13b --- /dev/null +++ b/docs/04-迭代记录/12-持仓内存快照/验收标准.md @@ -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 更新实现状态(已实现)并归档。 diff --git a/docs/05-需求池/R-014.md b/docs/05-需求池/R-014.md new file mode 100644 index 0000000..592c3b4 --- /dev/null +++ b/docs/05-需求池/R-014.md @@ -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`。 diff --git a/docs/05-需求池/需求池索引.md b/docs/05-需求池/需求池索引.md index aed55e7..fc1aa51 100644 --- a/docs/05-需求池/需求池索引.md +++ b/docs/05-需求池/需求池索引.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-持仓内存快照 | 已实现(待验收) | ## 渐进明细规划素材 diff --git a/scripts/test-position-sync.mjs b/scripts/test-position-sync.mjs new file mode 100644 index 0000000..6e5776c --- /dev/null +++ b/scripts/test-position-sync.mjs @@ -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); diff --git a/src/position/PositionManager.js b/src/position/PositionManager.js index e5b93db..f94061c 100644 --- a/src/position/PositionManager.js +++ b/src/position/PositionManager.js @@ -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; } /** 单只持仓 */ diff --git a/src/position/PositionSync.js b/src/position/PositionSync.js new file mode 100644 index 0000000..5a2f300 --- /dev/null +++ b/src/position/PositionSync.js @@ -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>} 本轮实际清仓的 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; + } +}