去年年报季我把重点跟踪的几十家公司年报拖进阅读列表打算用两周时间把管理层讨论、经营情况分析和附注里的关键调整项全部过一遍。结果只完成了七家——不是精力不够而是这种工作本质上不适合人脑批量处理每份年报几千上万字读完还要和往期数据对比、和同行横向比。另一边纯数字的基本面模型倒是能一次性覆盖全市场但它经常在报表数字与业务实质背离的时候给出完全错误的排序。这个矛盾让我动了改造管线的念头让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, levelperiod) r fwd_ret.xs(p, levelperiod) 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通道然后用分组回测和归因分析确认它确实带来了增量再逐步提高它的权重。这条路不性感但每一步踩下去都是实的。