d793d2cf35
- QuoteSync/QuoteHub 替换 MarketFeed/MarketDataHub(方案A 删除重写): 5s REST 同步 watch 集合 + 涨停/跌停经 /data/instrument 按交易日内存缓存 + 读穿透走适配层 getTicks(修正旧 hub 绕适配层的层级破洞) - WS 数据通路移除(从无生效结论;ingest 留 source 标签回归口子) - market_quotes_cache 表退役 DROP(幂等);价格单一入口 = QuoteHub - 数据同步指示灯组: QMT连接|持仓数据|行情数据(绿/黄/灰,点击即同步/即时探测) + sync-status 端点(吸收 market-stats)+ sync-now;修复 runtime 漏传 positionSync 致持灯恒灰 - 策略持仓表行情列: 涨停价/跌停价/今开/最高(COLUMN_META defaultVisible=false,纯价格) - 回归 test-quote-sync.mjs 27 项(含 DROP 幂等真实 SQLite 验证) - 文档链: R-015 + PLAN-014 + 迭代三件套 + 技术约束-010/012变更、018新增 + 产品约束-012 需求: R-015(老师五拍板: 替换/WS移除/纯内存DROP/watch现状/指示灯)
188 lines
8.4 KiB
JavaScript
188 lines
8.4 KiB
JavaScript
/**
|
||
* QuoteSync / QuoteHub 回归测试(R-015 / 迭代 13)
|
||
*
|
||
* 运行:node scripts/test-quote-sync.mjs
|
||
* 隔离:纯内存 mock(tick/instrument/positions 可编程);DROP 幂等用临时目录真实 SQLite 验证。
|
||
*/
|
||
|
||
import { mkdtempSync } from 'node:fs';
|
||
import { tmpdir } from 'node:os';
|
||
import { join } from 'node:path';
|
||
import { DatabaseSync } from 'node:sqlite';
|
||
|
||
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) { return { code, name: 's-' + code }; }
|
||
function tick(code, price) { return { [code]: { lastPrice: price, lastClose: price - 0.1, open: price - 0.05, high: price + 0.02, low: price - 0.08, volume: 100, amount: 1000, time: 1, timetag: 'x', updatedAt: 1 } }; }
|
||
|
||
function mockDataSource({ tradingDay = '20260902' } = {}) {
|
||
const ds = {
|
||
name: 'mock',
|
||
_positions: [pos('600519.SH'), pos('300057.SZ')],
|
||
_ticks: { '600519.SH': 10, '300057.SZ': 5 },
|
||
_instruments: { '600519.SH': { upStopPrice: 11, downStopPrice: 9, preClose: 9 } },
|
||
_tradingDay: tradingDay,
|
||
_failTicks: false,
|
||
_tickCalls: 0,
|
||
_instrumentCalls: 0,
|
||
async getPositions() { return ds._positions; },
|
||
async getTicks(codes) {
|
||
ds._tickCalls++;
|
||
if (ds._failTicks) throw new Error('tick boom');
|
||
const o = {};
|
||
for (const c of codes) {
|
||
if (ds._ticks[c] != null) o[c] = { lastPrice: ds._ticks[c], lastClose: ds._ticks[c] - 0.1, open: 1, high: 2, low: 0.5, volume: 1, amount: 1, time: 1, timetag: 'x', updatedAt: Date.now() };
|
||
}
|
||
return o;
|
||
},
|
||
async getAsset() { return { accountId: 'A', tradingDate: ds._tradingDay }; },
|
||
async getInstrument(code) {
|
||
ds._instrumentCalls++;
|
||
const i = ds._instruments[code];
|
||
if (!i) throw new Error('no instrument ' + code);
|
||
return { code, ...i, tradingDay: ds._tradingDay };
|
||
},
|
||
async isAvailable() { return true; },
|
||
};
|
||
return ds;
|
||
}
|
||
|
||
const { QuoteHub } = await import('../src/market/QuoteHub.js');
|
||
const { QuoteSync } = await import('../src/market/QuoteSync.js');
|
||
|
||
console.log('[1] prime + 定时同步基本流');
|
||
{
|
||
const ds = mockDataSource();
|
||
const hub = new QuoteHub({});
|
||
const sync = new QuoteSync({ hub, runtime: { settings: {}, dataSource: ds }, intervalMs: 30 });
|
||
sync.start();
|
||
await new Promise((r) => setTimeout(r, 120));
|
||
const base = sync.stats.syncCount; // prime 完成时定时轮可能已跑若干次,以此为基线
|
||
sync.stop();
|
||
ok(hub.quotes.size === 2, 'prime 后快照 2 只');
|
||
ok(hub.watchCodes.size === 2, 'watch 集合由 prime 登记');
|
||
ok(sync.stats.syncCount >= 1, '定时轮已运行(' + sync.stats.syncCount + ' 轮)');
|
||
}
|
||
|
||
console.log('[2] 同步失败保留上次价');
|
||
{
|
||
const ds = mockDataSource();
|
||
const hub = new QuoteHub({});
|
||
const sync = new QuoteSync({ hub, runtime: { settings: {}, dataSource: ds }, intervalMs: 20 });
|
||
sync.start();
|
||
await new Promise((r) => setTimeout(r, 80));
|
||
sync.stop();
|
||
const before = hub.quotes.get('600519.SH').lastPrice;
|
||
ds._failTicks = true;
|
||
const r = await sync.syncNow();
|
||
ok(r === null, '失败返回 null');
|
||
ok(hub.quotes.get('600519.SH').lastPrice === before, '内存价格未被动(保留上次价)');
|
||
ok(sync.stats.failCount >= 1 && sync.stats.lastError.includes('tick boom'), '失败统计与错误记录(累计语义)');
|
||
ds._failTicks = false;
|
||
await sync.syncNow();
|
||
ok(sync.getStatus().failCount === 0 || hub.quotes.get('600519.SH').lastPrice > 0, '恢复后快照可用');
|
||
}
|
||
|
||
console.log('[3] watch 空不空转 + 读穿透回填');
|
||
{
|
||
const ds = mockDataSource();
|
||
const hub = new QuoteHub({});
|
||
const sync = new QuoteSync({ hub, runtime: { settings: {}, dataSource: ds }, intervalMs: 20 });
|
||
sync.start();
|
||
await new Promise((r) => setTimeout(r, 60));
|
||
sync.stop();
|
||
hub.quotes.clear(); // 模拟重启后内存空(watch 仍在)
|
||
const callsBefore = ds._tickCalls;
|
||
const out = await hub.getQuotes(['600519.SH'], { dataSource: ds });
|
||
ok(out['600519.SH']?.lastPrice === 10, '读穿透命中(内存 miss → getTicks 回填)');
|
||
ok(hub.quotes.has('600519.SH'), '读穿透结果回填内存');
|
||
const c1 = ds._tickCalls - callsBefore;
|
||
await hub.getQuotes(['600519.SH'], { dataSource: ds });
|
||
ok(ds._tickCalls - callsBefore === c1, '第二次读走内存不再穿透');
|
||
// watch 空:syncNow 不打 tick
|
||
const hub2 = new QuoteHub({});
|
||
const sync2 = new QuoteSync({ hub: hub2, runtime: { settings: {}, dataSource: ds }, intervalMs: 20 });
|
||
const calls0 = ds._tickCalls;
|
||
await sync2.syncNow();
|
||
ok(ds._tickCalls === calls0, 'watch 空 → 不空转');
|
||
}
|
||
|
||
console.log('[4] 涨停/跌停按交易日缓存 + 日切失效');
|
||
{
|
||
const ds = mockDataSource();
|
||
const hub = new QuoteHub({});
|
||
const sync = new QuoteSync({ hub, runtime: { settings: {}, dataSource: ds }, intervalMs: 20 });
|
||
sync.start();
|
||
await new Promise((r) => setTimeout(r, 80));
|
||
sync.stop();
|
||
const calls0 = ds._instrumentCalls;
|
||
const i1 = await hub.getInstrumentCached('600519.SH', '20260902', ds.getInstrument);
|
||
ok(i1.upStopPrice === 11 && i1.downStopPrice === 9, '合约信息返回涨停/跌停');
|
||
const c0 = ds._instrumentCalls;
|
||
await hub.getInstrumentCached('600519.SH', '20260902', ds.getInstrument);
|
||
ok(ds._instrumentCalls === c0, '同交易日命中缓存不重拉');
|
||
const i2 = await hub.getInstrumentCached('600519.SH', '20260903', ds.getInstrument);
|
||
ok(i2 && ds._instrumentCalls > c0, '日切 → 失效重拉');
|
||
ok(!hub._instrumentFresh('300057.SZ', '20260902'), '无缓存的 code 判定为失效');
|
||
}
|
||
|
||
console.log('[5] 热切换清合约缓存');
|
||
{
|
||
const ds = mockDataSource();
|
||
const hub = new QuoteHub({});
|
||
const sync = new QuoteSync({ hub, runtime: { settings: {}, dataSource: ds }, intervalMs: 100000 });
|
||
sync.start();
|
||
await new Promise((r) => setTimeout(r, 60));
|
||
await hub.getInstrumentCached('600519.SH', '20260902', ds.getInstrument);
|
||
ok(hub.instruments.size === 1, '合约缓存已建立');
|
||
sync.setBaseUrl('http://new-host:8610');
|
||
ok(hub.instruments.size === 0, '热切换 → 合约缓存清空');
|
||
sync.stop();
|
||
}
|
||
|
||
console.log('[6] ingest 来源无关 + watch 过滤保留');
|
||
{
|
||
const hub = new QuoteHub({});
|
||
hub.watch(['A.SH']);
|
||
hub.ingest({ 'A.SH': { lastPrice: 1 }, 'B.SH': { lastPrice: 2 } }, { source: 'rest' });
|
||
ok(hub.quotes.has('A.SH') && !hub.quotes.has('B.SH'), 'watch 过滤生效(未关注的 code 不入快照)');
|
||
hub.ingest({ 'A.SH': { lastPrice: 3 } }, { source: 'future-ws' });
|
||
ok(hub.quotes.get('A.SH').lastPrice === 3, 'source 无关,增量合并');
|
||
}
|
||
|
||
console.log('[7] DROP 幂等(旧结构库 → init 后行情表消失、持仓无损)');
|
||
{
|
||
const dir = mkdtempSync(join(tmpdir(), 'odl-quote-drop-'));
|
||
const db = new DatabaseSync(join(dir, 'store.db'));
|
||
db.exec("CREATE TABLE market_quotes_cache (code TEXT PRIMARY KEY, last_price REAL, last_close REAL, updated_at INTEGER); CREATE TABLE strategy_holdings (holding_id INTEGER PRIMARY KEY AUTOINCREMENT, strategy_id TEXT, code TEXT, shares REAL, created_at INTEGER, closed_at INTEGER); INSERT INTO strategy_holdings (strategy_id, code, shares, created_at, closed_at) VALUES ('g', '600519.SH', 100, 1, NULL);");
|
||
db.close();
|
||
const { SqliteStore } = await import('../src/storage/SqliteStore.js');
|
||
const store = new SqliteStore({ dataDir: dir });
|
||
store.init();
|
||
const tables = store.db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map((r) => r.name);
|
||
ok(!tables.includes('market_quotes_cache'), '旧行情表被 DROP');
|
||
ok(store.getCurrentHoldings().length === 1, '持仓数据无损');
|
||
store.init();
|
||
ok(true, '二次 init 幂等');
|
||
store.close();
|
||
}
|
||
|
||
console.log('[8] sync-status 数据形态(state 推导边界)');
|
||
{
|
||
const now = Date.now();
|
||
const judge = (status) => (!status || !status.syncedAt ? 'never' : now - status.syncedAt <= status.periodMs * 3 ? 'fresh' : 'stale');
|
||
ok(judge({ syncedAt: now - 5000, periodMs: 10000 }) === 'fresh', '10s 周期 5s 前 → fresh');
|
||
ok(judge({ syncedAt: now - 31000, periodMs: 10000 }) === 'stale', '10s 周期 31s 前 → stale');
|
||
ok(judge(null) === 'never', '从未同步 → never');
|
||
ok(judge({ syncedAt: now - 4000, periodMs: 5000 }) === 'fresh', '5s 周期 4s 前 → fresh');
|
||
ok(judge({ syncedAt: now - 16000, periodMs: 5000 }) === 'stale', '5s 周期 16s 前 → stale');
|
||
}
|
||
|
||
console.log('RESULT: passed=' + passed + ' failed=' + failed);
|
||
process.exit(failed > 0 ? 1 : 0);
|