Files
one_divine_lot/src/position/PositionSync.js
T
kyugao 245fcad95c 迭代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(老师拍板: 内存不落库/读穿透兜底/幽灵自动清仓/不显示同步时间)
2026-09-03 13:25:50 +08:00

230 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
}
}