245fcad95c
- PositionSync: 内存快照 + 10s 定时全量同步 + 失败保留旧快照 + 空快照双重确认 (/health + getAsset 账户身份)+ 幽灵持仓自动清仓(连续3轮消失 + 账户身份守卫防误清) - PositionManager.getAllPositions 改读快照,空则读穿透兜底;未注入 sync 时兼容旧穿透 - 回归 test-position-sync.mjs 35 项(纯内存 mock,含 kept 分支刷新 syncedAt 防灯灰) - 文档链: R-014 + PLAN-013 + 迭代三件套 + 数据存储设计 §11(内存不落库决策与三问标准) 需求: R-014(老师拍板: 内存不落库/读穿透兜底/幽灵自动清仓/不显示同步时间)
244 lines
11 KiB
JavaScript
244 lines
11 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 }, ghostRounds: 3 });
|
|
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 && Array.isArray(r.closed), 'returns {positions, 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] ghost holdings auto-close (debounce + account guard)');
|
|
{
|
|
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 }, ghostRounds: 3 });
|
|
await sync.syncNow();
|
|
ok(st.calls.closed.length === 0, '4a round1 miss: no close');
|
|
await sync.syncNow();
|
|
ok(st.calls.closed.length === 0, '4a round2 miss: no close');
|
|
const r3 = await sync.syncNow();
|
|
ok(st.calls.closed.length === 2 && st.calls.closed.every((c) => c.code === '000001.SZ'), '4a round3: 000001.SZ closed across 2 strategies');
|
|
ok(r3.closed.length === 1 && r3.closed[0].code === '000001.SZ' && r3.closed[0].strategies.length === 2, '4a close detail returned');
|
|
ok(!st.calls.closed.some((c) => c.code === '600519.SH'), '4a code still in snapshot untouched');
|
|
|
|
const st2 = mockStorage(holdings);
|
|
const ds2 = mockDataSource({ positions: [pos('600519.SH')] });
|
|
const sync2 = new PositionSync({ runtime: { dataSource: ds2, storage: st2 }, ghostRounds: 3 });
|
|
await sync2.syncNow(); await sync2.syncNow();
|
|
ds2._positions = [pos('600519.SH'), pos('000001.SZ')];
|
|
await sync2.syncNow();
|
|
ds2._positions = [pos('600519.SH')];
|
|
await sync2.syncNow();
|
|
ok(st2.calls.closed.length === 0, '4b reappear resets debounce counter');
|
|
|
|
const st3 = mockStorage(holdings);
|
|
const ds3 = mockDataSource({ positions: [pos('600519.SH')] });
|
|
const sync3 = new PositionSync({ runtime: { dataSource: ds3, storage: st3 }, ghostRounds: 3 });
|
|
await sync3.syncNow(); await sync3.syncNow();
|
|
ds3._accountId = 'ACC-2';
|
|
await sync3.syncNow(); await sync3.syncNow(); await sync3.syncNow();
|
|
ok(st3.calls.closed.length === 0, '4c accountId change resets debounce (never reaches 3 consecutive)');
|
|
|
|
const st4 = mockStorage(holdings);
|
|
const ds4 = mockDataSource({ positions: [pos('600519.SH')] });
|
|
const sync4 = new PositionSync({ runtime: { dataSource: ds4, storage: st4 }, ghostRounds: 3 });
|
|
await sync4.syncNow(); await sync4.syncNow(); // miss x2(差 1 轮到阈值)
|
|
ds4._accountId = null; // 身份丢失
|
|
await sync4.syncNow(); await sync4.syncNow(); // 期间跳过判定,不累计
|
|
ok(st4.calls.closed.length === 0, '4d identity unknown -> skip close judgement');
|
|
ds4._accountId = 'ACC-1'; // 身份恢复
|
|
await sync4.syncNow(); // 第 3 次 miss → 达阈值清仓
|
|
ok(st4.calls.closed.length === 2, '4d identity restored -> 3rd miss closes');
|
|
}
|
|
|
|
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);
|