1. 从原始行情到模型可读矩阵:特征工程的真正起点
很多人做A股量化策略,第一步就卡在数据上。不是拿不到数据,而是拿到数据之后不知道该怎么处理。日线行情、财务指标、资金流向、舆情数据,这些东西堆在一起,看起来信息量很大,但直接丢给模型,结果往往惨不忍睹。原因很简单:原始数据是给人看的,不是给模型吃的。
特征工程要解决的核心问题,就是把"人看得懂的信息"翻译成"模型能计算的数值矩阵"。这个过程不是简单的归一化或者标准化,而是需要你对业务逻辑有足够深的理解,知道哪些信息真正影响股价走势,哪些只是噪声。
1.1 为什么原始行情数据不能直接喂给模型
先看一个最直观的例子。假设你拿到某只股票过去一年的日线数据,包含开盘价、收盘价、最高价、最低价、成交量、成交额。如果你直接把收盘价序列丢给一个时序模型,会发生什么?
模型会学到"股价在20块左右波动"这个事实,但它学不到"20块相对于这只股票的历史区间是高位还是低位"。不同股票的价格绝对值差异巨大,有的股票5块钱,有的股票500块钱,模型如果直接吃原始价格,就会把价格绝对值当成一个重要的特征维度,这显然是不合理的。
再比如成交量。某天成交100万手,这个数字本身没有意义。你需要知道的是:这100万手相对于过去20个交易日的平均成交量是放大了还是缩小了?放大了多少倍?这才是有效信息。
所以特征工程的第一个原则就是:所有输入模型的数值,必须是相对值或者经过某种变换的标准化值,而不是绝对值。
1.2 量价特征的构造逻辑与实操细节
量价特征是最基础也是最重要的一类特征。A股市场里,量价关系几乎是一切策略的底层逻辑。但怎么把量价关系变成模型能理解的特征,这里面有很多讲究。
收益率特征是最直接的。但注意,不要用简单的(今收-昨收)/昨收,这个公式在A股有除权除息的问题。正确的做法是使用复权后的价格计算对数收益率:
import numpy as np import pandas as pd # 假设df已经包含了复权后的收盘价 df['log_return'] = np.log(df['close_adj'] / df['close_adj'].shift(1)) # 不同周期的收益率 for period in [5, 10, 20, 60]: df[f'return_{period}d'] = df['close_adj'].pct_change(period)对数收益率的好处是它可以累加,而且分布更接近正态,对模型更友好。
波动率特征同样重要。最简单的是滚动标准差:
df['volatility_20d'] = df['log_return'].rolling(20).std() * np.sqrt(252)这里乘以根号252是因为要把日波动率年化。但实际使用中,我建议同时保留日波动率和年化波动率两个版本,因为不同模型对量纲的敏感度不一样。
成交量特征需要做相对化处理。我常用的几个构造方式:
- 量比:当日成交量 / 过去N日平均成交量
- 换手率:成交量 / 流通股本
- 量价背离:价格创新高但成交量没有创新高,这种背离往往预示着趋势可能反转
df['volume_ratio_5d'] = df['volume'] / df['volume'].rolling(5).mean() df['volume_ratio_20d'] = df['volume'] / df['volume'].rolling(20).mean() df['turnover_rate'] = df['volume'] / df['float_shares']注意:换手率计算时,流通股本要用最新的数据,但历史回测时要注意避免未来函数。我的做法是使用当时可得的流通股本数据,如果实在拿不到历史股本变动数据,就用当前股本近似,但要意识到这会引入一定的偏差。
1.3 财务特征与舆情特征的融合处理
财务数据的处理比量价数据更麻烦,因为财务数据是低频的,而且不同行业的财务指标差异巨大。
财务特征的标准化不能简单地做z-score,因为不同行业的估值逻辑完全不同。银行股的市盈率普遍在5-10倍,科技股可能在50倍以上。如果你直接把市盈率丢给模型,模型会认为银行股永远比科技股"便宜",这显然不对。
我的做法是:在行业内部做标准化。也就是说,把每只股票的财务指标减去同行业同期的均值,再除以标准差。这样得到的特征反映的是"这只股票相对于同行业其他公司的表现",而不是绝对水平。
# 假设df有行业列industry df['pe_relative'] = df.groupby('industry')['pe_ratio'].transform( lambda x: (x - x.mean()) / x.std() )舆情特征是近年来随着大模型兴起而变得越来越重要的一类特征。传统的做法是统计新闻数量、情感得分,但现在可以直接用大模型来提取更细粒度的信息。
比如,你可以让大模型阅读某只股票最近一周的新闻,然后输出一个0-100的"市场情绪分",或者提取出"政策利好""业绩超预期""高管变动"等标签。这些标签可以进一步转化为哑变量或者嵌入向量。
但这里有一个坑:舆情数据的时效性。新闻发布的时间和股价反应的时间往往有滞后,如果你用当天收盘后的新闻来预测当天收盘价,那就是未来函数。正确的做法是用T-1日收盘后到T日开盘前的新闻来预测T日的走势。
2. 大模型在量化决策中的角色定位与接入方式
大模型在量化策略里的应用,目前主要有三个方向:一是作为特征提取器,从非结构化数据中提取结构化信号;二是作为决策器,直接输出买卖信号;三是作为策略生成器,自动编写和优化策略代码。这三个方向的成熟度差异很大,落地难度也不一样。
2.1 大模型做特征提取:优势明显但要注意成本
用大模型做特征提取,是目前最成熟、最稳妥的落地方式。具体来说,就是把新闻、研报、公告、社交媒体帖子这些文本数据,通过大模型转换成结构化的特征向量。
我实测下来,效果比较好的几个提取任务包括:
- 情感极性判断:给定一段文本,输出正面/负面/中性的概率分布
- 事件类型识别:识别文本中是否包含"业绩预告""股东增减持""重大合同""监管处罚"等事件
- 影响程度打分:让模型对事件的影响程度打分,比如1-5分
# 伪代码示例:调用大模型API做情感分析 def extract_sentiment(text): prompt = f"""请分析以下文本对A股某只股票的影响,输出一个-1到1之间的分数, -1表示极度负面,1表示极度正面,0表示中性。只输出数字。 文本:{text} """ response = call_llm(prompt) return float(response)但这里有几个实操中的坑:
成本问题。如果你有几千只股票,每天产生几百条新闻,全部用大模型处理,成本会非常高。我的做法是:先用一个轻量级的分类模型(比如微调过的BERT)做初筛,只把那些可能包含重要信息的文本送给大模型做精细分析。
一致性问题。大模型的输出有时候不稳定,同样的输入可能得到不同的输出。解决办法是设置temperature=0,并且对同一个文本多次调用取平均值。
延迟问题。大模型的推理速度远慢于传统模型,如果你做的是日内策略,大模型可能来不及。所以大模型更适合做日频或者更低频的策略。
2.2 大模型直接做决策:可行但有严格前提
让大模型直接输出买卖信号,听起来很美好,但实际操作中限制很多。
首先,大模型没有记忆。它不知道你当前的持仓成本、仓位比例、可用资金。如果你直接问它"今天该不该买",它只能根据你提供的上下文来回答,而这个上下文往往是不完整的。
其次,大模型不擅长精确的数值计算。你让它算一个复杂的仓位公式,它可能会算错。所以更好的方式是:让大模型做定性判断,把定量计算交给传统代码。
比如,你可以这样设计:
# 大模型负责判断市场状态 market_state = llm_judge("当前市场处于什么状态?牛市/熊市/震荡市?") # 传统代码负责根据市场状态调整仓位 if market_state == "牛市": position = 0.8 elif market_state == "震荡市": position = 0.5 else: position = 0.2这种分工方式,既利用了大模型的语义理解能力,又避免了它在数值计算上的短板。
2.3 策略生成与代码辅助:提效利器但需人工审核
用大模型来生成策略代码,是最近半年我用的最多的功能。比如你想实现一个"双均线+成交量过滤"的策略,可以直接让大模型帮你写:
# 让大模型生成的策略代码示例 def dual_ma_strategy(data, short_window=5, long_window=20, volume_threshold=1.5): data['ma_short'] = data['close'].rolling(short_window).mean() data['ma_long'] = data['close'].rolling(long_window).mean() data['volume_ma'] = data['volume'].rolling(long_window).mean() # 金叉且成交量放大 data['signal'] = 0 data.loc[(data['ma_short'] > data['ma_long']) & (data['volume'] > data['volume_ma'] * volume_threshold), 'signal'] = 1 data.loc[(data['ma_short'] < data['ma_long']), 'signal'] = -1 return data但大模型生成的代码,你必须逐行审核。我遇到过好几次,大模型写的代码逻辑看起来没问题,但仔细一看,用了未来数据,或者边界条件处理有误。所以我的原则是:大模型写初稿,人工做审核,回测做验证。
3. 从特征到信号:模型训练与验证的完整链路
特征工程做完之后,下一步就是训练模型。但量化策略的模型训练,和传统的机器学习任务有很大区别。最大的区别在于:金融数据是低信噪比的时序数据,而且存在严重的非平稳性。
3.1 样本构造中的标签设计与时间窗口选择
标签设计是模型训练的第一步。你要预测什么?是未来1天的收益率,还是未来5天的涨跌方向,还是未来20天的最大回撤?
不同的标签对应不同的策略频率和持仓周期。我的经验是:
- 预测1天收益率:适合高频策略,但信噪比极低,模型很难学到有效信号
- 预测5天涨跌方向:适合周频调仓策略,信噪比适中,是比较平衡的选择
- 预测20天收益率:适合月频调仓策略,信噪比相对较高,但持仓周期长,对交易成本敏感
标签的构造方式也有讲究。不要直接用未来收益率作为标签,因为收益率的分布是尖峰厚尾的,极端值会对模型造成很大干扰。我通常会把收益率做截面排序,然后转换成百分位排名,或者直接做三分类(涨/平/跌)。
# 标签构造示例:未来5日收益率的三分类 df['future_return_5d'] = df['close_adj'].shift(-5) / df['close_adj'] - 1 # 按截面做三分类 df['label'] = df.groupby('date')['future_return_5d'].transform( lambda x: pd.qcut(x, q=3, labels=[0, 1, 2]) )注意:这里用了shift(-5),意味着用了未来数据。在实际训练时,必须确保训练集和验证集之间有时间间隔,否则会出现数据泄露。
3.2 时序交叉验证与防止过拟合的实操手段
传统的K折交叉验证在时序数据上不能用,因为会打乱时间顺序,导致未来数据泄露到训练集中。正确的做法是使用时序交叉验证,也叫滚动窗口验证。
具体来说,就是把数据按时间顺序分成多个窗口,每次用前面的窗口做训练,后面的窗口做验证,然后逐步向前滚动。
from sklearn.model_selection import TimeSeriesSplit tscv = TimeSeriesSplit(n_splits=5) for train_idx, val_idx in tscv.split(X): X_train, X_val = X[train_idx], X[val_idx] y_train, y_val = y[train_idx], y[val_idx] # 训练和验证但即使这样,过拟合仍然很容易发生。金融数据的信噪比太低,模型很容易学到噪声。我常用的防过拟合手段包括:
特征筛选。不要把所有特征都丢给模型,先用相关性分析或者特征重要性排序,筛选出最有效的20-30个特征。特征太多,模型容易过拟合。
正则化。L1和L2正则化是标配,但要注意正则化系数的选择。系数太大,模型欠拟合;系数太小,过拟合。我通常会用验证集上的表现来调这个系数。
早停。在训练过程中监控验证集的损失,如果连续多个epoch验证集损失没有下降,就停止训练。这是防止过拟合最有效的手段之一。
集成学习。不要只训练一个模型,而是训练多个模型(比如不同随机种子、不同特征子集),然后取平均。这样可以显著降低过拟合的风险。
3.3 回测中的滑点、手续费与容量约束
模型训练完之后,必须做回测。但很多人的回测结果和实盘结果差距巨大,原因往往出在回测的假设上。
滑点是最容易被忽略的因素。A股市场的流动性虽然不错,但如果你交易的股票市值较小,或者交易量较大,滑点会非常明显。我的做法是:在回测中设置一个保守的滑点假设,比如单边0.1%-0.2%,然后看看策略是否还能盈利。
手续费包括佣金、印花税、过户费。佣金一般是万分之一到万分之三,印花税是千分之一(卖出时收取),过户费是万分之零点二。这些都要在回测中精确计算。
# 回测中的交易成本计算 def calculate_cost(trade_value, is_sell): commission = trade_value * 0.0003 # 佣金万三 stamp_tax = trade_value * 0.001 if is_sell else 0 # 印花税千一 transfer_fee = trade_value * 0.00002 # 过户费 return commission + stamp_tax + transfer_fee容量约束是另一个关键问题。如果你的策略容量只有几百万,那它就没有太大的实际意义。容量约束主要取决于两个因素:一是策略持仓的股票流动性,二是策略的换手率。流动性越好、换手率越低,容量越大。
4. 实盘落地中的工程化问题与持续迭代
回测做完了,模型看起来也不错,但真正到了实盘,问题才刚刚开始。实盘和回测的差距,往往不是模型本身的问题,而是工程化的问题。
4.1 数据管道的稳定性与异常处理
实盘策略需要每天定时获取数据、计算特征、生成信号、执行交易。这个流程中任何一个环节出问题,都可能导致策略失效。
数据管道的稳定性是第一位的。我的做法是:
- 设置多个数据源,主数据源出问题时自动切换到备用数据源
- 对每个数据字段做完整性检查,比如检查是否有缺失值、是否有异常值
- 记录每次数据获取的时间戳和状态,方便排查问题
# 数据完整性检查示例 def check_data_quality(df): issues = [] if df['close'].isnull().any(): issues.append("收盘价存在缺失值") if (df['close'] <= 0).any(): issues.append("收盘价存在非正值") if df['volume'].isnull().any(): issues.append("成交量存在缺失值") return issues异常处理同样重要。如果某天数据获取失败,策略应该怎么办?是跳过当天交易,还是用前一天的数据代替?我的建议是:如果关键数据缺失,宁可跳过交易,也不要冒险用错误的数据做决策。
4.2 模型衰减监控与再训练触发机制
金融市场的规律在不断变化,今天有效的模型,三个月后可能就失效了。所以必须建立模型衰减监控机制。
我通常监控以下几个指标:
- 滚动窗口的IC值:如果IC值持续下降,说明模型的预测能力在减弱
- 策略的滚动收益率:如果连续多周跑输基准,说明策略可能失效
- 特征重要性变化:如果原本重要的特征变得不重要,说明市场逻辑可能发生了变化
当这些指标触发阈值时,就需要考虑再训练模型。再训练的频率取决于策略的频率和市场的变化速度。对于日频策略,我通常每季度做一次全量再训练,每月做一次增量再训练。
4.3 从模拟盘到小资金实盘的过渡策略
不要一上来就把大资金投入实盘。我的做法是:先跑模拟盘,跑至少一个月,确认策略的信号生成、订单执行、持仓管理都没有问题。然后投入小资金实盘,比如总资金的5%-10%,跑一到两个月。最后再逐步加大资金。
这个过程中,最重要的是对比模拟盘和实盘的差异。如果实盘的表现明显差于模拟盘,那就要排查原因:是滑点比预期大?还是订单执行有延迟?还是数据有问题?
提示:实盘初期,建议每天记录策略的持仓、交易、盈亏,并与模拟盘做对比。这个对比记录是排查问题的最重要依据。
4.4 策略迭代的节奏把控与常见误区
策略迭代不是越频繁越好。我见过很多人,策略跑了一周不赚钱就赶紧改,改完跑了一周还是不赚钱又改,最后把策略改得面目全非,也不知道到底是哪个版本有效。
我的建议是:给策略足够的观察期。一个日频策略,至少跑一个季度再评估。如果季度表现不佳,再分析原因:是市场环境变了?还是模型过拟合了?还是执行出了问题?
常见的误区包括:
- 过度优化:为了让回测曲线好看,不断调整参数,结果实盘一塌糊涂
- 忽略交易成本:回测时不算手续费和滑点,实盘发现根本不赚钱
- 追逐热点:看到某个板块涨得好就赶紧加进去,结果买在高点
- 缺乏纪律:信号说卖,但觉得还能涨,结果被套
5. 几个我踩过的坑和对应的解决方案
做量化策略这些年,踩过的坑不计其数。挑几个最有代表性的分享一下,希望能帮你少走弯路。
5.1 特征穿越:最隐蔽也最致命的错误
特征穿越是指你在计算特征时,不小心用了未来才知道的信息。这种错误非常隐蔽,因为回测的时候看起来一切正常,但实盘就会出问题。
我遇到过一次:在计算"过去20日平均成交量"时,用了当天的成交量。这看起来没问题,但实际上,如果你在当天开盘前做决策,你是不可能知道当天成交量的。正确的做法是用T-1日及之前的数据。
# 错误做法:用了当天数据 df['volume_ma20'] = df['volume'].rolling(20).mean() # 正确做法:用T-1日及之前的数据 df['volume_ma20'] = df['volume'].shift(1).rolling(20).mean()这个错误在回测中可能只带来一点点收益的提升,但在实盘中,这一点点提升往往就是策略盈利和亏损的分界线。
5.2 过拟合的识别与修正:从回测曲线看端倪
过拟合的回测曲线通常有几个特征:
- 曲线过于平滑,几乎没有回撤
- 夏普比率异常高,比如超过3
- 参数稍微变动,收益就大幅下降
如果你看到这样的回测曲线,不要高兴太早,大概率是过拟合了。
修正过拟合的方法包括:减少特征数量、增加正则化、使用更简单的模型、增加训练数据的时间跨度。我通常会把数据集分成三段:训练集、验证集、测试集。训练集用来训练模型,验证集用来调参数,测试集用来做最终评估。测试集的结果才是对策略真实能力的估计。
5.3 实盘与回测偏差的排查清单
当你发现实盘表现和回测差距很大时,可以按以下清单逐一排查:
| 排查项 | 可能的问题 | 检查方法 |
|---|---|---|
| 数据 | 实盘数据与回测数据不一致 | 对比同一天的收盘价、成交量 |
| 滑点 | 实盘滑点远大于回测假设 | 记录每笔交易的实际成交价与信号价的差异 |
| 手续费 | 回测中漏算或低估了手续费 | 核对交割单,计算实际手续费率 |
| 执行延迟 | 信号生成到订单执行有时间差 | 记录信号时间和成交时间 |
| 流动性 | 某些股票流动性不足,无法按预期价格成交 | 检查持仓股票的日均成交额 |
| 模型衰减 | 市场环境变化导致模型失效 | 监控滚动IC值和策略收益 |
这个清单我用了很多次,基本上每次都能找到问题所在。
5.4 大模型输出不稳定时的兜底方案
大模型的输出有时候会不稳定,同样的输入可能得到不同的输出。这在实盘策略中是不可接受的。
我的兜底方案是:
- 设置置信度阈值:如果大模型输出的置信度低于某个阈值,就不采用这个信号,而是用传统模型的信号代替
- 多次采样取多数:对同一个输入,调用大模型多次,取多数结果
- 人工审核:对于大额交易信号,设置人工审核环节
def get_stable_signal(text, n_samples=5): signals = [] for _ in range(n_samples): signal = call_llm(text) signals.append(signal) # 取多数 from collections import Counter most_common = Counter(signals).most_common(1)[0][0] return most_common这些方案会增加一些成本,但相比信号错误带来的损失,这些成本是值得的。
6. 关于策略容量与资金管理的几点个人体会
策略容量是很多人忽略的问题。一个策略如果只能容纳几百万资金,那它的实际价值就有限。容量主要受限于两个因素:持仓股票的流动性和策略的换手率。
流动性方面,我通常要求持仓股票的日均成交额不低于5000万。如果低于这个数,买卖时很容易对股价造成冲击。换手率方面,日频策略的年换手率通常在10-20倍,这意味着每次调仓的交易成本会累积得很快。
资金管理方面,我的原则是:单只股票不超过总资金的5%,单个行业不超过总资金的20%。这样可以避免单只股票或单个行业的黑天鹅事件对策略造成致命打击。
另外,我建议保留一部分现金作为缓冲。不要满仓操作,留10%-20%的现金,可以在市场大跌时补仓,也可以应对突发的赎回需求。
最后分享一个小技巧:定期做压力测试。假设你的策略持仓中,有10%的股票同时跌停,你的策略会亏多少?如果这个亏损超出了你的承受范围,那就需要调整仓位或者分散度。这个测试我每个季度做一次,虽然简单,但非常有效。