ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

逻辑运算符在PV Alpha因子中的用法与回测陷阱详解

逻辑运算符在PV Alpha因子中的用法与回测陷阱详解 写这篇的时候我本来觉得逻辑运算符这种基础东西没什么好写的。但真把第五章拆开做的时候发现恰恰是这类看起来简单的东西在实盘回测里坑最多。我见过不少人的因子表达式里塞了一堆和||连优先级都没搞明白结果信号出来自己都解释不了。这篇就把逻辑运算符在PV价格-成交量Alpha因子里的用法、语义细节和踩坑经验一次说清楚。1. 逻辑运算符在Alpha因子里到底扮演什么角色1.1 从连续数值到是/否信号的那一步价格和成交量这两个维度的原始数据本质上是连续数值序列——收盘价、最高价、最低价、成交量、成交额。但大量的有效交易信号其内在逻辑并不是涨多少或者放量多少倍这种连续度量而是是否发生了某件事。比如是否突破了20日最高价是否出现了N日以来的地量收盘价是否站上了开盘价这些问题的答案天然就是布尔值True或者False。逻辑运算符就是干这个的——把连续数值经过比较运算后产生布尔信号再用AND、OR、NOT、XOR这些逻辑关系把多个布尔信号组合成一个复合判断。这一步是整个因子表达式里最容易被人忽略、却又最影响因子性格的环节。一个用AND连接的因子和一个用OR连接的因子即便底层条件一模一样出来的信号频率、收益分布、回撤特征都会完全不同等于两个因子。1.2 逻辑运算符是因子表达式的骨架不只是if-else很多刚入门的读者以为逻辑运算符就是编程课里那个简单的if-else判断放进因子研究里也就是个锦上添花的工具。这个理解是不对的。在成熟的Alpha表达式里逻辑运算符决定了三件关键的事情信号的触发条件什么时候这个因子才产生有效信号是全天候运行还是只在某个特定市场状态下才激活。空头过滤机制哪些标的或哪些时间段需要被剔除避免因子在不适合的市场环境里强行交易。区间有效性逻辑运算符配合rank、ts_rank这类时序算子可以把一个连续因子的有效作用范围限制在特定行情特征之内。举个例子同样是动量因子裸的return_1d过去一天收益在震荡市里频繁发出错误信号。但加上一个收盘价位于20日均线之上的逻辑条件之后因子就只在上行趋势环境中工作信号质量会明显提升。这里的close ts_mean(close, 20)就是一个典型的逻辑判断它不是锦上添花而是整个因子能不能用的决定性骨架。2. 向量化逻辑运算的语义细节和普通条件判断不是一回事2.1and与的区别一个天天有人踩的坑写量化代码的人应该都有过这种经历在Python里写if cond1 and cond2:没问题但把同样的逻辑套到两个pandas.Series上写成cond1 and cond2立刻报错ValueError: The truth value of a Series is ambiguous。这个报错信息很多人见过但未必真理解它的含义。原因是这样的and是Python的标量逻辑运算符它要求两边的操作数都能被转换为单个布尔值。而一个Series是一个向量向量里面可能有True也有FalsePython无法判断整个Series是True还是False所以直接拒绝执行。正确的做法是用位运算符它对两个Series做逐元素的逻辑与返回一个新的布尔Seriesimport pandas as pd cond1 pd.Series([True, False, True]) cond2 pd.Series([True, True, False]) # 错误写法 # result cond1 and cond2 # 直接报错 # 正确写法 result cond1 cond2 print(result) # 0 True # 1 False # 2 False # dtype: bool对应的向量化逻辑或用|逻辑非用~异或用^。这几个符号在量化研究里几乎天天用但它们的操作语义和Python关键字and、or、not是不同的。搞清楚这一点能帮你少浪费至少一个下午的调试时间。2.2 运算符优先级括号是亲爹比and和更隐蔽的坑是优先级。在一个复杂的因子表达式里比较运算符、、、位运算符、|、算术运算符、-、*、/混在一起时运算顺序有严格的规则。Python里算术运算的优先级高于比较运算比较运算的优先级又高于位运算的和|。这意味着什么呢假设你想表达收盘价大于20日均线且成交量大于5日均量写的是signal close ts_mean(close, 20) volume ts_mean(volume, 5)这个表达式看起来没什么问题但按照优先级规则的优先级高于所以实际的解析顺序是(close ts_mean(close, 20)) (volume ts_mean(volume, 5))恰好是你要的意思。那为什么还要强调括号因为当你把条件反过来或者加一个逻辑非的时候优先级就会咬人signal ~close ts_mean(close, 20)这行的本意是收盘价不高于20日均线。但~的优先级高于表达式实际被解析成(~close) ts_mean(close, 20)——先对价格做实数的按位取反再和均线做比较。价格是一个浮点数按位取反后的结果完全不是你想的布尔取反信号自然全乱了。所以在因子代码里我的习惯是所有逻辑运算全部加括号哪怕有些括号是多余的。这不是代码洁癖而是给自己和看代码的人省去不必要的判断成本。真正的逻辑分组清晰了后面调试和迭代才会快。3. 一个PV Alpha的完整搭建过程把逻辑运算符用到位3.1 选一个经典思路放量突破说了这么多理论来看一个具体的PV Alpha搭建过程。这里选择经典的放量突破逻辑它同时用了价格和成交量两个维度且完全靠逻辑运算符把两个条件黏合在一起非常适合用来演示。因子的业务逻辑是这样定义的价格条件当日收盘价突破了过去N日的最高价注意是过去不包含当日。成交量条件当日成交量放大到过去N日均量的M倍以上。组合方式两个条件同时满足时产生做多信号。用逻辑表达式表示就是signal (close ts_max(high, N)) AND (volume M * ts_mean(volume, N))这里ts_max和ts_mean分别是时序最大值和时序均值。之所以两个条件都要是因为单独看任何一个都不够稳健价格突破可能是假突破需要放量来确认放量也可能是出货需要价格创新高来确认方向。逻辑AND在这里起的是双确认的作用这也是逻辑运算符在PV因子里最常见的用途。3.2 用pandas落地注意shift(1)这个细节下面给出完整的Python实现。这里的核心是rolling窗口配合shift(1)来避免未来函数import pandas as pd import numpy as np def pv_breakout_signal(close, high, volume, n20, m1.5): 放量突破因子 close: 收盘价 Series high: 最高价 Series volume:成交量 Series n: 回看窗口 m: 放量倍数 # 条件1收盘价突破前N日最高价用shift排除当日 cond1 close high.rolling(n).max().shift(1) # 条件2成交量放大到前N日均量的M倍 cond2 volume m * volume.rolling(n).mean().shift(1) # 逻辑与组合 signal (cond1 cond2).astype(int) return signal很多人写突破因子时会忽略.shift(1)直接用high.rolling(n).max()这等于把当天的最高价也纳入了过去N日最高价的比较基准。一个最简单的事实是high序列里最大值一定大于等于当天的close因为close不可能超过high所以一旦把当日包含进去close max(high)就永远不会成立信号直接归零。反过来说如果你用的是close和high以外的组合不shift也可能造成未来数据泄漏让回测收益虚高得离谱。这个细节在逻辑运算符相关的PV因子实现中是排在第一位的正确性检查点。3.3 用NOT和OR做因子变体基础版本跑通之后可以用逻辑运算符快速做出几个变体每个变体代表不同的交易哲学变体一加入NOT做无效样本过滤# 剔除成交量为0的停牌/无交易时段 cond3 ~(volume 0) signal_v1 (cond1 cond2 cond3).astype(int)这里的~把一个排除条件反转成保留条件语义是把停牌样本剔除。很多原始数据里停牌日成交量是0或NaN如果不过滤这些样本会在后续排名中被赋予无意义的值拖累整个因子的IC统计。变体二用OR放宽确认条件# 放宽逻辑突破或者放量都算数 signal_v2 (cond1 | cond2).astype(int)OR组合的信号触发频率明显高于AND组合但代价是信号精度下降。它适合用在交易频率要求较高、或者你愿意用更多交易次数换取样本量的场景里。我的经验是OR组合出来的因子通常和原始单条件的相关性很高因为它的信号是单条件信号的并集所以在做因子正交化的时候要特别小心。变体三用AND和NOT组合出过滤后的突破# 过滤掉20日内已经发生过突破的标的只做首次突破 cond4 ~(close high.rolling(n).max().shift(1)).rolling(n).max().astype(bool) signal_v3 (cond1 cond2 cond4).astype(int)第三个变体本质上是在做新鲜度过滤——避免在同一个标的的同一轮行情里反复触发重复信号。这个思路在很多中低频PV因子里非常有用能显著降低换手率。4. 回测中暴露的四个逻辑陷阱与处理办法4.1 NaN泄漏AND运算里一个NaN毁掉整行逻辑运算符处理NaN的方式和很多人直觉不一样。在pandas里True np.nan的结果是FalseFalse np.nan的结果是Falsenp.nan True的结果也是False——只要AND两边有一个NaN结果就是False。而OR则反过来True | np.nan是TrueFalse | np.nan是NaN。这意味着什么如果数据前面有一段NaN比如rolling窗口还没填满用AND组合出来的信号会把这段数据全部标记为False。这本身不算大问题因为在回测时这段样本通常本来就需要丢弃。真正麻烦的是后续处理有些人会把布尔信号.astype(int)之后再填充NaN比如fillna(0)这时候AND结果里的False和真正的缺失值就混在一起了你根本分不清这个标的是条件不满足还是数据缺失。我在实际处理里有个固定的流程在做逻辑运算之前先把所有输入序列的NaN统一检查一遍确定哪些位置的NaN是窗口初期导致的哪些是停牌导致的然后对两种NaN分开处理。窗口初期的直接截断停牌的用显式的volume 0条件过滤不要让NaN隐式地参与逻辑运算。4.2 布尔因子排名太粗糙要不要转成连续信号逻辑运算符的直接产物是布尔因子值只有0和1。如果直接把这个布尔因子丢进横截面排名cross-sectional rank那么所有True的标的全并列第一所有False的标的全并列最后。这种极端分布有两个问题排名后的因子值和原始布尔值呈非线性关系多空组合对排名的微小变化极其敏感。信息量被严重压缩原本突破幅度很大和刚刚突破的标的在因子值上完全没区分度。我的做法是用布尔条件做筛选用连续信号做度量。也就是说逻辑运算符负责回答这个标的值不值得关注而连续算子负责回答值得关注到什么程度。改造后的代码如下# 逻辑AND负责筛选 mask cond1 cond2 # 连续信号突破幅度 breakout_magnitude close / high.rolling(n).max().shift(1) - 1 # 最终因子不满足条件时为0满足条件时为突破幅度 factor mask.astype(int) * breakout_magnitude这样因子既保留了逻辑条件的事件触发特性又保留了突破幅度这个连续信息横截面排名就有区分度了。这个布尔筛选连续度量的组合模式是我在PV因子研究里用得最多的结构。4.3 空头信号的逻辑反转False不等于做空逻辑非~在构造空头信号时有一个语义陷阱。很多人做多空组合时直接写long_signal cond1 cond2 short_signal ~(cond1 cond2)表面上看多空信号互斥且互补逻辑上没什么毛病。但这里有个深刻的业务问题不满足做多条件并不等于应该做空。一个标的不放量、不突破可能是它正处于震荡整理也可能是趋势不明朗。把这类无信号样本强行标记为做空因子实际上是在赌所有不满足条件的标的都会跌这个假设大多数时候都不成立。更合理的做法是引入一个中性区间long_signal (cond1 cond2).astype(int) short_signal (cond_fail1 cond_fail2).astype(int) # 明确的破位放量做空条件也就是说做空也应该有自己独立的触发逻辑而不是简单地对做多逻辑取反。这算是逻辑运算符在因子语义层面最值得琢磨的一个点。4.4 逻辑非作用于浮点数符号反转还是按位取反最后一个是纯代码层面的坑。pandas里~对布尔Series做的是逻辑非对整数Series做的是按位取反对浮点数Series做的是浮点数的按位非运算——但浮点数的按位运算是没有数学意义的。如果你写cond close close.rolling(20).mean() signal ~cond # 正确cond是布尔Series这个没问题。但如果你在同一个表达式里不小心让~直接作用于价格序列比如~close得到的是一堆完全没有业务含义的垃圾数值。因为close是浮点数Python会对它的底层二进制表示逐位取反结果既不是-close也不是1/close就是一个无意义的数字。这类错误通常不会报错只会悄悄污染因子值是最难排查的隐性bug之一。排查技巧是对结果做value_counts()或者检查极值分布如果发现出现大量异常大或异常小的数值优先怀疑逻辑非用错了对象。5. 逻辑因子组合的检验与迭代思路5.1 信号稀疏度条件越多样本越少统计越不可靠逻辑运算符用得越多条件组合越严格发出的信号就越稀疏。这是一个很容易被忽视的问题一个因子在回测里表现很好但如果它每期只在全市场0.1%的标的上触发信号那它的收益统计基础就非常薄弱很可能只是少数几只股票贡献的运气。我的检查方法是计算平均每期信号标的数和信号触发总次数。一个简单标准是日频截面因子的平均信号标的数至少要有几十个信号触发样本总量至少要达到几百次IC和收益统计才有最低限度的可信度。如果达不到就需要考虑放宽逻辑条件——比如把AND改成OR或者把放量倍数从2.0降到1.2。这里有个反面经验我一度沉迷于多条件过滤出高质量信号把因子叠加了五六个逻辑条件回测净值曲线漂亮得不得了。结果换一个时间区间因子直接失效。后来才想明白那根本不叫高质量信号那叫过拟合。逻辑运算符叠加的每一个条件都在消耗样本量样本量耗尽的时候你看到的任何优异表现都只是数据挖掘的噪音。5.2 子条件贡献度拆解AND到底带来了什么判断一个逻辑组合是否真正有效不能只看组合后的整体表现还得拆开看每个子条件的边际贡献。我常用的方法是做三组回测回测组合因子表达式观察目的仅价格条件cond1 - factor价格条件单独的信号质量仅成交量条件cond2 - factor成交量条件单独的信号质量逻辑AND组合cond1 cond2 - factor组合后的增量价值如果组合后因子的IC显著高于两个单独条件说明AND确实带来了信息增量——两个条件各自捕捉了不同的市场维度叠加后才构成更强的信号。如果组合后的IC和单独条件差不太多甚至更低说明AND只是在限制触发次数没有实质的信息增量那这个逻辑组合就应该被砍掉。这种拆解做多了之后你会对逻辑运算符该用在因子表达的哪个位置产生一种直觉。我现在的体会是逻辑运算符最适合做确认和过滤而不是做核心收益来源。核心收益通常来自一个本身就有效的连续信号逻辑条件只是帮它排除掉不该交易的时段和标的。5.3 和连续因子的相关性监控别造出换皮动量因子用逻辑运算符构造的PV因子尤其是包含突破、新高这类条件的因子很容易和传统的动量因子高度相关。原因很直观突破本身就是动量的一种极端表现形式价格创N日新高这个事件和过去N日收益为正这件事本质上高度重叠。所以在完成一个逻辑因子之后我的标准动作是算它和基础动量因子比如rank(return_1m)的相关性。如果相关系数超过0.7就要警惕这个新因子是否只是在给已有因子换皮。处理办法有两种一是做正交化把因子对动量因子回归后的残差作为新因子二是调整逻辑条件的侧重点让它在价格维度之外更多依赖成交量信息。相关性检查这件事在逻辑运算符主导的因子里尤其重要因为逻辑条件会引入大量的0/1值而0/1值和连续动量因子的相关性计算容易被少数极端值主导建议同时看Spearman秩相关和Pearson线性相关两个指标避免误判。最后再分享一个具体的小技巧把逻辑条件模块化用函数分别封装每个条件再用一个配置字典控制条件组合方式。比如cond1、cond2各自写成函数组合层用AND/OR/NOT灵活切换。这样做最大的好处是可以快速做条件组合的A/B测试不用每次改完逻辑都重写一遍因子。我后来所有PV因子研究都按这个结构组织代码省下的调试时间非常可观。逻辑运算符本身不复杂但怎么组织它们、怎么验证它们、怎么避免它们悄悄引入的坑才是真正拉开差距的地方。
返回列表