☰
用LLM增强基本面分析:财报文本与量化评分的融合实践
2026/10/11 16:17:00 网站建设 项目流程

去年年报季,我把重点跟踪的几十家公司年报拖进阅读列表,打算用两周时间把管理层讨论、经营情况分析和附注里的关键调整项全部过一遍。结果只完成了七家——不是精力不够,而是这种工作本质上不适合人脑批量处理:每份年报几千上万字,读完还要和往期数据对比、和同行横向比。另一边,纯数字的基本面模型倒是能一次性覆盖全市场,但它经常在"报表数字与业务实质背离"的时候给出完全错误的排序。这个矛盾让我动了改造管线的念头:让LLM去读文本、做判断,让收入报表多指标评分去算数,再用回测验证把两者粘在一起看效果。这篇文章就是这个项目的完整复盘,包含从数据清洗、指标打分、LLM文本抽取到回测验证的每个环节,以及可复现的代码思路,很适合已经在做量化、想引入LLM增强基本面分析的朋友做参照。

1. 先盘点传统基本面分析的产能瓶颈,再看LLM该补哪个位

1.1 手工读财报的覆盖瓶颈

我最早做基本面研究的方式很原始:抓取财报PDF,定位关键段落,手动录入Excel。一套流程下来,每家公司大约要花40分钟到1小时,这里面还只是处理利润表、现金流量表的主要科目和净利润调节项。真正费时间的不是录入,而是"对照":收入和去年比变化了多少、毛利率环比是升是降、经营现金流和净利润的差是不是在拉大。这些对照要在脑子里同时维持好几家公司的历史序列,一旦覆盖范围扩大到三五十家,人的注意力就开始崩溃,更别提每份年报还夹着大量"看似重要、其实只是形式披露"的段落。

做过的人都懂,这种工作的瓶颈不是理解能力,而是产能。三个人轮班读一个行业的全部年报,一个季度也只能覆盖百来家。而且不同人读出来的结论还不一样——有人会注意到应收账款周转天数恶化了,有人会被"营收创新高"吸引而忽略它。这不是谁对谁错的问题,是不可扩展和不一致的问题。我当时一度以为这条路走死了,基本面研究要么牺牲广度、要么牺牲深度,两者只能选一个。

1.2 纯因子模型的"盲区"与手工判断的"不可扩展"是一体两面

纯量化的基本面策略没有产能问题,它有另一个问题:不读文本。我用十几项财务指标做截面排名的时候,模型是稳定的,跑全市场也不累。可它不读文本。最典型的例子:一家公司净利润同比大增40%,但净利润主要来自出售子公司股权的一次性收益。在财务指标里,净利润增速、利润率全都亮眼,因子得分很高;可只要翻过报表的人都知道,这个"增长"和主营业务没关系,明年大概率无法持续。

我手头正好有这样的样本。某家公司当年净利润增长很好看,定量模型把它排进前10%,但仔细读年报能发现,营业利润里的投资收益和资产处置收益占了过半,扣掉这些之后,主营业务实际是下滑的。手工研究能识别这个问题,但覆盖不了全市场;因子模型能覆盖全市场,但没有这个判断。那LLM能不能成为中间的那个人,承担"可扩展的初级分析师"角色?这正是我想试验的。

1.3 我的分工原则:LLM只读文本,不算算术

在后面的设计里,我给LLM划了非常清楚的工作边界:凡是能从结构化数据里算出来的数字,一律由代码计算;LLM只负责读文本、做分类、给解释、标风险。它输出的不是精确数值,而是类别标签和带置信度的判断。这样做的原因会在第4章详细说,总之一句话:千万别让大模型帮你填空财务报表里的任何数字——它算加减乘除不靠谱,而且天生会编造,这两件事合在一起会要命。

2. 数据底座:收入报表预处理里那几个"看似小、实则致命"的坑

2.1 报告期与公告日:第一天就得避开的未来函数

做财报类策略回测,第一个要过的坎就是事件时间。在A股这类实行累计披露制度的市场,年报披露截止日是次年4月底,一季报、半年报、三季报也各有各的最后期限,但具体到每家公司,实际公告日可能比截止日早很多,也可能拖到最后一刻。如果只用财报的"报告期"作为信号产生时间,比如默认2023年12月31日就知道全年年报数据,这是典型的未来函数,回测业绩会好看得离谱。

