/** * 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 } }); 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 && r.closed === undefined, 'returns {positions} (ghost-close retired, no 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] R-018 data-domain boundary: ghost-close retired (sync never writes ledger)'); { // 账本有活动行(本地仍记 000001.SZ),QMT 快照无此 code —— 同步机制不再自动 closeHolding(分界拍板) 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 } }); for (let i = 0; i < 5; i++) await sync.syncNow(); // 远超旧 3 轮阈值 ok(st.calls.closed.length === 0, '4a multiple rounds missing: ledger NOT closed (ghost retired)'); ok(sync.getSnapshot().every((p) => p.code === '600519.SH'), '4a snapshot only reflects QMT (600519.SH)'); ok(sync.stats.closedGhosts === undefined, '4a stats.closedGhosts removed'); } 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);