一个事件驱动量化算法的技术拆解

这篇文章不是比赛说明书,而是一篇从技术实现出发的算法拆解。目标很明确:围绕 pred_compact.py 这份代码,解释这套事件驱动量化框架为什么这样设计、每个模块分别承担什么功能,以及它背后的建模逻辑是什么。

我会尽量避免把讨论写成“代码注释扩写版”,而是把重点放在方法本身。换句话说,这里更关心的不是某一行代码怎么执行,而是这套方法如何把事件、图谱关系与价格信息组织成一个可训练、可验证、可交易的策略系统。


一、这段代码到底在做什么

概括地说,这个算法在做一件事:

把新闻事件、事件和股票的关联关系、股票历史价格这三类信息揉在一起,然后预测“下一个决策周应该买哪几只股票”。

它不是单纯看 K 线,也不是单纯做新闻情绪分析,而是一个混合框架:

  • 用事件信息判断“市场最近在炒什么”
  • 用事件与公司的映射关系判断“谁最可能受影响”
  • 用价格和成交数据判断“这种影响有没有可能兑现成股价表现”
  • 最后用机器学习做排序,挑出最值得买的 3 只股票

如果换一种更贴近研究问题的说法,这个算法试图回答的是:

近期发生的这些事,最后会利好哪些股票,而且这种利好在下周还有没有交易价值?


二、为什么“事件驱动”是一个值得研究的方向

传统量化研究里,价格、成交量与财务数据通常是最先被使用的变量。但如果把时间尺度缩短到几天到几周,市场波动往往并不只是统计规律的自然展开,而是与一系列具体事件密切相关,比如:

  • 某家公司中标大订单
  • 某个行业迎来政策利好
  • 某种资源品价格大涨
  • 某家公司被处罚、减持、爆雷

这些事件往往先作用于预期,再通过交易行为体现在价格上。

因此,事件驱动策略的核心思想可以概括为:

  1. 先找到事件
  2. 再找到被事件影响的股票
  3. 最后判断这种影响会不会在未来一段时间变成收益

这份代码正是沿着这条路径展开的。它并不假定“事件一出现,股价必然上涨”,而是试图进一步判断:哪些事件更重要、哪些股票更可能受益、这种受益是否仍具有尚未兑现的交易价值。


三、这份代码依赖哪些数据

pred_compact.py 不是直接拿一个表就开始训练,它依赖好几类数据。

1. 事件表

events.csv

这里面记录的是事件本身,比如:

  • 事件编号
  • 事件名称
  • 事件日期
  • 影响周期
  • 强度

它对应的是“市场上最近发生了什么”。

2. 事件-股票映射表

event_company_map.csv

这个表非常关键。因为事件本身并不能直接交易,真正可交易的是股票。
所以必须知道:

  • 这个事件影响哪些公司
  • 是直接影响还是间接影响
  • 影响关系有多强

换句话说,这张表负责回答:

事件和股票之间怎么连起来?

3. 股票价格数据

daily_kline_batchesprice_panel.csv

这部分提供:

  • 开盘价
  • 收盘价
  • 涨跌幅
  • 成交量
  • 成交额

这部分数据负责回答:

就算事件利好,这只股票现在的价格状态适不适合买?

4. 静态辅助数据

包括:

  • 行业信息
  • 上市日期
  • 指数成分股信息

这些变量不是主角,但它们能帮助模型理解股票的背景。


四、这不是泛化意义上的“预测股价”,而是周频交易决策

在量化语境里,“预测”这个词很容易带来误解。很多人看到机器学习模型,首先会想到逐日预测涨跌,甚至预测第二天的收盘价。

但这段代码处理的不是这个问题。

它处理的是一个更接近实务的问题:以周为单位进行选股决策

它把每周二设为统一的决策点,也就是 decision_tuesday
然后目标不是预测长期走势,而是预测:

从这个决策周开始,到本周结束,这只股票的收益怎么样?

代码里的标签大致可以理解为:

yi,t=Pi,tsellPi,tbuyPi,tbuyy_{i,t} = \frac{P^{\text{sell}}_{i,t} - P^{\text{buy}}_{i,t}}{P^{\text{buy}}_{i,t}}

