---
title: 当日看盘行情读写拆分方案
doc_type: feature-design
domain: market-data
keywords:
  - 当日看盘
  - 行情读写拆分
  - 后台刷新
  - today watch
summary: 说明当日看盘为什么要把行情刷新和查询读模型拆开。
when_to_read:
  - 要改今日看盘行情链路
  - 要确认查询和刷新职责边界
read_next:
  - ../operations/aktools-functional-test.md
  - ../business/data-fetch-tasks.md
---
# 当日看盘行情读写拆分方案

这篇文档只说明一件事：当日看盘不再在查询链路里临时拉实时行情，而是改成“后台任务先写本地库，查询只读本地库”。

当前正式目标只有一条：

- 当日看盘查询负责读模型与排序。
- 行情刷新负责后台任务与落库。
- 两者不再在一次 IPC 里耦合完成。
- “当日行情”必须作为独立能力在 Rust 服务层实现，而不是让页面层或单一 provider 直接决定结果。

如果你现在更关心当前 `aktools` sidecar 的手工验证步骤，先看 [Aktools 功能测试文档](../operations/aktools-functional-test.md)。如果你现在要确认全局分层和任务边界，先看 [项目架构](../overview/architecture.md)。

## 背景

这次方案调整解决的是两个反复出现的问题：

1. 当前 `get_today_watch_local` 在查询时直接触发 `aktools` 实时行情刷新，查询和刷新强耦合，页面响应时间直接受 provider 影响。
2. 当前分页发生在后端查询阶段，而排序发生在前端展示阶段，导致“按涨跌幅和异常分排序”的结果只在当前页内成立，不是全量正式排序。

这条链路需要对齐到仓库已经在公告和市场底座上采用的同一条原则：

- 长链路不在查询时临时补拉底座。
- 后台任务负责准备底座。
- 查询只读本地正式结果。

## 改造前问题

这次改造前，当日看盘链路存在下面这些问题：

- `get_today_watch_local` 在一次 IPC 里同时做了公告种子查询、`aktools` sidecar 就绪检查、实时行情刷新和结果回填。
- 当前实时行情写入的是通用表 `stock_market_snapshot`，但当日看盘没有自己的正式读模型表。
- 前端页面在当前页数据上再次排序，导致“分页前排序”和“分页后排序”语义错位。
- 如果查询日期不是今日，当前实现也没有一条正式的“直接读历史日行情并排序”的统一分支。

一句话：

当前实现更像“查询时顺手刷新行情”，而不是“后台持续准备今日看盘行情读模型”。

## 目标语义

这次方案确定后的正式语义如下：

1. 当日看盘查询如果查的是今日，只联表读取“今日看盘行情表”，不再触发实时查询。
2. 当日看盘查询如果查的是历史日期，只联表读取历史日行情表，不再触发实时查询。
3. 今日实时行情由独立自动任务按固定频率刷新，刷新率由任务配置决定。
4. 排序必须在 Rust 查询层完成，并且发生在分页之前。
5. 前端只负责发起查询、展示更新时间和定时重新读取本地结果，不负责驱动 provider 拉取。
6. Today Watch 的股票级主列表必须按 `biz_notice_fact + biz_notice_analysis.trade_date` 聚合成“公告种子”；`biz_notice_indicator_record` 和行情表只作为增强层挂载，不允许再按 `biz_notice_fact.publish_time` 的自然日去近似匹配。
7. 当日查询优先使用“当日 live quote 能力”；只有 live 能力全部失败且本地已经存在同交易日快照时，才允许显式回退到“同日本地快照”。

## 一张链路图

```mermaid
flowchart LR
    A["scheduler\n today.watch.quote.refresh"] --> B["Rust service\n计算今日看盘股票池"]
    B --> C["aktools / AkShare\n批量实时行情"]
    C --> D["today_watch_quote_snapshot\n今日看盘行情表"]
    E["TodayWatch 查询\n tDate = 今日"] --> F["indicator seed + notices + today_watch_quote_snapshot"]
    G["TodayWatch 查询\n tDate != 今日"] --> H["indicator seed + notices + 多来源日线查询层"]
    F --> I["Rust 服务层统一排序"]
    H --> I
    I --> J["分页后返回 renderer"]
```

这张图表达的重点是：

- 今日和非今日查询共享同一个页面入口，但不共享同一种行情来源。
- 实时行情拉取从查询链路移除，改成后台任务。
- 排序和分页都在 Rust 查询层完成，不再把正式排序丢给前端。

