diff --git a/AGENTS.md b/AGENTS.md index d0e948c..c1d72a5 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,4 +70,11 @@ - 成交额备用计算:当资金流向API获取失败时,使用 `chartData` 的 `volume × close` 近似计算成交额(单位:元) - 资金流向数据获取失败时,前端通过 `fundFlowError` 状态显示错误信息,便于排查问题 - ❌ 东方财富 API(push2his.eastmoney.com)在 Edge Function 环境被拒绝访问(peer closed connection),主力净流入数据无法获取,显示为 "-" -- ✅ 替代方案:使用腾讯实时行情API的外盘(索引7)和内盘(索引8)数据计算净主动买入额 = (外盘 - 内盘) × 当前价 × 100(单位:元) \ No newline at end of file +- ✅ 替代方案:使用腾讯实时行情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 不会更新旧表结构) diff --git a/backend/database.py b/backend/database.py index fbec163..74d626d 100644 --- a/backend/database.py +++ b/backend/database.py @@ -82,9 +82,19 @@ CREATE TABLE IF NOT EXISTS ai_reports ( tools_used TEXT, model TEXT NOT NULL, tokens_used INTEGER, + generation_count INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')), + updated_at TEXT, 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.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 clean_expired() diff --git a/backend/routes/admin.py b/backend/routes/admin.py index 5a3e98f..19e77c9 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -143,7 +143,8 @@ async def admin_list_reports(request: Request): conn = get_connection() try: 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" ).fetchall() items = [] diff --git a/backend/routes/ai_analysis.py b/backend/routes/ai_analysis.py index aa57721..f6ca617 100644 --- a/backend/routes/ai_analysis.py +++ b/backend/routes/ai_analysis.py @@ -60,7 +60,8 @@ async def list_reports(): conn = get_connection() try: 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" ).fetchall() items = [] diff --git a/backend/routes/market_dashboard.py b/backend/routes/market_dashboard.py index ec234a9..e107491 100644 --- a/backend/routes/market_dashboard.py +++ b/backend/routes/market_dashboard.py @@ -485,6 +485,67 @@ async def _build_dashboard() -> dict: "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 = { "indices": indices, @@ -492,6 +553,7 @@ async def _build_dashboard() -> dict: "sectorStrength": sector_strength[:31], "conceptStrength": concept_strength[:10], "events": events, + "limitLadder": limit_ladder, "updateTime": datetime.now(BJT).strftime("%Y-%m-%d %H:%M:%S"), } diff --git a/backend/services/ai_service.py b/backend/services/ai_service.py index 2a4f000..7b31202 100644 --- a/backend/services/ai_service.py +++ b/backend/services/ai_service.py @@ -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() diff --git a/backend/services/ai_tools.py b/backend/services/ai_tools.py index 96f99ea..25dfda0 100644 --- a/backend/services/ai_tools.py +++ b/backend/services/ai_tools.py @@ -16,7 +16,7 @@ TOOLS = [ "type": "function", "function": { "name": "get_market_dashboard", - "description": "获取A股市场看板数据,包含主要指数行情、涨跌统计、行业强度、概念热度、事件情报、市场温度评分", + "description": "获取A股市场看板数据,包含主要指数行情、全市场涨跌统计(涨跌家数/涨停/跌停/炸板率/成交额)、市场温度评分与竞价信号、行业强度榜、概念热度、完整连板梯队(limitLadder字段,含涨停原因与封单金额)、事件情报(热门股/龙虎榜/飙升/异动)", "parameters": {"type": "object", "properties": {}, "required": []} } }, diff --git a/src/lib/ai-analysis-api.ts b/src/lib/ai-analysis-api.ts index 214ee17..dbcd4e2 100644 --- a/src/lib/ai-analysis-api.ts +++ b/src/lib/ai-analysis-api.ts @@ -14,7 +14,9 @@ export interface AiReport { toolsUsed: string[]; model: string; tokens_used: number; + generation_count?: number; created_at: string; + updated_at?: string | null; } export async function fetchAiLatestReport(): Promise { diff --git a/src/routes/ai-analysis.tsx b/src/routes/ai-analysis.tsx index 190deff..4b727e0 100644 --- a/src/routes/ai-analysis.tsx +++ b/src/routes/ai-analysis.tsx @@ -53,9 +53,12 @@ function AiAnalysisPage() {
- {report.created_at} + {report.updated_at || report.created_at} {report.tokens_used?.toLocaleString()} tokens + {(report.generation_count ?? 1) > 1 && ( + 第 {report.generation_count} 次生成 + )} {report.toolsUsed && report.toolsUsed.length > 0 && ( @@ -66,6 +69,18 @@ function AiAnalysisPage() { + {/* 今日定调摘要 */} + {report.summary && ( +
+
+ + 今日定调 + +

{report.summary}

+
+
+ )} + {/* Report Content */}