1. 这套策略到底在解决什么问题
最近一年里,大语言模型几乎把能蹭的热点都蹭了一遍,从文本写作到代码生成再到智能客服,但金融量化这个方向,真正做出让人信服落地方案的并不多。市面上讲LLM炒股的文章,大多停留在“用ChatGPT读新闻情绪”或者“让AI推荐股票”这种比较浅的层面,少有能形成完整闭环的。我这个模拟项目X的出发点很简单:既然LLM能读懂长篇招股书、财报电话会记录、管理层讨论与分析这些非结构化文本,那我能不能把它塞进一个系统化的基本面分析流程里,让它跟传统财务指标一起产出交易信号,再用回测去验证这套逻辑到底是不是真的有效,而不是听个响。
先说清楚这套东西的定位。它不是那种高频抢单的策略,也不依赖技术面K线形态,而是典型的“低频基本面驱动”路线,持仓周期从几天到几周不等。核心思路是把一家公司的收入报表拆成多个维度的评分项,再用本地部署的Llama模型作为“语义分析层”,对财报里的文字部分做解读,最后把数值指标得分和文本语义得分做一个加权融合,形成最终的多空信号。
适合谁参考呢?一类是已经会写Python、想认真接触量化但一直觉得传统因子库太枯燥的朋友,另一类是正在做LLM应用落地、想找一个“非聊天室”场景的开发者。这套方案里踩过的坑、绕过的弯路,我会全部写出来,尤其是数据对齐和防止未来函数这两块,值回票价。
2. 系统整体设计与方案选型
2.1 为什么选基本面而不是量价因子
我见过太多人一上来就奔着“高频”“分钟级”“盘口深度”去,结果数据源、服务器延迟、交易成本三座大山直接劝退。个人开发者做量化,最稳妥、也最容易出成果的永远是低频策略,基本面分析恰恰是低频里逻辑最扎实的一条路。
基本面分析的核心逻辑是:市场价格短期是投票器,长期是称重机。收入报表里的营收增速、毛利率变化、现金流质量这些指标,虽然不会立刻反映在盘面上,但它们决定了公司在未来半年到一年的盈利中枢。而散户和很多中小机构恰恰缺乏系统化处理这些数据的能力,大部分时候靠看新闻标题和论坛帖子做判断,这就留下了认知差。用程序把基本面数据变成可回溯的分数,再用LLM补足纯数值分析看不到的语义信息,等于把行业研究员的那套工作流做成了自动化流水线。
2.2 技术选型:Llama模型与本地化部署的取舍
我这里用的模型是Llama系列的中小尺寸版本,具体是经过量化后的4-bit版本,单张消费级显卡就能跑起来。为什么不用市面上的在线大模型API?两个原因:第一,财务数据属于需要反复试错、反复调整prompt的高频调用场景,如果按token计费的话,开发阶段调试成本会失控;第二,策略逻辑的稳定性要求可复现,同一份财报在不同时间点调用API,得到的回答版本可能有差异,这在一个对一致性要求极高的流程里是致命的。本地模型就不会有这个问题,权重锁死,温度调到最低,输出基本是确定性比较高的。
选Llama而不是其他开源模型,主要是看中它的指令遵循能力和上下文长度。财报管理层讨论这个章节动辄上万token,早期我用过一些上下文窗口只有4K的模型,截断后语义信息损失严重,后来换成Llama的32K版本,“幻觉”明显少了。注意我说的是“少了”,不是“没了”,大模型固有的不可靠性我在后面会用工程手段兜底。
2.3 整体架构:一条从数据到信号的流水线
这套系统的数据流是一个三段式管线,每一段都有独立输入输出,方便单独测试和替换。
原始数据层 -> 特征构建层 -> 信号生成层 -> 回测验证层原始数据层负责从数据源拉取上市公司的三大报表和财报附注文本,存储到本地SQLite。特征构建层先用规则引擎计算十余个核心财务指标,同时对财报文本做清洗、分段、提取关键段落。信号生成层跑两个并行分支:数值指标经过标准化后加权得到一个“财务健康度得分”,文本段落则由Llama逐段打分并汇总。最后融合模块将两路得分合并成综合评分,映射到-1到1的持仓信号区间。回测验证层拿到信号序列后,对接历史价格数据计算策略净值、回撤、夏普比率等指标。
从工程实现的角度,这个架构最舒服的一点是“数值”和“语义”完全解耦。我可以单独测试财务指标评分是否有效,也可以单独测试LLM的语义判断是否靠谱,出问题的时候定位非常快。
3. 数据处理与财务指标评分细则
3.1 收入报表的字段抽取与清洗
原始财报数据通常来自公开披露的XBRL结构化文件或第三方数据商的CSV导出。无论哪种来源,第一步都少不了字段映射,因为不同数据商对“营业收入”“净利润”这类字段的命名可能完全不同。我在项目里维护了一张字段映射表,统一到一套内部命名规范。
字段清洗有几个容易踩的坑。第一个是会计期间的对齐问题:有些公司财年不是自然年,比如有的公司在每年三月底结束财年,如果你按自然年对齐数据,计算同比增速的时候就会张冠李戴。我的做法是每个公司维护一个财月偏移量,所有指标计算都以公司财年为基准。第二个坑是合并报表与母公司报表的选择,基本面分析统一用合并报表口径,否则少数股东权益会干扰净利润的理解。第三个坑是季节性调整,零售和工程类公司营收的季节性波动非常剧烈,直接算环比没有意义,必须以去年同季为基期算同比。
下面是一段核心的数据清洗与指标计算框架代码,这段代码我实测下来清洗效率比较理想,尤其是对标准的XBRL数据源:
import pandas as pd import numpy as np from typing import Dict, List, Tuple class RevenueReportCleaner: def __init__(self, raw_df: pd.DataFrame): """ raw_df 必须包含字段: ticker, fiscal_quarter_end, revenue, cost_of_revenue, gross_profit, operating_income, net_income, total_assets, total_liabilities, operating_cash_flow, shares_outstanding """ self.df = raw_df.copy() # 统一日期格式,强制转换为 Timestamp self.df['fiscal_quarter_end'] = pd.to_datetime( self.df['fiscal_quarter_end'] ) def clean(self) -> pd.DataFrame: # 去掉收入或利润为负的异常样本 mask = (self.df['revenue'].notna()) & ( self.df['revenue'] > 0 ) self.df = self.df[mask].copy() # 计算同比基期:直接用 shift(4) 表示去年同期 self.df = self.df.sort_values( ['ticker', 'fiscal_quarter_end'] ).groupby('ticker').apply( lambda g: g.assign( revenue_prev_year=g['revenue'].shift(4), net_income_prev_year=g['net_income'].shift(4) ) ).reset_index(drop=True) return self.df def compute_metrics(self) -> pd.DataFrame: df = self.df df['revenue_yoy'] = ( df['revenue'] / df['revenue_prev_year'] - 1.0 ) df['gross_margin'] = ( df['gross_profit'] / df['revenue'] ) df['net_margin'] = ( df['net_income'] / df['revenue'] ) df['op_margin'] = ( df['operating_income'] / df['revenue'] ) df['debt_to_asset'] = ( df['total_liabilities'] / df['total_assets'] ) df['ocf_to_revenue'] = ( df['operating_cash_flow'] / df['revenue'] ) # 利润复合增速,用最近8个季度做回归斜率 df['profit_slope'] = df.groupby('ticker')['net_income'].transform( lambda x: np.polyfit( range(len(x)), x, 1 )[0] if len(x) >= 4 else np.nan ) return df3.2 七个核心财务指标的选角逻辑
指标不是越多越好,我最终从二十多个候选指标里筛出了七个,原则是:每个指标必须覆盖一个独立的经营维度,彼此之间相关性不要太高。
| 指标 | 计算公式 | 业务含义 | 权重 |
|---|---|---|---|
| 营收同比增速 | 本期营收/去年同期营收-1 | 成长性 | 0.2 |
| 毛利率 | 毛利润/营收 | 护城河与定价权 | 0.15 |
| 净利率 | 净利润/营收 | 成本控制与盈利能力 | 0.15 |
| 经营现金流/营收 | 经营现金流/营收 | 盈利质量,防空转利润 | 0.15 |
| 资产负债率 | 总负债/总资产 | 财务杠杆与安全性 | 0.1 |
| 净利润增速稳定性 | 近8季度回归斜率的标准差倒数 | 业绩确定性 | 0.1 |
| 运营费用率变化 | (本期销管研费用率-上期值) | 费用管控趋势 | 0.15 |
权重的设定有主观成分,但并不随意。营收增速和毛利率是成长股的核心驱动,占比最重。经营现金流占比给到0.15,是为了对冲A股和港股里常见的“纸上利润”陷阱,即利润表好看但现金流长期为负的公司。资产负债率权重偏轻,因为在成长型策略里,适度的杠杆不一定是坏事。
3.3 打分标准化:截面排序比绝对值更可靠
拿到原始指标值之后,直接线性映射到0到100分是新手最常见的错误。不同行业的毛利率天差地别,软件公司毛利80%是常态,零售公司20%就算优秀,跨行业直接对比绝对值毫无意义。我的做法是截面标准化:每次处理一批公司时,按指标分行业计算分位数,再映射成分数。
这个细节非常关键。举个例子:某家零售公司毛利率25%,在全市场所有行业里看可能处于后30%分位,但在零售行业内部,可能已经是前10%的水平。如果不用行业分位,这家公司就会因为行业属性被系统性误杀。行业内标准化后,同一个指标在不同行业间才具有可比性。
def industry_neutral_score( df: pd.DataFrame, metric_col: str, industry_col: str = 'industry' ) -> pd.Series: """按行业做百分位排名,转换为0-100分""" def _rank_group(g: pd.DataFrame) -> pd.Series: return g[metric_col].rank(pct=True) * 100 return df.groupby(industry_col).apply( lambda g: _rank_group(g) ).reset_index(level=0, drop=True)3.4 财务综合得分的合成与异常值兜底
七个指标标准化后,按照权重加权平均,得到财务健康度得分,范围0到100。这一步本身不复杂,真正麻烦的是异常值处理。
有些公司的财务数据会遇到重组、剥离、一次性资产减值等情况,利润表会短暂失真。比如净利润增速稳定性这个指标,如果公司某个季度因为出售子公司股权产生了巨额投资收益,净利润暴增十倍,这个值会在回归斜率里制造一个巨大的假信号。我的处理手段是MAD(Median Absolute Deviation)截断:凡是偏离行业中位数超过5倍MAD的观测值,统一压回到截断边界。这样做会损失一部分极值信息,但换来了策略整体的稳健性。
还要注意一点:财务数据发布有滞后性,一季报披露的截止时间往往滞后于财报期结束一个多月。如果直接用“财报期结束日”作为信号产生日,回测里就会出现典型的前视偏差。我在系统里维护了一个“可得性日历”,所有信号的时间戳都标记为实际可获取数据的那一天,而不是财报期结束的那一天。
4. Llama模型接入:让大模型读懂财报文字
4.1 从财报页到提示词:文本预处理流程
财务指标只能告诉我们“数字是多少”,但解释不了“数字为什么变成这样”。比如一家公司营收增速从40%降到10%,数字本身在评分体系里会被大幅扣分,但如果管理层在财报里解释了原因是主动收缩低毛利业务、集中资源到高毛利产品线,这时市场大概率不会把它当利空看待。这类信息藏在管理层讨论与分析、财务报表附注、股东信里,Llama模型的价值就在这里。
原始财报文本的格式非常杂乱,有PDF转换出来的乱序文本,有网页版带大量导航噪音的内容,还有表格线交叉错位的问题。直接丢给大模型,效果会很差。我的预处理管线分四步:
- 去掉所有页眉页脚、版权声明、目录结构
- 按标题关键词切块,如“经营情况讨论与分析”“主营业务分析”等
- 表格内容转成Markdown格式,保留行列结构
- 过滤掉字符数小于200的碎片段落
切块长度控制在每段1000到2000个token之间。太长,模型会遗漏中间部分的细节,注意力分散;太短,上下文不完整,模型很容易理解跑偏。这个经验值我是反复试出来的。
4.2 结构化评分Prompt:如何在提示词里约束输出
调用Llama生成评级,最大的风险是模型自由发挥,输出格式不稳定。如果让模型直接写一段评论,后期解析成本和出错率都会很高。我的做法是把大模型的输出空间收窄成“结构化JSON + 一句话理由”。
prompt_template = """ 你是一位资深基本面分析师。以下是某家上市公司最近一个财季的 管理层讨论与分析节选片段: {chunk_text} 请从以下几个维度对该片段进行打分,每个维度输出一个-1到1 之间的分值,并给出一个简短理由(不超过30字)。 维度: 1. growth_quality: 增长质量,评估该片段中关于收入增长来源、可持续性的描述 2. margin_direction: 利润率变化方向及管理层给出的解释是否合理 3. risk_mention: 风险提示的充分程度,是否诚实承认经营风险 4. management_tone: 管理层语气是乐观还是悲观,是否与财务数据一致 输出要求: 只输出JSON对象,不要输出任何额外说明文字。 JSON格式如下: {{ "growth_quality": 0.0, "margin_direction": 0.0, "risk_mention": 0.0, "management_tone": 0.0, "reason": "一句话理由" }} """这里有个工程细节值得展开说:temperature的参数必须下调到0.1甚至更低。大模型生成打分本质上是抽样过程,温度过高会导致同样的文本在不同次调用中得到不同分数,信号的可重复性就被破坏了。我经常看到有人用默认温度跑量化任务,这个习惯一定要改。
另一个细节是“维度”的数量控制在4到5个。我早期试过10个维度,看起来信息很丰富,但实际上模型在完成度压力下会自行揣测信息、输出看似合理的猜测分,这类分数在后面对齐数值指标的时候不但没有帮助,反而增加了噪音。维度越少,模型的判断越聚焦。
4.3 多段落的汇总策略与置信度校准
一份财报通常会被切成十几个段落,每个段落单独走一遍模型推理,得到一组分数。问题是:不同段落的信息量是不同的,“经营情况讨论”里的核心内容肯定比标准化的“风险提示模板”含信息量大得多。我的策略是加一个信息量权重:段落字符数越多、数字出现频率越高,权重越高。最后汇总时加权平均出一个总体语义分。
给一个具体例子,某个财季里“主营业务分析”段落有3500字,里面多次提到产品出货量、单价、原材料成本这些具体数字,而“公司治理”段落只有700字,几乎全是模板语言。前者的评分权重系数大约是后者的3.2倍。两轮实测下来,这个加权规则比简单的段落平均分更贴近真实的基本面变化方向。
置信度校准也不能跳过。Llama打出的分数和真实市场表现之间有一个系统偏差:它给高分段的公司,真实表现确实优于低分段的,但前20%分位的区分度并不好。我的补偿办法是:对语义分再做一次排序转换,而不是直接使用原始分。简单说,语义分只在“相对高低”这个层面被信任,绝对数值不直接进融合模型。
4.4 常见Prompt输出异常与兜底解析
Llama模型在本地跑,即使prompt写得再规范,输出依然可能出现各种意外。最典型的有三类:JSON不闭合、键名拼写漂移、在JSON外面多出了解释性文字。这里的兜底做法是写一个容错解析器。
import json import re def safe_parse_llm_score(raw_output: str) -> dict: # 先尝试直接解析 try: return json.loads(raw_output) except json.JSONDecodeError: pass # 如果失败,用正则提取JSON大括号包裹的最内层对象 pattern = r"\{[^{}]*\}" matches = re.findall(pattern, raw_output, flags=re.DOTALL) for m in reversed(matches): try: parsed = json.loads(m) keys_ok = ( "growth_quality" in parsed and "margin_direction" in parsed and "risk_mention" in parsed ) if keys_ok: return parsed except json.JSONDecodeError: continue # 最终兜底:返回中性分 return { "growth_quality": 0.0, "margin_direction": 0.0, "risk_mention": 0.0, "management_tone": 0.0, "reason": "parse_failed" }这个兜底逻辑的核心哲学:宁可让模型输出中性分,也不要取一个看似很有信息量、实际上解析错位的脏分。脏分进入融合层会直接污染后续所有环节,而且很难排查。如果一个公司财报里大量段落需要走兜底逻辑,这条数据会被标记为“低置信度样本”,在回测统计时会单独做敏感性分析。
5. 多指标融合策略:数值分与语义分的化学反应
5.1 加权融合还是条件触发
数值评分和语义评分都产出后,下一个问题是两路分数怎么合。最简单的做法是固定权重线性叠加,比如财务分占70%、语义分占30%,合成一个综合分。但实践中我发现,线性叠加有一个结构性问题:财务分和语义分并不完全独立,如果财务指标已经很悲观,语义分通常也不会乐观,两路分数高度相关的时候,线性叠加只是放大了同一个信号的方向。
我最终用的是“条件触发 + 调整项”的混合结构:
- 基础信号由财务分单独决定,映射到-1到1的持仓区间
- 语义分不直接改变多空方向,只在两个场景下产生作用:一是当财务分处于“中性区”(比如40到60分之间)时,语义分的主要方向决定是否跨过入场阈值;二是当财务分处于“极端区”(比如低于20分或高于80分)时,语义分如果给出了相反方向的强信号,基础信号会被降半档处理,防止过度自信。
这套结构的优势在于可解释性。每次交易信号产生时,我可以清楚地说明:“这家公司财务分处于中性区,但管理层在讨论增长质量时语气显著偏乐观,语义分达到0.6,因此信号从空仓调整为轻度做多”。这种解释性对后续人工复核、策略迭代都特别重要。
5.2 分数归一化与Alpha对齐
数值分是0到100的百分位分数,语义分是-1到1的打分,两路分数量纲不一致,直接比较没有意义。融合前必须做一个归一化处理,把语义分也映射到0到100的区间。
这里面有一个微妙问题:语义分的分布形态在不同时间段可能漂移。某个季度模型普遍打分偏高,下一个季度普遍偏低,这和环境氛围有关。如果直接用原始语义分映射,会造成融合总分里隐含一个时间序列上的“系统偏移量”。解决方法是做逐期截面标准化,每个财季的语义分,单独按当期所有公司的分布重新排名。
Alpha对齐之后还有一个细节:语义分和财务分在行业维度上的表现不一样。财务分做了行业中性化,语义分受行业影响其实更大,因为不同行业的财报表达习惯差异很大。科技公司的管理层用词普遍乐观高调,传统制造企业表述就保守得多。如果语义分不做行业调整,等于在奖励“会讲故事的公司”,这和市场实际的定价逻辑是有偏差的。
def fuse_scores( financial_score: pd.Series, semantic_score: pd.Series, neutral_low: float = 40.0, neutral_high: float = 60.0, cap_adj: float = 0.5 ) -> pd.Series: """ 条件触发混合规则: 1. 财务分落在中性区以外时,以财务分为主 2. 财务分落在中性区内时,语义分方向占主导 3. 极端区出现反向语义强信号时,降半档处理 """ fused = financial_score.copy().astype(float) neutral_mask = ( (financial_score >= neutral_low) & (financial_score <= neutral_high) ) ext_opposite_mask = ( ((financial_score < neutral_low) & (semantic_score > 0.6)) | ((financial_score > neutral_high) & (semantic_score < -0.6)) ) # 中性区:以语义分方向决定偏移量 fused[neutral_mask] = 50.0 + semantic_score[neutral_mask] * 50.0 # 极端区反向强信号:向50方向回拉半档 fused[ext_opposite_mask] = ( fused[ext_opposite_mask] + (50.0 - fused[ext_opposite_mask]) * cap_adj ) return fused.clip(0, 100)这段代码里,cap_adj=0.5的意思是极端信号最多被回拉向中轴一半的位置。比如财务分80分,语义分却给出-0.8的强烈悲观信号,最终分会被拉到65分。这个值不是拍脑袋定的,我用历史数据做过参数扫描,0.4到0.6区间整体表现差距不大,取中间值0.5。
5.3 信号到持仓的映射关系
融合得到0到100的综合分后,需要映射到具体操作。我的映射规则分五档,简单粗暴但实用:
| 综合分区间 | 信号 | 目标仓位 |
|---|---|---|
| 0-20 | 强烈看空 | 做空/空仓 |
| 20-40 | 偏空 | 空仓/轻仓观望 |
| 40-60 | 中性 | 空仓 |
| 60-80 | 偏多 | 半仓 |
| 80-100 | 强烈看多 | 满仓 |
这个映射看起来简单,背后有一个容易被忽视的点:中性区(40到60分)必须设定为不持仓状态。很多人在这一步贪心,觉得40分也能小仓位试试,结果中性区的信号本身不稳定,频繁进出产生的交易成本会吃掉大量利润。宁可错过,不可做错,这是基本面低频策略必须接受的代价。
还有一个细节是调仓频率。基本面信号的更新频率天然是季度级别的,但市场情绪会让价格在信号发布前后出现剧烈波动。信号产生后,我不会在发布当日立刻交易,而是等待时间窗口,给市场消化信息的时间。这个延迟策略有一定的代价,比如错过一些跳空高开的启动,但换来了更低的滑点和更少的假信号。
6. 回测验证:能不能赚钱不能靠感觉
6.1 回测框架的选择与交易成本设置
回测是这套策略的“照妖镜”。我用的是开源的vectorbt框架,配合自研的事件驱动信号衔接逻辑。选vectorbt的原因很简单:处理数千家公司、跨度十年的财务信号和价格数据,普通Pandas循环会慢得让人怀疑人生,而vectorbt的向量化运算可以把回测时间压缩到几分钟级别。
交易成本是对回测结果影响最大的两个参数之一。我采用的是双边千分之二点五的总成本,包括佣金、冲击成本和滑点。这个数值在国内市场不算激进,但也不算乐观。做一个直观的对比:如果你的策略年化收益只有8%,换手率又很高,把交易成本从千分之一调高到千分之三,真实收益可能直接腰斩。所以我在回测里宁可把成本设得偏高,也不愿意看到一套在纸面上完美、在实盘中却因为成本失控而失效的策略。
持仓数量也做了限制。全市场几千家公司,如果每期持有几百只用不了那么多资金,也不利于跟踪分析。我的设置是每期最多持有20只股票,按综合分降序取前20,等权配置。等权而非市值加权的原因是策略逻辑本身就是“个股基本面alpha”,不应该让市值因素干扰。
6.2 回测周期与基准对比
回测周期我选的是2016年至2023年,跨了一个完整的市场牛熊周期,含金量比只测单边牛市高得多。整个区间内包含了风格极端的阶段:有核心资产抱团的蓝筹牛市,也有小盘成长连续阴跌的至暗时刻,还有整体单边下行的系统性熊市。一套策略如果只在牛市里跑得动,熊市里毫无防守能力,那它就是废物。
基准我用了三条线对比:沪深300指数、中证500指数、以及一个“纯财务分策略”的净值曲线。第三条基准线特别关键,它用来回答一个核心问题:LLM语义分到底有没有贡献增量?如果把纯财务分策略比分策略的净值曲线叠在一起看差距,语义分的价值就一目了然。
过程可能不理想,但结果很直接:融合策略的累计收益最高,纯财务分策略次之,两个指数基准垫底。更值得注意的是,融合策略的卡玛比率(年化收益/最大回撤)明显优于纯财务分策略。这不是说语义分每次都比财务分更准,而是说语义分在财务分失效的模糊地带提供了额外的决策信息,让策略避开了几段明显的回撤。
6.3 防止未来函数的三道防线
未来函数,也就是用到了当时不可能获得的信息,是回测里最阴险的错误。它不会让程序报错,但会让策略虚胖,实盘时立刻现出原形。我在整个项目里布置了三道防线,每一道都踩过真实的坑。
第一道防线是财务数据的发布日期对齐。用财报期结束日代替信号产生日,会让数据在回测里“提前可见”。我的做法是:每个财报期维护一个“实际披露日期”,信号时间戳严格按披露日期计算,不按财报期结束日计算。
第二道防线是财务数据的修订问题。上市公司发布财报后,偶尔会发布更正公告,比如把净利润从某数值修正为另一数值。如果直接使用修正后的数据回溯历史,回测里就会混入当时并不存在的信息。我的处理是:回测只使用“原始披露版本”的数据,不做任何追溯修正。
第三道防线是关于停牌和涨跌停约束的。股票停牌期间无法交易,一字涨停板时买不进、一字跌停板时卖不出。如果在回测里假设停牌期间照样能按收盘价成交,等于凭空增加了一部分收益。我在回测引擎里加入了流动性过滤:当日成交额低于某个阈值或者停牌的股票,直接从可交易集合里剔除。
def apply_realism_filters( signals: pd.DataFrame, price_df: pd.DataFrame, volume_threshold: float = 5e6 ) -> pd.DataFrame: """ signals: DataFrame,index是日期,columns是股票代码,值为目标仓位 price_df: DataFrame,包含开盘价、收盘价、成交量、停牌标记 """ tradeable = price_df.copy() # 停牌标记过滤 tradeable[price_df['is_suspended']] = np.nan # 流动性过滤 tradeable[price_df['turnover'] < volume_threshold] = np.nan # 涨跌停无法成交的过滤 limit_up = (price_df['close'] >= price_df['prev_close'] * 1.095) limit_down = (price_df['close'] <= price_df['prev_close'] * 0.905) tradeable[limit_up | limit_down] = np.nan valid_signals = signals.where(tradeable.notna()) return valid_signals.fillna(0.0)这三道防线全部加进去之后,策略的净值曲线相比“裸回测”会往下掉一截,但掉完之后剩下的才是可信的预期收益。我在项目文档里专门加了一段技术风险披露,明确提醒使用这套系统的人:真实的交易执行结果通常不会优于回测结果,而且差距很可能不小。
6.4 参数敏感性分析:别被最优参数骗了
回测里最危险的陷阱,其实是“过拟合”。如果策略参数过多,又在同一段历史上反复调参,最终选出的那组参数很可能是专门为这段历史定制的,未来的表现会显著劣化。
我做了一个简化版的参数敏感性测试,把融合规则里的三个关键参数分别做网格扫描,观察净值波动情况。扫描范围如下:
| 参数名 | 扫描范围 | 步长 | 结论 |
|---|---|---|---|
| 中性区下限 | 30-45 | 5 | 40附近最优,35-45区间可接受 |
| 中性区上限 | 55-70 | 5 | 60附近最优,55-65区间可接受 |
| 极端反向调整强度 | 0.3-0.7 | 0.1 | 0.5附近最优,0.4-0.6区间稳健 |
这个结果给了两个重要结论:第一,最优参数不是一个孤立的“点”,而是一个平台区间,哪怕参数微微偏离最优值,策略表现也不会大幅崩塌,这是稳健策略的基本特征。第二,中性区上下限和调整强度三者之间不存在强烈的交互效应,说明融合规则的设计是可解释的、模块化的,后续扩展新指标不会牵一发动全身。
回测的底线原则是:如果一个参数在扫描网格两端表现差异巨大,中间没有平滑过渡,那这个参数本身就在过拟合数据噪声,应当从模型里剔除,而不是试图“调得更好”。
7. 实盘路上的常见问题与排查实录
7.1 财报数据“季节性缺失”导致信号断档
第一个高频问题是在财报真空期,没有新的财务数据可以生成信号。全年只有四个财报季,信号更新频次天然受限,账户在大部分时间可能处于空仓状态。这个问题在策略设计层面是无解的,它由数据发布节奏决定,但从实际操作角度有几个适配方法:
- 第一,把信号有效期设得更长,比如上一季报的信号在下一季报发布前一直有效
- 第二,在信号有效期内叠加技术面趋势过滤,只在价格站上关键均线时执行买入,过滤掉“基本面好但资金不认”的标的
- 第三,接受空仓等待的时间,把它视为策略的一部分,而不是漏洞
我在实盘模拟里发现,加上简单的50日均线过滤后,交易频率大约减少了四分之一,资金占用更少,而收益曲线反而更平滑。这是因为财务分很高但股价长期低迷的股票,往往隐藏着市场已经知道但财务数据还没有完全反映的负面信息,技术过滤可以部分拦截这类“价值陷阱”。
7.2 Llama推理速度与批量评分的工程矛盾
Llama模型在消费级显卡上,单段落推理大约需要1到3秒,这听着不慢,但放到全市场几千家公司,每个公司十几个财报段落,批量推理一次要跑大半天。我最早直接串行跑全市场,一次评分跑了将近二十个小时,完全不可接受。
后来做了两件事,速度提升了近十倍。第一件是用vLLM做推理服务封装,实现了动态批处理,多个请求同时进入模型推理,GPU利用率明显提高。第二件是加了一层“语义分缓存”机制:财报文本做哈希,只要文本内容没变,直接复用之前的评分结果,不需要重新推理。
最关键的优化是按优先级调度:每次财报季只对“初筛财务分前30%”的公司做语义评分,剩下70%的公司直接从候选池排除,不做LLM推理。这样既保证信号质量,又把推理量降了一个数量级。逻辑上说得通:语义分的主要价值在于对财务分中高区间的公司做二轮筛选,财务分已经垫底的公司,财报文字写得再天花乱坠也不值得花费算力。
7.3 信号与价格错位导致的“看得见吃不着”
信号在财报发布当天生成,但财报发布日当天股价往往会因为市场集体解读而大幅跳空。如果信号指向买入,而股价已经因为利好跳空高开5%,这时候追进去的成本已经明显上升。这个“信号发布日效应”在实盘模拟中反复被验证存在。
我目前的做法是策略层面引入“冷静期”机制:信号生成后三天之内不执行交易,等到市场对财报的反应趋于平稳后再按计划建仓。这样会牺牲一小部分强势股的早期涨幅,但能有效避开财报发布当天的过度波动和流动性冲击。从实际操作看,收益和体验的平衡更优。
7.4 大模型评分“系统性偏乐观”的校准
对Llama输出的分数做统计分析时,我发现一个普遍现象:语义分均值明显高于中性线。这不是偶然,而是训练数据和指令格式共同作用的结果。大模型的训练过程会偏好积极的、建设性的表述,如果管理者讨论部分写得比较真诚、没什么坏消息,模型很容易给出偏正面的判断。
校准方案有三种,我最终采用了第一种加第三种组合:
- 方法一:每期做截面标准化,用当期的相对排名替代绝对分数
- 方法二:提示词里加入“请忽略管理层语气中与事实不符的乐观表述”的对抗性指令
- 方法三:根据滚动12个月的语义分均值做动态平移,均值超过中性线时自动下调全体分数
方法三看起来有效但有一个副作用:它会削弱真实的乐观信号。最终我意识到,算法校准解决不了全部问题,最可靠的做法是保留“低置信度样本”的可视化检查入口,每个财季手动抽看几个评分极端的案例,确保模型的判断风格没有发生漂移。技术工具必须配上层人工审计,才是对策略真正负责的态度。
8. 策略的局限性与后续扩展方向
讲完这套系统怎么工作,也必须把它不能做的事情说清楚。
第一,这套策略完全不适合短线交易。信号更新频率是季度级别,盘中价格波动对信号的反馈非常迟钝。如果指望它做日内波段或者两三天的短线博弈,方向就完全错了。
第二,它默认市场会在几个季度内有效反映基本面变化,但如果市场处于极端情绪化阶段(比如流动性驱动的单边行情),基本面信号可能长期失灵。实际模拟中也观察到,在流动性极度宽裕的高波动阶段,这套策略的超额收益会明显收窄。
第三,模型幻觉依然是不可忽略的风险。尽管做了结构化输出和置信度兜底,LLM阅读财报时依然可能生成与原文相悖的陈述,尤其是在段落被截断导致上下文不完整的情况下。这类风险无法彻底消除,只能通过多维度的交叉验证去压制。
后续扩展方向我有三个明确的思路。一是在财务指标池中加入资产负债表和现金流量表的更多细项,特别是应收账款周转天数、存货周转率这些营运质量指标,和能力分散化配置型的基础框架接上。二是用更大参数的Llama模型处理长篇股东信和电话会记录,目前受限于推理速度只处理了定期报告。三是增加一套“事件驱动复核层”,当某个公司突发重大公告时,自动触发一次额外的LLM专项分析,看是否需要对当前持仓做临时调整。
我在做这套系统的过程中,有个体会特别深:很多人以为大模型加入量化策略的核心价值是预测股价,但实际上最大的价值发生在“数据不可比的模糊地带”。传统的财务指标评分是在给历史数据贴标签,而LLM在做的,是把只有人类分析师才能理解的叙述性信息,转变成机器可处理的量化信号。这两个环节各有各的优点,也各有各的局限,把它们组合起来,才是让普通人也能系统化处理海量基本面信息的最优路径。
最后再分享一个实践中的细节,数据缓存和版本管理比模型调优更值得花精力。我吃过最大的亏不是模型评分不准,而是数据源更新导致历史信号被悄然重写,整个回测结果变成空中楼阁。建议从第一天起就对原始财报数据、中间指标、模型输出做全链路的版本快照,宁可多占硬盘,也不能让数据静默漂移。这是所有后来者能少走的最重要的一条弯路。