/** * 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; } }