/~learn/s/a4188a3d
拆解「截面选基」(/cross-section) 从点击到首屏可用的完整耗时,定位主要耗时来源,给出可落地的优化方案(首屏优先 / 分块加载 / 缓存),目标把首屏等待从当前的 5~14 秒 压到 3 秒以内(理想 1 秒内)。
> 本文只做现状梳理 + 方案设计,不含代码改动。方案确认后再进入实现。
首屏慢的根因是「一个巨型接口 _format_snapshot 一次性 eager 计算全部数据,阻塞整页渲染」,外加「第二个接口 etf-kline-batch 每次重复读 109 个文件且完全无缓存」。
snapshot 冷启动 5.2~9.3s(仅 30s 缓存,超时即全量重算);etf-kline-batch(109 个 code)每次都要 4~5s,且紧跟在 snapshot 之后串行阻塞表格填充。
> 关键洞察:_format_snapshot 里最贵的「相关性 Top10 全量计算」(占 ~58% 时间)只在用户点击 r=0.88 ▼ 时才用到,属于典型的「首屏不该算」。
---
页面 /cross-section 由 unified_server.py 路由渲染,模板为 CROSS_SECTION_PAGE(L10788-L13362)。
前端初始化 loadSnapshot() 触发以下请求链:
点击 /cross-section
│
├─ ① GET /cross-section (HTML 外壳 184KB, 24ms) ← 快
│
└─ ② GET /api/cross-section/snapshot (5.2~9.3s 冷 / 0.1s 热) ← 阻塞首屏 ⛔
│ 返回 391KB JSON(含全部排名109 + 持仓11 + 关注20 + regime历史 + 择时明细)
│ 前端 renderMetaBar → renderPortfolio → renderRanking(排名/持仓骨架先出)
│
├─ ③ GET /api/etf-kline-batch?codes=<109只>&range=7d (4~5s, 无缓存) ← 阻塞表格单元格填充 ⛔
│ .then → updatePortfolioCells / updateRankingCells / loadWatchlist
│
├─ ④ GET /api/black-swan (0.01s, 300s缓存) ← 快
└─ ⑤ GET /api/etf-kline-batch?codes=<Top20>&range=3m (K线预览)
用户感知:③ 完成前,持仓/排名表格的涨跌幅、K线等单元格是空的(rankLoading / wlLoading 遮罩)。所以真正"可用"的时间 = ② + ③ ≈ 9~14 秒。
---
| # | 请求 | 冷(>30s 未请求 / 进程首次) | 热(<30s 内重复) | 响应体 | 是否有缓存 |
| --- | ------ | ------ | ------ | ------ | ------ |
| ① | GET /cross-section | 0.024s | 0.024s | 184 KB | 静态模板 |
| ② | GET /api/cross-section/snapshot | 5.17s(danjuan 已热)/首次进程冷 9.25s | 0.09~0.11s | 391 KB | 仅 30s TTL |
| ③ | GET /api/etf-kline-batch?codes=109&range=7d | 4.2~5.0s | 4.2~5.0s(无缓存) | 94 KB | ❌ 无 |
| ④ | GET /api/black-swan | 0.01s | 0.01s | 1.3 KB | 300s TTL |
| ⑤ | GET /api/etf-kline-batch?codes=20&range=3m | 见 ③ | — | — | ❌ 无 |
| — | GET /api/cross-section/dates | 0.03~0.06s | — | 1.9 KB | — |
关键结论
snapshot 的缓存 TTL 只有 30 秒(L9299)——只要隔 30s 再打开就全量重算,这就是"每次点开都慢"的直接原因。etf-kline-batch 完全没有跨请求缓存(_get_etf_kline_batch_json),每次都要重新读 109 个文件的磁盘数据,每次都付 4~5s。---
_format_snapshot 内部分段耗时(cProfile)
用 cProfile 对 _format_snapshot(L9317-L10478)做单次冷路径剖析(独立进程,单次约 2.9s):
| 累计耗时 | 调用次数 | 函数 | 说明 |
| ------ | ------ | ------ | ------ |
| 1.70s | 59 | _corr_top_list | 每个组合/关注品种 × 全部 14 个锚点算 120 日相关性 |
| 1.42s | 615 | _corr_ret | 被上者调用,逐对计算相关系数 |
| 1.27s | 18 | select_anchor_dynamic | 关注板块「动态选锚」,内部又加载大量候选序列 |
| 1.51s | 69 | DataCacheManager.load_best | CSV/磁盘读取(含 68 次 read_csv 0.68s) |
| 0.92s | 799 | pandas.to_datetime | 每次读盘后重复做时间索引转换 |
| 0.77s | 488 | _wl_load_close | 关注板块另一个独立序列加载器 |
| 0.49s | — | _compute_data_freshness | 对 125 个 code 逐个查新鲜度 |
数据块规模(响应体 391KB 构成)
| 数据块 | 条数 | JSON 体积 |
| ------ | ------ | ------ |
regime_history | 120 | 172.7 KB |
rankings | 109 | 81.9 KB |
timing_strategy_details | 26 | 70.3 KB |
watchlist | 20 | 28.0 KB |
portfolio | 11 | 20.2 KB |
freshness | 125 | 10.7 KB |
| 其余 | — | 7.2 KB |
---
_format_snapshot 是一个 ~1160 行的大函数,在一次请求内同步算完:排名、持仓、关注、新鲜度、regime 历史、择时明细、相关性……前端必须等全部算完才拿到任何数据。没有「先返回骨架、后补细节」的机制。
_corr_top_list(code) 对每个组合/关注品种,遍历全部 14 个锚点各自 load 一段序列并算 120 日相关性(共 615 次 _corr_ret)。
anchor_corr_top 字段在前端只出现在点击行内 toggleCorrTop(...) 的 onclick 参数里(L12083、L12860)——不点开就白算。anchor_confidence(L9699-L9709),却被迫算了全部 14 个锚点。
etf-kline-batch 无任何缓存,每次重读 109 个文件_get_etf_kline_batch_json 用 8 线程读 109 个 code 的磁盘数据(_load_kline_df_from_cache),无内存/响应级缓存,且首屏一次性请求全部 109 只(持仓 + 全部排名 + 关注),而不是只请求首屏可见的部分。
_corr_close(请求级缓存)与 _wl_load_close(另一个请求级缓存)各自调用 DataCacheManager.load,同一个 proxy 序列可能被读两遍;相关性矩阵结果也没有跨请求复用。
regime_history(173KB)与 rankings(82KB)是最大块。虽然计算不贵,但 391KB 的 JSON 传输 + 解析在移动端也有成本,且这些数据并非首屏刚需。
---
| 措施 | 做法 | 预期收益 |
| ------ | ------ | ------ |
| P0-1 拉长 snapshot 缓存 TTL | 由 30s → 10~30 分钟,或改为「按快照文件 mtime / 当日收盘后」失效 | 消除"隔 30s 重算",热路径稳定 0.1s |
| P0-2 相关性按需计算 | 首屏只算 anchor_confidence 所需的单个锚点相关性(新增 _corr_one(code, anchor));anchor_corr_top 移到新接口 /api/cross-section/anchor-corr?code=,点击时再拉 | _format_snapshot 省掉 ~1.5s(占 ~58%) |
| P0-3 kline-batch 加响应缓存 | 按 (codes, range) 做 5~15 分钟内存缓存(数据日内不变) | 消除每次 4~5s 的重复读盘 |
| P0-4 首屏只请求可见 code | 首屏 kline-batch 只带持仓 + Top10(而非 109 只),其余滚动/切换 Tab 时再拉 | 首屏 kline 耗时按比例下降 |
> P0-1 + P0-2 + P0-3 组合后,热路径首屏预计可到 1s 内,冷路径(当日首次)预计 3s 内。
把 snapshot 拆成「首屏必需」+「延迟块」,前端并行/按需拉取:
| 块 | 内容 | 加载时机 |
| ------ | ------ | ------ |
| 首屏核心 | rankings(摘要) + portfolio(当前策略持仓) + timing_dates + regime/exposure_cap | 立即 |
| 块 A | watchlist(固定关注板块,含动态选锚) | 首屏渲染后异步 |
| 块 B | regime_history(173KB 历史图) | 切换到「历史」Tab 时 |
| 块 C | freshness(125 条新鲜度标注) | 首屏后异步 |
| 块 D | anchor_corr_top(相关性 Top10) | 点击 r=▼ 时 |
对应后端可新增 /api/cross-section/snapshot/basic、/watchlist、/regime-history 等子接口,复用同一份缓存计算,避免重复。
_corr_close 与 _wl_load_close 统一为一个请求级 loader,proxy 序列只读一次。to_datetime:序列加载时一次性建好时间索引,避免 799 次重复转换。etf-kline-batch 的原始 DataFrame 读到内存 LRU(数据日内有效)。---
| 指标 | 现状 | 目标 |
| ------ | ------ | ------ |
| 首屏可用(热,<T 内重访) | ~4~5s | ≤1s |
| 首屏可用(冷,当日首次) | ~9~14s | ≤3s |
snapshot 冷耗时 | 5.2~9.3s | ≤2s |
snapshot 热耗时 | 0.1s(仅 30s 内) | ≤0.1s(10~30min 内) |
etf-kline-batch(109) | 4~5s(每次) | ≤0.5s(命中缓存) |
| snapshot payload | 391KB | 首屏 ≤120KB(拆分后) |
验收方式:本机 curl -w "%{time_total}" 多次取样 + 浏览器实测首屏 rankLoading/wlLoading 遮罩消失时间;改动后必须手机实测纵向滑动与首屏加载。
---
_multi_anchor_conf,不能被单锚逻辑覆盖)。CROSS_SECTION_PAGE 内联 CSS 时,花括号必须双写 {{ }}(.format() 渲染)。---
背景:用户反馈「相关性现场计算有问题,数据接口大多是日级别,不该每次进页面重算;改成每 15 分钟算一次、进页面直接命中缓存即可」。
方案:不做「按需计算(P0-2)」这类涉及口径改动的改造,先走最稳的缓存路线 —— 后台线程每 15 分钟主动重算并刷新缓存,用户请求直接命中,不现场计算。
代码改动(unified_server.py):
| # | 位置 | 改动 |
| --- | ------ | ------ |
| 1 | L9306-9307 | _SNAPSHOT_FORMAT_CACHE_TTL:30s → 1200s(20 分钟);新增 _SNAPSHOT_PREWARM_INTERVAL = 900.0(15 分钟后台重算周期) |
| 2 | L9325-9331 | _format_snapshot(summary, date, _force=False) 新增 _force 参数:_force=True 时跳过缓存强制重算(供后台预热用);旧缓存保留到新结果算完才覆盖,避免服务空窗 |
| 3 | L10493-10521 | 新增 _start_snapshot_prewarm():daemon 线程,启动后 sleep 3s 避开初始化高峰,然后每 15 分钟 get_available_dates() → _format_snapshot(..., _force=True) 主动重算并刷新缓存 |
| 4 | L18049 | __main__ 中调用 _start_snapshot_prewarm() 启动预热线程 |
验证结果(本机 127.0.0.1:8765,重启后):
[unified] 截面选基快照预热线程已启动 + [snapshot-prewarm] 已预热快照 2026-10-09(耗时 15.4s)(首次含 danjuan 网络刷新)。https://quant.aohan.net/api/cross-section/snapshot:HTTP 200(含公网往返/隧道开销约 3.5s)。
未处理(本轮范围外):~~etf-kline-batch(109 codes, 7d)本机仍 3.18~3.63s,是首屏剩余的 4~5s 瓶颈~~ → 见下方 2026-10-11(2)。
etf-kline-batch 改「同款预热缓存」
背景:用户要求「你说的这个(etf-kline-batch)也改成一样的缓存」。根因:_get_etf_kline_batch_json 对 100+ 个 code 逐个 DataCacheManager.load_best → pd.read_csv(_load_kline_df_from_cache、load_best),无任何内存缓存,每次请求都重读 109 个文件。
方案:与快照完全同款 —— (range, codes) 响应缓存 + 由同一个预热线程每 15 分钟一并刷新。
代码改动(unified_server.py):
| # | 位置 | 改动 |
| --- | ------ | ------ |
| 1 | L9310-9314 | 新增 _KLINE_BATCH_CACHE = {} + _KLINE_BATCH_CACHE_TTL = 1200.0(20 分钟) |
| 2 | L10722-10726 | _get_etf_kline_batch_json(codes, range_key, _force=False):命中 (range, sorted(codes)) 且未过期则直接返回 |
| 3 | L10761 | 计算完成后写缓存 _KLINE_BATCH_CACHE[_cache_key] = (ts, results) |
| 4 | L10535-10570 | 新增 _prewarm_kline_batch(snapshot):复用前端首屏的 code 组合(portfolio+rankings+bottom+new_entries+_WATCHLIST非short)预热 7d,再预热 Top20 的 3m;在快照预热完成后调用 |
验证结果(本机 127.0.0.1:8765,重启后):
[snapshot-prewarm] 已预热 K线批量 7d×109(耗时 4.7s) + 已预热 K线预览 3m×20(耗时 1.1s)。7d×109 = 0.005~0.019s,3m×20 = 0.011~0.021s(改造前 3.2~4.5s,命中缓存)。https://quant.aohan.net/api/cross-section/snapshot 仍约 3~4s —— 该耗时是 391KB payload 经 Cloudflare 隧道的传输开销(非服务端计算),属 P1 payload 瘦身范畴,与本次缓存无关。---
etf-kline-batch 加响应级缓存(按 (codes, range),5~15 分钟)~~ ✅ 已完成(2026-10-11(2)) —— (range, codes) 响应缓存 + 预热线程 15 分钟刷新。snapshot 391KB 经隧道传输约 3~4s(服务端已缓存)。若要进一步压公网首屏,需做 P1 的 payload 瘦身(拆分 regime_history/rankings 等大块)或开启 gzip。anchor_confidence,anchor_corr_top 点击时再拉),需保证口径与现展示一致。当前用缓存绕开,暂不实施。