处理方式很直接:信号产生时间一律使用公告日,而不是报告期。以数据接口里的字段来说,就是要用公告日字段而不是报告期字段。在此基础上,我还习惯再加3个交易日的缓冲——公告日当晚数据入库、跑批出信号、再留出下单执行的时间,真正以"公告日+3个交易日"作为组合调仓的信号时点。宁可少赚公告后头两天的收益,也不能让回测里混入哪怕一天的未来信息。

报告类型最晚披露期限公告日与报告期典型间隔
一季报4月30日1至3个月
半年报8月31日2至3个月
三季报10月31日1至3个月
年报次年4月30日3至4个月

提示:拿到任何财报数据,先把"报告期"和"公告日"两个字段都查一遍。如果数据源里只有报告期,回测结果必须打一个问号。这一条能筛掉市面上相当一部分质量堪忧的"财报因子"策略。

2.2 累计数转单季与TTM:数据口径错了,后面全错

国内市场的利润表按累计口径披露,三季报的"营业收入"是1到9月的合计,不是第三季度单季。做同比增速时,如果用三季报累计数和去年同期累计数比,得到的是"前三季度累计同比增速",这个值本身没问题,但如果想做单季趋势分析,就必须转成单季值:单季营收 = 本期累计数 - 上一期累计数。转TTM(滚动十二个月)时更是常见错误高发区,公式是:TTM = 本期末累计数 + 上年年报全年数 - 上年同期末累计数。

这一段我吃过亏。最早写转换函数时,我直接用"往前数四个报告期的累计值"当作去年同期累计数,结果在一季报上出了错——一季报往前四个报告期其实没有对应关系,因为一季报是当年第一期。后来我把转换逻辑彻底重写,加上单测,用已知手算结果去校验每个报告期。

# 示意:累计值转单季值(单季 = 本期累计 - 上一报告期累计) def cumulative_to_single(df, value_cols): df = df.sort_values(['code', 'end_date']) for col in value_cols: df[col + '_single'] = df.groupby('code')[col].diff() return df # 示意:TTM计算,关键是用【公告日之后能拿到的】累计值 # TTM = 本期累计 + 去年年报全年 - 去年同期累计 def add_ttm(df, value_cols): df = df.sort_values(['code', 'end_date']) annual = df[df['end_date'].dt.month == 12].copy() # 先把"去年年报全年数"映射回每个code每个季度末那一行 annual_map = annual.set_index(['code', 'end_date'])[value_cols] # 再按code、往前一年的年报节点对齐 # 这里需要根据公司财年/上市日期做边界处理,示意从略 return df

这个函数看起来简单,但边界情况很多:新上市公司没有上年数据、变更财年的公司、停牌导致公告日延后的公司。我建议把它当成和下单模块同等重要的核心代码来对待,单独写测试,而不是塞在数据清洗里顺手带过。

2.3 缺失值、停牌和不会按时披露的公司

财报季结束后,总有一部分公司拖着不披露。按"公告日+缓冲"生成信号时,这些公司在当前调仓窗口就没有新数据。我的处理是:绝不用旧数据填充冒充新数据,直接在本期调仓中剔除,等它真正披露后再重新纳入。市场上有人喜欢用"上次已知值填充"来保证面板完整,这个做法在因子研究里有一定道理,但用在实盘模拟上会掩盖一个真实约束——你根本不知道这家公司现在的经营状况。剔除和填充是两种不同的假设,回测里选哪种,直接影响组合的实际可交易性。

2.4 行业标签也要用"当时的",别拿今天的分类去排昨天的名

做截面打分要分行业,因为毛利率、周转率这类指标行业差异太大。但行业分类是会被调整的:公司业务变了、行业标准改名,历史标签会被回改。如果拿今天的行业划分去给三年前的报表排名,这同样是前视偏差。比较稳的办法是保留每个时点的行业标签版本,或者退一步,用公司营业收入中占比最大的业务分部来近似历史行业——这个能直接从历史年报的"分行业收入"文本里提取。我在第一版里图省事用了最新行业标签,后来抽样复核发现有一批公司因为收购业务被重新归类,导致三年前的截面排名整体失真,只能推倒重跑。

