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
+8 -1
View File
@@ -70,4 +70,11 @@
- 成交额备用计算:当资金流向API获取失败时,使用 `chartData` 的 `volume × close` 近似计算成交额(单位:元) - 成交额备用计算:当资金流向API获取失败时,使用 `chartData` 的 `volume × close` 近似计算成交额(单位:元)
- 资金流向数据获取失败时,前端通过 `fundFlowError` 状态显示错误信息,便于排查问题 - 资金流向数据获取失败时,前端通过 `fundFlowError` 状态显示错误信息,便于排查问题
- ❌ 东方财富 API(push2his.eastmoney.com)在 Edge Function 环境被拒绝访问(peer closed connection),主力净流入数据无法获取,显示为 "-" - ❌ 东方财富 API(push2his.eastmoney.com)在 Edge Function 环境被拒绝访问(peer closed connection),主力净流入数据无法获取,显示为 "-"
- ✅ 替代方案:使用腾讯实时行情API的外盘(索引7)和内盘(索引8)数据计算净主动买入额 = (外盘 - 内盘) × 当前价 × 100(单位:元) - ✅ 替代方案:使用腾讯实时行情API的外盘(索引7)和内盘(索引8)数据计算净主动买入额 = (外盘 - 内盘) × 当前价 × 100(单位:元)
## 每日 AI 分析(backend FastAPI,15:10 自动触发)
- 报告结构约定:正文标题(#)后第一行必须是引用块 `> 今日定调:<80字内核心结论+关键数字>`;保存时由 `_extract_summary()` 正则提取进 `summary` 字段,前端顶部高亮展示,缺失时退回正文截断
- 环比数据:`collect_ai_analysis` 每次运行先采集当日盘面快照(指数+涨跌统计 JSON)写入 `daily_market_stats` 表(UNIQUE trade_date,upsert),再读上一交易日快照格式化为 prompt 中的"环比数据段";首跑无昨日数据时 AI 须如实标注"暂无昨日基准"
- 连板梯队:`get_market_dashboard` 返回 `limitLadder` 字段(完整版,2连板以上全量+首板前8),涨停原因/封单金额来自涨停池按 thscode 匹配;看板 events 里的天梯仍只取每层前2(保持 UI 精简),两处用途不同不要合并
- tokens 口径:`ai_reports.tokens_used` 只记"当次生成"消耗(约7万/份);同日重新生成走 UPSERT,`generation_count` 自增、`updated_at` 刷新、`created_at` 保留首次生成时间;表有 UNIQUE(trade_date, report_type),禁止改回 INSERT OR REPLACE 之外还要注意别用 lastrowid(UPSERT 更新时不可靠,须回查 id)
- 旧库补列用 init_db 里的 try/except ALTER TABLE 轻量迁移(CREATE TABLE IF NOT EXISTS 不会更新旧表结构)
+20
View File
@@ -82,9 +82,19 @@ CREATE TABLE IF NOT EXISTS ai_reports (
tools_used TEXT, tools_used TEXT,
model TEXT NOT NULL, model TEXT NOT NULL,
tokens_used INTEGER, tokens_used INTEGER,
generation_count INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')), created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
updated_at TEXT,
UNIQUE(trade_date, report_type) UNIQUE(trade_date, report_type)
); );
CREATE TABLE IF NOT EXISTS daily_market_stats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trade_date TEXT NOT NULL,
payload TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
UNIQUE(trade_date)
);
""" """
@@ -107,6 +117,16 @@ def init_db():
conn = get_connection() conn = get_connection()
conn.executescript(SCHEMA_SQL) conn.executescript(SCHEMA_SQL)
# 轻量迁移:为已存在的旧表补列(CREATE TABLE IF NOT EXISTS 不会更新旧表结构)
for alter_sql in (
"ALTER TABLE ai_reports ADD COLUMN generation_count INTEGER NOT NULL DEFAULT 1",
"ALTER TABLE ai_reports ADD COLUMN updated_at TEXT",
):
try:
conn.execute(alter_sql)
except sqlite3.OperationalError:
pass # 列已存在
# 清理过期缓存 # 清理过期缓存
from services.cache import clean_expired from services.cache import clean_expired
clean_expired() clean_expired()
+2 -1
View File
@@ -143,7 +143,8 @@ async def admin_list_reports(request: Request):
conn = get_connection() conn = get_connection()
try: try:
rows = conn.execute( rows = conn.execute(
"SELECT id, trade_date, report_type, title, summary, tools_used, model, tokens_used, created_at " "SELECT id, trade_date, report_type, title, summary, tools_used, model, tokens_used, "
"generation_count, created_at, updated_at "
"FROM ai_reports ORDER BY trade_date DESC, id DESC LIMIT 50" "FROM ai_reports ORDER BY trade_date DESC, id DESC LIMIT 50"
).fetchall() ).fetchall()
items = [] items = []
+2 -1
View File
@@ -60,7 +60,8 @@ async def list_reports():
conn = get_connection() conn = get_connection()
try: try:
rows = conn.execute( rows = conn.execute(
"SELECT id, trade_date, report_type, title, summary, tools_used, model, tokens_used, created_at " "SELECT id, trade_date, report_type, title, summary, tools_used, model, tokens_used, "
"generation_count, created_at, updated_at "
"FROM ai_reports ORDER BY trade_date DESC, id DESC LIMIT 50" "FROM ai_reports ORDER BY trade_date DESC, id DESC LIMIT 50"
).fetchall() ).fetchall()
items = [] items = []
+62
View File
@@ -485,6 +485,67 @@ async def _build_dashboard() -> dict:
"detail": f"涨停 {limit_up_count} 跌停 {limit_down_count} 炸板 {break_count}", "detail": f"涨停 {limit_up_count} 跌停 {limit_down_count} 炸板 {break_count}",
}) })
# ── 连板梯队(完整版,供 AI 分析用;上方 events 天梯只取前2保持看板精简) ──
# 涨停池里带 limit_up_reason / seal_money,按代码匹配给梯队个股
reason_by_code = {}
if isinstance(limit_up_data, dict):
for lu in limit_up_data.get("item", []) or []:
code = lu.get("thscode", "")
if code:
reason_by_code[code] = {
"reason": (lu.get("limit_up_reason") or "").strip(),
"sealWan": round(_safe_float(lu.get("seal_money")) / 10000),
}
_LADDER_LEVELS = [
("seven_over", 7, "7连板+"),
("six_board", 6, "6连板"),
("five_board", 5, "5连板"),
("four_board", 4, "4连板"),
("three_board", 3, "3连板"),
("two_board", 2, "2连板"),
("first_board", 1, "首板"),
]
limit_ladder = []
if isinstance(limit_ladder_data, dict):
ladder_items = limit_ladder_data.get("item", [])
if ladder_items:
today_boards = ladder_items[0].get("boards", {}) or {}
seen_keys = set()
for key, board_num, label in _LADDER_LEVELS:
seen_keys.add(key)
# 首板数量多(几十家)只取前8家;2连板以上全量保留
cap = 8 if board_num == 1 else None
entries = today_boards.get(key, []) or []
if cap is not None:
entries = entries[:cap]
for item in entries:
code = item.get("thscode", "")
extra = reason_by_code.get(code, {})
limit_ladder.append({
"board": board_num,
"label": label,
"name": item.get("name", ""),
"code": code,
"reason": extra.get("reason", ""),
"sealWan": extra.get("sealWan"),
})
# 兜底:天梯返回了未知层级 key 时也带上(跳过已处理的已知 key)
for key, entries in today_boards.items():
if key in seen_keys:
continue
for item in entries or []:
code = item.get("thscode", "")
extra = reason_by_code.get(code, {})
limit_ladder.append({
"board": _safe_float(item.get("board_num")) or 1,
"label": f"{int(_safe_float(item.get('board_num')) or 1)}连板",
"name": item.get("name", ""),
"code": code,
"reason": extra.get("reason", ""),
"sealWan": extra.get("sealWan"),
})
# ── 组装结果 ── # ── 组装结果 ──
result = { result = {
"indices": indices, "indices": indices,
@@ -492,6 +553,7 @@ async def _build_dashboard() -> dict:
"sectorStrength": sector_strength[:31], "sectorStrength": sector_strength[:31],
"conceptStrength": concept_strength[:10], "conceptStrength": concept_strength[:10],
"events": events, "events": events,
"limitLadder": limit_ladder,
"updateTime": datetime.now(BJT).strftime("%Y-%m-%d %H:%M:%S"), "updateTime": datetime.now(BJT).strftime("%Y-%m-%d %H:%M:%S"),
} }
+152 -16
View File
@@ -3,10 +3,14 @@
负责: 负责:
1. 调用 OpenAI 兼容 API 进行分析 1. 调用 OpenAI 兼容 API 进行分析
2. Function Calling 循环(AI 可主动获取数据) 2. Function Calling 循环(AI 可主动获取数据)
3. 保存报告到数据库 3. 采集当日盘面快照(供次日环比)
4. 保存报告到数据库
""" """
import json import json
import re
import traceback
import httpx import httpx
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
@@ -25,7 +29,7 @@ SYSTEM_PROMPT = """你是一位专业的A股市场分析师,擅长从数据中
- 风险提示,每次推荐都需说明风险点 - 风险提示,每次推荐都需说明风险点
可用工具: 可用工具:
- get_market_dashboard: 获取市场整体数据(指数/涨跌统计/行业强度/事件情报/市场温度) - get_market_dashboard: 获取市场整体数据(指数/涨跌统计/市场温度/连板梯队/行业强度/事件情报)
- get_theme_history: 获取指定日期的题材涨幅排行 - get_theme_history: 获取指定日期的题材涨幅排行
- get_active_core_stocks: 获取核心股追踪数据(10日涨幅矩阵+所属题材) - get_active_core_stocks: 获取核心股追踪数据(10日涨幅矩阵+所属题材)
- get_stock_quote: 获取个股实时行情 - get_stock_quote: 获取个股实时行情
@@ -40,26 +44,39 @@ SYSTEM_PROMPT = """你是一位专业的A股市场分析师,擅长从数据中
DAILY_ANALYSIS_PROMPT = """请对 {trade_date} 的A股市场进行收盘分析,生成一份完整的分析报告。 DAILY_ANALYSIS_PROMPT = """请对 {trade_date} 的A股市场进行收盘分析,生成一份完整的分析报告。
{prev_report_section} {prev_report_section}
{prev_snapshot_section}
请先调用以下工具获取数据: 请先调用以下工具获取数据:
1. get_market_dashboard - 获取市场整体数据 1. get_market_dashboard - 获取市场整体数据(含涨跌统计、市场温度、连板梯队 limitLadder)
2. get_theme_history(date="{trade_date}") - 获取今日题材涨幅 2. get_theme_history(date="{trade_date}") - 获取今日题材涨幅
3. get_active_core_stocks - 获取核心股数据 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题材 - 今日涨幅前5题材
- 持续活跃的题材 - 持续活跃的题材
- 新兴热点题材 - 新兴热点题材
- 明显退潮的题材(警示)
## 三、核心股追踪 ## 三、核心股追踪
- 连板股分析 - 连板梯队分析(按格式规则3完整呈现)
- 核心股表现 - 核心股表现
- 龙头股辨识 - 龙头股辨识
@@ -80,6 +97,9 @@ DAILY_ANALYSIS_PROMPT = """请对 {trade_date} 的A股市场进行收盘分析
请用 Markdown 格式输出,适当使用表格展示数据对比。""" 请用 Markdown 格式输出,适当使用表格展示数据对比。"""
# 报告头部的"今日定调"引用行,保存时提取为 summary
_TONE_LINE_RE = re.compile(r"^>\s*今日定调[::]\s*(.+)$", re.MULTILINE)
async def call_llm(messages: list, tools: list = None) -> dict: async def call_llm(messages: list, tools: list = None) -> dict:
"""调用 OpenAI 兼容 API""" """调用 OpenAI 兼容 API"""
@@ -119,11 +139,16 @@ async def collect_ai_analysis(trade_date: str) -> dict:
{"id": int, "tokens_used": int, "tools_used": list} {"id": int, "tokens_used": int, "tools_used": list}
""" """
prev_report_section = _get_prev_report_section(trade_date) 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 = [ messages = [
{"role": "system", "content": SYSTEM_PROMPT}, {"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": DAILY_ANALYSIS_PROMPT.format( {"role": "user", "content": DAILY_ANALYSIS_PROMPT.format(
trade_date=trade_date, 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 "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) 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} 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() conn = get_connection()
try: try:
cursor = conn.execute( row = conn.execute(
"""INSERT OR REPLACE INTO ai_reports "SELECT trade_date, payload FROM daily_market_stats WHERE trade_date < ? ORDER BY trade_date DESC LIMIT 1",
(trade_date, report_type, title, content, summary, tools_used, model, tokens_used) (trade_date,),
VALUES (?, 'daily', ?, ?, ?, ?, ?, ?)""", ).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, trade_date,
f"{trade_date} A股收盘分析", 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), json.dumps(tools_used),
AI_MODEL, AI_MODEL,
tokens_used, tokens_used,
) ),
) )
conn.commit() 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: finally:
conn.close() conn.close()
+1 -1
View File
@@ -16,7 +16,7 @@ TOOLS = [
"type": "function", "type": "function",
"function": { "function": {
"name": "get_market_dashboard", "name": "get_market_dashboard",
"description": "获取A股市场看板数据,包含主要指数行情、涨跌统计、行业强度、概念热度、事件情报、市场温度评分", "description": "获取A股市场看板数据,包含主要指数行情、全市场涨跌统计(涨跌家数/涨停/跌停/炸板率/成交额)、市场温度评分与竞价信号、行业强度榜、概念热度、完整连板梯队(limitLadder字段,含涨停原因与封单金额)、事件情报(热门股/龙虎榜/飙升/异动)",
"parameters": {"type": "object", "properties": {}, "required": []} "parameters": {"type": "object", "properties": {}, "required": []}
} }
}, },
+2
View File
@@ -14,7 +14,9 @@ export interface AiReport {
toolsUsed: string[]; toolsUsed: string[];
model: string; model: string;
tokens_used: number; tokens_used: number;
generation_count?: number;
created_at: string; created_at: string;
updated_at?: string | null;
} }
export async function fetchAiLatestReport(): Promise<AiReport> { export async function fetchAiLatestReport(): Promise<AiReport> {
+16 -1
View File
@@ -53,9 +53,12 @@ function AiAnalysisPage() {
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 sm:gap-4 text-xs sm:text-sm text-muted-foreground"> <div className="flex flex-wrap items-center gap-x-3 gap-y-1 sm:gap-4 text-xs sm:text-sm text-muted-foreground">
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5 shrink-0" /> <Clock className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{report.created_at}</span> <span className="truncate">{report.updated_at || report.created_at}</span>
</span> </span>
<span>{report.tokens_used?.toLocaleString()} tokens</span> <span>{report.tokens_used?.toLocaleString()} tokens</span>
{(report.generation_count ?? 1) > 1 && (
<span>第 {report.generation_count} 次生成</span>
)}
{report.toolsUsed && report.toolsUsed.length > 0 && ( {report.toolsUsed && report.toolsUsed.length > 0 && (
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<Wrench className="h-3.5 w-3.5 shrink-0" /> <Wrench className="h-3.5 w-3.5 shrink-0" />
@@ -66,6 +69,18 @@ function AiAnalysisPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* 今日定调摘要 */}
{report.summary && (
<div className="mb-3 sm:mb-4 rounded-lg border border-primary/30 bg-primary/5 px-3 sm:px-4 py-3">
<div className="flex items-start gap-2.5">
<span className="shrink-0 mt-0.5 rounded bg-primary/10 px-1.5 py-0.5 text-[11px] font-semibold text-primary">
今日定调
</span>
<p className="text-sm leading-relaxed">{report.summary}</p>
</div>
</div>
)}
{/* Report Content */} {/* Report Content */}
<Card className="bg-card border-border"> <Card className="bg-card border-border">
<CardContent className="p-3 sm:p-4 md:p-6 ai-report-content"> <CardContent className="p-3 sm:p-4 md:p-6 ai-report-content">