乐于分享
好东西不私藏

FqCopilot缠论插件(被静默吞没的信号)

FqCopilot缠论插件(被静默吞没的信号)

一个浮点截断 Bug 的诊断与进化

「量化实战手记」— 记录从想法到落地的真实开发历程

摘要:一个缠论背驰信号在验证环节被悄悄吞没——浮点方向的 ±0.5 传入 int 形参时截断为 0,中枢查找返回空,信号静默失效。三行按符号规范化的修复,顺势牵出三买三卖口径的重构:向上趋势严格只认唯一中枢。最危险的 Bug 不崩溃,只是悄悄返回空。

引言:一个不报错的 Bug

量化系统里最可怕的 Bug 不是崩溃,而是它「看起来一切正常」。

S0014 是一个缠论信号验证器。它的职责很明确:拿到上游 S0008 产生的背驰信号后,检查收盘价是否真的突破了一个线段中枢——符合就保留,不符合就剔除。

某天发现,一些本该被保留的信号凭空消失了。没有报错,没有异常,没有日志,信号就是不见了。

这种 Bug 最难抓——因为它什么都不说。

第一章:信号去哪了

要理解信号为何消失,先要看懂它经过的链路。

S0008 背驰信号divergence detectedS0014 验证器找中枢 → 比收盘价locate_pivots(direction)±0.5 → int 0 → 无 else → 空中枢列表 = 空pivots.size() == 0信号被静默取消,无任何报错

图 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 → 0

trend_direction 是 float。当它是 ±1.0 时,截断成 int 还是 ±1,一切正常。但当它是 ±0.5 时——

float 输入int 截断后结果+1.0+1✓ 走向上段-1.0-1✓ 走向下段+0.50✗ 两个分支都不进-0.50✗ 返回空 pivots

图 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 个(严格三买/三卖);逆势维持取最后一个中枢的原口径。

量化实战手记

本系列记录作者用代码理解市场的真实历程——每个想法如何变成设计,每个设计如何变成可运行的系统。不谈理论,只聊实战。

加入量化探索之旅