mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 15:34:16 +08:00
feat: 概念涨幅RPS轮动分析
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 前缀列表 =====
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user