diff --git a/backend/app/api/rps.py b/backend/app/api/rps.py new file mode 100644 index 0000000..512abe5 --- /dev/null +++ b/backend/app/api/rps.py @@ -0,0 +1,27 @@ +"""涨幅轮动矩阵 API。 + +供「概念分析 → 涨幅RPS轮动」对话框调用。返回最近 N 个交易日的概念涨幅 +排名矩阵:每列(日期)各自把所有概念按当天涨幅从高到低排序。 +""" +from __future__ import annotations + +from fastapi import APIRouter, Query, Request + +from app.services import rps_rotation + +router = APIRouter(prefix="/api/rps", tags=["rps"]) + + +@router.get("/rotation") +def get_rotation( + request: Request, + days: int = Query(12, ge=7, le=30, description="最近 N 个交易日(7-30)"), +) -> dict: + """概念涨幅轮动矩阵。 + + Returns: + dates: 日期字符串列表(最新在最前) + columns: {日期: [[概念名, 涨幅小数], ...]} 每列各自降序 + concept_count: 去重概念总数 + """ + return rps_rotation.build_rps_rotation(request.app.state.repo, days) diff --git a/backend/app/main.py b/backend/app/main.py index 40b1049..d863c26 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -11,7 +11,7 @@ from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles from app import __version__ -from app.api import analysis, auth as auth_api, backtest, data, ext_data, financials, indices, intraday, kline, market_recap, monitor_rules, alerts, overview, pipeline, screener, settings as settings_api, signals, stock_analysis, strategy, watchlist +from app.api import analysis, auth as auth_api, backtest, data, ext_data, financials, indices, intraday, kline, market_recap, monitor_rules, alerts, overview, pipeline, rps, screener, settings as settings_api, signals, stock_analysis, strategy, watchlist from app.api.routes import router as core_router from app.config import settings from app.jobs import daily_pipeline @@ -266,6 +266,7 @@ app.include_router(strategy.router) app.include_router(signals.router) app.include_router(monitor_rules.router) app.include_router(alerts.router) +app.include_router(rps.router) # 能力门控异常 → 403(而非默认 500) diff --git a/backend/app/services/rps_rotation.py b/backend/app/services/rps_rotation.py new file mode 100644 index 0000000..95921e8 --- /dev/null +++ b/backend/app/services/rps_rotation.py @@ -0,0 +1,214 @@ +"""概念涨幅轮动矩阵 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) -> tuple[pl.DataFrame, int]: + """构建并缓存 {symbol_upper → 概念} 的已展开 polars 映射表。 + + 复用 market_overview_builder 的概念识别 + 成分股读取逻辑(_dimension_field / + _read_ext_rows / _symbol_keys / _dimension_values), 但要的是「反向映射」 + (symbol → 概念), 且直接产出 polars DataFrame 供 join 使用。 + + 返回 (map_df, concept_count): + - map_df: 两列 (_sym_up: 大写 symbol, concept: 概念名), 已 explode, 一个 + symbol 属多概念时有多行。无概念数据时返回空 DataFrame。 + - concept_count: 去重概念总数。 + + 缓存: 概念成分股是 snapshot, 进程内不变, 缓存 600s。 + 直接缓存 DataFrame 而非 Python dict —— 后续 join 时省掉每次 ~1s 的 dict→DataFrame + 重建开销(这是结果缓存失效后重算的主要瓶颈)。 + """ + global _concept_map_cache, _concept_map_count, _concept_map_ts + now = time.time() + if _concept_map_cache is not None and (now - _concept_map_ts) < 600: + return _concept_map_cache, _concept_map_count + + data_dir = repo.store.data_dir + store = ExtConfigStore(data_dir) + # 先收集成扁平的 (sym, concept) 行, 再一次性构造 DataFrame(比 list 列快得多) + pairs: list[tuple[str, str]] = [] + concepts_seen: set[str] = set() + + for config in store.load_all(): + field = _dimension_field(config, "concept") + if not field: + continue + for ext_row in _read_ext_rows(data_dir, config, field): + concepts = _dimension_values(ext_row.get(field)) + if not concepts: + continue + keys = _symbol_keys(ext_row, config) + for key in keys: + for c in concepts: + pairs.append((key, c)) + concepts_seen.add(c) + + if pairs: + # 去重: 同一 (symbol, concept) 对会因多 key 形式(SZ/000001)和 + # 多 config 重复出现, 去重后从 ~48万 行降到 ~14万, join 快 3x+ + _concept_map_cache = pl.DataFrame( + {"_sym_up": [p[0] for p in pairs], "concept": [p[1] for p in pairs]}, + schema={"_sym_up": pl.Utf8, "concept": pl.Utf8}, + ).unique() + _concept_map_count = len(concepts_seen) + else: + _concept_map_cache = pl.DataFrame( + schema={"_sym_up": pl.Utf8, "concept": pl.Utf8} + ) + _concept_map_count = 0 + _concept_map_ts = now + return _concept_map_cache, _concept_map_count + + +_concept_map_cache: pl.DataFrame | None = None +_concept_map_count: int = 0 +_concept_map_ts: float = 0.0 + + +def build_rps_rotation(repo, days: int = 12) -> dict: + """构建概念涨幅轮动矩阵。 + + Args: + repo: KlineRepository(含 _enriched_history_cache 内存历史)。 + days: 取最近 N 个交易日, 范围 [7, 30], 默认 12。 + + Returns: + { + "dates": ["2026-06-30", ...], # 最新在最前, 长度 ≤ days + "columns": {"2026-06-30": [[概念, 涨幅], ...], ...}, # 每列各自排序(高→低) + "concept_count": 387, # 去重概念总数(0 表示无概念数据) + } + 涨幅是小数(0.0522 = +5.22%)。无数据时返回空 columns。 + """ + days = max(7, min(30, days)) + + # 结果缓存: 同 days(→ 同 start/end)的请求在 TTL 内直接返回 + latest = _latest_enriched_date(repo) + if latest is None: + return {"dates": [], "columns": {}, "concept_count": 0} + + cache_key = latest.isoformat() + now = time.time() + cached = _cache.get(cache_key) + if cached and (now - _cache_ts.get(cache_key, 0)) < _CACHE_TTL: + # 缓存的是所有日期, 按需要的 days 截取(避免不同 days 各存一份) + return _slice_cached(cached, days) + + # 1. 概念映射(symbol → 概念), 已缓存为 polars DataFrame + map_df, concept_count = _load_concept_map_df(repo) + if map_df.is_empty(): + logger.info("rps_rotation: no concept data (ext_gn_ths not fetched yet)") + 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} + + # 4. 按 (date, concept) 聚合 avg change_pct —— 与 _dimension_rank:288 的简单平均口径一致 + agg = joined.group_by(["date", "concept"]).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 把每组的 (concept, 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("concept"), 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["concept"], row["avg_pct"])) + + full = { + "dates": [str(d) for d in all_dates_sorted], + "columns": columns, + "concept_count": concept_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"], + } diff --git a/frontend/src/components/RpsRotationDialog.tsx b/frontend/src/components/RpsRotationDialog.tsx new file mode 100644 index 0000000..a5bbddd --- /dev/null +++ b/frontend/src/components/RpsRotationDialog.tsx @@ -0,0 +1,329 @@ +import { useState, useMemo, useRef, useEffect, useCallback } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { X, Repeat, Sparkles, ArrowDownUp, Search } from 'lucide-react' +import { useQuery } from '@tanstack/react-query' +import { api } from '@/lib/api' +import { QK } from '@/lib/queryKeys' +import { cn } from '@/lib/cn' +import { fmtPct } from '@/lib/format' + +interface Props { + onClose: () => void +} + +const DEFAULT_DAYS = 12 +const ROW_HEIGHT = 30 // 每行高度(px), 与单元格样式配合 +const OVERSCAN = 8 // 上下额外渲染行数, 减少滚动时的白屏闪烁 +const MIN_DAYS = 7 +const MAX_DAYS = 30 + +// 涨幅 → 背景色梯度(A 股语义: 红涨绿跌)。强度越大色越深, 一眼看出强势/弱势概念 +function pctBgClass(pct: number): string { + if (pct >= 0.05) return 'bg-bull/25' + if (pct >= 0.03) return 'bg-bull/18' + if (pct >= 0.01) return 'bg-bull/10' + if (pct > -0.01) return '' + if (pct > -0.03) return 'bg-bear/10' + if (pct > -0.05) return 'bg-bear/18' + return 'bg-bear/25' +} + +// 把 "2026-07-01" 格式化成 "7/01" 紧凑显示(表头窄列) +function shortDate(s: string): string { + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s) + if (!m) return s + return `${Number(m[2])}/${m[3]}` +} + +export function RpsRotationDialog({ onClose }: Props) { + const [days, setDays] = useState(DEFAULT_DAYS) + const [reversed, setReversed] = useState(false) // false=高→低, true=低→高 + const [selected, setSelected] = useState(null) // 点中的概念名, 高亮追踪 + const [search, setSearch] = useState('') + + // 数据请求: React Query 缓存, 同 days 5 分钟内重开秒开 + const { data, isLoading, error } = useQuery({ + queryKey: QK.rpsRotation(days), + queryFn: () => api.rpsRotation(days), + staleTime: 5 * 60 * 1000, + }) + + const dates = data?.dates ?? [] + const columns = data?.columns ?? {} + const conceptCount = data?.concept_count ?? 0 + + // 行数 = 最长那列的长度(理论上每天概念数应一致, 取最大兜底) + const rowCount = useMemo( + () => dates.reduce((m, d) => Math.max(m, columns[d]?.length ?? 0), 0), + [dates, columns], + ) + + // 行索引: 翻转时不重排数据, 只翻转访问索引(省一次大数组操作) + const getRowIndex = useCallback( + (displayIdx: number) => (reversed ? rowCount - 1 - displayIdx : displayIdx), + [reversed, rowCount], + ) + + // ---- 手写虚拟滚动 ---- + // 监听滚动容器 scrollTop, 只渲染 [firstIdx, lastIdx] 范围内的行。 + // 387 行只画可视的 ~25 行 + overscan, DOM 恒定 ~30 行 × N 列, 滚动 60fps。 + const scrollRef = useRef(null) + const [visibleRange, setVisibleRange] = useState({ start: 0, end: 25 }) + + const handleScroll = useCallback(() => { + const el = scrollRef.current + if (!el) return + const scrollTop = el.scrollTop + const viewportH = el.clientHeight + const start = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN) + const end = Math.min(rowCount, Math.ceil((scrollTop + viewportH) / ROW_HEIGHT) + OVERSCAN) + setVisibleRange(prev => (prev.start === start && prev.end === end ? prev : { start, end })) + }, [rowCount]) + + useEffect(() => { + // rowCount 变化(切天数/数据到达)时重算可视范围 + handleScroll() + }, [handleScroll, rowCount]) + + // ESC 关闭 + useEffect(() => { + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [onClose]) + + // 搜索命中: 找出该概念在(未翻转的)每列中的排名, 用于跳转高亮 + // 仅在有搜索词时计算, 避免每次渲染都遍历 + const searchMatch = useMemo(() => { + const q = search.trim() + if (!q || rowCount === 0) return null + // 在最新日期列里找第一个含搜索词的概念, 返回它的显示行号(考虑翻转) + const latest = dates[0] + const col = columns[latest] ?? [] + const rawIdx = col.findIndex(([name]) => name.includes(q)) + if (rawIdx < 0) return null + return reversed ? rowCount - 1 - rawIdx : rawIdx + }, [search, columns, dates, reversed, rowCount]) + + // 搜索命中时自动滚到该行 + useEffect(() => { + if (searchMatch == null) return + const el = scrollRef.current + if (el) el.scrollTo({ top: searchMatch * ROW_HEIGHT - el.clientHeight / 2, behavior: 'smooth' }) + }, [searchMatch]) + + const renderRows = useMemo(() => { + const rows: JSX.Element[] = [] + for (let displayIdx = visibleRange.start; displayIdx < visibleRange.end; displayIdx++) { + const rawIdx = getRowIndex(displayIdx) + const cells = dates.map((d) => { + const cell = columns[d]?.[rawIdx] + if (!cell) { + return ( + + + + ) + } + const [name, pct] = cell + const isSelected = selected === name + return ( + setSelected(prev => prev === name ? null : name)} + className={cn( + 'px-2 py-1 cursor-pointer whitespace-nowrap text-center align-middle transition-colors', + pctBgClass(pct), + isSelected && 'ring-1 ring-inset ring-accent bg-accent/20', + )} + > +
+ {name} + 0 ? 'text-bull' : pct < 0 ? 'text-bear' : 'text-muted', + )}>{fmtPct(pct)} +
+ + ) + }) + rows.push( + + + {displayIdx + 1} + + {cells} + , + ) + } + return rows + }, [visibleRange, getRowIndex, dates, columns, selected]) + + return ( + + { if (e.target === e.currentTarget) onClose() }} + > + + {/* 标题栏 */} +
+
+ + 概念涨幅轮动 + + {conceptCount > 0 ? `${dates.length} 天 · ${conceptCount} 个概念` : '暂无数据'} + +
+ +
+ + {/* 上半区: AI 分析占位 */} +
+
+ + AI 轮动分析 +
+
+
+ + AI 轮动分析功能开发中,敬请期待 +
+
+
+ + {/* 工具栏 */} +
+
+ 天数 + setDays(Number(e.target.value))} + className="w-24 accent-accent cursor-pointer" + /> + {days} +
+ +
+ + setSearch(e.target.value)} + placeholder="搜索概念定位…" + className="w-full pl-7 pr-2 py-1 text-[11px] bg-elevated/50 border border-border rounded-btn text-foreground placeholder:text-muted/50 focus:outline-none focus:border-accent/40" + /> +
+ {selected && ( + + )} +
+ + {/* 下半区: 涨幅轮动矩阵(虚拟滚动) */} +
+ {isLoading ? ( +
+
+
+ ) : error ? ( +
+ 加载失败,请稍后重试 +
+ ) : rowCount === 0 ? ( +
+ 暂无概念数据,请先在「概念分析」页配置并获取概念数据源 +
+ ) : ( +
+ + {/* 表头: 日期列, 最新在最左 */} + + + + {dates.map(d => ( + + ))} + + + + {/* 顶部占位: 把滚动位置撑起来 */} + {visibleRange.start > 0 && ( + + + )} + {renderRows} + {/* 底部占位 */} + {visibleRange.end < rowCount && ( + + + )} + +
+ # + + {shortDate(d)} +
+
+
+
+ )} +
+ + {/* 底部提示 */} +
+ + 每列各自按当日涨幅排序 · 点击单元格追踪概念在各日的排名变化 + +
+ + + + ) +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 6db5241..2ada651 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -319,6 +319,14 @@ export interface OverviewMarket { industry_rank: { leading: OverviewDimensionRankItem[]; lagging: OverviewDimensionRankItem[] } } +// ===== 概念涨幅轮动矩阵 ===== +// dates: 日期字符串列表(最新在最前); columns: {日期: [[概念名, 涨幅小数], ...]} 每列各自降序 +export interface RpsRotationData { + dates: string[] + columns: Record + concept_count: number +} + // ===== 大盘复盘 ===== export interface AiReviewReport { id: string @@ -1094,6 +1102,10 @@ export const api = { request<{ as_of: string | null; rows: MarketSnapshotRow[] }>('/api/screener/market-snapshot'), overviewMarket: (asOf?: string) => request(`/api/overview/market${asOf ? `?as_of=${asOf}` : ''}`), + // 概念涨幅轮动矩阵: 每列(日期)各自把所有概念按当天涨幅从高到低排序 + rpsRotation: (days: number) => + request(`/api/rps/rotation?days=${days}`), + limitLadder: (asOf?: string, extColumns?: string, direction?: 'up' | 'down') => { const params = new URLSearchParams() if (asOf) params.set('as_of', asOf) diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 61cbb69..d20c9d5 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -73,6 +73,9 @@ export const QK = { // AI 大盘复盘 reviewReports: ['review-reports'] as const, + + // 概念涨幅轮动矩阵 + rpsRotation: (days: number) => ['rps-rotation', days] as const, } as const // ===== SSE 应该 invalidate 的 key 前缀列表 ===== diff --git a/frontend/src/pages/ConceptAnalysis.tsx b/frontend/src/pages/ConceptAnalysis.tsx index 689460e..419d6b3 100644 --- a/frontend/src/pages/ConceptAnalysis.tsx +++ b/frontend/src/pages/ConceptAnalysis.tsx @@ -16,12 +16,12 @@ import { PageHeader } from '@/components/PageHeader' import { EmptyState } from '@/components/EmptyState' import { AnalysisConfigDialog, PresetFetchState, type AnalysisFieldConfig } from '@/components/analysis-shared' import { StockPreviewDialog } from '@/components/StockPreviewDialog' +import { RpsRotationDialog } from '@/components/RpsRotationDialog' import { api, type MarketSnapshotRow } from '@/lib/api' import { QK } from '@/lib/queryKeys' import { storage } from '@/lib/storage' import { fmtBigNum, fmtPct, priceColorClass } from '@/lib/format' import { cn } from '@/lib/cn' -import { toast } from '@/components/Toast' import { resolveDimension, type DimensionGroup, type StockRow } from '@/lib/analysis-adapter' const KEYWORDS = ['concept', '概念', 'theme', '题材', '板块'] @@ -241,6 +241,7 @@ export function ConceptAnalysis() { const [sortMode, setSortMode] = useState('heat') const [previewSymbol, setPreviewSymbol] = useState(null) const [previewName, setPreviewName] = useState('') + const [showRps, setShowRps] = useState(false) const configsQuery = useQuery({ queryKey: QK.extData, queryFn: api.extDataList }) const availableConfigs = configsQuery.data?.items ?? [] @@ -360,13 +361,13 @@ export function ConceptAnalysis() { subtitle={`${marketQuery.data?.as_of ?? rowsQuery.data?.date ?? '最新'} · ${stats.length} 个概念 · ${totalSymbols} 只标的`} right={
- {/* RPS 轮动计算(占位, 功能开发中) */} + {/* RPS 轮动: 打开涨幅轮动矩阵对话框 */}