aad34202f1
Co-Authored-By: Claude <noreply@anthropic.com>
188 lines
7.7 KiB
Python
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)
|