这个标签的金融含义并不复杂:

  • 在周内找一个买入点
  • 在周末附近卖出
  • 计算这一周的收益率

因此,这套策略既不是高频交易,也不是中长期配置,而是一个典型的短周期事件交易框架


五、第一步:把事件对齐到可交易的决策时点

事件发生的时间是乱的:

  • 有的在周一
  • 有的在周三
  • 有的在周五

但模型训练需要统一的时间截面,否则不同事件之间就很难放在同一个决策框架下比较。

所以代码做了一件很关键的事情:把不同日期的事件统一映射到决策周二

核心逻辑是:

  • 如果事件发生在周一,就归到本周周二
  • 如果事件发生在周二及以后,就归到下周周二

代码中的数学表达是: $$ T(e) = \operatorname{next_decision_tuesday}(t_e) $$

这一步的直观含义是:

每一条事件,最终都要被分配到一个“该拿来做交易决策的周”

这一步之所以重要,是因为事件研究里最容易失控的地方恰恰是时间对齐。一旦时间定义含混,后续标签、特征和验证都会一起失真。


六、第二步:为每条“事件-股票边”构造影响权重

这份代码最有意思的地方之一,在于它没有把“事件影响股票”处理成简单的 0/1 关系,而是进一步为每条关系计算了一个连续权重。

这样做的原因并不复杂。

因为现实里不同事件的交易价值差别很大:

  • 有些事件非常强
  • 有些只是边角信息
  • 有些是刚发生的
  • 有些已经发酵很多天
  • 有些股票是直接受益
  • 有些股票只是蹭概念

这些差别如果不被量化,模型最终看到的就只有“有关联”或“没有关联”,信息会过于粗糙。

代码中的权重公式是:

we,i=max(relation_strength,0.05)(1+intensity_score5)decay(1+0.5direct)w_{e,i} = \max(\text{relation\_strength}, 0.05) \cdot \left(1+\frac{\text{intensity\_score}}{5}\right) \cdot \text{decay} \cdot (1+0.5\cdot \text{direct})

这个公式可以拆开来看。

1. relation_strength

它表示事件与股票之间的关联强弱。

比如:

  • 某公司就是事件主体,那关联强度通常很高
  • 某公司只是产业链外围映射,强度就低

2. intensity_score

它表示事件本身的冲击强度。

例如:

  • 普通公告和重大政策的强度显然不一样
  • 一般订单和超大订单的市场影响也不一样

3. decay

它表示时间衰减。

代码里用了半衰期思想:

decay=exp(ln(2)Δd7)\text{decay} = \exp\left(-\ln(2)\cdot \frac{\Delta d}{7}\right)

它表达的是:

一个事件离决策时点越远,它的影响越弱;大致每过 7 天,影响衰减一半。

这与事件交易的经验事实是吻合的。很多利好如果在一周前已经被充分传播,那么等到正式决策时,其可交易价值往往已经明显下降。

4. direct

它表示该股票是否属于直接受益对象。

如果是直接关系,代码额外乘了 1.5 的加成:

1+0.5direct1 + 0.5 \cdot \text{direct}

这里的含义也很直观:真正的一线受益标的,通常比外围映射标的更具有交易确定性。


七、第三步:识别事件方向

代码没有引入复杂的文本模型,而是用规则法完成了一个相当务实的方向识别。

它会根据:

  • event_name
  • relation_type
  • note

里的关键词,把事件方向划成:

signe,i{1,0,1}\text{sign}_{e,i} \in \{-1,0,1\}

也就是:

  • 1:利多
  • -1:利空
  • 0:中性或不确定

比如:

  • “中标”“订单”“增长”“景气”偏利多
  • “处罚”“亏损”“违约”“减持”偏利空

这一步的意义在于:

模型不只是知道“这只股票有关联事件”,还知道这个事件大概率是好消息还是坏消息。

从建模角度看,这里体现的是一种很克制的取舍:当样本量有限、文本标签质量不完全稳定时,规则法未必落后于复杂 NLP,反而可能更稳健、更可解释。


