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

6.7 KiB
Raw Permalink Blame History

技术实现方案:13-盘口内存快照

迭代编号:13 依据:PLAN-014 + R-015(老师拍板:方案 A 替换 / WS 移除 / 纯内存 DROP / watch 维持现状 / 指示灯推荐逻辑)

1. 数据源扩展(QmtBridgeRestDataSource.getTicks

/** 批量盘口(GET /data/tick?codes=a,b,c)→ { [code]: snapshot }(保留原始字段 + updatedAt */
async getTicks(codes) {
  // j.ok && j.data → data 原样返回(tick 结构已有语义字段 lastPrice/lastClose/open/high/low/... + time/timetag
  // 每 snapshot 统一附加 updatedAt = Date.now()(服务端收到时刻,价龄判断基准)
}
  • 首次启用 /data/tick 的适配层通道(此前 MarketDataHub 自己 fetch,绕过了适配层——本轮修正层级);
  • 涨停跌停走既有 getInstrument(code)(首次投入使用):upStopPrice/downStopPrice/preClose。

2. QuoteHubsrc/market/QuoteHub.js,新)

class QuoteHub {
  quotes = new Map();        // code → snapshot(全量 tick 字段 + updatedAt;只读约定)
  instruments = new Map();   // code → { upStopPrice, downStopPrice, preClose, tradingDay }(交易日内存缓存)
  watchCodes = new Set();    // 关注集合(维持现状只进不出,无上限——老师拍板)
  stats = { syncCount, failCount, lastError, lastSyncedAt, restCount, watchSize };
}
  • getQuotes(codes):内存命中 + miss 读穿透(dataSource.getTicks 补拉并回填,等价旧三级命中的内存→REST 两级,磁盘层删除);
  • getQuote(code):单码便捷;watch(codes):关注集合登记(QuoteSync prime/刷新、api 查询共用);
  • ingest(data, { source }):写入统一入口(source 标签:rest|instrument——WS 将来回归时加 source 即可,入口不变);
  • 合约信息:getInstrumentCached(code, tradingDay)——QuoteSync 判定交易日变化后失效重拉(涨停跌停当天不变)。

3. QuoteSyncsrc/market/QuoteSync.js,新;与 PositionSync 同构)

start(): prime(持仓 code 盘口,走 hub.watch + getTicks 批量)+ setInterval(5s)
  每轮:codes = [...hub.watchCodes](维持现状)
        分批 50 → dataSource.getTicks(batch) → hub.ingest(data, {source:'rest'})
        watch 中无合约缓存或 tradingDay 变化的 code → getInstrument 懒拉(失败不阻塞本轮)
  失败:stats.failCount++ / lastError**内存不动(保留上次价)**
setBaseUrl(url):热切换 → 清空 instruments 缓存(新账户/新市场环境)+ 立即 prime 一次
stop(): 清定时器;内存保留
getSyncedAt() / getStatus(): 指示灯数据源
  • 对称性:PositionSync10s/持仓)/ QuoteSync5s/盘口)/ TradeSync(60s/交易)三域同构,全部「失败保留、stats 可观测、手动 syncNow 可触发」。

4. 存储清理(market_quotes_cache 退役,老师拍板 DROP

  • SqliteStoreSCHEMA_SQL 删建表;新增 DROP TABLE IF EXISTS market_quotes_cache(init 内幂等执行,存量库清理);删 getMarketQuote/getMarketQuotes/setMarketQuotes/_mapQuotemigrateJson 删行情迁移段;isEmpty 只看 strategy_holdings
  • DataStore:删 loadMarketreturn this 残迹,warmup bug 根源)/getMarketQuote/getMarketQuotes/setMarketQuotes/marketLoaded
  • 存量 store.market.json 的 .bak 不动(历史备份无碍)。

5. APIsrc/api/market.js + 新 sync-status

market-snapshot { codes } → { [code]: { lastPrice, lastClose, upStopPrice, downStopPrice, updatedAt, ...tick 字段 } }
sync-status → {
  position: { syncedAt, ageMs, fresh: bool, snapshotSize, failCount, lastError, periodMs },
  quote:    { syncedAt, ageMs, fresh: bool, snapshotSize, watchSize, failCount, lastError, periodMs },
  qmt:      { ...qmtHealthMonitor.getStatus() }
}
sync-now { domain: 'position' | 'quote' } → 各 Sync.syncNow()(手动触发,指示灯点击用)
market-stats 端点删除(并入 sync-status
  • 状态判定:fresh = syncedAt 在 3×周期内(持仓 30s / 盘口 15s);前端按 ageMs 二次校准显示。

6. 前端(SyncIndicators.jsx 新 + 两处清理)

  • SyncIndicators(挂 QmtConnectionChip 内 QmtHealthIndicator 右侧):两圆点「持」「行」;
    • 10s 轮询 sync-status;点击圆点 → sync-now({domain}) → 刷新;
    • 颜色:green(fresh) / yellow(stale) / gray(never)title 含同步时间、快照量、失败数、lastError;
    • 样式复用 QmtHealthIndicator 圆点(10px 圆 + glow),色值走 --dsw-* token
  • MarketDataProvider:删 wsInfo: null 残留;轮询/注册逻辑不变(getByCodes 签名兼容);
  • QmtConnectionChip:挂载 SyncIndicators(健康灯右侧)。

7. 装配(src/index.js

const marketHub = new QuoteHub({ logger });
const marketFeed = new QuoteSync({ hub: marketHub, runtime: { settings, dataSource }, logger });
marketFeed.start(startup.baseUrl ?? config?.qmtBaseUrl);
// disposemarketFeed.stop()(保留旧变量名,改动最小;注释标注新职责)
  • registerApi 入参 marketHub/marketFeed 对象形态不变(api/market.js 内部改用新方法);
  • QmtHealthMonitor 不动。

8. 回归脚本(scripts/test-quote-sync.mjs,纯内存 mock

  • mock:可编程 dataSourceticks/instrument/positions 可变状态);
  • 用例:prime + 5s 刷新基本流 / 失败保留上次价 / watch 空不空转 / 读穿透回填 / 涨停跌停按交易日缓存与日切失效 / 热切换清合约缓存 / sync-status 状态推导(绿黄灰边界)/ 存储清理(DROP 幂等:SqliteStore 临时目录建旧结构库 → init 后表消失且持仓数据无损)。

9. 策略持仓表行情列(老师确认并入本轮)

  • settings.jsCOLUMN_META 追加 4 项(均 defaultVisible: false)——{ key:'upStopPrice', label:'涨停价' } / { key:'downStopPrice', label:'跌停价' } / { key:'open', label:'今开' } / { key:'high', label:'最高' }normalizeStrategyColumns 两处适配:默认列构造带 defaultVisible,未覆盖列的缺省显隐从恒 true 改为 defaultVisible !== false(存量 strategyColumns 无需迁移——新列缺省即隐藏);
  • StrategyTab.renderDataCell switch 增 4 casegetPrice(p.code)?.upStopPrice / downStopPrice / open / high,复用 fmtPrice 纯价格展示(数据缺失显示 —,与现价列同行为);
  • 列设置弹层零改动(自动从 COLUMN_META 出现在列表,kind=base 标「数据」);
  • 取值来源:market-snapshot 下发的 tick 字段 + 合约信息(upStopPrice/downStopPrice 来自 QuoteSync 交易日缓存),经 MarketDataProvider prices map join 到行。

10. 验证

  • typecheck + buildtest-quote-sync 全绿;存量回归(test-position-sync 34 项 + test-r013 21 项)全绿;
  • 老师人工验收(见验收标准)。