Files
one_divine_lot/scripts/test-position-sync.mjs
T

212 lines
9.7 KiB
JavaScript

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