← 文档库
📄 文档learn/量化工程优化/2026-10-11-截面选基板块启动性能分析与优化方案.md
🌱 创建于2026-10-19 19:54
✏️ 修改于2026-10-19 19:54
👁️ 浏览量—
🔗 短链接/~learn/s/a4188a3d
🔐 可见性

截面选基板块 — 启动性能与耗时拆解分析

文档目标

拆解「截面选基」(/cross-section) 从点击到首屏可用的完整耗时,定位主要耗时来源,给出可落地的优化方案(首屏优先 / 分块加载 / 缓存),目标把首屏等待从当前的 5~14 秒 压到 3 秒以内(理想 1 秒内)。

> 本文只做现状梳理 + 方案设计,不含代码改动。方案确认后再进入实现。

一句话结论

首屏慢的根因是「一个巨型接口 _format_snapshot 一次性 eager 计算全部数据,阻塞整页渲染」,外加「第二个接口 etf-kline-batch 每次重复读 109 个文件且完全无缓存」。

> 关键洞察:_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 秒。

---

二、实测耗时(本机 127.0.0.1:8765,服务运行中)

# 请求 冷(>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 —

关键结论

---

三、后端 _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

---

四、根因拆解

根因 1:单体大函数 eager 计算,全部阻塞首屏

_format_snapshot 是一个 ~1160 行的大函数,在一次请求内同步算完:排名、持仓、关注、新鲜度、regime 历史、择时明细、相关性……前端必须等全部算完才拿到任何数据。没有「先返回骨架、后补细节」的机制。

根因 2:相关性 Top10 全量计算,但只在点击时才用

_corr_top_list(code) 对每个组合/关注品种,遍历全部 14 个锚点各自 load 一段序列并算 120 日相关性(共 615 次 _corr_ret)。

根因 3:缓存 TTL(30s)与数据更新频率(每日数次)不匹配

快照数据每天只更新几次,但缓存 30 秒即失效 ⇒ 用户隔一会儿再打开就重算 → 反复付 5~9s。

根因 4:etf-kline-batch 无任何缓存,每次重读 109 个文件

_get_etf_kline_batch_json 用 8 线程读 109 个 code 的磁盘数据(_load_kline_df_from_cache),无内存/响应级缓存,且首屏一次性请求全部 109 只(持仓 + 全部排名 + 关注),而不是只请求首屏可见的部分。

根因 5:两套独立的序列加载器,重复读盘

_corr_close(请求级缓存)与 _wl_load_close(另一个请求级缓存)各自调用 DataCacheManager.load,同一个 proxy 序列可能被读两遍;相关性矩阵结果也没有跨请求复用。

根因 6:payload 偏大(391KB)

regime_history(173KB)与 rankings(82KB)是最大块。虽然计算不贵,但 391KB 的 JSON 传输 + 解析在移动端也有成本,且这些数据并非首屏刚需。

---

五、优化方案(分阶段)

P0 — 快赢(改动小、收益大,预计首屏 → 3s 内)

措施 做法 预期收益
------------------
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 内。

P1 — 首屏拆分(分块/分 type 加载)

把 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 等子接口,复用同一份缓存计算,避免重复。

P2 — 架构级(预热与复用)

---

六、目标与验收标准

指标 现状 目标
------------------
首屏可用(热,<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 遮罩消失时间;改动后必须手机实测纵向滑动与首屏加载。

---

七、风险与回归点

---

八、已实施变更记录

2026-10-11 — 快照/相关性改「后台预热缓存」(P0-1 变体)

背景:用户反馈「相关性现场计算有问题,数据接口大多是日级别,不该每次进页面重算;改成每 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,重启后):

未处理(本轮范围外):~~etf-kline-batch(109 codes, 7d)本机仍 3.18~3.63s,是首屏剩余的 4~5s 瓶颈~~ → 见下方 2026-10-11(2)。

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,重启后):

---

九、TODO(实时性 / 后续)

▶ 📜 变更历史(1 次提交)

69522592026-10-19 19:54docs(learn): 更新文档分类与推送归档,新增项目灵感/MQL5资源库
🍃quant 知识库
by 伊甸园和枫

🔊 播放设置

勾选音源后调节配比,总和自动归一化
已选 0 个 · 合计:0%
合成中...
00:00/00:00
1.0x

分享图片

生成中...