## 当日行情能力边界

当前“当日行情”不再等同于“调用某一个 aktools 接口”。

正式边界已经收口成下面这条能力链：

1. 在 Rust 服务层先尝试“当日 live quote 能力”。
2. 当前第一版 live provider 仍是 `aktools`，但它内部必须支持多个子渠道切换，不再只绑死 `stock_zh_a_spot`。
   这条规则不仅适用于“主渠道整批失败”场景；如果主渠道请求成功但漏掉部分目标股票，也必须继续用其他子渠道补齐缺失代码。
   live 子渠道请求还必须带端点级超时，避免单个慢渠道把整轮 `today.watch.quote.refresh` 长时间阻塞；超时后应立即继续尝试下一个子渠道。
3. 未来如果其他 provider 真正实现了“当日行情能力”，可以接入同一条 live quote 能力链，而不是在页面层单独加分支。
4. 如果所有 live 渠道都失败，且本地已经存在同交易日完整多来源日线查询结果或其他正式快照，则允许回退到“同日本地快照”。
5. 如果既没有 live 渠道，也没有同日本地快照，则返回空行情，不伪造实时结果。

这里有两条硬边界：

- `tushare daily` 不能伪装成“当日实时行情”接口。
- Today Watch 页面层不能自己拼接多来源或做 runtime 兜底猜测。

### 返回语义

当前 Today Watch 的正式行情语义分成 3 档：

| `quoteSource` | 含义 | 什么时候出现 |
| --- | --- | --- |
| `todayWatchLiveQuote` | 当日 live 渠道成功返回的实时行情 | aktools 子渠道可用 |
| `todayWatchSameDaySnapshot` | live 渠道失败，但本地已经存在同交易日快照 | 收盘后或本地已完成同日快照同步 |
| `marketDailyQuery` | 非今日查询的多来源日线查询结果 | `tDate != 今日` |

同时返回 `quoteProvider`，用于说明当前命中的具体渠道，例如：

- `aktools:sina`
- `aktools:eastmoney`
- `local:tushare:stk_factor_pro`

返回结构分两层解释：

- `DesktopTodayWatchData.quoteSource + quoteProvider + updateTime` 是这次查询结果的页级汇总口径，主要给页面头部状态区使用。
- `DesktopTodayWatchItem.quoteSource + quoteProvider + quoteUpdateTime` 是每只股票真实命中的行情来源，主要给卡片和行内来源展示使用。
- 如果同一页里不同股票来自不同 live 子渠道，页级 `quoteProvider` 可以汇总成 `aktools:mixed`，但不能覆盖记录级真实 provider。
- 如果今日查询命中了“同日本地快照”或历史正式日线，记录级字段也必须保留该股票实际使用的来源和时间，不能只靠页级字段反推。

## 正式数据模型

### 1. 今日看盘行情表

新增业务读模型表：

- 表名：`today_watch_quote_snapshot`

这张表不是原始 API 快照表，也不是通用缓存表。它的正式职责是：

- 保存“今日看盘股票池”的当日行情读模型
- 只服务当日看盘今日查询
- 每日切日时清空旧数据，再写入新交易日快照

建议字段：

| 字段 | 说明 |
| --- | --- |
| `trade_date` | 交易日，格式 `YYYY-MM-DD` |
| `market` | 市场，例如 `A` |
| `stock_code` | 证券代码 |
| `stock_name` | 证券简称，便于直接读模型查询 |
| `t_open` | 今日开盘价 |
| `t_close` | 当前价或最新成交价，沿用当日看盘正式字段名 |
| `t_high` | 今日最高价 |
| `t_low` | 今日最低价 |
| `t_volume` | 今日成交量 |
| `t_pct_chg` | 今日涨跌幅 |
| `quote_time` | provider 返回的行情时间 |
| `source_provider` | 当前写入来源，例如 `aktools` |
| `updated_at` | 本地写入时间 |

建议唯一键：

- `(trade_date, market, stock_code)`

### 2. 历史查询行情来源

历史日期不写入 `today_watch_quote_snapshot`，正式来源直接使用：

- 多来源日线查询层（优先 `api_tushare_stk_factor_pro`，必要时回退 `api_tushare_daily_basic / api_akshare_price_daily`）

历史分支只读取历史日线正式字段，不再在查询时补拉 Tushare 或其他 provider。

## 自动任务方案

