feat: 每日AI分析改造——定调摘要、环比快照、完整连板梯队带涨停原因、生成次数记账

This commit is contained in:
Sakurasan
2026-09-02 02:59:14 +08:00
parent 375d196fef
commit 0fb2f8d3a5
9 changed files with 265 additions and 21 deletions
+152 -16
View File
@@ -3,10 +3,14 @@
负责:
1. 调用 OpenAI 兼容 API 进行分析
2. Function Calling 循环(AI 可主动获取数据)
3. 保存报告到数据库
3. 采集当日盘面快照(供次日环比)
4. 保存报告到数据库
"""
import json
import re
import traceback
import httpx
from datetime import datetime, timezone, timedelta
@@ -25,7 +29,7 @@ SYSTEM_PROMPT = """你是一位专业的A股市场分析师,擅长从数据中
- 风险提示,每次推荐都需说明风险点
可用工具:
- get_market_dashboard: 获取市场整体数据(指数/涨跌统计/行业强度/事件情报/市场温度)
- get_market_dashboard: 获取市场整体数据(指数/涨跌统计/市场温度/连板梯队/行业强度/事件情报)
- get_theme_history: 获取指定日期的题材涨幅排行
- get_active_core_stocks: 获取核心股追踪数据(10日涨幅矩阵+所属题材)
- get_stock_quote: 获取个股实时行情
@@ -40,26 +44,39 @@ SYSTEM_PROMPT = """你是一位专业的A股市场分析师,擅长从数据中
DAILY_ANALYSIS_PROMPT = """请对 {trade_date} 的A股市场进行收盘分析,生成一份完整的分析报告。
{prev_report_section}
{prev_snapshot_section}
请先调用以下工具获取数据:
1. get_market_dashboard - 获取市场整体数据
1. get_market_dashboard - 获取市场整体数据(含涨跌统计、市场温度、连板梯队 limitLadder)
2. get_theme_history(date="{trade_date}") - 获取今日题材涨幅
3. get_active_core_stocks - 获取核心股数据
生成报告时必须遵守以下格式规则:
1. 报告标题(# 一级标题)之后的第一行,必须是一个引用块"定调摘要",格式严格为:
> 今日定调:<一句话核心结论,不超过80字,必须包含1-2个关键数字(如成交额、涨停家数、市场温度)>
2. 量能与情绪类数字必须给环比:若上方提供了"前一交易日盘面数据快照",成交额、涨跌家数、涨停数、市场温度等在与昨日对比后表述(如"成交额2.05万亿,较昨日缩量约700亿");没有昨日快照则如实说明"暂无昨日数据"。
3. 连板梯队必须完整呈现 get_market_dashboard 返回的 limitLadder:从最高连板到2连板逐级列表格,每只标注涨停原因(reason字段)与封单金额(sealWan,单位万,为空则不写);首板只挑3-5只人气最高的点评。
4. 适当使用表格展示数据对比。
然后基于数据生成报告,结构如下:
## 一、市场总览
- 主要指数表现(上证、深证、创业板)
- 涨跌家数统计
- 主要指数表现(上证、深证、创业板、科创50)
- 涨跌家数统计(含涨停/跌停/炸板率,须环比)
- 市场温度评估
## 二、题材热点分析
- 今日涨幅前5题材
- 持续活跃的题材
- 新兴热点题材
- 明显退潮的题材(警示)
## 三、核心股追踪
- 连板股分析
- 连板梯队分析(按格式规则3完整呈现)
- 核心股表现
- 龙头股辨识
@@ -80,6 +97,9 @@ DAILY_ANALYSIS_PROMPT = """请对 {trade_date} 的A股市场进行收盘分析
请用 Markdown 格式输出,适当使用表格展示数据对比。"""
# 报告头部的"今日定调"引用行,保存时提取为 summary
_TONE_LINE_RE = re.compile(r"^>\s*今日定调[::]\s*(.+)$", re.MULTILINE)
async def call_llm(messages: list, tools: list = None) -> dict:
"""调用 OpenAI 兼容 API"""
@@ -119,11 +139,16 @@ async def collect_ai_analysis(trade_date: str) -> dict:
{"id": int, "tokens_used": int, "tools_used": list}
"""
prev_report_section = _get_prev_report_section(trade_date)
# 采集当日盘面快照(供次日环比),并读取上一交易日快照注入 prompt
await _capture_market_snapshot(trade_date)
prev_snapshot_section = _get_prev_snapshot_section(trade_date)
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": DAILY_ANALYSIS_PROMPT.format(
trade_date=trade_date,
prev_report_section=prev_report_section
prev_report_section=prev_report_section,
prev_snapshot_section=prev_snapshot_section,
)}
]
@@ -171,20 +196,127 @@ async def collect_ai_analysis(trade_date: str) -> dict:
"content": result
})
summary = final_content[:200].replace("\n", " ") if final_content else ""
summary = _extract_summary(final_content)
report_id = _save_report(trade_date, final_content, summary, tools_used, total_tokens)
return {"id": report_id, "tokens_used": total_tokens, "tools_used": tools_used, "truncated": was_truncated}
def _save_report(trade_date: str, content: str, summary: str, tools_used: list, tokens_used: int) -> int:
"""保存报告到数据库"""
def _extract_summary(content: str) -> str:
"""提取报告头部的"今日定调"引用行作为摘要;缺失时退回正文截断"""
match = _TONE_LINE_RE.search(content or "")
if match:
text = match.group(1).strip()
if text:
return text[:200]
return (content or "")[:200].replace("\n", " ")
def _num(v) -> str:
"""快照数值安全转字符串"""
if isinstance(v, float):
return f"{v:g}"
return str(v if v is not None else "-")
def _fmt_amount(v) -> str:
"""成交额(元)→ 万亿/亿 可读格式"""
try:
v = float(v)
except (TypeError, ValueError):
return "-"
if v >= 1e12:
return f"{v / 1e12:.2f}万亿"
if v >= 1e8:
return f"{v / 1e8:.0f}亿"
return f"{v:.0f}元"
def _fmt_index(idx: dict) -> str:
if not idx:
return "-"
sign = "+" if idx.get("changePct", 0) >= 0 else ""
return f"{idx.get('price', '-')}({sign}{idx.get('changePct', 0)}%)"
async def _capture_market_snapshot(trade_date: str) -> bool:
"""采集当日盘面快照入库(指数+涨跌统计),供次日分析做环比"""
try:
from routes.market_dashboard import _build_dashboard
data = await _build_dashboard()
payload = json.dumps(
{"indices": data.get("indices", []), "marketStats": data.get("marketStats", {})},
ensure_ascii=False,
default=str,
)
conn = get_connection()
try:
conn.execute(
"""INSERT INTO daily_market_stats (trade_date, payload) VALUES (?, ?)
ON CONFLICT(trade_date) DO UPDATE SET payload = excluded.payload""",
(trade_date, payload),
)
conn.commit()
finally:
conn.close()
return True
except Exception:
print(f"[ai-service] 市场快照采集失败 {trade_date}:")
traceback.print_exc()
return False
def _get_prev_snapshot_section(trade_date: str) -> str:
"""读取上一交易日盘面快照,格式化为 prompt 中的环比数据段"""
conn = get_connection()
try:
cursor = conn.execute(
"""INSERT OR REPLACE INTO ai_reports
(trade_date, report_type, title, content, summary, tools_used, model, tokens_used)
VALUES (?, 'daily', ?, ?, ?, ?, ?, ?)""",
row = conn.execute(
"SELECT trade_date, payload FROM daily_market_stats WHERE trade_date < ? ORDER BY trade_date DESC LIMIT 1",
(trade_date,),
).fetchone()
if not row:
return ""
try:
snap = json.loads(row["payload"])
except (TypeError, ValueError):
return ""
stats = snap.get("marketStats") or {}
indices = {i.get("name"): i for i in (snap.get("indices") or []) if isinstance(i, dict)}
idx_line = "、".join(
f"{name} {_fmt_index(indices.get(name))}"
for name in ("上证指数", "深证成指", "创业板指", "科创50")
)
return f"""以下是前一交易日({row["trade_date"]})的盘面数据快照,报告中的量能与情绪数字必须给出与它的环比对比:
- 两市成交额:{_fmt_amount(stats.get("totalTurnover"))}
- 上涨/下跌/平盘:{_num(stats.get("upCount"))}/{_num(stats.get("downCount"))}/{_num(stats.get("flatCount"))},涨停 {_num(stats.get("limitUp"))} 家、跌停 {_num(stats.get("limitDown"))} 家、炸板 {_num(stats.get("limitBreak"))} 家(炸板率 {_num(stats.get("breakRate"))}%)
- 强势/弱势股:{_num(stats.get("strongCount"))}/{_num(stats.get("weakCount"))},市场宽度 {_num(stats.get("marketBreadth"))}%
- 市场温度:{_num(stats.get("temperature"))} 分,竞价信号:{stats.get("auctionSignal") or "-"}
- 指数收盘:{idx_line}
---
"""
finally:
conn.close()
def _save_report(trade_date: str, content: str, summary: str, tools_used: list, tokens_used: int) -> int:
"""保存报告到数据库(同日重生成:覆盖内容、generation_count+1、tokens 记当次消耗)"""
conn = get_connection()
try:
conn.execute(
"""INSERT INTO ai_reports
(trade_date, report_type, title, content, summary, tools_used, model, tokens_used, updated_at)
VALUES (?, 'daily', ?, ?, ?, ?, ?, ?, datetime('now','localtime'))
ON CONFLICT(trade_date, report_type) DO UPDATE SET
title = excluded.title,
content = excluded.content,
summary = excluded.summary,
tools_used = excluded.tools_used,
model = excluded.model,
tokens_used = excluded.tokens_used,
updated_at = excluded.updated_at,
generation_count = ai_reports.generation_count + 1""",
(
trade_date,
f"{trade_date} A股收盘分析",
@@ -193,10 +325,14 @@ def _save_report(trade_date: str, content: str, summary: str, tools_used: list,
json.dumps(tools_used),
AI_MODEL,
tokens_used,
)
),
)
conn.commit()
return cursor.lastrowid
row = conn.execute(
"SELECT id FROM ai_reports WHERE trade_date = ? AND report_type = 'daily'",
(trade_date,),
).fetchone()
return row["id"]
finally:
conn.close()
+1 -1
View File
@@ -16,7 +16,7 @@ TOOLS = [
"type": "function",
"function": {
"name": "get_market_dashboard",
"description": "获取A股市场看板数据,包含主要指数行情、涨跌统计、行业强度、概念热度、事件情报、市场温度评分",
"description": "获取A股市场看板数据,包含主要指数行情、全市场涨跌统计(涨跌家数/涨停/跌停/炸板率/成交额)、市场温度评分与竞价信号、行业强度榜、概念热度、完整连板梯队(limitLadder字段,含涨停原因与封单金额)、事件情报(热门股/龙虎榜/飙升/异动)",
"parameters": {"type": "object", "properties": {}, "required": []}
}
},