212 lines
9.7 KiB
JavaScript
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);
|