迭代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现状/指示灯)
This commit is contained in:
2026-09-03 13:26:25 +08:00
parent 245fcad95c
commit d793d2cf35
26 changed files with 1075 additions and 589 deletions
+68 -19
View File
@@ -1,12 +1,11 @@
/**
* 服务端 API:行情域(2026-08-31 拆分;2026-09-01 精简
* 服务端 API:行情域(2026-08-31 拆分;2026-09-02 R-015/迭代 13 改造
*
* 端点:
* market-snapshot → 按 code 查询行情缓存(miss 自动补拉在 MarketDataHub 内部,此处只做编排
* qmt-health → QMT 连接健康检查(服务端缓存refresh=true 触发即时探测
*
* 2026-09-01QMT health 改为「服务端定时探测(5 分钟)+ 缓存」机制(QmtHealthMonitor),
* 前端打开读缓存秒回,点击/悬停传 refresh=true 触发即时探测。
* market-snapshot → 按 code 查盘口内存快照(含涨停/跌停;miss 读穿透
* qmt-health → QMT 连接健康检查(服务端缓存 + refresh 即时探测,2026-09-01
* sync-status → 数据同步指示灯状态(持仓/盘口/QMT 三域,吸收原临时诊断 market-stats
* sync-now → 手动触发某域立即同步(指示灯点击)
*/
import { resolveActiveBaseUrl } from './common.js';
@@ -15,21 +14,45 @@ import { resolveActiveBaseUrl } from './common.js';
export const MARKET_METHODS = new Set([
'market-snapshot',
'qmt-health',
'market-stats', // 临时诊断:WS/REST 通道统计
'sync-status',
'sync-now',
]);
/** 盘口快照对外投影:UI 消费字段 + updatedAt(价龄);涨停/跌停来自 QuoteHub 合约缓存 */
function projectQuote(hub, code, snap) {
const inst = hub.instruments.get(code) ?? {};
return {
code,
lastPrice: snap?.lastPrice ?? null,
lastClose: snap?.lastClose ?? null,
open: snap?.open ?? null,
high: snap?.high ?? null,
low: snap?.low ?? null,
volume: snap?.volume ?? null,
amount: snap?.amount ?? null,
upStopPrice: inst.upStopPrice ?? null,
downStopPrice: inst.downStopPrice ?? null,
updatedAt: snap?.updatedAt ?? 0,
};
}
/**
* 处理行情端点
* @param {string} method
* @param {object} args
* @param {object} runtime { settings, dataSource, marketHub, marketFeed, qmtHealthMonitor }
* @param {object} runtime { settings, dataSource, marketHub, marketFeed(=QuoteSync), qmtHealthMonitor, positionSync }
*/
export async function handleMarket(method, args, { ctx, settings, dataSource, marketHub, marketFeed, qmtHealthMonitor }) {
export async function handleMarket(method, args, { settings, dataSource, marketHub, marketFeed: marketSync, qmtHealthMonitor, positionSync }) {
switch (method) {
case 'market-snapshot': {
const codes = Array.isArray(args.codes) ? args.codes : [];
if (!marketHub) return {};
return await marketHub.getByCodes(codes, { settings, dataSource });
const quotes = await marketHub.getQuotes(codes, { settings, dataSource });
const out = {};
for (const [code, snap] of Object.entries(quotes)) {
out[code] = projectQuote(marketHub, code, snap);
}
return out;
}
case 'qmt-health': {
// QMT 连接健康检查(服务端缓存 + 即时探测,2026-09-01
@@ -37,20 +60,46 @@ export async function handleMarket(method, args, { ctx, settings, dataSource, ma
return { healthy: null, baseUrl: resolveActiveBaseUrl(settings, dataSource), fromCache: false };
}
if (args.refresh) {
// 前端点击/悬停触发即时探测(刷新缓存)
return await qmtHealthMonitor.probe();
}
// 默认读缓存(秒回)
return qmtHealthMonitor.getStatus();
}
case 'market-stats': {
// 临时诊断:WS 推送 vs REST 拉取 计数(确认 WS 是否生效)
return {
stats: marketHub?.stats ?? null,
cacheSize: marketHub?.size ?? 0,
watchSize: marketHub?.watchCodes?.size ?? 0,
serverTime: Date.now(),
case 'sync-status': {
// 数据同步指示灯(R-015 产品约束-012):绿=新鲜 / 黄=失败中陈旧 / 灰=从未;阈值 = 3×周期
const state = (status) => {
if (!status || !status.syncedAt) return 'never';
return Date.now() - status.syncedAt <= status.periodMs * 3 ? 'fresh' : 'stale';
};
return {
serverTime: Date.now(),
position: positionSync
? {
syncedAt: positionSync.getSyncedAt(),
periodMs: 10000,
state: state({ syncedAt: positionSync.getSyncedAt(), periodMs: 10000 }),
snapshotSize: positionSync.getSnapshot().length,
failCount: positionSync.stats.failCount,
lastError: positionSync.stats.lastError,
}
: null,
quote: marketSync
? { ...marketSync.getStatus(), state: state(marketSync.getStatus()) }
: null,
qmt: qmtHealthMonitor ? qmtHealthMonitor.getStatus() : null,
};
}
case 'sync-now': {
// 指示灯点击:立即触发对应域同步(不等下个周期)
const domain = args.domain;
if (domain === 'position' && positionSync) {
const r = await positionSync.syncNow();
return { ok: r !== null, domain, result: r };
}
if (domain === 'quote' && marketSync) {
const r = await marketSync.syncNow();
return { ok: r !== null, domain, result: r };
}
throw Object.assign(new Error('unknown sync domain: ' + domain), { code: 'bad-request' });
}
default:
throw Object.assign(new Error('unknown market method: ' + method), { code: 'not-found' });