Files
stock/serve/backend/app/services/concept_rotation_analyzer.py
T
2026-07-04 16:59:25 +08:00

358 lines
14 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.
"""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)