diff --git a/backend/app/api/rps.py b/backend/app/api/rps.py index 512abe5..558aa9a 100644 --- a/backend/app/api/rps.py +++ b/backend/app/api/rps.py @@ -6,8 +6,11 @@ from __future__ import annotations from fastapi import APIRouter, Query, Request +from fastapi.responses import StreamingResponse +from pydantic import BaseModel from app.services import rps_rotation +from app.services.concept_rotation_analyzer import analyze_rotation_stream router = APIRouter(prefix="/api/rps", tags=["rps"]) @@ -25,3 +28,40 @@ def get_rotation( concept_count: 去重概念总数 """ return rps_rotation.build_rps_rotation(request.app.state.repo, days) + + +class AnalyzeRequest(BaseModel): + """AI 概念轮动分析请求。""" + days: int = 12 # 分析最近 N 个交易日 + focus: str = "" # 用户追加的关注点 + + +@router.post("/rotation-analyze") +async def analyze_rotation(request: Request, req: AnalyzeRequest): + """AI 概念轮动分析 — NDJSON 流式返回。 + + 装配轮动矩阵信号 + 大盘背景 → 分析提示词 → 流式调用 LLM → + 逐 chunk 以 NDJSON 推给前端(每行一个 JSON)。 + + 协议: + {"type":"meta","days","summary"} + {"type":"delta","content":"..."} + {"type":"error","message":"..."} + {"type":"done"} + """ + repo = request.app.state.repo + quote_service = getattr(request.app.state, "quote_service", None) + depth_service = getattr(request.app.state, "depth_service", None) + days = max(7, min(30, req.days)) + + async def stream_gen(): + async for chunk in analyze_rotation_stream( + repo, days, req.focus, quote_service, depth_service, + ): + yield chunk + "\n" + + return StreamingResponse( + stream_gen(), + media_type="application/x-ndjson", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) diff --git a/backend/app/services/concept_rotation_analyzer.py b/backend/app/services/concept_rotation_analyzer.py new file mode 100644 index 0000000..e6721a0 --- /dev/null +++ b/backend/app/services/concept_rotation_analyzer.py @@ -0,0 +1,357 @@ +"""AI 概念轮动分析 — 从概念涨幅排名矩阵提炼主线/新晋/退潮信号。 + +数据来源: + - rps_rotation.build_rps_rotation: 概念涨幅排名矩阵 (N 日 × ~387 概念) + - market_overview_builder.build_market_overview: 大盘背景 (指数/情绪/涨停) + +架构 (复刻 market_recap): + 预计算轮动信号 → 拼装 prompt → stream_ai_text 流式调用 → NDJSON 协议输出 + 协议事件: meta(摘要) / delta(文本片段) / error / done +""" +from __future__ import annotations + +import json +import logging +import math +from collections.abc import AsyncIterator + +logger = logging.getLogger(__name__) + + +# ================================================================ +# System Prompt — 轮动策略师人格 + 固定章节模板 +# ================================================================ + +_SYSTEM_PROMPT = """你是一位专注 A 股题材轮动的资深策略师,拥有 12 年一线实战经验,擅长从概念板块的**涨幅排名矩阵**中识别主力资金脉络,区分机构主导的持续性主线与游资驱动的脉冲式轮动,产出可直接指导题材跟踪与节奏把握的轮动分析。 + +## 输出规范 + +用 **Markdown** 格式输出,严格遵循以下结构。不要输出任何 JSON 或代码块,直接输出 Markdown 正文。 + +### 1. 🎯 主线研判(2-3 句) +点名当前最核心的 1-2 条主线题材(连续多日霸榜的强势概念),用一句话概括其逻辑(政策/产业/业绩/事件驱动),并判断是**主升期/加速期/扩散期/见顶期**。结尾用【主线强度:强 / 中 / 弱】定性。 + +### 2. 🆕 新晋强势 +列出排名快速跃升的概念(从榜单中后段冲进前列的),逐个给出: +- 概念名 + 近 N 日排名变化(如 `45→20→8`) +- 涨幅加速度(连日递增 = 趋势加强) +- 可能的驱动逻辑(从板块属性推断,不要编造具体消息) +- 判断是**主力切入**还是**消息脉冲** + +### 3. 📉 退潮预警 +列出从高位明显滑落的概念(连续排名下滑或涨幅骤降),逐个给出: +- 概念名 + 排名下滑轨迹 +- 退潮性质(高位分歧/资金撤离/补跌) +- 是否扩散风险 + +### 4. 🏛️ 机构主线 vs 🎰 游资轮动 +基于排名稳定性区分两类资金行为: +- **机构主线**:排名标准差小、长期稳居前列的概念 → 持续性判断、是否可作底仓方向 +- **游资轮动**:排名剧烈波动、脉冲式冲高的概念 → 短线节奏提示、追高风险 +给出当前市场**整体轮动节奏**(快轮动/慢轮动/主线聚焦)的判断。 + +### 5. 🌐 结合大盘 +结合提供的大盘数据(指数涨跌/情绪/涨停数),判断: +- 当前大盘环境对题材轮动是助力还是阻力 +- 情绪温度与轮动节奏的匹配度(如情绪冰点但题材活跃 = 抱团;情绪火热但轮动快 = 末段) + +### 6. 🎯 操作建议 +- **跟踪方向**:主线延续 + 新晋确认的概念 +- **规避方向**:明确退潮 + 高位脉冲的概念 +- **节奏提示**:当前适合追高 / 低吸 / 观望,及切换信号(如"主线概念连续 2 日跌出前 10 则确认退潮") + +### 7. ⚠️ 风险提示 +列出需要盯的风险(如主线断层、情绪与轮动背离、成交萎缩)。末尾附一行: +"> ⚠️ 本报告由 AI 基于公开行情数据生成,仅供参考,不构成任何投资建议。交易有风险,入市需谨慎。" + +## 分析准则(务必遵守) + +0. **只输出结论,不输出思考过程**:禁止复述你的分析步骤。不要写"我先看...""基于上述数据我认为"——直接给结论。 +1. **数据说话**:每个判断引用具体排名/涨幅数值,严禁空泛套话("强势"必须改成"连续 4 日稳居前 5,均涨 +4.2%")。 +2. **诚实中立**:数据不支持的结论就直言"信号不足,暂无法判断",不要硬凑。 +3. **区分资金性质**:这是本分析的核心价值——机构 vs 游资的判断必须基于排名稳定性(标准差),不要凭感觉。 +4. **不重复数字**:正文负责解读信号含义,不要照抄罗列已提供的全部原始数据。 +5. **简明实战**:总字数 1000-1800 字,重在可执行。 +6. **客观推断**:若无明确消息,从量价异动推断可能逻辑并给结论,不要标注"[推断]"或编造具体新闻。 + +现在请基于下方概念轮动数据进行分析。""" + + +# ================================================================ +# 预计算: 把排名矩阵转成结构化轮动信号 +# ================================================================ + +# 每类信号最多取多少个概念喂给 AI (控制 token) +_TOP_N = 8 + + +def _compute_rotation_signals(dates: list[str], columns: dict) -> dict: + """从概念涨幅排名矩阵计算轮动信号。 + + Args: + dates: 日期列表 (最新在最前, 与 columns key 一致) + columns: {日期: [[概念, 涨幅], ...]} 每列各自降序 + + Returns: + { + "persistent_leaders": [...], # 连续多日稳居前列 (主线) + "rising": [...], # 排名快速跃升 (新晋) + "fading": [...], # 从高位滑落 (退潮) + "institutional": [...], # 排名稳定 (机构特征) + "hot_money": [...], # 排名波动大 (游资特征) + } + 每项含: concept, ranks (按 dates 顺序), pcts, avg_rank, rank_std + ranks 时间方向: ranks[0] = 最早日, ranks[-1] = 最新日 (已反转, 左老右新) + """ + if not dates or not columns: + return {} + + # 按时间正序 (左老右新) 处理 + dates_asc = list(reversed(dates)) + + # 收集每个概念在各日期的 (排名, 涨幅)。排名 = 该日在列中的索引 + 1。 + concept_data: dict[str, list[tuple[int, float]]] = {} + for d in dates_asc: + col = columns.get(d) or [] + for idx, (name, pct) in enumerate(col): + concept_data.setdefault(name, []).append((idx + 1, pct)) + + n_dates = len(dates_asc) + + def _stats(ranks_pcts: list[tuple[int, float]]) -> dict: + ranks = [r for r, _ in ranks_pcts] + pcts = [p for _, p in ranks_pcts] + avg = sum(ranks) / len(ranks) if ranks else 0 + var = sum((r - avg) ** 2 for r in ranks) / len(ranks) if ranks else 0 + return { + "ranks": ranks, + "pcts": [round(p, 4) for p in pcts], + "avg_rank": round(avg, 1), + "rank_std": round(math.sqrt(var), 1), + } + + persistent: list[dict] = [] + rising: list[dict] = [] + fading: list[dict] = [] + institutional: list[dict] = [] + hot_money: list[dict] = [] + + for concept, rp in concept_data.items(): + # 缺失日补 (大排名, 0 涨幅) 保持时间轴对齐 + if len(rp) < n_dates: + rp = rp + [(999, 0.0)] * (n_dates - len(rp)) + s = _stats(rp) + s["concept"] = concept + + ranks = s["ranks"] + latest_rank = ranks[-1] + earliest_rank = ranks[0] + # 最近 3 日 (不足则全部) 均排名, 判断近期强度 + recent = ranks[-min(3, len(ranks)):] + recent_avg = sum(recent) / len(recent) + + # 主线: 近期稳居前 10 + if recent_avg <= 10 and latest_rank <= 10: + persistent.append(s) + + # 新晋: 早期排名靠后(>30), 最新冲进前 20, 跃升幅度大 + jump = earliest_rank - latest_rank + if earliest_rank > 30 and latest_rank <= 20 and jump >= 20: + rising.append(s) + + # 退潮: 早期排名靠前(<=10), 最新滑落到 30 外 + drop = latest_rank - earliest_rank + if earliest_rank <= 10 and latest_rank > 30 and drop >= 20: + fading.append(s) + + # 机构: 排名标准差小且平均排名靠前 (稳定强势) + if s["rank_std"] <= 5 and s["avg_rank"] <= 20: + institutional.append(s) + + # 游资: 排名标准差大 (波动剧烈) + if s["rank_std"] >= 20: + hot_money.append(s) + + # 排序: 主线按近期排名升序; 新晋按跃升幅度降序; 退潮按跌幅降序 + persistent.sort(key=lambda x: x["avg_rank"]) + rising.sort(key=lambda x: x["ranks"][0] - x["ranks"][-1], reverse=True) + fading.sort(key=lambda x: x["ranks"][-1] - x["ranks"][0], reverse=True) + institutional.sort(key=lambda x: (x["rank_std"], x["avg_rank"])) + hot_money.sort(key=lambda x: x["rank_std"], reverse=True) + + return { + "persistent_leaders": persistent[:_TOP_N], + "rising": rising[:_TOP_N], + "fading": fading[:_TOP_N], + "institutional": institutional[:_TOP_N], + "hot_money": hot_money[:_TOP_N], + } + + +# ================================================================ +# Prompt 构建 +# ================================================================ + +def _fmt_pct(v) -> str: + if v is None: + return "—" + return f"{v*100:+.2f}%" + + +def _build_market_block(overview: dict) -> str: + """大盘背景精简块 (复用 market_overview 已算好的字段)。""" + indices = overview.get("indices") or [] + emo = overview.get("emotion") or {} + lim = overview.get("limit") or {} + amt = overview.get("amount") or {} + + idx_lines = [] + for idx in indices[:4]: + name = idx.get("name") or idx.get("symbol") or "?" + chg = idx.get("change_pct") + idx_lines.append(f"{name} {_fmt_pct(chg)}") + idx_str = " / ".join(idx_lines) or "指数缺失" + + total_amount = (amt.get("total") or 0) / 1e8 # 元 → 亿 + + return ( + f"- 指数: {idx_str}\n" + f"- 情绪: {emo.get('score', 50)} ({emo.get('label', '—')})\n" + f"- 涨停/炸板/跌停: {lim.get('limit_up', 0)} / {lim.get('broken', 0)} / {lim.get('limit_down', 0)}" + f" (最高连板 {lim.get('max_boards', 0)})\n" + f"- 两市成交额: {total_amount:.0f} 亿元" + ) + + +def _build_signal_block(title: str, items: list[dict]) -> str: + """轮动信号块: 把预计算的概念信号转成紧凑文本。""" + if not items: + return f"### {title}\n(本类无明显信号)" + lines = [f"### {title}"] + for it in items: + ranks_str = "→".join(str(r) if r < 999 else "—" for r in it["ranks"]) + avg_pct = sum(it["pcts"]) / len(it["pcts"]) if it["pcts"] else 0 + lines.append( + f"- {it['concept']}: 排名 {ranks_str} | 均排名 {it['avg_rank']} " + f"| 排名波动σ {it['rank_std']} | 区间均涨 {_fmt_pct(avg_pct)}" + ) + return "\n".join(lines) + + +def _build_user_prompt(signals: dict, overview: dict, days: int, dates: list[str], focus: str) -> str: + """组装 user 消息: 大盘背景 + 轮动信号 + focus。""" + dates_asc = list(reversed(dates)) + date_range = f"{dates_asc[0]} ~ {dates_asc[-1]}" if dates_asc else "—" + + parts = [ + f"# 概念涨幅轮动数据 (最近 {days} 个交易日: {date_range})", + "", + "## 大盘背景", + _build_market_block(overview), + "", + "## 轮动信号 (排名时间方向: 左→右 = 旧→新, 排名越小越强)", + "", + _build_signal_block("🎯 主线 (连续霸榜)", signals.get("persistent_leaders", [])), + "", + _build_signal_block("🆕 新晋强势 (排名跃升)", signals.get("rising", [])), + "", + _build_signal_block("📉 退潮预警 (高位滑落)", signals.get("fading", [])), + "", + _build_signal_block("🏛️ 机构特征 (排名稳定)", signals.get("institutional", [])), + "", + _build_signal_block("🎰 游资特征 (排名波动大)", signals.get("hot_money", [])), + ] + + if focus.strip(): + parts.extend(["", f"## 用户关注点\n{focus.strip()}"]) + + return "\n".join(parts) + + +def _build_summary(signals: dict) -> str: + """meta 事件的摘要 (前端可立即展示)。""" + leaders = signals.get("persistent_leaders", []) + rising = signals.get("rising", []) + fading = signals.get("fading", []) + leader_names = "、".join(it["concept"] for it in leaders[:3]) or "暂无明确主线" + return f"主线: {leader_names} | 新晋 {len(rising)} | 退潮 {len(fading)}" + + +# ================================================================ +# 流式主入口 +# ================================================================ + +async def analyze_rotation_stream( + repo, + days: int = 12, + focus: str = "", + quote_service=None, + depth_service=None, +) -> AsyncIterator[str]: + """流式概念轮动分析: yield 出每个 NDJSON 事件。 + + Args: + repo: KlineRepository (必填)。 + days: 分析最近 N 个交易日 (7-30)。 + focus: 用户追加的关注点。 + quote_service / depth_service: 可选, 大盘背景装配依赖。 + """ + from app.services.rps_rotation import build_rps_rotation + from app.services.market_overview_builder import build_market_overview + + # 1. 取轮动矩阵 + rotation = build_rps_rotation(repo, days) + dates = rotation.get("dates") or [] + columns = rotation.get("columns") or {} + + if not dates or not columns: + yield json.dumps({ + "type": "error", + "message": "暂无概念轮动数据,请先在「概念分析」页获取概念数据源", + }, ensure_ascii=False) + return + + # 2. 预计算轮动信号 + signals = _compute_rotation_signals(dates, columns) + + # 3. 大盘背景 (失败不阻断, 降级为空) + try: + overview = build_market_overview(repo, quote_service, depth_service) + except Exception as e: # noqa: BLE001 + logger.warning("rotation analyze: 大盘背景获取失败, 降级为空: %s", e) + overview = {} + + # 4. meta 事件 + yield json.dumps({ + "type": "meta", + "days": days, + "summary": _build_summary(signals), + }, ensure_ascii=False) + + # 5. 构建 prompt + 流式调用 LLM + try: + from app.services.ai_provider import stream_ai_text, ai_configured + + if not ai_configured(): + yield json.dumps({ + "type": "error", + "message": "AI 未配置,请在「设置」页填写 API Key 与接口地址", + }, ensure_ascii=False) + return + + user_prompt = _build_user_prompt(signals, overview, days, dates, focus) + async for delta in stream_ai_text( + [ + {"role": "system", "content": _SYSTEM_PROMPT}, + {"role": "user", "content": user_prompt}, + ], + temperature=0.5, + max_tokens=4000, + ): + yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False) + + except Exception as e: # noqa: BLE001 + logger.exception("AI concept rotation analyze failed: %s", e) + yield json.dumps({"type": "error", "message": f"AI 轮动分析失败: {e}"}, ensure_ascii=False) + + yield json.dumps({"type": "done"}, ensure_ascii=False) diff --git a/frontend/src/components/RpsRotationDialog.tsx b/frontend/src/components/RpsRotationDialog.tsx index a5bbddd..be669f8 100644 --- a/frontend/src/components/RpsRotationDialog.tsx +++ b/frontend/src/components/RpsRotationDialog.tsx @@ -1,11 +1,12 @@ import { useState, useMemo, useRef, useEffect, useCallback } from 'react' import { motion, AnimatePresence } from 'framer-motion' -import { X, Repeat, Sparkles, ArrowDownUp, Search } from 'lucide-react' +import { X, Repeat, Sparkles, ArrowDownUp, RefreshCw, AlertCircle } 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' +import { MarkdownRenderer } from '@/components/financials/MarkdownRenderer' interface Props { onClose: () => void @@ -35,11 +36,44 @@ function shortDate(s: string): string { return `${Number(m[2])}/${m[3]}` } +// 排名 → 前景色(A 股语义: 红=强, 绿=弱)。前 10 红, 后 10 绿, 中间默认强调色。 +// total 兜底: 概念总数未知时只判前 10, 不判后 10。 +function rankColorClass(rank: number, total: number): string { + if (rank <= 10) return 'text-bull' + if (total > 20 && rank > total - 10) return 'text-bear' + return 'text-accent' +} + 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('') + + // ---- AI 轮动分析状态 (组件内, 不建全局 store: 切页即关对话框) ---- + const [analysis, setAnalysis] = useState('') // 累积的 Markdown 报告 + const [analyzing, setAnalyzing] = useState(false) // 生成中 + const [analysisError, setAnalysisError] = useState('') // 错误信息 + const [analysisMeta, setAnalysisMeta] = useState<{ summary?: string } | null>(null) + const [focus, setFocus] = useState('') // 用户追加的关注点 + + const runAnalysis = useCallback(async (daysParam: number, focusParam: string) => { + setAnalyzing(true) + setAnalysis('') + setAnalysisError('') + setAnalysisMeta(null) + try { + for await (const ev of api.rotationAnalyzeStream(daysParam, focusParam)) { + if (ev.type === 'meta') setAnalysisMeta({ summary: ev.summary }) + else if (ev.type === 'delta') setAnalysis(a => a + (ev.content ?? '')) + else if (ev.type === 'error') setAnalysisError(ev.message ?? '未知错误') + // done: 无操作 + } + } catch (e) { + setAnalysisError(e instanceof Error ? e.message : String(e)) + } finally { + setAnalyzing(false) + } + }, []) // 数据请求: React Query 缓存, 同 days 5 分钟内重开秒开 const { data, isLoading, error } = useQuery({ @@ -68,8 +102,17 @@ export function RpsRotationDialog({ onClose }: Props) { // 监听滚动容器 scrollTop, 只渲染 [firstIdx, lastIdx] 范围内的行。 // 387 行只画可视的 ~25 行 + overscan, DOM 恒定 ~30 行 × N 列, 滚动 60fps。 const scrollRef = useRef(null) + // AI 报告区滚动容器: 流式生成时自动滚到底部 + const analysisRef = useRef(null) const [visibleRange, setVisibleRange] = useState({ start: 0, end: 25 }) + // 流式生成中: analysis 每次追加都把报告区滚到底部, 跟踪最新文字 + useEffect(() => { + if (!analyzing) return + const el = analysisRef.current + if (el) el.scrollTop = el.scrollHeight + }, [analysis, analyzing]) + const handleScroll = useCallback(() => { const el = scrollRef.current if (!el) return @@ -92,25 +135,19 @@ export function RpsRotationDialog({ onClose }: Props) { 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]) + // 选中概念的追踪行: 找出它在每个日期列的(排名, 涨幅)。 + // 每列已按涨幅降序排好, 故排名 = 该概念在数组里的索引 + 1。 + // 未入选该日(概念当天无数据)显示空, 便于横向看排名变化。 + const selectedRow = useMemo(() => { + if (!selected) return null + const cells: ({ rank: number; pct: number } | null)[] = [] + for (const d of dates) { + const col = columns[d] ?? [] + const idx = col.findIndex(([name]) => name === selected) + cells.push(idx >= 0 ? { rank: idx + 1, pct: col[idx][1] } : null) + } + return cells + }, [selected, dates, columns]) const renderRows = useMemo(() => { const rows: JSX.Element[] = [] @@ -196,18 +233,67 @@ export function RpsRotationDialog({ onClose }: Props) { - {/* 上半区: AI 分析占位 */} -
-
- - AI 轮动分析 -
-
-
- - AI 轮动分析功能开发中,敬请期待 + {/* 上半区: AI 轮动分析 */} +
+ {/* 标题栏: 标题 + meta 摘要 + focus 输入 + 触发按钮 */} +
+ + AI 轮动分析 + {analysisMeta?.summary && ( + {analysisMeta.summary} + )} +
+ setFocus(e.target.value)} + placeholder="关注点(可选)" + disabled={analyzing} + className="w-28 px-2 py-0.5 text-[11px] bg-elevated/50 border border-border rounded-btn text-foreground placeholder:text-muted/50 focus:outline-none focus:border-accent/40 disabled:opacity-50" + /> +
+ + {/* 报告内容区: 四态渲染 */} +
+ {analysisError ? ( +
+ + {analysisError} + +
+ ) : analysis || analyzing ? ( +
+ + {analyzing && ( + + )} +
+ ) : ( +
+ 点击「生成分析」,AI 将从主线研判 / 新晋强势 / 退潮预警 / 机构vs游资 等角度分析最近 {days} 天的概念轮动 +
+ )} +
{/* 工具栏 */} @@ -238,16 +324,6 @@ export function RpsRotationDialog({ onClose }: Props) { {reversed ? '低→高' : '高→低'} -
- - 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 && (