feat: 概念涨幅RPS轮动分析

This commit is contained in:
shy3130
2026-07-01 17:48:28 +08:00
parent c6541569c9
commit 5a9fa9b255
7 changed files with 598 additions and 7 deletions
+27
View File
@@ -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)
+2 -1
View File
@@ -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)
+214
View File
@@ -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"],
}
@@ -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<string | null>(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<HTMLDivElement>(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 (
<td key={d} className="px-2 py-1 text-center text-muted/40">
<span className="text-[10px]"></span>
</td>
)
}
const [name, pct] = cell
const isSelected = selected === name
return (
<td
key={d}
onClick={() => 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',
)}
>
<div className="flex flex-col items-center gap-0.5 leading-tight">
<span className={cn(
'text-[11px] max-w-[84px] truncate',
isSelected ? 'text-accent font-medium' : 'text-secondary',
)} title={name}>{name}</span>
<span className={cn(
'text-[10px] tabular-nums',
pct > 0 ? 'text-bull' : pct < 0 ? 'text-bear' : 'text-muted',
)}>{fmtPct(pct)}</span>
</div>
</td>
)
})
rows.push(
<tr
key={displayIdx}
style={{ height: ROW_HEIGHT }}
className="border-b border-border/30"
>
<td className="sticky left-0 z-10 bg-surface px-2 text-center text-[10px] text-muted tabular-nums border-r border-border/40">
{displayIdx + 1}
</td>
{cells}
</tr>,
)
}
return rows
}, [visibleRange, getRowIndex, dates, columns, selected])
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={e => { if (e.target === e.currentTarget) onClose() }}
>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
transition={{ duration: 0.15, ease: [0.16, 1, 0.3, 1] }}
className="w-[92vw] max-w-[1100px] h-[88vh] bg-surface border border-border rounded-card shadow-xl flex flex-col"
>
{/* 标题栏 */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border shrink-0">
<div className="flex items-center gap-2">
<Repeat className="h-4 w-4 text-accent" />
<span className="text-sm font-medium text-foreground"></span>
<span className="text-[11px] text-muted">
{conceptCount > 0 ? `${dates.length} 天 · ${conceptCount} 个概念` : '暂无数据'}
</span>
</div>
<button onClick={onClose} className="p-1 rounded hover:bg-elevated transition-colors cursor-pointer">
<X className="h-4 w-4 text-muted" />
</button>
</div>
{/* 上半区: AI 分析占位 */}
<div className="shrink-0 border-b border-border">
<div className="flex items-center gap-1.5 px-4 py-1.5 bg-elevated/30">
<Sparkles className="h-3.5 w-3.5 text-accent/60" />
<span className="text-[11px] text-muted">AI </span>
</div>
<div className="px-4 py-3 text-center">
<div className="inline-flex items-center gap-1.5 text-[11px] text-muted/60">
<Sparkles className="h-3.5 w-3.5" />
<span>AI ,</span>
</div>
</div>
</div>
{/* 工具栏 */}
<div className="flex items-center gap-3 px-4 py-2 border-b border-border shrink-0">
<div className="flex items-center gap-1.5">
<span className="text-[11px] text-muted"></span>
<input
type="range"
min={MIN_DAYS}
max={MAX_DAYS}
step={1}
value={days}
onChange={e => setDays(Number(e.target.value))}
className="w-24 accent-accent cursor-pointer"
/>
<span className="text-[11px] text-secondary tabular-nums w-5">{days}</span>
</div>
<button
onClick={() => setReversed(r => !r)}
className={cn(
'inline-flex items-center gap-1 px-2 py-1 rounded-btn text-[11px] transition-colors cursor-pointer border',
reversed
? 'bg-accent/10 text-accent border-accent/30'
: 'border-border text-muted hover:text-secondary hover:bg-elevated',
)}
title="翻转排序(高↔低)"
>
<ArrowDownUp className="h-3 w-3" />
{reversed ? '低→高' : '高→低'}
</button>
<div className="relative flex-1 max-w-[220px] ml-auto">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3 w-3 text-muted/50" />
<input
type="text"
value={search}
onChange={e => 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"
/>
</div>
{selected && (
<button
onClick={() => setSelected(null)}
className="text-[11px] text-accent hover:underline cursor-pointer"
>
{selected}
</button>
)}
</div>
{/* 下半区: 涨幅轮动矩阵(虚拟滚动) */}
<div className="flex-1 min-h-0 flex flex-col">
{isLoading ? (
<div className="flex items-center justify-center py-16">
<div className="w-5 h-5 border-2 border-accent/30 border-t-accent rounded-full animate-spin" />
</div>
) : error ? (
<div className="flex items-center justify-center py-16 text-[11px] text-danger">
,
</div>
) : rowCount === 0 ? (
<div className="flex items-center justify-center py-16 text-[11px] text-muted">
,
</div>
) : (
<div
ref={scrollRef}
onScroll={handleScroll}
className="flex-1 overflow-auto"
>
<table className="min-w-full border-collapse">
{/* 表头: 日期列, 最新在最左 */}
<thead className="sticky top-0 z-20 bg-surface">
<tr>
<th className="sticky left-0 z-30 bg-surface px-2 py-1.5 text-[10px] font-normal text-muted border-b border-r border-border/40">
#
</th>
{dates.map(d => (
<th
key={d}
className="px-2 py-1.5 text-[10px] font-normal text-muted border-b border-border/40 whitespace-nowrap text-center"
title={d}
>
{shortDate(d)}
</th>
))}
</tr>
</thead>
<tbody>
{/* 顶部占位: 把滚动位置撑起来 */}
{visibleRange.start > 0 && (
<tr style={{ height: visibleRange.start * ROW_HEIGHT }}>
<td colSpan={dates.length + 1} />
</tr>
)}
{renderRows}
{/* 底部占位 */}
{visibleRange.end < rowCount && (
<tr style={{ height: (rowCount - visibleRange.end) * ROW_HEIGHT }}>
<td colSpan={dates.length + 1} />
</tr>
)}
</tbody>
</table>
</div>
)}
</div>
{/* 底部提示 */}
<div className="px-4 py-1.5 border-t border-border shrink-0">
<span className="text-[10px] text-muted">
·
</span>
</div>
</motion.div>
</motion.div>
</AnimatePresence>
)
}
+12
View File
@@ -319,6 +319,14 @@ export interface OverviewMarket {
industry_rank: { leading: OverviewDimensionRankItem[]; lagging: OverviewDimensionRankItem[] }
}
// ===== 概念涨幅轮动矩阵 =====
// dates: 日期字符串列表(最新在最前); columns: {日期: [[概念名, 涨幅小数], ...]} 每列各自降序
export interface RpsRotationData {
dates: string[]
columns: Record<string, [string, number][]>
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<OverviewMarket>(`/api/overview/market${asOf ? `?as_of=${asOf}` : ''}`),
// 概念涨幅轮动矩阵: 每列(日期)各自把所有概念按当天涨幅从高到低排序
rpsRotation: (days: number) =>
request<RpsRotationData>(`/api/rps/rotation?days=${days}`),
limitLadder: (asOf?: string, extColumns?: string, direction?: 'up' | 'down') => {
const params = new URLSearchParams()
if (asOf) params.set('as_of', asOf)
+3
View File
@@ -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 前缀列表 =====
+11 -6
View File
@@ -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<SortMode>('heat')
const [previewSymbol, setPreviewSymbol] = useState<string | null>(null)
const [previewName, setPreviewName] = useState<string>('')
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={
<div className="flex items-center gap-1">
{/* RPS 轮动计算(占位, 功能开发中) */}
{/* RPS 轮动: 打开涨幅轮动矩阵对话框 */}
<button
onClick={() => toast('涨幅RPS轮动功能开发中,敬请期待')}
className="inline-flex items-center gap-1 rounded-btn border border-border bg-elevated px-2.5 py-1.5 text-[11px] text-secondary transition-colors hover:border-accent/40 hover:text-accent"
title="涨幅RPS轮动(开发中)"
onClick={() => setShowRps(true)}
className="inline-flex items-center gap-1 rounded-btn border border-amber-400/40 bg-amber-400/15 px-2.5 py-1.5 text-[11px] text-amber-400 font-medium transition-colors hover:bg-amber-400/25 hover:border-amber-400/60"
title="概念涨幅轮动矩阵"
>
<Repeat className="h-3.5 w-3.5" />RPS轮动
<Repeat className="h-3.5 w-3.5" />RPS轮动分析
</button>
<button
onClick={() => { rowsQuery.refetch(); marketQuery.refetch() }}
@@ -435,6 +436,10 @@ export function ConceptAnalysis() {
onClose={() => { setPreviewSymbol(null); setPreviewName('') }}
/>
)}
<AnimatePresence>
{showRps && <RpsRotationDialog onClose={() => setShowRps(false)} />}
</AnimatePresence>
</>
)
}