3. 多指标评分体系:选哪些指标、怎么打分、权重从哪来

3.1 指标清单:为什么是这几项

收入报表的多指标评分,我最后保留了五类:营业收入同比增速、毛利率、净利率、经营现金流/营业收入、ROE。它们分别对应成长性、产品毛利竞争力、最终盈利水平、利润的现金含量、资本使用效率。选择标准有两条:第一,全部能从收入报表和现金流表里直接算出来,不依赖预测数据;第二,彼此之间相关度不能太高,否则加权形同虚设——比如净利率和毛利率天然高度相关,我不会同时让它们占两个独立权重。

指标口径侧重方向
营业收入同比增速(本期单季/去年同期单季)-1成长性越高越好
毛利率(营业收入-营业成本)/营业收入产品竞争力越高越好
净利率净利润/营业收入盈利水平越高越好
经营现金流/营业收入经营活动现金流净额/营业收入利润含金量越高越好
ROE归母净利润/归母净资产资本效率越高越好

这里有朋友会问,为什么不加资产负债率、应收账款周转这类指标?不是不需要,而是它们属于资产负债表和营运能力维度,和"收入报表多指标评分"这个主题是两套体系。我为了让第一版管线足够聚焦,先把收入报表相关的指标做扎实,其他维度留作二期扩展。

3.2 打分母:分行业百分位,而不是绝对数值

跨行业直接比较绝对值没有意义:软件公司毛利率70%,连锁零售可能只有20%,但两者都是各自行业里的好公司。所以打分要分两步:先按行业分组,再在组内做百分位排名,把每个指标转成0到100分。这样毛利率70%的软件公司只会和它同行业的公司比,连锁零售也只用和零售同行比。行业组太小的(比如少于30家),为了避免小样本噪音,我把它并到上级行业链条里再排名。

合成复合分时,我给五类指标一套权重,再加一个第4章要说的LLM文本分,最终得到0到100的复合分。权重的初始值可以等权,但绝对不能一直等权——必须让回测里的信息系数告诉你谁该被重视。

3.3 权重不是拍脑袋:用IC均值与ICIR迭代调权重

信息系数是评价因子最常用的工具:每个调仓周期,计算因子排名和下一期收益排名的Spearman秩相关系数;把多个周期的信息系数取平均,得到平均信息系数;平均信息系数除以信息系数的标准差,就是ICIR,它衡量因子表现是否稳定。平均信息系数大于0.05算是不错的因子,ICIR大于0.3已经值得持续跟踪。

import pandas as pd import numpy as np from scipy.stats import spearmanr # factor_df: 索引为(period, code),值为因子得分 # fwd_ret: 索引为(period, code),值为下一持有期收益率 def calc_ic(factor_df, fwd_ret): periods = factor_df.index.get_level_values('period').unique() ic_list = [] for p in periods: f = factor_df.xs(p, level='period') r = fwd_ret.xs(p, level='period') common = f.index.intersection(r.index) if len(common) < 30: continue ic, _ = spearmanr(f.loc[common], r.loc[common]) ic_list.append(ic) ic = np.array(ic_list) return ic.mean(), ic.std(), (ic.mean() / ic.std() if ic.std() > 0 else 0) # 返回: (平均IC, IC标准差, ICIR)

然后我再分两步定权重:在前70%的历史窗口里,用ICIR最大化做权重寻优,约束是权重非负、和为1;寻优方法用简单的网格加随机搜索就够,因为指标只有五六个,不需要复杂的优化器。寻优完的权重拿到后30%窗口去验证,避免整个回测期间都参与调参导致的过拟合。这一步是很多人容易忽略的——直接在整个回测区间上寻优,等于让模型偷看了答案。

4. LLM在管线里的分工:抽取、分类、预警和幻觉隔离

4.1 双通道并行:数字通道算数,文本通道读内容

