一个浮点截断 Bug 的诊断与进化
「量化实战手记」— 记录从想法到落地的真实开发历程
摘要:一个缠论背驰信号在验证环节被悄悄吞没——浮点方向的 ±0.5 传入 int 形参时截断为 0,中枢查找返回空,信号静默失效。三行按符号规范化的修复,顺势牵出三买三卖口径的重构:向上趋势严格只认唯一中枢。最危险的 Bug 不崩溃,只是悄悄返回空。
引言:一个不报错的 Bug
量化系统里最可怕的 Bug 不是崩溃,而是它「看起来一切正常」。
S0014 是一个缠论信号验证器。它的职责很明确:拿到上游 S0008 产生的背驰信号后,检查收盘价是否真的突破了一个线段中枢——符合就保留,不符合就剔除。
某天发现,一些本该被保留的信号凭空消失了。没有报错,没有异常,没有日志,信号就是不见了。
这种 Bug 最难抓——因为它什么都不说。
第一章:信号去哪了
要理解信号为何消失,先要看懂它经过的链路。
图 1 | 信号在 locate_pivots 入口被悄悄吞没
S0014 的核心是一句调用:在一段趋势里找出所有的「中枢」。中枢是缠论里价格反复震荡的区域,由相邻线段的高低点重叠形成。找到中枢,才能判断背驰时的收盘价是否突破了它。
而找中枢的函数 locate_pivots,需要知道一个关键信息:这段趋势是向上还是向下。因为向上段和向下段的中枢构造方式镜像相反。
问题,就出在「向上还是向下」这个参数上。
浮点编码的方向信号
在缠论的分析结果里,趋势方向不是一个简单的布尔值,而是一个浮点数:
- +1.0 / -1.0
:向上段 / 向下段的「当前有效终点」,趋势正在发生 - +0.5 / -0.5
:「历史终点的降级标记」——趋势已被更新的走势覆盖,但痕迹还在
这套编码本身没问题。它用强度的强弱,区分了「正在发生」和「已经过去」——同一个方向,当前的有效终点记满强度 1.0,被覆盖后的历史终点降为 0.5。
int 形参的暗礁
但 locate_pivots 的签名是这样的:
std::vector<Pivot>locate_pivots(..., int direction, int i, int j) {std::vector<Pivot> pivots;// ...if (direction == 1) {// 向上段:扫 +1/-1 配对成中枢 } else if (direction == -1) {// 向下段:扫 -1/+1 配对成中枢 }return pivots; // direction==0 时,pivots 为空,且无任何提示}第四个参数 direction 是 int。函数内部只处理两种情况:direction == 1 走向上段逻辑,direction == -1 走向下段逻辑。
没有 else。
调用方原本是这样写的——直接把那个浮点的方向值传进去:
// trend_direction 是 float:可能为 ±1.0,也可能为 ±0.5std::vector<Pivot> pivots = locate_pivots( stretch_sigs, high, low, trend_direction, trend_start, trend_end);// ^^^^^^^^^^^^^^// float 隐式截断为 int:±0.5 → 0trend_direction 是 float。当它是 ±1.0 时,截断成 int 还是 ±1,一切正常。但当它是 ±0.5 时——
图 2 | C++ float→int 是向零截断,±0.5 双双塌缩为 0
+0.5 截断成 0,-0.5 也截断成 0。而 direction == 0 既不等于 1 也不等于 -1,两个分支都进不去,函数直接走到末尾,返回一个空的中枢列表。
中枢列表为空 → 后续判断认为「没有可用中枢」 → 信号被取消。
整个过程没有触发任何错误。C++ 的隐式窄化转换是静默的——编译器不警告,运行时不报错,日志里一片干净。
原则:最危险的 Bug 不崩溃,只是悄悄返回空。它把「出错」伪装成了「正常但无结果」,让排查失去了入口。
第二章:修复与口径审视
第一刀:按符号规范化
修复只用了三行。
// trend_direction 取自 trend_sigs,可能为 ±0.5 或 ±1.0// locate_pivots 的 direction 形参只识别 ±1,故按符号规范化,// 避免 ±0.5 经 float→int 截断为 0 导致空返回const int trend_dir = (trend_direction > 0) ? 1 : -1;std::vector<Pivot> pivots = locate_pivots( stretch_sigs, high, low, trend_dir, trend_start, trend_end);不再把浮点值直接交给 int 形参,而是先用它的符号判定方向:正的就是向上,负的就是向下。
关键洞察在于:±0.5 和 ±1.0 在「符号」这个维度上是一致的。它们都表达同一个方向,只是强度不同。而 locate_pivots 关心的恰恰只有方向,不关心强度。
于是把「带强度的浮点」投影到「只看方向的整数」上,截断的暗礁就被绕过了。这一刀精准、最小、不引入任何新假设。
第二刀:借势审视口径
Bug 修了。但这次改动让我重新看了一眼三买三卖的「中枢选择」逻辑。
经典缠论里,「三买」有一个严谨的定义:趋势中第一个中枢的突破。它强调的是「第一个」——因为第一个中枢代表趋势确立时的初始震荡区,突破它意味着趋势进入加速段。
而原代码的做法是:无论什么情况,都取最后一个中枢。
这不是错,但不够纯粹。当一个向上趋势里积累了多个中枢时,取最后一个中枢来判断「三买」,已经偏离了经典定义——你判断的可能是第三、第四个中枢的突破。
于是有了第二刀:让口径随趋势方向分化。
| 严格:中枢必须恰为 1 个 | ||
| 严格:中枢必须恰为 1 个 | ||
为什么是不对称的?因为「严格三买」只在顺势时才有意义——向上趋势里的第一个中枢突破,才叫三买。逆势的买点本就是另一类信号(左侧抄底),不该套用三买的严格定义。卖点镜像同理。
原则:口径要随语义分化,而不是一刀切。同一个动作(取中枢)在不同语义下(顺势 vs 逆势)应有不同的严格度。
第三章:核心实现
分化后的买点验证逻辑长这样:
// 向上趋势严格三买:要求 trend 内中枢数量恰为 1if (trend_dir == 1 && pivots_num != 1) { inner_result[signal_pos] = 0;return;}// 通过 guard 后,取中枢、比收盘价constPivot last_pivot = pivots[pivots_num - 1];if (close[signal_pos] > last_pivot.zg) { inner_result[signal_pos] = reencode(original_signal); // 保留信号} else { inner_result[signal_pos] = 0; // 取消信号}这里有一个细节值得停留:取中枢用的是 pivots[pivots_num - 1],也就是最后一个。既然严格模式下只有一个中枢,那「第一个」和「最后一个」其实是同一个。
这个观察,引出了第三刀。
第四章:简化的艺术
最初的实现里,取中枢这行是这样写的:
// before —— 看似合理的三元constPivot pivot = (trend_dir == 1) ? pivots[0] // 向上取第一个 : pivots[pivots_num - 1]; // 其他取最后一个看起来很合理——它在「表达」严格三买取第一个中枢的意图,卖点维持原口径取最后一个。
但它其实是多余的。
因为上面的 guard 已经保证了:trend_dir == 1 时,pivots_num 必为 1。既然只有一个中枢,pivots[0] 和 pivots[pivots_num - 1] 是同一个东西。
三元表达式的两个分支,在唯一能到达它们的路径上,返回的是同一个值。这是个 dead branch——它存在,但从不真正分流。
// after —— guard 已锁定 pivots_num==1,首末中枢等价constPivot last_pivot = pivots[pivots_num - 1];删掉它,统一取最后一个。意图由 guard 承担(要求唯一中枢),取值由统一形式承担(取最后)。代码更短,反而更准——因为它不再假装自己在一个不存在的分支上做选择。
原则:能消失的分支永远比能写对的分支更优雅。当一个分支的两个出口指向同一个值,这个分支就不该存在。
总结:两次改动,一个根源
这次改动覆盖了两件事:一个静默的截断 Bug,一次顺势的口径分化。
它们看似无关,其实同源——都是「类型边界上的隐式假设」暴露出来的设计债务。
浮点方向 ±0.5 假设了接收方能理解半强度;int 形参假设了调用方只传 ±1。两个假设在接口边界上没有对齐,就生成了空返回。
三买取最后中枢假设了「最后一个就是第一个」;当趋势里真的出现多个中枢时,这个假设也失效了。
修 Bug 不是终点。每除掉一个隐式假设,代码就更结实一分。
核心原则:Bug 是设计债务的信号灯。修 Bug 时顺手审视口径,往往能挖出比 Bug 本身更深的债——而那个 dead branch 的消除,是这次审查最意外的收获。
附录:技术速查表
如果你想深入了解实现细节,这里是要点速查:
locate_pivots 的 direction 形参:int 类型,内部只识别 +1(向上段)/ -1(向下段),无 else 兜底。传入 0 时静默返回空 pivots。
trend_sigs 浮点编码:
±1.0:当前有效的趋势终点( recognise_trend写入)±0.5: former_ends历史终点降级标记,趋势已被覆盖
C++ float→int 隐式转换:向零截断(truncation toward zero),非四舍五入。±0.5 → 0,±1.5 → ±1。窄化转换是静默的,编译器默认不警告。
修复模式:跨类型边界传值前,先把值显式规范化到目标类型的有效值域。这里把「带强度的浮点方向」投影为「只看符号的整数方向」,用 (x > 0) ? 1 : -1 一行完成。
三买三卖分化表:顺势(买↑/卖↓)要求中枢恰为 1 个(严格三买/三卖);逆势维持取最后一个中枢的原口径。
量化实战手记
本系列记录作者用代码理解市场的真实历程——每个想法如何变成设计,每个设计如何变成可运行的系统。不谈理论,只聊实战。
夜雨聆风