八、第四步:把多个事件压缩成一个股票周样本

到这里为止,每一条数据还是“某事件影响某股票”的边级别数据。
但机器学习训练时,需要的是“某周某只股票”的样本。

问题在于,一只股票在同一周往往不只对应一个事件。

这里的答案就是:聚合。

设某个决策周里,股票 ii 对应的事件集合为 Ei,tE_{i,t}
代码会围绕这个集合计算一批统计特征。

1. 事件数量

event_counti,t=Ei,t\text{event\_count}_{i,t} = |E_{i,t}|

它反映的是,这只股票在本周被多少事件共同覆盖。

2. 正向事件和负向事件数量

positive_event_counti,t=eEi,t1(signe,i>0)\text{positive\_event\_count}_{i,t} = \sum_{e\in E_{i,t}} \mathbf{1}(\text{sign}_{e,i}>0) negative_event_counti,t=eEi,t1(signe,i<0)\text{negative\_event\_count}_{i,t} = \sum_{e\in E_{i,t}} \mathbf{1}(\text{sign}_{e,i}<0)

这组变量告诉模型:

  • 当前这只股票是“利多事件扎堆”
  • 还是“利空事件堆积”

3. 事件总权重、最大权重、平均权重

row_weight_sumi,t=eEi,twe,i\text{row\_weight\_sum}_{i,t} = \sum_{e\in E_{i,t}} w_{e,i} row_weight_maxi,t=maxeEi,twe,i\text{row\_weight\_max}_{i,t} = \max_{e\in E_{i,t}} w_{e,i} row_weight_meani,t=1Ei,teEi,twe,i\text{row\_weight\_mean}_{i,t} = \frac{1}{|E_{i,t}|}\sum_{e\in E_{i,t}} w_{e,i}

这三个量分别对应:

  • 总体驱动力有多强
  • 最强单一事件有多强
  • 平均事件质量怎么样

4. 方向统计

event_sign_sumi,t=eEi,tsigne,i\text{event\_sign\_sum}_{i,t} = \sum_{e\in E_{i,t}} \text{sign}_{e,i}

它本质上描述的是:

这一周围绕该股票的事件氛围,整体偏正面还是偏负面?


九、第五步:把事件类别信息压缩进特征体系

事件不只有强弱和方向,还有类型差异。

比如:

  • 公司层面的事件
  • 行业层面的事件
  • 宏观政策事件
  • 地缘事件

这些事件即使都利多,交易逻辑也不一样。

代码在这里采取的是一种很节制的做法:它没有直接展开高维 one-hot,而是把类别先压缩成少量桶,再统计每类事件权重之和。

例如宏观类事件:

driver_w_macro(i,t)=eEi,t, driver(e)=macrowe,i\text{driver\_w\_macro}(i,t) = \sum_{e\in E_{i,t},\ \text{driver}(e)=\text{macro}} w_{e,i}

这样处理的好处是:

  • 信息保留了
  • 维度没有爆炸
  • 对小样本更稳

如果要用一句话概括这里的方法论,那就是:好的特征工程,不是把所有信息都塞给模型,而是把信息整理成模型真正能学习的形式。


十、第六步:拼接价格特征,避免“只看事件不看市场状态”

如果一个模型只看事件,不看价格,常见问题是:

  • 有些事件早就被交易过了
  • 有些股票虽然有利好,但走势已经走坏
  • 有些股票短期波动太大,不适合参与

因此,代码又拼接了一批价格状态特征。

1. 动量

close_mom_5=ClosetCloset51\text{close\_mom\_5} = \frac{\text{Close}_t}{\text{Close}_{t-5}} - 1 close_mom_20=ClosetCloset201\text{close\_mom\_20} = \frac{\text{Close}_t}{\text{Close}_{t-20}} - 1

这两个特征分别刻画短期和中短期趋势。

2. 波动率

rt=ClosetCloset11r_t = \frac{\text{Close}_t}{\text{Close}_{t-1}} - 1 volatility_5=std(rt4:t)\text{volatility\_5} = \operatorname{std}(r_{t-4:t}) volatility_20=std(rt19:t)\text{volatility\_20} = \operatorname{std}(r_{t-19:t})

