Files
stock/serve/backend/app/services/financial_analyzer.py
T
2026-07-04 16:59:25 +08:00

188 lines
7.7 KiB
Python

"""AI 财务分析服务 — 读取个股财务数据 → 构建专业提示词 → 流式调用 LLM。
职责: 拉取单只标的的 4 张财务表 → 转成紧凑 JSON → 拼装 CFA 分析师级系统提示词
→ 流式调用 OpenAI 兼容 API → 逐 chunk 吐给前端。
不知道: HTTP、前端、配置持久化。
"""
from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import AsyncIterator
import polars as pl
from app.services.financial_sync import get_financial_df
logger = logging.getLogger(__name__)
# 最多注入的报告期数(最新 N 期),避免上下文爆炸 / token 浪费
_MAX_PERIODS = 4
def _load_stock_financials(data_dir: Path, symbol: str) -> dict[str, list[dict]]:
"""读取该标的的 4 张财务表,返回 {table: [records...]}(按 period_end 降序,截取最新 N 期)。
数值统一做 NaN/Inf → null 清洗,保证 JSON 序列化不报错。
"""
result: dict[str, list[dict]] = {}
for table in ("metrics", "income", "balance_sheet", "cash_flow"):
df = get_financial_df(data_dir, table)
if df.is_empty():
result[table] = []
continue
df = df.filter(pl.col("symbol") == symbol)
if df.is_empty():
result[table] = []
continue
# 按 period_end 降序,截取最新 N 期
if "period_end" in df.columns:
df = df.sort("period_end", descending=True).head(_MAX_PERIODS)
# 清洗 NaN/Inf,转成 JSON 安全的 dict 列表
rows = []
for rec in df.to_dicts():
clean = {}
for k, v in rec.items():
if k == "symbol":
continue # 不需要重复回传 symbol
if isinstance(v, float):
import math
clean[k] = None if not math.isfinite(v) else v
else:
clean[k] = v
rows.append(clean)
result[table] = rows
return result
def _summarize(fins: dict[str, list[dict]]) -> str:
"""生成一行业务摘要,便于 LLM 快速把握数据全貌(行数/期数)。"""
parts = []
for table in ("metrics", "income", "balance_sheet", "cash_flow"):
rows = fins.get(table, [])
if rows:
periods = [r.get("period_end") for r in rows if r.get("period_end")]
parts.append(f"{table}: {len(rows)}期 ({', '.join(str(p) for p in periods[:3])})")
else:
parts.append(f"{table}: 无数据")
return " · ".join(parts)
# ================================================================
# 系统提示词 —— CFA 分析师级,九维分析框架
# ================================================================
_SYSTEM_PROMPT = """你是一位拥有 15 年 A 股投研经验的资深财务分析师(CFA + CPA),服务于专业机构投资者。你的任务是:基于提供的上市公司财务数据,产出一份**严谨、专业、可直接用于投资决策**的财务分析报告。
## 输出规范
用 **Markdown** 格式输出,严格遵循以下结构。不要输出任何 JSON 或代码块,直接输出 Markdown 正文。
### 1. 📌 核心摘要(1-2 句)
用一句话概括该公司的财务画像:盈利质量、成长动能、财务健康度的最关键判断。结尾用【综合评级:★★★☆☆】给出 1-5 星评级。
### 2. ✅ 亮点(2-3 条)
列出最值得关注的**积极信号**,每条用加粗短语领起,配数据支撑。例如盈利高增、ROE 持续提升、现金流充沛等。
### 3. ⚠️ 风险提示(2-3 条)
客观指出**潜在风险或值得警惕的信号**,例如应收激增、存货堆积、经营现金流与净利润背离、债务攀升等。宁可保守,不要回避。
### 4. 📊 分项诊断
用**表格**呈现各维度的诊断结论,列为「维度 / 关键指标 / 判断」。维度包括:
- **盈利能力**:ROE / ROA / 毛利率 / 净利率
- **成长性**:营收同比 / 净利润同比
- **偿债能力**:资产负债率 / 流动比率(用资产/负债估算)
- **现金流**:经营现金流净额 / 与净利润的匹配度
- **营运效率**:存货周转率等(有数据时)
每个判断给「优秀 / 良好 / 一般 / 偏弱 / 警惕」之一,并一句话说明依据。
### 5. 🎯 综合评估与展望
2-3 段总结:该公司当前的财务状态(优秀/稳健/承压/恶化)、核心驱动力、未来需重点跟踪的指标。**结尾给出"投资参考"**:从纯财务质量角度,该股属于(高质量蓝筹 / 稳健成长 / 周期波动 / 财务承压 / 高风险)中的哪一类。
## 分析准则(务必遵守)
1. **数据说话**:每个判断必须引用具体数值(如"营收同比 +28.5%"),严禁空泛套话
2. **纵向对比**:利用多期数据看趋势(改善/恶化),而非只看单期
3. **交叉验证**:经营现金流 vs 净利润(是否造血)、毛利率 vs 费用率(盈利结构)、负债 vs 资产(杠杆)
4. **行业常识**:对照 A 股常识判断水平(如 ROE>15% 优秀,资产负债率>70% 偏高,毛利率<20% 偏低)
5. **诚实中立**:数据不支持时直言"数据不足,无法判断",绝不编造或过度演绎
6. **简明有力**:避免冗长,用专业投资者能扫读的密度输出,总字数 800-1500 字
## 重要免责
报告末尾附一行:"> ⚠️ 本报告由 AI 基于公开财务数据生成,仅供参考,不构成任何投资建议。"
现在请基于下方数据进行分析。"""
def _build_user_prompt(fins: dict[str, list[dict]], symbol: str, focus: str) -> str:
"""构建用户消息:标的代码 + 数据 JSON + 可选关注点。"""
data_json = json.dumps(fins, ensure_ascii=False, indent=2)
lines = [
f"标的标准代码: {symbol}",
f"数据概览: {_summarize(fins)}",
"",
"以下是该标的最新财务数据(JSON 格式,金额单位为元,比率类指标为百分点):",
"```json",
data_json,
"```",
]
if focus.strip():
lines.extend([
"",
f"本次分析请特别关注: {focus.strip()}",
])
return "\n".join(lines)
async def analyze_financials_stream(
data_dir: Path,
symbol: str,
focus: str = "",
) -> AsyncIterator[str]:
"""流式分析:yield 出每个文本 chunk。
- 启动时先 yield 一条 {"type":"meta",...} 让前端显示数据摘要
- 之后逐 chunk yield {"type":"delta","content":"..."}
- 出错时 yield {"type":"error","message":"..."}
- 结束 yield {"type":"done"}
"""
# 1. 加载数据
fins = _load_stock_financials(data_dir, symbol)
total_rows = sum(len(v) for v in fins.values())
if total_rows == 0:
yield json.dumps({"type": "error", "message": f"标的 {symbol} 暂无任何财务数据,请先同步财务表"}, ensure_ascii=False)
return
# 2. meta
yield json.dumps({
"type": "meta",
"symbol": symbol,
"summary": _summarize(fins),
"periods": total_rows,
}, ensure_ascii=False)
# 3. 调用 LLM 流式
try:
from app.services.ai_provider import stream_ai_text
user_prompt = _build_user_prompt(fins, symbol, focus)
async for delta in stream_ai_text(
[
{"role": "system", "content": _SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
temperature=0.4,
max_tokens=4000,
):
yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
except Exception as e: # noqa: BLE001
logger.exception("AI financial analysis failed for %s: %s", symbol, e)
yield json.dumps({"type": "error", "message": f"AI 分析失败: {e}"}, ensure_ascii=False)
return
yield json.dumps({"type": "done"}, ensure_ascii=False)