### 1. 新增任务

新增正式任务键：

- `today.watch.quote.refresh`

任务职责只做一件事：

- 计算“今日看盘股票池”
- 批量执行“当日行情能力”查询
- 清理旧交易日数据
- upsert 写入 `today_watch_quote_snapshot`

注意：

- 这个任务不是“只调 aktools 单接口”。
- 它会优先尝试 live 渠道；如果 sidecar 预检失败，也不会直接中断整个任务，而是继续评估本地同日本地快照回退。
- 只有 live 全部失败且本地已存在同交易日快照时，才允许把本地快照写入 `today_watch_quote_snapshot`。
- 这里的同交易日快照必须来自完整物化的多来源日线查询结果；至少要有 `open / high / low / close / volume` 这组核心 OHLCV 字段，不能把只有 `close`、`pct_chg` 或估值字段的半成品行当成 same-day snapshot ready。
- 当 `tushare.stk_factor_pro.sync` 在盘中补齐了同交易日正式日线后，调度器必须立即补刷一次 `today.watch.quote.refresh`，避免早先写入的残缺本地快照继续污染 Today Watch 页面。

### 2. 股票池来源

第一版股票池边界固定为：

- 只刷新在 `biz_notice_fact + biz_notice_analysis.trade_date` 中命中的“今日当日看盘股票代码”

不扩到下面这些范围：

- 自选股
- 观察池
- 全市场股票
- 其他页面正在浏览的股票

这样做的原因很直接：

- 任务范围和查询目标完全一致
- 刷新成本可控
- 不需要额外发明跨页面共享股票池规则

### 3. 刷新频率

刷新频率由自动任务决定，不由页面决定。

当前调度器按 60 秒 tick 检查任务是否到点，因此第一版正式口径建议是：

- 工作日盘中按 1 分钟刷新

如果后续需要更高频率，应先调整调度器能力，再改任务频率；不要在页面层重新发明 30 秒并发刷新链路。

### 4. 切日清理

这张表每天都清空旧数据，正式口径如下：

- 任务运行时先判断当前交易日
- 如果表内存在其他交易日数据，先清空旧日数据
- 再写入当前交易日数据

不采用“长期保留多日今日表数据”的模式，避免这张表语义漂移成通用行情历史表。

## 查询方案

### 1. 今日查询

当 `tDate` 是今日时：

- 先按 `biz_notice_fact + biz_notice_analysis.trade_date = tDate` 聚合股票级公告种子
- 再左联同股同 `T` 日的 `biz_notice_indicator_record` 作为指标增强层
- 再联表 `today_watch_quote_snapshot`
- 在 Rust 服务层补齐当日行情字段
- 在 Rust 服务层完成正式排序
- 最后再分页返回

今日查询不再触发下面这些动作：

- `aktools` sidecar 就绪检查
- `refresh_spot_quotes`
- 任意 provider 实时请求

今日查询只消费已经落好的正式结果，并通过 `quoteSource + quoteProvider` 明确告诉页面当前看到的是：

- live 实时行情
- 同日本地快照
- 或根本没有可用当日行情
- 如果本地只剩旧交易日快照，Rust 查询层必须返回空行情态，不能把旧交易日快照冒充成今日结果。
- Today Watch 页面跨天重新进入时，默认查询日期也必须回到当天；历史日期只能来自用户这次主动切换，不能让旧本地缓存继续伪装成“今日查询”。

页面展示边界也固定如下：

- 头部刷新状态区使用页级 `quoteSource + quoteProvider + updateTime`。
- 股票卡片上的来源徽标使用记录级 `quoteSource + quoteProvider + quoteUpdateTime`。
- 页面层不允许拿页级来源去覆盖记录级来源，也不允许在多 provider 结果里自己拼接“更细”的来源说明。

### 2. 历史查询

当 `tDate` 不是今日时：

- 先按 `biz_notice_fact + biz_notice_analysis.trade_date = tDate` 聚合股票级公告种子
- 再左联同股同 `T` 日的 `biz_notice_indicator_record` 作为指标增强层
- 再走多来源日线查询层
- 读取该交易日对应的日线字段
- 在 Rust 服务层完成正式排序
- 最后再分页返回

历史查询不再触发下面这些动作：

- 实时行情拉取
- sidecar 自动拉起
- “查不到就顺手补历史”的临时补拉

### 3. 排序与分页

正式规则：

- 先补齐行情字段
- 再排序
- 最后分页