波动率高,通常意味着不确定性更高。
同样的事件,在高波动股票和低波动股票上的兑现效率往往并不相同。

3. 成交活跃度

amount_ratio5,20=MA5(amount)MA20(amount)\text{amount\_ratio}_{5,20} = \frac{MA_5(\text{amount})}{MA_{20}(\text{amount})} volume_ratio5,20=MA5(volume)MA20(volume)\text{volume\_ratio}_{5,20} = \frac{MA_5(\text{volume})}{MA_{20}(\text{volume})}

它们反映的是:

这只股票最近有没有明显放量、放额

如果事件刚出现、量价就已经明显活跃,那么至少可以说明市场资金正在注意它。


十一、第七步:补充个股背景与市场背景

除了事件和价格,代码还加了两类常见的辅助变量。

1. 个股背景

比如:

  • 行业是否和事件逻辑匹配
  • 是否是最近上市的新股
  • 是否属于重要指数成分股

这些变量帮助模型理解:

  • 它是不是事件主线上的核心标的
  • 它的风格属性是什么

2. 上周表现

代码还加入了:

prev_week_returni,t=yi,t1\text{prev\_week\_return}_{i,t} = y_{i,t-1}

以及市场整体的上一周收益背景。

这一步其实是在捕捉一个很现实的现象:

有些股票在事件真正爆发前,资金已经提前埋伏了。

因此,模型不能只看“这周发生了什么”,还要看“市场此前如何反应”。


十二、第八步:为什么这里要用两个模型

这是这份代码里另一个相当值得注意的设计。

它没有只训练一个回归模型,而是同时训练了两个模型:

1. 回归模型

预测未来一周收益率:

y^i,t=freg(xi,t)\hat{y}_{i,t} = f_{\text{reg}}(x_{i,t})

2. 分类模型

预测未来一周上涨概率:

p^i,t=P(yi,t>0xi,t)=fcls(xi,t)\hat{p}_{i,t} = P(y_{i,t} > 0 \mid x_{i,t}) = f_{\text{cls}}(x_{i,t})

原因在于:

因为“涨不涨”和“涨多少”不是一回事。

举个例子:

  • A 股票上涨概率高,但每次只涨一点
  • B 股票上涨概率一般,但一旦涨就涨很多

如果你只做分类,可能选到很多“小赚但空间不大”的股票。
如果你只做回归,又可能过度偏好高波动标的。

所以代码把两个问题拆开:

  • 分类模型负责胜率
  • 回归模型负责空间

这是一个非常典型、也非常实用的量化建模思路。


十三、第九步:如何把两个模型的结果合成为最终选股分数

模型训练完之后,还没有直接输出买哪只股票。
它还要再做一层交易决策。

代码定义了一个综合得分:

scorei,t=0.58p^i,t+0.22y^i,tscalet+0.12weight_normi,t+0.08prev_week_returni,t\text{score}_{i,t} = 0.58 \cdot \hat{p}_{i,t} + 0.22 \cdot \frac{\hat{y}_{i,t}}{\text{scale}_t} + 0.12 \cdot \text{weight\_norm}_{i,t} + 0.08 \cdot \text{prev\_week\_return}_{i,t}

这几个部分分别代表:

  • 0.58 * prob_up:先保证上涨概率
  • 0.22 * pred_ret:再看收益空间
  • 0.12 * weight_norm:强化事件主线强度
  • 0.08 * prev_week_return:保留一定趋势延续信息

这一步很像一个“二次决策层”。

也就是说,机器学习模型不是直接给出最终答案,而是先给出若干预测值,再由策略规则把这些预测值转成可交易分数。

如果要概括这里的要点,那就是:量化策略往往不是“模型 = 策略”,而是“模型 + 交易规则 = 策略”。


十四、第十步:最终如何选股与分仓

最终流程是这样的:

  1. 先筛掉上涨概率太低的股票
  2. 在剩下的股票中按综合分排序
  3. 取前 3 只
  4. 给这 3 只股票分配资金比例

分配方法不是主观指定,而是 softmax:

wi=escoreimax(score)jescorejmax(score)w_i = \frac{e^{\text{score}_i - \max(\text{score})}}{\sum_j e^{\text{score}_j - \max(\text{score})}}

这个公式的好处是:

  • 分数越高,仓位越大
  • 总权重自动归一化到 1
  • 不容易出现特别极端的仓位

最终输出文件里最重要的三列就是:

  • 事件名称
  • 股票代码
  • 资金比例

到这里,一套完整的事件驱动选股框架就闭环了。


十五、从工程角度看,这份代码为什么值得讨论

在我看来,这份代码最值得讨论的并不是 XGBoost 本身,而是其中体现出的几种建模习惯。

1. 先把问题改写成可监督学习的形式

真实问题是“下周买什么”,代码把它转成了:

  • 输入:事件、图谱、价格特征
  • 输出:下一周收益和上涨概率

只有先把问题结构化,模型才真正有发挥空间。

2. 特征工程比模型堆料更重要

这份代码没有追求特别花哨的模型,但特征工程很扎实:

  • 事件权重
  • 方向判断
  • 时间衰减
  • 多事件聚合
  • 价格状态
  • 上周背景

这再次说明一个现实:在很多中小型量化任务里,特征设计往往比更换模型更重要。

3. 时序验证比随机验证更靠谱

代码没有乱打训练集和测试集,而是按时间滚动验证。
这符合真实交易场景,也能避免未来信息泄露。

4. 模型输出不等于最终交易决策

代码没有直接拿回归结果排序,而是进一步做了综合打分和仓位分配。
这说明它的目标不只是“拟合数据”,而是“落地交易”。


十六、如果要读这份代码,什么顺序更合适

我建议按下面顺序看:

  1. 先看 generate_decision
  2. 再看 fit_as_of
  3. 再看 build_decision_samples
  4. 然后看 _build_price_snapshot
  5. 最后回头看 load_data 和辅助函数

之所以推荐这样看,是因为:

如果一开始就从头顺着读,很容易陷入局部细节。
而从主流程逆推,更容易先看清:

  • 最终输出是什么
  • 为了得到这个输出,需要哪些中间变量
  • 每个特征到底是为了哪一步服务

十七、这套算法的优点与局限

优点

  • 思路完整,从事件到选股再到分仓是一条闭环
  • 特征设计很有金融含义,解释性强
  • 没有盲目堆复杂模型,整体结构较为克制
  • 周频框架噪声相对较小,稳定性更容易控制

不足

  • 事件方向判断还是偏规则法,语义能力有限
  • 没有建模更复杂的事件传播链
  • 没有显式风险控制模块
  • 对更高频、更复杂的交易场景还不够

但从方法展示与比赛算法实现的角度看,这套方案已经相当具有代表性。


十八、写在最后:这套方法真正值得带走的东西

如果读完这篇文章只保留一个判断,我希望是这个:

一个好的量化算法,不是上来就调模型,而是先把金融问题拆成可以计算、可以验证、可以交易的多个小问题。

这份 pred_compact.py 做得好的地方就在这里:

  • 它先定义交易周期
  • 再量化事件影响
  • 再聚合成特征
  • 再训练模型
  • 最后再做选股和分仓

这就是一个完整的技术框架。

把这份代码当成一个模板去看,至少可以学到四件事:

  1. 学会怎样把原始数据整理成样本
  2. 学会怎样把事件转成特征
  3. 学会怎样让模型服务于交易目标
  4. 学会怎样把预测结果变成实际投资决策

如果沿着这个框架继续扩展,比较自然的方向通常有三个:

  • 更好的事件文本理解
  • 更丰富的行业与市场背景特征
  • 更严格的风险控制和仓位管理

附:运行入口

核心代码文件仍然是 pred_compact.py

运行示例:

python pred_compact.py --root-dir . --target-date 2024-06-18 --output result.xlsx --allow-roll-back

如果准备进一步读这份工程,建议一边看这篇文章,一边对照源代码里的这几个函数:

  • build_weekly_labels
  • build_decision_samples
  • _walk_forward_validate
  • fit_as_of
  • generate_decision

这样会更容易把方法和实现对应起来。