整个策略的LLM部分,我把它设计成与数字管线并行的第二个通道。数字通道负责算收入报表指标并合成数值分;文本通道负责读年报、半年报的经营情况讨论,输出结构化标签。两条通道最后合成一个复合分,送进同一个回测框架。这样设计最大的好处是:文本通道出了问题,数字通道不受影响;反之亦然。我见过一些团队把LLM直接嵌进特征工程里,让大模型输出一个"基本面得分"来替代所有指标,这是在拿最不稳定的组件承载整个策略的核心逻辑,一旦模型升级或接口波动,整个策略都得跟着返工。

4.2 读哪些段落、抽哪些信息,输出格式是什么

我让大模型只处理三块文本:业务概览(用来识别主营结构和行业属性)、经营情况讨论与分析(增长驱动和风险事项)、未来展望(前瞻性语气)。每块输出固定JSON,字段包括:增长驱动是否属于一次性、是否存在经营现金流恶化信号、是否出现客户集中度/诉讼/监管风险提示、未来展望的语气偏向。说直白点,LLM不是写作文,是填表。

你是基本面分析助手。请阅读以下年报片段并仅输出JSON: { "one_off_growth": "yes|no|unknown", "explanation_zh": "不超过50字的中文解释", "cash_flow_risk": "yes|no|unknown", "risk_flags": [], "outlook_tone": "positive|neutral|cautious" } 规则: 1. 不要编造原文没有的数字,不要重复原文数字,只做定性判断; 2. 拿不准就输出unknown,不要猜; 3. 只输出JSON,不要输出其他内容。 [年报片段]

这个提示词看起来简单,但有几个设计细节:要求只做定性判断,不重复数字;明确给出"unknown"选项,让模型有承认自己不确定的空间;限定输出字段,避免模型自由发挥出一些无法落库的结论。

4.3 幻觉隔离的三道闸门

很多人跟我说LLM会一本正经地胡说八道,这我同意,所以我在设计上直接不给它胡说八道的机会。三道闸门分别是:

  • 第一道,结构校验。解析失败就重试,最多重试两次,温度参数设为0;两次仍然解析失败,丢弃本次结果,并把该条文本通道的评分记为中性值。这里的逻辑是:一次解析失败可能是运气,连续失败大概率是这段文本本身有模型处理不了的格式,不值得反复烧钱。
  • 第二道,数字核对。如果模型在解释里提到了具体数字,我把它和结构化数据里的已知数值做比对,偏差超过容差就直接丢弃这条解释。模型在解释里引用数字,往往是复述原文,但它复述得并不总是准确,与其赌它记性好,不如直接不采信。
  • 第三道,特征隔离。LLM输出的标签只进入一个独立的文本分会,这个分在复合分里的权重被压到比较低(我试下来10%到15%比较合适)。就算模型某天批量抽风输出错误标签,对整体排序的影响也是有限的,不会让整个组合被带偏。

提示:做LLM增强基本面分析,最核心的原则是——模型输出的任何内容,都不能直接覆盖或修改结构化数据里的真实数字。它的定位是"第二读者",不是"数据源"。

4.4 一个具体的例子:数字很漂亮,文本在报警

说个实际遇到的案例。某消费类公司甲,定量分非常高——营收同比增长35%,毛利率也涨了2个百分点。但经营现金流/营业收入这个比率却从0.21掉到0.08。单看定量指标,它的名次很高。LLM读经营情况讨论后标注:公司向渠道客户延长了信用期,并计划为新品备货;增长驱动判定为"部分不可持续",风险标签加了"现金转化恶化"。两个通道合并后,复合分从85分下调到72分。后来这家公司下一季度营收增速回落到12%。这个例子说明,文本通道真正有增量价值的场景是:当数字看起来很好、但报表文字在暗示可持续性有问题的时候。这种"数字与叙述背离"的信号,单独靠指标打分是抓不到的,单独靠人读又覆盖不了全市场。

5. 回测验证全流程:分组、换手和超额收益归因

5.1 回测的基本设定,以及我踩过的样本池坑

回测框架的参数设定,直接决定实验结果是否可信。我的基础设定是:股票池剔除风险警示股票、上市不足一年的新股、长期停牌股票,并且用的是当期时点的名单——不是今天的名单。这一点很关键,因为用今天的成分股名单去回测历史,会把已经退市的股票排除在外,等于变相只保留了活下来的公司,这就是幸存者偏差,会让回测结果系统性高估。

