重置项目
This commit is contained in:
@@ -0,0 +1,343 @@
|
||||
"""AI 大盘复盘 —— 流式 LLM 复盘生成。
|
||||
|
||||
复刻 stock_analyzer.py 的 NDJSON 流式协议(meta/delta/error/done),
|
||||
将「市场总览」聚合数据交给 LLM 生成结构化复盘报告。
|
||||
|
||||
数据来源:services.market_overview_builder.build_market_overview
|
||||
(与 GET /api/overview/market 同源,保证复盘与看板数据口径一致)。
|
||||
|
||||
流式协议(与 stock_analyzer / financial_analyzer 一致,前端解析无差异):
|
||||
{"type":"meta", "as_of", "emotion_score", "emotion_label", "summary"}
|
||||
{"type":"delta","content":"..."} 逐 chunk 文本
|
||||
{"type":"error","message":"..."}
|
||||
{"type":"done"}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import date
|
||||
from typing import AsyncIterator
|
||||
|
||||
from app.services.market_overview_builder import build_market_overview
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# 指数简称映射:摘要里用简称(上/深/创/科),全称太长列表放不下。与前端 INDEX_SHORT 对齐。
|
||||
_INDEX_SHORT = {
|
||||
"上证指数": "上",
|
||||
"深证成指": "深",
|
||||
"创业板指": "创",
|
||||
"科创综指": "科",
|
||||
"科创50": "科",
|
||||
}
|
||||
|
||||
# ================================================================
|
||||
# 系统提示词(市场策略师人格 + 固定七节模板)
|
||||
# ================================================================
|
||||
|
||||
_SYSTEM_PROMPT = """你是一位拥有 15 年 A 股一线实战经验的资深市场策略师,擅长从指数结构、涨跌家数、连板梯队、板块轮动与资金情绪中提炼交易主线,产出可直接指导次日仓位与节奏的盘后复盘报告。
|
||||
|
||||
## 输出规范
|
||||
|
||||
用 **Markdown** 格式输出,严格遵循以下结构。不要输出任何 JSON 或代码块,直接输出 Markdown 正文。
|
||||
|
||||
### 1. 🎯 一句话定调(1-2 句)
|
||||
用一句话概括今日市场的**核心矛盾与状态**(如"放量普涨、情绪修复,主线围绕科技扩散"/"指数虚高、个股杀跌,赚钱效应冰点")。结尾用【明日基调:进攻 / 均衡 / 防守】给出明确倾向。
|
||||
|
||||
### 2. 📊 盘面总览
|
||||
- 三大指数(上证/深证/创业板)表现:谁强谁弱、量能配合
|
||||
- 涨跌家数、涨停/跌停/炸板结构、两市成交额(放量/缩量判断)
|
||||
- 情绪温度(强势/偏暖/震荡/偏冷/冰点)及一句话依据
|
||||
|
||||
### 3. 📈 指数结构
|
||||
谁在护盘、谁在拖累;指数是否同步;关键支撑/压力位(基于当日点位推断);是否存在量价背离。
|
||||
|
||||
### 4. 🔥 板块主线
|
||||
- 领涨板块:背后的逻辑(消息/业绩/资金/技术)、持续性判断、是否形成可交易主线
|
||||
- 领跌板块:风险信号、是否扩散
|
||||
- 连板梯队与投机情绪:最高连板、封板率、炸板率反映的资金激进程度
|
||||
|
||||
### 5. 💰 资金与情绪
|
||||
成交额结构(增量/存量)、市场宽度(上涨占比、站上均线占比)、量能指标(量比)解读;风险偏好是修复还是转弱。
|
||||
|
||||
### 6. 📰 消息催化
|
||||
结合提供的近期新闻,提炼真正影响明日交易节奏的催化或扰动,明确区分"已兑现"与"待发酵"。**若无新闻数据,则直接从量价异动推断可能的催化逻辑并给出结论,不要标注"[推断]"之类的过程标签,更不要编造具体消息。**
|
||||
|
||||
### 7. 🎯 明日交易计划
|
||||
- 进攻 / 均衡 / 防守:基于今日盘面给出次日基调
|
||||
- 仓位区间建议(轻仓/半仓/重仓的粗略指引)
|
||||
- 关注方向(领涨延续 / 低吸 / 反包)与回避方向(高位滞涨 / 杀跌扩散)
|
||||
- 一个明确的触发失效条件(如"若上证跌破 X 点则转为防守")
|
||||
|
||||
### 8. ⚠️ 风险提示
|
||||
列出需要重点盯的风险点(如量能跟不上、外资流出、连板断层等)。末尾附一行:
|
||||
"> ⚠️ 本报告由 AI 基于公开行情数据生成,仅供参考,不构成任何投资建议。交易有风险,入市需谨慎。"
|
||||
|
||||
## 分析准则(务必遵守)
|
||||
|
||||
0. **只输出结论,不输出思考过程**:禁止复述你的分析步骤或方法论。不要写"我先按...做结构化复盘""接下来看...""基于上述数据我认为"这类元话语——直接给结论。读者要的是复盘结果,不是你怎么推导出来的。
|
||||
1. **数据说话**:每个判断引用具体数值,严禁空泛套话("情绪回暖"必须改成"涨停 68 家较前日 +22,封板率 75%")
|
||||
2. **诚实中立**:看多就写多,看空就写空,不要骑墙;数据不支持时直言无法判断
|
||||
3. **结构优先**:先看指数同步性与量能结构,再看板块与情绪,最后才是消息
|
||||
4. **不重复数字**:正文负责解读表格数据背后的含义,不要照抄罗列已提供的大段原始数字
|
||||
5. **风险前置**:任何进攻建议都要配触发失效条件
|
||||
6. **简明实战**:用交易员能扫读的密度输出,总字数 1200-2000 字,重在可执行
|
||||
|
||||
现在请基于下方数据进行复盘。"""
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 用户消息构建(精简切片,控制 token)
|
||||
# ================================================================
|
||||
|
||||
def _fmt_pct(v, suffix="%") -> str:
|
||||
if v is None:
|
||||
return "—"
|
||||
return f"{v:+.2f}{suffix}" if suffix else f"{v:.2f}"
|
||||
|
||||
|
||||
def _build_indices_block(overview: dict) -> str:
|
||||
"""指数行情精简块。"""
|
||||
indices = overview.get("indices") or []
|
||||
if not indices:
|
||||
return "(暂无指数)"
|
||||
lines = []
|
||||
for idx in indices:
|
||||
name = idx.get("name") or idx.get("symbol")
|
||||
price = idx.get("last_price")
|
||||
chg = idx.get("change_pct")
|
||||
price_s = f"{price:.2f}" if price is not None else "—"
|
||||
lines.append(f"- {name}: {price_s} {_fmt_pct(chg)}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _build_breadth_block(overview: dict) -> str:
|
||||
b = overview.get("breadth") or {}
|
||||
amt = overview.get("amount") or {}
|
||||
lim = overview.get("limit") or {}
|
||||
tr = overview.get("trend") or {}
|
||||
act = overview.get("activity") or {}
|
||||
|
||||
total_amount = amt.get("total") or 0
|
||||
# 成交额单位换算为亿元(原始为元)
|
||||
amount_yi = total_amount / 1e8 if total_amount else 0
|
||||
|
||||
lines = [
|
||||
f"- 上涨/下跌/平盘: {b.get('up',0)} / {b.get('down',0)} / {b.get('flat',0)}"
|
||||
f" (上涨占比 {b.get('up_pct',0):.1f}%)",
|
||||
f"- 涨停/炸板/跌停: {lim.get('limit_up',0)} / {lim.get('broken',0)} / {lim.get('limit_down',0)}"
|
||||
f" (封板率 {lim.get('seal_rate',0):.0f}%, 最高连板 {lim.get('max_boards',0)})",
|
||||
]
|
||||
if lim.get("tiers"):
|
||||
tiers_str = "、".join(f"{t['boards']}板×{t['count']}" for t in lim["tiers"][:5])
|
||||
lines.append(f"- 连板梯队: {tiers_str}")
|
||||
lines.append(f"- 两市成交额: {amount_yi:.0f} 亿元")
|
||||
lines.append(
|
||||
f"- 均线站位: MA5 {tr.get('above_ma5_pct',0):.0f}% / "
|
||||
f"MA20 {tr.get('above_ma20_pct',0):.0f}% / MA60 {tr.get('above_ma60_pct',0):.0f}%"
|
||||
)
|
||||
lines.append(
|
||||
f"- 量能: 平均换手 {act.get('avg_turnover',0):.2f}%, "
|
||||
f"量比5日均 {act.get('vol_ratio',1):.2f}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _build_sector_block(rank: dict, label: str) -> str:
|
||||
"""板块排名精简块(领涨/领跌 top5)。"""
|
||||
if not rank:
|
||||
return f"### {label}\n(暂无数据)"
|
||||
def _fmt(items):
|
||||
if not items:
|
||||
return "—"
|
||||
return "、".join(
|
||||
f"{it.get('name')}({(it.get('avg_pct') or 0)*100:+.2f}%,领涨:{it.get('leader',{}).get('name','—')})"
|
||||
for it in items[:5]
|
||||
)
|
||||
return (
|
||||
f"- 领涨{label}: {_fmt(rank.get('leading'))}\n"
|
||||
f"- 领跌{label}: {_fmt(rank.get('lagging'))}"
|
||||
)
|
||||
|
||||
|
||||
def _build_emotion_block(overview: dict) -> str:
|
||||
emo = overview.get("emotion") or {}
|
||||
radar = overview.get("radar") or []
|
||||
score = emo.get("score", 50)
|
||||
label = emo.get("label", "—")
|
||||
lines = [f"- 情绪温度: {score} ({label})"]
|
||||
if radar:
|
||||
dims = "、".join(f"{r.get('label')}{r.get('value',0)}" for r in radar)
|
||||
lines.append(f"- 六维雷达: {dims}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _build_user_prompt(overview: dict, news: list[dict], focus: str) -> str:
|
||||
"""构建用户消息:复盘日期 + 市场数据精简切片 + 新闻 + 关注点。"""
|
||||
as_of = overview.get("as_of") or "今日"
|
||||
|
||||
parts: list[str] = [
|
||||
f"复盘日期: {as_of}",
|
||||
"",
|
||||
"## 主要指数",
|
||||
_build_indices_block(overview),
|
||||
"",
|
||||
"## 盘面数据",
|
||||
_build_breadth_block(overview),
|
||||
"",
|
||||
"## 市场情绪",
|
||||
_build_emotion_block(overview),
|
||||
"",
|
||||
"## 概念板块排名",
|
||||
_build_sector_block(overview.get("concept_rank"), "概念"),
|
||||
"",
|
||||
"## 行业板块排名",
|
||||
_build_sector_block(overview.get("industry_rank"), "行业"),
|
||||
]
|
||||
|
||||
if news:
|
||||
news_lines = []
|
||||
for i, n in enumerate(news[:8], 1):
|
||||
title = (n.get("title") or "").strip()
|
||||
snippet = (n.get("snippet") or "").strip()
|
||||
source = (n.get("source") or "").strip()
|
||||
pub = (n.get("published_date") or "").strip()
|
||||
meta = " / ".join(p for p in (source, pub) if p)
|
||||
news_lines.append(f"{i}. {title} ({meta})\n {snippet}" if meta else f"{i}. {title}\n {snippet}")
|
||||
parts.extend(["", "## 近期市场新闻", "\n".join(news_lines)])
|
||||
else:
|
||||
parts.extend([
|
||||
"",
|
||||
"## 近期市场新闻",
|
||||
"(暂无新闻数据:本功能新闻检索能力将在后续版本接入。"
|
||||
"消息催化一节请直接从量价异动给出可能的催化逻辑结论,不要编造具体消息,也不要复述本说明。)",
|
||||
])
|
||||
|
||||
if focus.strip():
|
||||
parts.extend(["", f"本次复盘请特别关注: {focus.strip()}"])
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 摘要生成(供 meta 事件 / 历史报告 summary)
|
||||
# ================================================================
|
||||
|
||||
def _recap_summary(overview: dict) -> str:
|
||||
"""一句话摘要(供 meta 事件与历史列表展示)。
|
||||
|
||||
指数用简称(上/深/创/科),与前端摘要条一致,避免列表里全称放不下。
|
||||
"""
|
||||
indices = overview.get("indices") or []
|
||||
emo = overview.get("emotion") or {}
|
||||
lim = overview.get("limit") or {}
|
||||
amt = overview.get("amount") or {}
|
||||
total_amount = (amt.get("total") or 0) / 1e8
|
||||
|
||||
idx_str = "、".join(
|
||||
f"{_INDEX_SHORT.get(i.get('name') or '', i.get('name') or '')}{(i.get('change_pct') or 0):+.2f}%"
|
||||
for i in indices[:4]
|
||||
) or "指数缺失"
|
||||
return (
|
||||
f"{idx_str} | 情绪{emo.get('score',50)}({emo.get('label','—')}) | "
|
||||
f"涨停{lim.get('limit_up',0)} | 成交{total_amount:.0f}亿"
|
||||
)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 流式主入口
|
||||
# ================================================================
|
||||
|
||||
async def recap_market_stream(
|
||||
repo,
|
||||
quote_service=None,
|
||||
depth_service=None,
|
||||
as_of: date | None = None,
|
||||
focus: str = "",
|
||||
news: list[dict] | None = None,
|
||||
) -> AsyncIterator[str]:
|
||||
"""流式大盘复盘:yield 出每个 NDJSON 事件。
|
||||
|
||||
Args:
|
||||
repo: KlineRepository(必填)。
|
||||
quote_service / depth_service: 可选,数据装配依赖。
|
||||
as_of: 复盘日期,None 取最新有数据日。
|
||||
focus: 用户追加的复盘关注点。
|
||||
news: 预检索的新闻列表(P1 不传,留 None 走降级说明;P3 由 news_search 注入)。
|
||||
"""
|
||||
# 1. 装配市场总览
|
||||
overview = build_market_overview(repo, quote_service, depth_service, as_of)
|
||||
as_of_str = overview.get("as_of")
|
||||
|
||||
if not as_of_str:
|
||||
yield json.dumps({
|
||||
"type": "error",
|
||||
"message": "暂无市场数据,请先在「数据」页同步日 K 与指数后再复盘",
|
||||
}, ensure_ascii=False)
|
||||
return
|
||||
|
||||
emo = overview.get("emotion") or {}
|
||||
|
||||
# 2. meta 事件(前端据此先渲染信号灯/看板)
|
||||
yield json.dumps({
|
||||
"type": "meta",
|
||||
"as_of": as_of_str,
|
||||
"emotion_score": emo.get("score", 50),
|
||||
"emotion_label": emo.get("label", "—"),
|
||||
"summary": _recap_summary(overview),
|
||||
}, ensure_ascii=False)
|
||||
|
||||
# 3+4. 构建 prompt + 流式调用 LLM(整体 try-except,任何异常 yield error,避免前端卡死)
|
||||
try:
|
||||
from app.services.ai_provider import stream_ai_text
|
||||
|
||||
user_prompt = _build_user_prompt(overview, news or [], focus)
|
||||
async for delta in stream_ai_text(
|
||||
[
|
||||
{"role": "system", "content": _SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
temperature=0.5,
|
||||
max_tokens=4500,
|
||||
):
|
||||
yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
|
||||
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.exception("AI market recap failed for %s: %s", as_of_str, e)
|
||||
yield json.dumps({"type": "error", "message": f"AI 复盘失败: {e}"}, ensure_ascii=False)
|
||||
return
|
||||
|
||||
yield json.dumps({"type": "done"}, ensure_ascii=False)
|
||||
|
||||
|
||||
async def recap_market_once(
|
||||
repo,
|
||||
quote_service=None,
|
||||
depth_service=None,
|
||||
as_of: date | None = None,
|
||||
focus: str = "",
|
||||
news: list[dict] | None = None,
|
||||
) -> tuple[str | None, dict]:
|
||||
"""非流式版本(供定时任务调用):累积全部 delta,返回 (content, meta)。
|
||||
|
||||
content 为完整 Markdown 文本;失败时为 None。
|
||||
meta 含 as_of / emotion_score / emotion_label / summary(即使失败也尽量回填)。
|
||||
"""
|
||||
content_parts: list[str] = []
|
||||
meta: dict = {"as_of": as_of.isoformat() if as_of else None}
|
||||
async for evt in recap_market_stream(repo, quote_service, depth_service, as_of, focus, news):
|
||||
try:
|
||||
obj = json.loads(evt)
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
t = obj.get("type")
|
||||
if t == "meta":
|
||||
meta = obj
|
||||
elif t == "delta":
|
||||
content_parts.append(obj.get("content", ""))
|
||||
elif t == "error":
|
||||
logger.warning("market recap error event: %s", obj.get("message"))
|
||||
return None, meta
|
||||
return "".join(content_parts), meta
|
||||
Reference in New Issue
Block a user