- 研究和实盘要逐值对得上:研究员给的是 pandas 写的因子,实盘用 C++ 重写,同一份行情喂进去,两边每个值都要一样(或在约定的容差内)。
- 对不上多半不是因子逻辑写错了:更常见的是两边的默认行为不同。比如 pandas 默认跳过 NaN,C++ 的算术不会;pandas 的
std默认除以 n − 1,NumPy 默认除以 n。 - 每一条都回答两个问题:两边默认各怎么做,C++ 怎么写才能和研究侧对上。
1. 数值本身:浮点加法不满足结合律
1.1 求和顺序
为什么加的顺序会改结果
- 每做一次加法都要舍入一次:所以 $(a+b)+c$ 和 $a+(b+c)$ 的末位可能不同。
- 顺序一变,末几位就跟着变:同样一列数,换个顺序加,结果的最后几位就可能不一样。
两边各是怎么加的
怎么加 NumPy np.sum成对求和:分块两两相加,误差比顺序累加小 pandas rolling(...).sum()/.mean()窗口滑动时加新减旧,带误差补偿(Kahan 求和) C++ 手写循环 从头到尾顺序累加;滚动窗口常写成 sum += x_new - x_old- 一次性求和通常只差末一两位:C++ 用一个循环从头加到尾,和
np.sum的成对求和比,一般只在最后一两位上不同。 - 滚动窗口的误差会越积越大:C++ 加新减旧时没有补偿,跑上一整天,误差一直累积,后面的值可能差得比末位还多。
- 一次性求和通常只差末一两位:C++ 用一个循环从头加到尾,和
C++ 怎么和研究侧对上
- 先和研究侧定好标准:是在容差内一致就行,还是要求逐位一致。这一步要先定,后面的写法都取决于它。
- 容差内一致就按相对误差比:例如 $|a-b| \le 10^{-9}\max(|a|,|b|)$ 就算对上。
- 逐位一致就得按同样的顺序加:一次性求和照成对求和的分块方式写;滚动窗口也加上 Kahan 补偿,或者每隔一段从头重算一次窗口和,把累积的误差清掉。
1.2 数学函数
exp、log、pow的实现两边可能不同:NumPy 在支持 AVX-512 的机器上用自己的向量化实现,C++ 调的是 glibc 的实现,结果可能差一两个末位单位(ulp)。- 只有要求逐位一致时才看得到:差的量级很小,按容差比的话不会被发现。
- 先查两边是不是同一台机器、同一个库:机器或库版本不同,这一类差别就可能冒出来。
2. 缺失值和窗口
2.1 NaN
两边默认怎么处理 NaN
默认行为 pandas sum/mean跳过 NaN( skipna=True);全是 NaN 时,sum得 0,mean得 NaNNumPy np.sum/np.meanNaN 会传染,结果是 NaN;要跳过得用 np.nansum/np.nanmeanC++ 算术 和 NumPy 一样,NaN 一路传下去 C++ std::max(a, b)结果取决于参数顺序: std::max(NaN, 1.0)得 NaN,std::max(1.0, NaN)得 1.0- 例子:
[1, NaN, 3]求平均,pandas 跳过 NaN 得 $(1+3)/2=2$;C++ 直接sum / n得 NaN;跳过了 NaN 却仍除以 3,得 $4/3$。
- 例子:
std::max的结果为什么看参数顺序- 它只比一次大小:
std::max(a, b)只做一次a < b,成立就返回b,否则返回a。 - 和 NaN 比较永远是 false:所以只要有一个参数是 NaN,
a < b就不成立,它总是返回第一个参数。 - 想忽略 NaN 就用
std::fmax:它碰到一个 NaN 时会返回另一个数。
- 它只比一次大小:
C++ 怎么对上 pandas
- 显式写逻辑跳过 NaN:累加之前先判断
std::isnan(x),是 NaN 就不加。 - 单独记下非 NaN 的个数:平均值除以这个个数,不除以窗口长度,否则就是上面 $4/3$ 那种错法。
- 全是 NaN 时照 pandas 的约定输出:
sum给 0,mean给 NaN。 - C++ 的算术不会替你跳过 NaN,要对上 pandas,每一处都得显式写出来。
- 显式写逻辑跳过 NaN:累加之前先判断
2.2 ddof:方差的分母
| 默认分母 | |
|---|---|
pandas std / var,包括 rolling(...).std() | n − 1(ddof=1,样本标准差) |
NumPy np.std / np.var | n(ddof=0) |
- 研究员用的是除以 n − 1 的那个:研究员在 pandas 里调
std,默认得到的就是样本标准差。 - 例子:
[1, 2, 3]的均值是 2,偏差平方和是 2,除以 3 得方差 0.667,除以 2 得 1。 - 除错了分母,每个值都偏同一个比例:C++ 照教科书公式除以 n,窗口 20 时标准差差 $\sqrt{20/19}$ 倍,约 2.6%。差值是固定比例,一眼就能认出是 ddof。
2.3 rolling 的前几个值
pandas 窗口没攒满时输出 NaN
- 默认
min_periods=n:rolling(n)的窗口里凑不满 n 个非 NaN 的值,就输出 NaN。 - 所以开头前 n − 1 个都是 NaN:例如
rolling(3).mean()作用在[1, 2, 3, 4]上,输出是[NaN, NaN, 2, 3]。 - 中间夹一个 NaN 也一样:这个 NaN 留在窗口里的 n 步,输出全是 NaN。
- 按时间开的窗口规则不同:
rolling("5min")这类默认min_periods=1,有一个值就出数。
- 默认
C++ 怎么对上
- 自然写法从第一条就出值:同样的输入,C++ 会输出
[1, 1.5, 2, 3],前 n − 1 个对不上,窗口里有 NaN 时也对不上。 - C++ 要维护窗口里非 NaN 的个数,个数不够 n 就输出 NaN。
- 自然写法从第一条就出值:同样的输入,C++ 会输出
2.4 ewm 的 adjust
ewm 是什么,因子里为什么常用
- ewm 是指数加权移动平均:和
rolling(n).mean()一样是求平均,但不只看最近 n 个,而是看全部历史,最新的权重是 1,往前一个是 $1-\alpha$,再往前是 $(1-\alpha)^2$,越旧的权重越小。 - 因子里常拿它平滑成交量、价差、收益这类噪声大的序列:近的数据说话更多;也没有 rolling 那种硬边界,一个大值不会在第 n + 1 步突然整个掉出窗口。
- 它天生适合逐 tick 增量算:递推只要记住上一步的一个数,每来一条 O(1) 更新。
span换算成 $\alpha$:$\alpha = 2/(\text{span}+1)$,span=10时 $\alpha \approx 0.18$,大致相当于最近 10 条的平均。adjust管的只是开头:历史还短时,这个加权平均怎么算,下面两种算法就差在这里。
- ewm 是指数加权移动平均:和
pandas 有两种算法,默认的不是递推
adjust=True(默认)按全部历史加权:每一步都用到目前为止的全部数据,按权重 $(1-\alpha)^i$ 加权,再除以权重之和:
adjust=False是递推:这也是 C++ 最自然的写法:
两种算法差在开头
- 例子:取 $\alpha=0.5$,数据是
[10, 0]。递推得 $y_1=0.5\times0+0.5\times10=5$;adjust=True得 $y_1=\dfrac{1\times0+0.5\times10}{1+0.5}\approx3.33$。 - 递推让第一个值的权重偏大:$y_0=x_0$ 相当于把还没出现的历史全当成 $x_0$;
adjust=True只按真实出现过的数据加权。 - 往后差距越来越小:第一个值的权重按 $(1-\alpha)^t$ 衰减,权重之和趋近 $1/\alpha$,两种算法慢慢收敛到一起。
- 大约 25 条以后两种算法就几乎一样:$\alpha \approx 0.18$ 时,$(1-\alpha)^{25} \approx 0.007$,第一个值的影响已不到 1%。因子如果每天开盘重新开始算,每天开头这一段都会对不上。
- ewm 对不上,典型的样子是开头差得大、后面慢慢收敛。
- 例子:取 $\alpha=0.5$,数据是
C++ 怎么对上
adjust=True- 分子和分母各自递推,每步相除:两者每步都先乘 $1-\alpha$,分子再加 $x_t$,分母再加 1,输出两者之比。这样仍是 O(1) 的增量计算。
span、halflife换算 $\alpha$ 的公式照抄 pandas:例如 $\alpha = 2/(\text{span}+1)$,换算写错了,每个值都会对不上。
3. 取整和整数
3.1 四舍五入
正好在 .5 时两边取的方向不同
结果 Python round、np.round、pandas.round取偶数: round(2.5)是 2,round(3.5)是 4C++ std::round远离 0: std::round(2.5)是 3,std::round(-2.5)是 −3二进制表示本身也会改变取整的结果
2.675存不准:它在二进制里存的是 2.67499999…,所以 Pythonround(2.675, 2)得 2.67,不是 2.68。- C++ 也有同样的问题:
std::round(x * 100) / 100里乘 100 这一步也会舍入,结果不一定和 Python 一样。 - 价格这类本来就是定点的数,一直用放大后的整数存,不在浮点上取整。
3.2 整数除法和取模
两边的规则
7 / 2-7 // 2或-7 / 2-7 % 2Python 3.5( /是真除法)−4( //向负无穷取整)1(符号跟除数) C++(两个整数) 3(整数除法) −3(向 0 截断) −1(符号跟被除数) 最常犯的是两类错
- C++ 两个整数相除会丢掉小数:例如成交量除以笔数求平均每笔量,Python 的
/得小数,C++ 两边都是整数时直接截断。这是最常见的一类。 - 负数时取整方向不同:算时间桶编号时最容易遇到,例如时间减去基准时刻得到负数(集合竞价在 9:30 之前),Python 的
//和 C++ 的/给出的桶号差 1。
- C++ 两个整数相除会丢掉小数:例如成交量除以笔数求平均每笔量,Python 的
3.3 溢出
- Python 的整数不会溢出:位数不够会自动变长。
- NumPy、pandas 的 int64 会溢出而且不报错:超出范围直接回绕。
- C++ 有符号整数溢出是未定义行为:编译器可能按“不会溢出”来优化,结果不可预测。
- 最容易撞上的是金额:价格放大 10000 倍存成整数,100 元就是 1,000,000,再乘 100 万股是 $10^{12}$,远超 int32 的上限(约 $2.1\times10^9$)。
- 价×量一律用 int64 或 double,并且先转类型再相乘。否则乘法本身已经在 int32 里溢出了,转换来不及救。
3.4 类型
- 带 NaN 的整数列在 pandas 里会变成 float64:除非用可空整数类型
Int64,所以研究侧的“成交量”可能其实是浮点;超过 $2^{53}$ 的整数转成 float64 还会丢精度。 - float32 只有约 7 位有效数字:C++ 为了省内存用
float存价格或因子值,和 pandas 的 float64 比,从第 7、8 位开始就不同了。
4. 排名和时间
4.1 rank
pandas 默认给并列的取平均名次
- 截面因子常用排名:对全市场股票排名,再归一化到 0–1。
- 同值用
method="average":并列的取它们名次的平均,例如两只并列第 3,都记 3.5。 - NaN 不参与排名:结果也是 NaN(
na_option="keep")。
C++ 的自然写法按下标给名次
- 排序后按位置编号:这相当于
method="first",同值的按出现顺序排成 3、4。 - 同值并不少见:停牌股的收益都是 0,涨停股的收益相同,名次就在这些股票上对不上。
- 排序后按位置编号:这相当于
C++ 怎么对上
- 给同值的一段赋平均名次:排序后找出值相同的一段,每只都记这段名次的平均。
pct=True除的是非 NaN 的个数:不是股票总数,有停牌、缺数据的股票时两者不同。
4.2 时间切分和前视
- bar 的边界和标签要照研究侧来:区间是左闭右开还是左开右闭,标签用起点还是终点。pandas
resample按分钟切时默认左闭右开、标签在左;研究侧可能改过参数,要照着它的来。 - bar 什么时候算结束,两边可能不同:研究侧用
shift(1)保证 t 时刻只用到 t 之前的数据;C++ 逐条收数据,只有下一条数据的时间越过边界、或者定时器到点时,才知道一根 bar 结束了。触发时机定得不一样,同一根 bar 用到的数据就不一样。 - 午休和集合竞价要单独约定:11:30–13:00 中间没有数据,13:00 那根 bar 跟谁连在一起;9:25 集合竞价的成交归入哪根 bar,两边都要按同一个规则。
- 时序对齐的更多内容:整理在 A 股业务知识 第 7 节。
5. 编译选项:同一份 C++ 代码,换个选项结果就变
三个选项各会不会改结果
选项 会不会改浮点结果 为什么 -O2→-O3一般不会 只要不放开结合律,编译器不能为了向量化改变加法顺序 -march=native会 打开 FMA 指令后,GCC(默认的 gnu++ 方言)和 Clang 会把 a * b + c合成一条乘加,中间结果不再单独舍入,末位就变了-ffast-math会,而且可能更糟 放开结合律;假定没有 NaN 和无穷大;把非常小的数(次正规数)当成 0 因子代码里不能开
-ffast-math- 它会删掉 NaN 判断:它假定没有 NaN,
std::isnan(x)可能被直接优化成 false,第 2 节里所有跳过 NaN 的写法都会悄悄失效。 - 它会重排求和:放开结合律后,求和顺序会被向量化重排,第 1 节的问题又回来了。
- 它会删掉 NaN 判断:它假定没有 NaN,
想要
-march=native又不要 FMA- 加上
-ffp-contract=off:这样能用上-march=native的其他指令,但编译器不再把乘和加合成一条 FMA。 - 研究侧也要定死实现:NumPy 内部也有自己的向量化实现,逐位一致的前提是两边都定死实现方式,不只管 C++ 这一边。
- 加上
构建配置要统一
- 例子:回测框架用
-O2、实盘用-O3 -march=native,同一份因子代码就可能在末位上对不上。 - 研究侧和实盘侧的编译选项要一致,并写进构建配置。
- 例子:回测框架用
6. 对不上时:先看差值的样子
按差值的形状定位原因
- 不要一条条看:逐值比完以后,先看差值在时间上怎么分布,形状通常直接指向原因。
差值的样子 先查什么 整列错开一格(C++ 的第 t 个等于 pandas 的第 t + 1 个) 时间对齐、 shift、bar 的边界和标签只有开头几个不对,后面都对 min_periods;ewm 的adjust;初始值开头差得大,往后越来越小 ewm 的 adjust=True和递推写法的差别越往后差得越大 滚动窗口加新减旧,浮点误差在累积 每个值都差同一个比例 ddof;单位或放大倍数(价格放大了 10000 倍还是 100 倍) 零星几个点差很多 NaN 的处理;停牌、涨跌停;同值排名;整数除法截断 全部只差末一两位 求和顺序、FMA、数学函数实现;按约定的容差判过 从源头避免:两边跑同一份实现
- 把 C++ 实现暴露给 Python:同一份 C++ 因子代码用 pybind11 暴露给研究侧,研究和实盘跑的是同一个实现,一致性从源头上保证。
- 代价是研究员迭代变慢:改因子要改 C++、重新编译。
- 所以通常只对稳定下来的因子这样做。