"""AI 分析核心服务 负责: 1. 调用 OpenAI 兼容 API 进行分析 2. Function Calling 循环(AI 可主动获取数据) 3. 采集当日盘面快照(供次日环比) 4. 保存报告到数据库 """ import json import re import traceback import httpx from datetime import datetime, timezone, timedelta from services.ai_config import AI_API_BASE, AI_API_KEY, AI_MODEL, AI_MAX_TOKENS, AI_TEMPERATURE from services.ai_tools import TOOLS, execute_tool from database import get_connection _CST = timezone(timedelta(hours=8)) SYSTEM_PROMPT = """你是一位专业的A股市场分析师,擅长从数据中发现投资机会。 你的分析风格: - 数据驱动,基于真实数据而非主观臆断 - 逻辑清晰,先总后分,层层递进 - 观点明确,给出具体的操作建议 - 风险提示,每次推荐都需说明风险点 可用工具: - get_market_dashboard: 获取市场整体数据(指数/涨跌统计/市场温度/连板梯队/行业强度/板块资金流/两融/事件情报) - get_theme_history: 获取指定日期的题材涨幅排行 - get_active_core_stocks: 获取核心股追踪数据(10日涨幅矩阵+所属题材) - get_stock_quote: 获取个股实时行情 - get_fund_flow: 获取个股资金流向 - get_news: 获取财经快讯(新浪7x24,用于重要消息面) 重要规则: 1. 你必须先调用工具获取数据,然后基于数据进行分析 2. 不要凭空编造数据,所有数据必须来自工具返回 3. 如果工具返回空数据,如实说明数据不可用 4. 分析完成后给出明确的结论和建议""" DAILY_ANALYSIS_PROMPT = """请对 {trade_date} 的A股市场进行收盘分析,生成一份完整的分析报告。 {prev_report_section} {prev_snapshot_section} 请先调用以下工具获取数据: 1. get_market_dashboard - 获取市场整体数据(含涨跌统计、市场温度、连板梯队 limitLadder、板块资金流 sectorFundFlow、两融) 2. get_theme_history(date="{trade_date}") - 获取今日题材涨幅 3. get_active_core_stocks - 获取核心股数据 4. get_news(limit=30) - 获取今日财经快讯 生成报告时必须遵守以下格式规则: 1. 报告标题(# 一级标题)之后的第一行,必须是一个引用块"定调摘要",格式严格为: > 今日定调:<一句话核心结论,不超过80字,必须包含1-2个关键数字(如成交额、涨停家数、市场温度)> 2. 量能与情绪类数字必须给环比:若上方提供了"前一交易日盘面数据快照",成交额、涨跌家数、涨停数、两融余额、市场温度等在与昨日对比后表述(如"成交额2.05万亿,较昨日缩量约700亿");没有昨日快照则如实说明"暂无昨日数据"。 3. 连板梯队必须完整呈现 get_market_dashboard 返回的 limitLadder:从最高连板到2连板逐级列表格,每只标注涨停原因(reason字段)与封单金额(sealWan,单位万,为空则不写);首板只挑3-5只人气最高的点评。 4. 板块资金面必须引用 get_market_dashboard 返回的 sectorFundFlow:行业主力净流入TOP3、净流出TOP3、概念净流入TOP3(单位亿元),结合题材分析说明资金动向。 5. 重要消息面必须基于 get_news 返回的快讯整理:挑5-8条对次日盘面影响最大的消息,每条格式为"【分类】一句话新闻 —— 一句影响解读"(分类用:宏观/政策/行业/公司/海外);快讯中若没有某方面的重要消息,如实说明,严禁编造工具中不存在的新闻。 6. 适当使用表格展示数据对比。 然后基于数据生成报告,结构如下: ## 一、市场总览 - 主要指数表现(上证、深证、创业板、科创50) - 涨跌家数统计(含涨停/跌停/炸板率,须环比) - 市场温度评估 ## 二、题材热点分析 - 今日涨幅前5题材 - 持续活跃的题材 - 新兴热点题材 - 明显退潮的题材(警示) - 板块主力资金流(按格式规则4引用数据) ## 三、核心股追踪 - 连板梯队分析(按格式规则3完整呈现) - 核心股表现 - 龙头股辨识 ## 四、关注方向 - 明日值得关注的题材方向 - 潜在的交易机会 ## 五、下个交易日建议 - 明日大盘预判(支撑/压力位) - 建议关注的题材方向(2-3个) - 建议关注的核心股(附理由) - 操作策略(仓位建议、买卖时机) - 需要规避的方向 ## 六、重要消息面 - 基于 get_news 快讯整理(按格式规则5) ## 七、风险提示 - 需要警惕的风险因素 - 操作建议 请用 Markdown 格式输出,适当使用表格展示数据对比。""" # 报告头部的"今日定调"引用行,保存时提取为 summary _TONE_LINE_RE = re.compile(r"^>\s*今日定调[::]\s*(.+)$", re.MULTILINE) async def call_llm(messages: list, tools: list = None) -> dict: """调用 OpenAI 兼容 API""" async with httpx.AsyncClient() as client: payload = { "model": AI_MODEL, "messages": messages, } if AI_MAX_TOKENS is not None: payload["max_tokens"] = AI_MAX_TOKENS if AI_TEMPERATURE is not None: payload["temperature"] = AI_TEMPERATURE if tools: payload["tools"] = tools payload["tool_choice"] = "auto" resp = await client.post( f"{AI_API_BASE}/chat/completions", headers={ "Authorization": f"Bearer {AI_API_KEY}", "Content-Type": "application/json", }, json=payload, timeout=300, # 续写调用携带全部上下文且输出很长,需要较宽的读超时 ) resp.raise_for_status() return resp.json() async def collect_ai_analysis(trade_date: str) -> dict: """AI 分析主流程 Args: trade_date: 交易日 YYYY-MM-DD Returns: {"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_snapshot_section=prev_snapshot_section, )} ] tools_used = [] total_tokens = 0 final_content = "" was_truncated = False continuations = 0 max_rounds = 30 # 安全上限,正常分析约 3-8 轮 for i in range(max_rounds): response = await call_llm(messages, tools=TOOLS) total_tokens += response.get("usage", {}).get("total_tokens", 0) choice = response["choices"][0] message = choice["message"] messages.append(message) finish_reason = choice.get("finish_reason", "") print(f"[ai-service] round {i}: finish={finish_reason}, " f"content_len={len(message.get('content') or '')}, " f"tool_calls={len(message.get('tool_calls') or [])}") if finish_reason == "stop": final_content += message.get("content") or "" break if finish_reason == "length": # 单次输出上限截断:拼接已有内容并让模型续写(最多3次) final_content += message.get("content") or "" if continuations < 3: continuations += 1 print(f"[ai-service] 响应被截断,第 {continuations} 次续写") messages.append({ "role": "user", "content": "报告输出被截断了。请从截断处无缝续写剩余内容:直接接着写,不要重复已输出的部分,也不要重新输出标题。", }) continue was_truncated = True print("[ai-service] 警告:多次续写后仍被截断") break if finish_reason == "tool_calls": for tool_call in message.get("tool_calls", []): func_name = tool_call["function"]["name"] func_args = json.loads(tool_call["function"]["arguments"]) tools_used.append(func_name) result = await execute_tool(func_name, func_args) messages.append({ "role": "tool", "tool_call_id": tool_call["id"], "content": result }) 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 _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 "-" try: pct = round(float(idx.get("changePct", 0)), 2) except (TypeError, ValueError): pct = 0 sign = "+" if pct >= 0 else "" return f"{idx.get('price', '-')}({sign}{pct}%)" def _fmt_temperature(v) -> str: """温度可能是 dict(score/label/factors),取分数与标签""" if isinstance(v, dict): score = v.get("score") label = v.get("label") or "" return f"{score}分{('(' + label + ')') if label else ''}" return _num(v) async def _capture_market_snapshot(trade_date: str) -> bool: """采集当日盘面快照入库(指数+涨跌统计),供次日分析做环比""" try: from routes.market_dashboard import _build_dashboard data = await _build_dashboard() stats = data.get("marketStats") or {} # 数据有效性校验:fuyao 拉取失败时涨跌统计全 0,空快照会污染次日环比 if not data.get("indices") or (stats.get("upCount", 0) + stats.get("downCount", 0) == 0): print(f"[ai-service] 盘面数据无效,跳过快照入库 {trade_date}") return False payload = json.dumps( {"indices": data.get("indices", []), "marketStats": stats}, 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: 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") ) margin_line = "" if stats.get("marginBalanceYi"): change = stats.get("marginChangeYi") change_txt = "" if change is not None: sign = "+" if float(change) >= 0 else "" change_txt = f"(较前一日 {sign}{_num(change)}亿)" margin_line = f"\n- 两融余额:{_num(stats.get('marginBalanceYi'))}亿{change_txt},数据日期 {stats.get('marginDate') or '-'}(T+1)" 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"))}% - 市场温度:{_fmt_temperature(stats.get("temperature"))},竞价信号:{stats.get("auctionSignal") or "-"}{margin_line} - 指数收盘:{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股收盘分析", content, summary, json.dumps(tools_used), AI_MODEL, tokens_used, ), ) conn.commit() row = conn.execute( "SELECT id FROM ai_reports WHERE trade_date = ? AND report_type = 'daily'", (trade_date,), ).fetchone() return row["id"] finally: conn.close() def _get_prev_report_section(trade_date: str) -> str: """获取前一个交易日的报告,用于上下文参考""" conn = get_connection() try: row = conn.execute( "SELECT trade_date, content FROM ai_reports WHERE trade_date < ? AND report_type = 'daily' ORDER BY trade_date DESC LIMIT 1", (trade_date,) ).fetchone() if not row: return "" prev_date = row["trade_date"] prev_content = row["content"] or "" return f"""以下是前一个交易日({prev_date})的分析报告,请参考其中的分析逻辑和关注方向,结合今日数据进行对比分析: {prev_content} --- """ finally: conn.close()