# -*- 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", "解读失败"))