Files
kyugao d793d2cf35 迭代13: 盘口内存快照(QuoteSync/QuoteHub)+ 同步指示灯 + 行情列
- 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现状/指示灯)
2026-09-03 13:26:25 +08:00

188 lines
8.4 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* QuoteSync / QuoteHub 回归测试(R-015 / 迭代 13
*
* 运行:node scripts/test-quote-sync.mjs
* 隔离:纯内存 mocktick/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);