不能继续保留当前这种行为：

- 后端按发布时间先分页
- 前端再按当前页内涨跌幅排序

否则“第一页的第 1 名”并不等于“全量结果的第 1 名”。

## 排序口径

当前正式排序不再只有一条，而是跟查询模式绑定：

1. `公告模式`：`market_cap DESC -> latest_notice_publish_time DESC -> stock_code ASC`
2. `涨幅榜`：只保留 `t_pct_chg > 0` 的股票，按 `t_pct_chg DESC -> market_cap DESC -> latest_notice_publish_time DESC -> stock_code ASC`
3. `跌幅榜`：只保留 `t_pct_chg < 0` 的股票，按 `t_pct_chg ASC -> market_cap DESC -> latest_notice_publish_time DESC -> stock_code ASC`

这里的关键边界是：

- 没有行情、涨跌幅为 0 或与当前榜单方向不一致时，该股票不进入涨幅榜 / 跌幅榜，但仍然保留在公告模式里。
- `biz_notice_indicator_record` 不再决定“能不能看到这只股票”，只决定指标增强字段是否已补齐。

## 前端口径

前端页面只保留下面两类行为：

1. 查询本地正式结果
2. 定时重新读取本地正式结果

前端页面不再承担下面这些职责：

- 把股票代码分批并发发给服务端
- 用“百叶窗”方式驱动 provider 实时拉取
- 在当前页内重新定义正式排序

前端仍然可以展示这些信息：

- 当前列表更新时间
- 今日行情刷新时间
- 当前数据来自“今日行情表”还是“历史日行情”
- 模式按钮上的数量使用后端分页前的 `total`，不使用当前页 `records` 条数

## 边界与非目标

### 1. 这次方案明确要做的

- 查询和刷新彻底拆开
- 今日与非今日走不同行情来源
- 新增专用今日行情表
- 把正式排序收回 Rust
- 把刷新频率交给后台任务

### 2. 这次方案明确不做的

- 不把今日看盘股票池扩成全市场实时行情池
- 不在前端做多批次并发实时拉取
- 不让历史查询在运行时补拉日线
- 不让页面层自己决定数据源优先级

## 需要同步调整的代码点

### 1. Rust

- `src-tauri/src/db.rs`
  - 新增 `today_watch_quote_snapshot` 表和索引
  - 初始化默认任务 `today.watch.quote.refresh`
- `src-tauri/src/scheduler.rs`
  - 新增任务分支
  - 在任务里按正式股票池刷新今日行情表
- `src-tauri/src/market_sync_service.rs`
  - 新增“写今日看盘行情表”的服务方法
- `src-tauri/src/commands.rs`
  - `get_today_watch_local` 改成纯查询
  - 今日和历史日期走不同联表逻辑
  - 排序发生在分页之前

### 2. Shared contract / renderer

- `src/shared/desktop-api.ts`
  - 明确 `DesktopTodayWatchData` 的 `tradingDay / quoteSource / quoteProvider / updateTime` 页级汇总字段
  - 明确 `DesktopTodayWatchItem` 的 `quoteSource / quoteProvider / quoteUpdateTime` 记录级真值字段
- `src/renderer/src/api/types.ts`
  - 去掉“服务端已组装完整业务与行情字段”里隐含的同步刷新假设
- `src/renderer/src/views/TodayWatchView.vue`
  - 去掉前端二次正式排序
  - 头部使用页级来源汇总，卡片使用记录级来源真值
  - 自动刷新只做重新查询本地结果

## 验证口径

这次改动属于：

- 业务逻辑改动
- 状态推进改动
- 跨层编排改动

因此提交前至少需要覆盖下面 3 类验证：

1. 契约校验
  - `TodayWatch` 返回结构新增的页级 / 记录级来源字段和语义一致
   - 今日与历史日期的返回来源口径明确
2. 核心逻辑校验
   - 今日查询不触发实时拉取
   - 历史查询只读历史日行情
   - 排序先于分页
   - 切日会清空旧数据
3. 关键链路校验
   - 自动任务刷新今日行情表后，当日看盘查询能直接读到并正确排序

统一入口仍然是：

```bash
npm run verify:logic
```

## 对后续改动最重要的结论

1. 当日看盘查询不再承担任何实时行情刷新职责。
2. 今日和历史日期必须走不同的正式行情来源，不能在运行时混用。
3. 正式排序必须在 Rust 查询层完成，并且发生在分页之前。