调仓频率我选月度,信号时点是"公告日+3个交易日"。交易成本按单边0.15%计提,包含佣金、印花税和滑点,双边就是0.3%。这个参数宁可估高,不要估低,因为财报驱动策略的收益窗口往往就在几周内,成本敏感度极高。整体采用滚动验证:权重在前段窗口估计,在后段窗口验证,绝不把整个回测区间拿来调参数。

5.2 分组检验:收益的单调性比总收益数字更重要

只做一条净值曲线是不够的。我把复合分从低到高排成5组,每组做成等权组合,月度调仓,观察各组年化收益是否单调。如果第5组显著跑赢第1组、但中间3组表现随机,说明因子只对极端股票有区分度——策略的实际效果接近于"买最差的,避开最好的",和"按质量排序"的初衷不符。好的因子应该是梯度式的:分越低,收益越低;分越高,收益越高。

下面是我在一个验证窗口里得到的分组结果,数值做了脱敏,但形态是真实的:

分组年化收益波动率最大回撤月均换手
组1(最低分)5.2%18.1%-25.4%18%
组29.8%17.6%-22.1%15%
组312.3%17.2%-20.6%13%
组414.9%16.8%-19.8%12%
组5(最高分)18.7%17.3%-18.9%16%

第5组相对第1组有年化13个百分点以上的超额,而且中间组的收益基本呈现梯度上升,说明复合分确实具备排序能力。同时要注意,第5组的最大回撤反而低于低分组,这说明这套打分体系选出来的"好公司",不只在上涨时能跟上,下跌时也相对抗跌——这是我更看重的指标。

5.3 换手率与缓冲带:毛收益和净收益的两本账

第一次完整回测跑完,毛收益让我兴奋,但扣除交易成本后收益接近腰斩。原因是全市场每月重新打分排序,前10%的名单每个月要换掉一大半——一家公司出财报的那个月分数跳升进榜,下个月别人出财报又把它挤出榜单,这种高频进出在月度调仓策略里非常吃亏。后来我加了一个简单的缓冲带:已持有的股票,除非跌出前20%、且替代股票的复合分比它高出至少5分,否则不换;新买入只在股票进入前10%时触发。这一改,月均换手从22%降到9%,净收益明显改善。

这个教训值得展开说:财报类策略天然是"事件驱动"的,谁先披露谁先被纳入名单,带着强烈的先后顺序噪音。如果你严格按"所有财报披露完毕后统一打分"来调仓,又会损失时效性。缓冲带是两者之间的折中——它容忍短期的分数波动,只有差异足够大才交易。

5.4 超额收益归因:LLM增强的alpha不是均匀分布的

把复合分拆开回测,能看清楚增量来自哪里。纯数字通道做的组合,基准年化收益已经不错;叠加文本通道后,总收益提升不大,但最大回撤和波动率都有改善。拆开看明细,文本通道的增量主要体现在两类时期:

  • 市场整体偏恐慌、情绪化抛售时,文本通道能识别出"账上现金充足+经营现金流健康"的公司,组合里会保留这些看起来不性感但能扛的标的;
  • 财报集中披露期,市场来不及细读海量报告,定量因子容易被一次性收益、会计调整迷糊,文本通道能提前排除陷阱。

而在市场平稳期,文本分的增量接近于零,甚至因为权重占用而略微拉低收益。所以如果你也想做类似方案,别期待它每时每刻都在贡献alpha;它的价值更多是降低尾部风险和避开虚假增长,这对长期回撤曲线的改善比年化收益的增量更明显。

6. 代码落地细节:缓存、容错、成本与可复现性

6.1 五段式管线,每一段都落盘,支持断点续跑

这个项目从数据到最终结果,我拆成五段:数据抓取、特征清洗、LLM标注、合成打分、回测。每一段都有独立输入输出,中间结果存Parquet文件,下次跑的时候如果发现某一段已经有产物,就直接跳过。

