Files

462 lines
21 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
定期报告分析解读引擎 v7.1.0 (report_interpreter.py)
把上市公司定期报告(年报/半年报/季报)的财务数据转化为**自然语言解读**:
业绩概览、同比/环比、盈利能力、成长性、现金流质量、亮点信号、风险警示、
综合评分与评级建议。纯规则引擎(阈值+模板),零 LLM 依赖、零 pip 依赖。
数据来源(多路输入):
1. interpret_stock("600519") —— 东财 RPT_LICO_FN_CPD 直连(含同比/环比增速字段)
2. interpret_data([{...}, ...]) —— 直接喂财务数据 dict(离线/自定义,字段名兼容中英文)
3. interpret_periods(periods) —— 已归一化的周期序列
用法:
from scripts.report_interpreter import interpret_stock_report
text = interpret_stock_report("600519") # 一站式解读
result = interpret_data({"营收": 100, "净利润": 20, ...}) # 结构化结果
"""
from __future__ import annotations
import json
import re
import sys
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
try:
from .http_utils import http_get
except ImportError: # 脚本直跑
from http_utils import http_get # type: ignore
# ── 东财财务报告接口(RPT_LICO_FN_CPD,含同比/环比增速字段)────────────
_EM_FN_URL = ("https://datacenter-web.eastmoney.com/api/data/v1/get"
"?reportName=RPT_LICO_FN_CPD&columns=ALL"
"&filter=(SECUCODE%3D%22{secucode}%22)"
"&pageNumber=1&pageSize={limit}&source=WEB&client=WEB")
_EM_HEADERS = {
"User-Agent": ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/133.0.0.0 Safari/537.36"),
"Referer": "https://data.eastmoney.com/",
"Accept": "application/json, text/plain, */*",
}
# ── 解读规则(阈值 → 档位标签 + 评语模板)─────────────────────────
RULES: Dict[str, Dict[str, Any]] = {
"revenue_yoy": { # 营收同比增速(%)
">30": ("高增长", "营收同比大增 {v:.1f}%,规模扩张动能强劲"),
"10~30": ("稳健增长", "营收同比增长 {v:.1f}%,处于稳健增长区间"),
"0~10": ("低增长", "营收同比增长 {v:.1f}%,增速平缓"),
"<0": ("营收下滑", "营收同比下滑 {v:.1f}%,需关注需求端压力"),
},
"profit_yoy": { # 归母净利润同比增速(%)
">50": ("利润爆发", "归母净利润同比大增 {v:.1f}%,盈利爆发式增长"),
"20~50": ("利润强劲", "归母净利润同比增长 {v:.1f}%,盈利强劲"),
"0~20": ("利润平稳", "归母净利润同比增长 {v:.1f}%,盈利平稳"),
"<0": ("利润承压", "归母净利润同比下滑 {v:.1f}%,盈利承压"),
},
"gross_margin": { # 销售毛利率(%)
">60": ("极高毛利", "毛利率 {v:.1f}%,具备极强的定价权与成本优势"),
"40~60": ("高毛利", "毛利率 {v:.1f}%,盈利空间充裕"),
"25~40": ("中等毛利", "毛利率 {v:.1f}%,处于行业中游水平"),
"<25": ("毛利偏低", "毛利率 {v:.1f}%,产品或面临同质化竞争"),
},
"roe": { # 加权净资产收益率(%)
">20": ("ROE优秀", "ROE {v:.1f}%,资本回报水平优秀"),
"12~20": ("ROE良好", "ROE {v:.1f}%,资本回报良好"),
"6~12": ("ROE一般", "ROE {v:.1f}%,资本回报一般"),
"<6": ("ROE偏弱", "ROE {v:.1f}%,资本回报偏弱"),
},
"cash_ratio": { # 每股经营现金流 / 每股收益
">1": ("现金流充沛", "每股经营现金流为 EPS 的 {v:.2f} 倍,盈利含金量高"),
"0.5~1": ("现金流尚可", "每股经营现金流为 EPS 的 {v:.2f} 倍,盈利质量尚可"),
"<0.5": ("现金流偏紧", "每股经营现金流仅 EPS 的 {v:.2f} 倍,盈利含金量不足"),
},
}
SCORE_WEIGHTS = { # 综合评分权重(合计 100)
"profit_yoy": 25, "revenue_yoy": 20, "roe": 20,
"gross_margin": 15, "cash_ratio": 10, "eps": 10,
}
def _band(value: float, rule: Dict[str, Any]) -> Tuple[str, str]:
"""按阈值区间取档位标签与评语模板。"""
for key, (label, tpl) in rule.items():
ok = False
if key == ">30":
ok = value > 30
elif key == ">60":
ok = value > 60
elif key == ">50":
ok = value > 50
elif key == ">20":
ok = value > 20
elif key == ">12":
ok = value > 12
elif key == ">1":
ok = value > 1
elif key == "10~30":
ok = 10 <= value <= 30
elif key == "40~60":
ok = 40 <= value <= 60
elif key == "25~40":
ok = 25 <= value < 40
elif key == "12~20":
ok = 12 <= value < 20
elif key == "6~12":
ok = 6 <= value < 12
elif key == "0~10":
ok = 0 <= value < 10
elif key == "0~20":
ok = 0 <= value < 20
elif key == "0.5~1":
ok = 0.5 <= value <= 1
elif key == "<0":
ok = value < 0
elif key == "<25":
ok = value < 25
elif key == "<6":
ok = value < 6
elif key == "<0.5":
ok = value < 0.5
if ok:
return label, tpl
return "数据缺失", "该指标本期数据缺失"
def _f(v: Any) -> Optional[float]:
"""安全转 float(兼容 None/'-'/空串)。"""
if v is None:
return None
if isinstance(v, (int, float)):
return float(v)
s = str(v).replace(",", "").replace("%", "").strip()
if not s or s in ("-", "--", "nan", "None"):
return None
try:
return float(s)
except ValueError:
return None
# ── 字段归一化:东财字段名 / 中英文常见别名 → 统一键 ────────────────
_FIELD_ALIASES = {
"revenue": ["TOTAL_OPERATE_INCOME", "OPERATE_INCOME", "营收", "营业收入",
"营业总收入", "revenue", "total_revenue"],
"net_profit": ["PARENT_NETPROFIT", "NETPROFIT", "归母净利润", "净利润",
"net_profit", "netprofit"],
"eps": ["BASIC_EPS", "EPSJB", "EPS", "每股收益", "基本每股收益"],
"roe": ["WEIGHTAVG_ROE", "ROEJQ", "ROE", "净资产收益率", "加权净资产收益率"],
"gross_margin": ["XSMLL", "毛利率", "销售毛利率", "gross_margin"],
"revenue_yoy": ["YSTZ", "营收同比", "营业收入同比增长率", "TOTAL_OPERATE_INCOME_YOY"],
"profit_yoy": ["SJLTZ", "净利同比", "净利润同比增长率", "PARENT_NETPROFIT_YOY"],
"revenue_qoq": ["YSHZ", "营收环比", "营业收入环比增长率"],
"profit_qoq": ["SJLHZ", "净利环比", "净利润环比增长率"],
"cash_per_share": ["MGJYXJJE", "每股经营现金流", "每股经营现金流量净额"],
"bps": ["BPS", "每股净资产"],
"report_date": ["REPORTDATE", "REPORT_DATE", "报告期", "date"],
"name": ["SECURITY_NAME_ABBR", "公司名称", "名称", "name"],
}
def normalize_period(row: Dict[str, Any]) -> Dict[str, Any]:
"""把任意来源的财务数据行归一化为统一键。"""
out: Dict[str, Any] = {}
for key, aliases in _FIELD_ALIASES.items():
for a in aliases:
if a in row and row[a] is not None:
v = row[a]
if key in ("revenue", "net_profit", "eps", "roe", "gross_margin",
"revenue_yoy", "profit_yoy", "revenue_qoq", "profit_qoq",
"cash_per_share", "bps"):
v = _f(v)
out[key] = v
break
return out
# ── 东财直连拉取 ──────────────────────────────────────────────
def fetch_periods(stock_code: str, limit: int = 8) -> List[Dict[str, Any]]:
"""东财 RPT_LICO_FN_CPD 拉取财务周期(自动补 .SH/.SZ/.BJ)。"""
code = stock_code.strip().upper()
if "." in code:
secucode, suffix = code.split(".")[:2]
secucode = secucode + "." + suffix
else:
market = "BJ" if code.startswith(("4", "8", "92")) else (
"SH" if code.startswith(("6", "9")) else "SZ")
secucode = f"{code}.{market}"
try:
resp = http_get(_EM_FN_URL.format(secucode=secucode, limit=limit),
headers=_EM_HEADERS, timeout=20, rate_limit_delay=0.3)
if resp is None:
return []
data = (resp.json().get("result") or {}).get("data") or []
return [normalize_period(r) for r in data]
except Exception as e:
print(f"[report_interpreter] 拉取 {stock_code} 财务数据失败: {e}",
file=sys.stderr)
return []
# ── 解读核心 ──────────────────────────────────────────────────
class ReportInterpreter:
"""定期报告解读器:指标计算 → 规则命中 → 评分评级 → 文本生成。"""
def __init__(self, rules: Optional[Dict] = None,
weights: Optional[Dict[str, int]] = None):
self.rules = rules or RULES
self.weights = weights or SCORE_WEIGHTS
# ── 指标提取 ────────────────────────────────────────
@staticmethod
def _latest(periods: List[Dict]) -> Dict[str, Any]:
periods = [p for p in periods if p.get("report_date") or p.get("revenue")]
if not periods:
return {}
# 报告期日期倒序取最新(无日期则取列表第一个)
def _key(p: Dict) -> str:
d = str(p.get("report_date") or "")
return d.replace("-", "").replace("/", "").replace(" ", "")
return sorted(periods, key=_key, reverse=True)[0] if any(
_key(p) for p in periods) else periods[0]
def compute(self, periods: List[Dict]) -> Dict[str, Any]:
"""计算指标与信号。"""
latest = self._latest(periods)
if not latest:
return {"error": "无财务数据"}
rev, np_ = latest.get("revenue"), latest.get("net_profit")
eps, roe = latest.get("eps"), latest.get("roe")
gm, cps = latest.get("gross_margin"), latest.get("cash_per_share")
rev_yoy = latest.get("revenue_yoy")
np_yoy = latest.get("profit_yoy")
rev_qoq = latest.get("revenue_qoq")
np_qoq = latest.get("profit_qoq")
cash_ratio = None
if cps is not None and eps is not None and eps > 0:
cash_ratio = cps / eps # 负 EPS 时比率无意义(亏损公司直接看现金流正负)
# 历史趋势:最近两期同比增速
trend = None
if len(periods) >= 2:
prev = periods[1]
if (np_yoy is not None and prev.get("profit_yoy") is not None
and np_yoy < 0 and prev.get("profit_yoy", 0) < 0):
trend = "连续两期利润下滑"
metrics = {
"name": latest.get("name") or "",
"report_date": latest.get("report_date") or "",
"revenue": rev, "net_profit": np_, "eps": eps, "roe": roe,
"gross_margin": gm, "revenue_yoy": rev_yoy, "profit_yoy": np_yoy,
"revenue_qoq": rev_qoq, "profit_qoq": np_qoq,
"cash_per_share": cps, "cash_ratio": cash_ratio, "bps": latest.get("bps"),
"trend": trend,
}
return metrics
# ── 规则命中 → 亮点/风险 ─────────────────────────────
def signals(self, m: Dict[str, Any]) -> Tuple[List[str], List[str]]:
highlights, risks = [], []
if m.get("error"):
return highlights, risks
# 营收/利润增速
if m.get("revenue_yoy") is not None:
label, tpl = _band(m["revenue_yoy"], self.rules["revenue_yoy"])
text = tpl.format(v=m["revenue_yoy"])
(risks if m["revenue_yoy"] < 0 else highlights).append(text)
if m.get("profit_yoy") is not None:
label, tpl = _band(m["profit_yoy"], self.rules["profit_yoy"])
text = tpl.format(v=m["profit_yoy"])
(risks if m["profit_yoy"] < 0 else highlights).append(text)
# 环比短期承压
for key, name in (("revenue_qoq", "营收"), ("profit_qoq", "净利润")):
v = m.get(key)
if v is not None and v < 0:
risks.append(f"{name}环比下滑 {v:.1f}%,短期动能转弱")
# 毛利率
if m.get("gross_margin") is not None:
label, tpl = _band(m["gross_margin"], self.rules["gross_margin"])
text = tpl.format(v=m["gross_margin"])
(risks if m["gross_margin"] < 25 else highlights).append(text)
# ROE
if m.get("roe") is not None:
label, tpl = _band(m["roe"], self.rules["roe"])
(risks if m["roe"] < 6 else highlights).append(
tpl.format(v=m["roe"]))
# 现金流质量
if m.get("cash_ratio") is not None:
label, tpl = _band(m["cash_ratio"], self.rules["cash_ratio"])
text = tpl.format(v=m["cash_ratio"])
(risks if m["cash_ratio"] < 0.5 else highlights).append(text)
elif m.get("cash_per_share") is not None and m.get("cash_per_share", 0) < 0:
risks.append("每股经营现金流为负,经营性现金流出")
# 趋势
if m.get("trend"):
risks.append(m["trend"])
return highlights[:6], risks[:6]
# ── 综合评分与评级 ───────────────────────────────────
def score(self, m: Dict[str, Any]) -> Tuple[float, str]:
if m.get("error"):
return 0.0, "无数据"
total = 0.0
got = 0
# 各维度得分(0-100 按档位)
def _dim(value, bands):
for lo, hi, s in bands:
if lo is None or value > lo:
if hi is None or value <= hi:
return s
return 50
dims = {
"profit_yoy": (m.get("profit_yoy"), [
(50, None, 95), (20, 50, 85), (0, 20, 65),
(-10, 0, 40), (None, -10, 25)]),
"revenue_yoy": (m.get("revenue_yoy"), [
(30, None, 90), (10, 30, 75), (0, 10, 55),
(-10, 0, 35), (None, -10, 20)]),
"roe": (m.get("roe"), [
(20, None, 90), (12, 20, 75), (6, 12, 55), (None, 6, 30)]),
"gross_margin": (m.get("gross_margin"), [
(60, None, 90), (40, 60, 75), (25, 40, 55), (None, 25, 30)]),
"cash_ratio": (m.get("cash_ratio"), [
(1, None, 90), (0.5, 1, 65), (None, 0.5, 30)]),
"eps": (m.get("eps"), [
(1, None, 80), (0.3, 1, 65), (0, 0.3, 50), (None, 0, 25)]),
}
for key, weight in self.weights.items():
if key not in dims:
continue
value, bands = dims[key]
if value is None:
continue
total += weight * _dim(value, bands)
got += weight
if got == 0:
return 0.0, "数据不足"
final = total / got
if final >= 80:
rating = "积极"
elif final >= 65:
rating = "中性偏积极"
elif final >= 45:
rating = "中性"
else:
rating = "谨慎"
return round(final, 1), rating
# ── 文本解读 ────────────────────────────────────────
def format_text(self, m: Dict[str, Any], highlights: List[str],
risks: List[str], score: float, rating: str) -> str:
if m.get("error"):
return f"❌ 解读失败:{m['error']}"
L = []
name = m.get("name") or "该公司"
L.append(f"📊 定期报告解读:{name}"
f"(报告期 {m.get('report_date') or '—'})")
L.append("=" * 52)
if m.get("revenue") is not None:
rev_txt = f"营收 {m['revenue']/1e8:.2f} 亿元"
if m.get("revenue_yoy") is not None:
rev_txt += f"(同比 {m['revenue_yoy']:+.1f}%"
rev_txt += (f" 环比 {m['revenue_qoq']:+.1f}%)"
if m.get("revenue_qoq") is not None else ")")
L.append(f"【业绩概览】{rev_txt}")
else:
L.append("【业绩概览】营收数据缺失")
if m.get("net_profit") is not None:
np_txt = f"归母净利润 {m['net_profit']/1e8:.2f} 亿元"
if m.get("profit_yoy") is not None:
np_txt += f"(同比 {m['profit_yoy']:+.1f}%"
np_txt += (f" 环比 {m['profit_qoq']:+.1f}%)"
if m.get("profit_qoq") is not None else ")")
L.append(f" {np_txt}")
if m.get("eps") is not None:
bps_txt = f";每股净资产 {m['bps']:.2f} 元" if m.get("bps") is not None else ""
L.append(f" 基本每股收益 {m['eps']:.3f} 元{bps_txt}")
L.append("")
L.append(f"【盈利能力】ROE {m['roe']:.1f}% 毛利率 {m['gross_margin']:.1f}%"
if m.get("roe") is not None and m.get("gross_margin") is not None
else "【盈利能力】数据缺失")
L.append("")
if highlights:
L.append("✅ 亮点信号")
for h in highlights:
L.append(f" · {h}")
if risks:
L.append("⚠️ 风险提示")
for r in risks:
L.append(f" · {r}")
L.append("")
L.append(f"【综合评分】{score}/100 评级:{'🟢' if rating == '积极' else '🟡' if rating in ('中性偏积极', '中性') else '🔴'} {rating}")
L.append("_" * 52)
L.append("注:规则引擎自动解读,供研究参考,不构成投资建议。")
return "\n".join(L)
def interpret(self, periods: List[Dict]) -> Dict[str, Any]:
# 统一归一化(已归一化行幂等;原始东财行/中文字段行均可)
periods = [normalize_period(p) for p in periods]
m = self.compute(periods)
if m.get("error"):
return m
highlights, risks = self.signals(m)
score, rating = self.score(m)
return {**m, "highlights": highlights, "risks": risks,
"score": score, "rating": rating,
"text": self.format_text(m, highlights, risks, score, rating)}
# ── 便捷入口 ──────────────────────────────────────────────────
def interpret_data(data: Any) -> Dict[str, Any]:
"""直接喂财务数据(dict 单期 / list 多期 / 东财原始行均可)。"""
if isinstance(data, dict):
periods = [normalize_period(data)]
elif isinstance(data, (list, tuple)):
periods = [normalize_period(d) if not _is_normalized(d) else d
for d in data]
else:
return {"error": "无法识别的数据格式"}
return ReportInterpreter().interpret(periods)
def _is_normalized(d: Dict) -> bool:
return any(k in d for k in ("revenue", "net_profit", "eps"))
def interpret_stock(stock_code: str, limit: int = 8) -> Dict[str, Any]:
"""一站式:拉取东财财务数据并解读。"""
periods = fetch_periods(stock_code, limit=limit)
if not periods:
return {"error": f"未获取到 {stock_code} 的财务数据(代码可能错误或接口暂不可用)"}
return ReportInterpreter().interpret(periods)
def interpret_stock_report(stock_code: str, limit: int = 8) -> str:
"""便捷文本入口(供 MCP/CLI/对话调用)。"""
result = interpret_stock(stock_code, limit=limit)
return result.get("text") or f"❌ 解读失败:{result.get('error', '未知错误')}"
# ── CLI ──────────────────────────────────────────────────────
if __name__ == "__main__":
import argparse
ap = argparse.ArgumentParser(description="v7.1.0 定期报告解读引擎")
ap.add_argument("stock", help="股票代码,如 600519 / 000001.SZ")
ap.add_argument("--limit", type=int, default=8, help="拉取报告期数")
ap.add_argument("--json", action="store_true", help="输出结构化 JSON")
args = ap.parse_args()
result = interpret_stock(args.stock, limit=args.limit)
if args.json:
print(json.dumps(result, ensure_ascii=False, indent=1, default=str))
else:
print(result.get("text") or result.get("error", "解读失败"))