Files
tick-stock-panel/backend/app/services/rps_rotation.py
T
shy3130 8c28132361 fix(analysis): 概念/行业映射缓存命中返回类型与签名不一致 (#186)
_load_concept_map_df 正常路径返回 (map_df, count) 元组, 但缓存只存了
裸 map_df —— 600s TTL 内二次访问命中缓存返回 DataFrame, 调用方按元组
解包把两列拆成两个 Series, 概念/行业分析二次打开必报错 (issue #186
报告者定位)。

- 缓存与返回值同构: 存 (map_df, count) 元组, 类型注解同步
- 移除 market_mainline / rps_rotation 两处针对旧 bug 的防御性兼容层
- 删除失效的 bare-DataFrame 兼容测试, 新增缓存契约回归测试 (3 次连跑通过)
2026-09-04 11:59:00 +08:00

222 lines
9.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""概念涨幅轮动矩阵 service。
输出「每列(日期)各自把所有概念按当天涨幅从高到低排序」的矩阵,供前端
「概念分析 → 涨幅RPS轮动」对话框渲染。
数据来源全部复用现有资产, 不引入新数据源:
- 个股历史涨跌幅: repo.get_enriched_range(..., columns=["symbol","date","change_pct"])
命中启动时构建的 _enriched_history_cache (0ms, 含 change_pct 小数列)
- 概念成分股映射: 复用 market_overview_builder 的 _dimension_field / _read_ext_rows /
_symbol_keys / _dimension_values, 与看板/复盘的概念聚合口径完全一致
性能: 387 概念 × 30 天的 group_by + sort 是 polars 内存操作, 实测 <50ms;
另加进程级结果缓存 (_CACHE_TTL=120s), 重复请求 <1ms。
"""
from __future__ import annotations
import logging
import time
from datetime import date, timedelta
import polars as pl
from app.services.market_overview_builder import (
_dimension_field,
_dimension_values,
_read_ext_rows,
_symbol_keys,
)
from app.services.ext_data import ExtConfigStore
logger = logging.getLogger(__name__)
# 进程级结果缓存 (照搬 overview.py:18 的模式, TTL 拉长到 120s —— 轮动矩阵
# 不像看板那样需要近实时, 盘后数据稳定, 缓存久一点无妨)
_CACHE_TTL = 120.0
_cache: dict[str, dict] = {}
_cache_ts: dict[str, float] = {}
def invalidate_cache() -> None:
"""清空轮动矩阵结果缓存(数据管道完成后调用, 避免返回旧数据)。"""
_cache.clear()
_cache_ts.clear()
def _latest_enriched_date(repo) -> date | None:
"""取 enriched 缓存里的最新交易日(矩阵的右端=最新日期)。"""
cache = repo._enriched_history_cache # noqa: SLF001 —— 缓存字段无公开 getter
if cache is None or cache.is_empty() or "date" not in cache.columns:
return None
return cache["date"].max()
def _load_concept_map_df(repo, kind: str = "concept") -> tuple[pl.DataFrame, int]:
"""构建并缓存 {symbol_upper → 维度成员} 的已展开 polars 映射表。
kind: "concept"(概念) 或 "industry"(行业)。复用 market_overview_builder 的
_dimension_field(config, kind) 识别维度 —— 该函数两种维度都支持。
返回 (map_df, member_count):
- map_df: 两列 (_sym_up: 大写 symbol, <kind>: 维度成员名), 已 explode。
无数据时返回空 DataFrame。
- member_count: 去重维度成员总数。
缓存: 维度成分股是 snapshot, 进程内不变, 缓存 600s。按 kind 分别缓存。
"""
now = time.time()
cached = _map_cache.get(kind)
if cached is not None and (now - _map_ts.get(kind, 0)) < 600:
return cached
data_dir = repo.store.data_dir
store = ExtConfigStore(data_dir)
pairs: list[tuple[str, str]] = []
members_seen: set[str] = set()
for config in store.load_all():
field = _dimension_field(config, kind)
if not field:
continue
for ext_row in _read_ext_rows(data_dir, config, field):
members = _dimension_values(ext_row.get(field))
if not members:
continue
keys = _symbol_keys(ext_row, config)
for key in keys:
for m in members:
pairs.append((key, m))
members_seen.add(m)
if pairs:
map_df = pl.DataFrame(
{"_sym_up": [p[0] for p in pairs], kind: [p[1] for p in pairs]},
schema={"_sym_up": pl.Utf8, kind: pl.Utf8},
).unique()
else:
map_df = pl.DataFrame(schema={"_sym_up": pl.Utf8, kind: pl.Utf8})
# 缓存与返回值同构 ((map_df, count) 元组): 旧版只缓存裸 map_df, 命中路径
# 返回 DataFrame 被调用方当元组解包, 600s 内二次访问必报错 (#186)
payload = (map_df, len(members_seen))
_map_cache[kind] = payload
_map_ts[kind] = now
return payload
# 维度映射缓存: {kind: (map_df, count)}。按 kind 隔离(概念/行业分别缓存)。
_map_cache: dict[str, tuple[pl.DataFrame, int]] = {}
_map_ts: dict[str, float] = {}
def build_rps_rotation(repo, days: int = 12, kind: str = "concept", level: int | None = None) -> dict:
"""构建维度涨幅轮动矩阵(概念或行业)。
Args:
repo: KlineRepository(含 _enriched_history_cache 内存历史)。
days: 取最近 N 个交易日, 范围 [7, 30], 默认 12。
kind: "concept"(概念) 或 "industry"(行业), 决定维度映射来源。
level: 行业层级(仅 kind=industry 有效, 1/2/3 级)。None 表示用原始全路径名。
行业名形如 "银行-银行-股份制银行", level=2 取第二段"银行", 同级下多个
三级会合并聚合(与 _dimension_rank 的 level 口径一致)。
Returns:
{
"dates": ["2026-06-30", ...], # 最新在最前, 长度 ≤ days
"columns": {"2026-06-30": [[成员, 涨幅], ...], ...}, # 每列各自排序(高→低)
"concept_count": 387, # 去重维度成员总数(0 表示无数据)
}
涨幅是小数(0.0522 = +5.22%)。无数据时返回空 columns。
字段名 concept_count 保留兼容(前端按 kind 显示"X 个概念/行业")。
"""
days = max(7, min(30, days))
# 结果缓存: 同 (kind, level, latest) 的请求在 TTL 内直接返回。
latest = _latest_enriched_date(repo)
if latest is None:
return {"dates": [], "columns": {}, "concept_count": 0}
cache_key = f"{kind}|{level}|{latest.isoformat()}"
now = time.time()
cached = _cache.get(cache_key)
if cached and (now - _cache_ts.get(cache_key, 0)) < _CACHE_TTL:
return _slice_cached(cached, days)
# 1. 维度映射(symbol → 维度成员), 已按 kind 缓存为 (map_df, count) 元组 (#186)。
map_df, member_count = _load_concept_map_df(repo, kind)
if map_df.is_empty():
logger.info("rps_rotation: no %s data (ext dimension not fetched yet)", kind)
return {"dates": [], "columns": {}, "concept_count": 0}
# 2. 取最近 N 交易日的个股 change_pct(命中内存缓存)
start = latest - timedelta(days=days * 2 + 10) # 日历天 ≈ 2/3 交易日, 多取余量
df = repo.get_enriched_range(
start, latest, columns=["symbol", "date", "change_pct"]
)
if df is None or df.is_empty():
return {"dates": [], "columns": {}, "concept_count": 0}
# 3. 把个股 symbol 映射到维度成员, 一只股票拆成多行(每个成员一行)
# symbol 大写匹配(map_df 的 _sym_up 已大写)
df = df.with_columns(pl.col("symbol").str.to_uppercase().alias("_sym_up"))
joined = df.join(map_df, on="_sym_up", how="inner").drop("_sym_up")
if joined.is_empty():
return {"dates": [], "columns": {}, "concept_count": 0}
# 行业层级聚合: kind=industry 且指定 level 时, 把 "一级行业-二级行业-三级行业"
# 拆分取对应层级(level=2 → "二级行业"), 同级下多个三级会合并。
# 与 market_overview_builder._dimension_rank 的 level 口径完全一致。
if kind == "industry" and level is not None:
# polars: 按 "-" 拆分取第 level 段; 段数不足时取最后一段(兜底)
parts = pl.col(kind).str.split("-")
idx = pl.min_horizontal(pl.lit(level - 1), pl.col(kind).str.count_matches("-"))
joined = joined.with_columns(parts.list.get(idx).alias(kind))
# 4. 按 (date, <kind>) 聚合 avg change_pct —— 与 _dimension_rank 的简单平均口径一致
agg = joined.group_by(["date", kind]).agg(
pl.col("change_pct").mean().alias("avg_pct")
)
# 去掉 NaN/Null(停牌等无行情的成员日)
agg = agg.filter(pl.col("avg_pct").is_not_null() & pl.col("avg_pct").is_not_nan())
# 5. 每个日期内按 avg_pct 降序排, 再 group_by 把每组的 (成员, avg_pct)
# 收集成并行 list —— 一次 polars 操作拿到全部列, 避免 partition_by 的 tuple key 歧义
agg = agg.sort(["date", "avg_pct"], descending=[False, True])
grouped = agg.group_by("date", maintain_order=True).agg(
pl.col(kind), pl.col("avg_pct")
)
# 最新日期排最前
grouped = grouped.sort("date", descending=True)
columns: dict[str, list[list]] = {}
all_dates_sorted: list[str] = []
for row in grouped.iter_rows(named=True):
d_str = str(row["date"])
all_dates_sorted.append(d_str)
columns[d_str] = list(zip(row[kind], row["avg_pct"]))
full = {
"dates": [str(d) for d in all_dates_sorted],
"columns": columns,
"concept_count": member_count,
}
# 写缓存(存全量, 按需 slice)
_cache[cache_key] = full
_cache_ts[cache_key] = now
return _slice_cached(full, days)
def _slice_cached(full: dict, days: int) -> dict:
"""从全量缓存截取最近 N 天(days)。"""
dates_all = full["dates"]
if len(dates_all) <= days:
return full
keep_dates = dates_all[:days]
return {
"dates": keep_dates,
"columns": {d: full["columns"][d] for d in keep_dates},
"concept_count": full["concept_count"],
}