@@ -0,0 +1,187 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user