# 伪代码:五段式管线,每段落盘,支持从任意阶段恢复 pipeline = [ ("fetch", fetch_reports), ("clean", clean_features), ("llm", llm_annotate), ("score", score_portfolio), ("backtest", run_backtest), ] last_ok = load_checkpoint() # 记录已完成到哪一段 for name, fn in pipeline[last_ok:]: out = fn() save_parquet(out, f"{name}.parquet") save_checkpoint(name)

这个设计带来的直接好处是:LLM标注那一段最贵也最慢,如果中途接口报错或者被限流,修复后不需要重跑前两段。我前前后后调试了几十次,如果每次都要全量重跑,光是API费用就够呛。

6.2 控制调用成本:缓存优先,且只让LLM读最重要的材料

调用大模型接口是按token计费的,读年报全文非常费钱。我的控制手段有三个:

  • 缓存所有响应。缓存键是"报表ID+文本块ID+提示词版本号",命中缓存就直接读历史结果。同一份材料无论跑多少遍,都不会重复计费。
  • 只对年报和半年报做LLM标注。一季报和三季报的信息量密度较低,而且文本篇幅不小,我干脆不做LLM处理,只用定量指标合成数字分。这一条把API成本砍掉了约六成。
  • 分批并发加指数退避。每批控制并发数,遇到限流错误就按指数退避重试。宁可慢,不能挂。

如果API重试多次仍然失败,我的兜底逻辑是:该文本块的标注结果记为中性值(对应标签都填unknown),并在结果表里打一个"未标注"标记。因为文本分的权重本来就控制在10%到15%,个别标的的缺失不会严重影响整体组合。

6.3 可复现性的三板斧:快照、版本、落盘

做LLM增强的量化策略,最头疼的是不可复现——今天跑的分数和上周跑的分数可能不一样,因为模型接口背后的模型也许升级了,或者提示词被改了一行。我用了三板斧:

  • 数据快照。每次回测都记录用到的数据快照日期,而不是让程序自己读取"当前最新数据"。数据源更新后,旧快照必须能完整还原。
  • 版本记录。每一行LLM输出都带上三个字段:数据版本、提示词版本、模型版本标识。这样随时可以回答"这批分数到底是哪个模型、哪版提示词、哪份数据算出来的"。
  • 原始响应落盘。不仅存解析后的JSON,还存每一次API返回的原始文本。解析代码出问题时,原始文本还在,可以重新解析,不用重新调用API。

6.4 几个减少实现痛苦的小技巧

最后分享几个实操中的小技巧。第一,先用100只股票的子集跑通全流程再放量,LLM标注那段尤其要这样,不然一次全量跑完才发现提示词有语法错误,浪费的时间和钱都让人肉疼。第二,给累计转单季函数写单元测试,用手算结果校验每个报告期——这个函数太容易出错了,尤其是在一季报和年报的边界上。第三,回测之后加一道"未来函数"自查:对于每个持仓,验证信号时点确实晚于公告日,把违规样本打出来人工检查。第四,保留一个手工排除名单,用来处理净资产为负、长期停牌恢复这类极端公司——它们会让ROE、营收增速这些指标彻底失真,规则里写不清楚,只能人工盯。

写在最后的实际操作体会

这套系统我从第一版到能稳定跑完一整年回测,大概改了十几次。最深的感触是:LLM增强基本面分析,真正的价值不是让模型替你做投资决策,而是把"读得懂财报的人"从少数几个分析师变成一套可扩展的程序。过程中最大的教训是,永远不要让LLM处理它本就不擅长的事,也就是数学计算,也不要让它的文本输出直接进入你的量化特征表。如果纯数字版本本身就漏洞百出,LLM不会帮你补上那些洞,只会增加新的洞。而一旦你把数字管线和文本管线的边界划清楚,LLM给出的那些"解释性标签"——增长是否可持续、现金流是否恶化、未来展望是否谨慎——反而会变成整条策略里最有信息增量的部分。最后再给一个小建议:如果你要从头复现一个类似项目,先把纯数字版本做到能稳定赚钱,再考虑加入LLM通道,然后用分组回测和归因分析确认它确实带来了增量,再逐步提高它的权重。这条路不性感,但每一步踩下去都是实的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询