feat(rules): CUSUM event gating, with the threshold priced in round trips (#341) - #683
Merged
Conversation
…rips (#341) Research brief B-1, prerequisite-gated on #339's honest-cost re-measurement, which closed 2026-08-17. A fifth `RULE_REGISTRY` kind -- a deterministic trading-core change, filed as an issue first, exactly as the registry's own comment requires. WHAT IT DOES DIFFERENTLY. The three shipped signal rules evaluate a condition on every bar. This one asks a prior question: has price moved far enough since the last event to be worth evaluating at all? A symmetric CUSUM filter accumulates returns from a rolling anchor and fires only when the running sum crosses a threshold. Bars where nothing happened produce no decision. THE THRESHOLD IS A MULTIPLE OF FRICTION, NOT A PERCENTAGE, and that is the whole design ask of the issue. The source's 2.0-2.5% threshold is not conservative here: it is almost exactly one round trip on this venue (2 x 1.2% taker + 2 x 0.05% slippage = 2.5%), so "a 2.5% move" names a move that pays for the trade and leaves nothing. `threshold_friction_mult` makes that visible in the knob itself -- `1.0` reproduces the paper AND says out loud that it is break-even before the order is placed. Default 2. `ROUND_TRIP_FRICTION_PCT` is imported from `strategy.backtest` rather than restated, so a rule DEFINED as a multiple of friction cannot drift from the number the backtest charges it, and the percentage is a derived property rather than a persisted param -- a stored 2.5% would keep answering 2.5% after a fee change that made 2.5% mean something else. THE RESET IS THE LOAD-BEARING PART, and the first draft did not have it. Without resetting the crossing side, `S+` climbs monotonically through a rally and stays above the threshold for every later bar -- so the rule fires on EVERY bar of the move, which is the every-bar evaluation it exists to replace, wearing a threshold. It was caught by the test fixture: a rally with "comfortably more" rise than the threshold does NOT fire, because the crossing already happened earlier in the window and the anchor moved. The fixtures are built to cross on the final bar, and that constraint is the reset property made concrete. Both sides are tracked though keel can act only on the upward one: a one-sided filter accumulates an unbounded downward sum that never resets and then mis-times the next upward event. The exit is the same filter on the other side -- symmetric, because the entry's claim is that a move of this size is the smallest worth paying for, and a different exit threshold would be a second free parameter with no evidence behind it. The triple-barrier exits the source pairs CUSUM with are #342's and are deliberately not smuggled in: this rule must be measurable alone before it is combined.⚠️ THE HONEST PRIOR IS ON THE RECORD, IN THE MODULE AND IN THE REGISTRY. Zero of ninety asset-rule-parameter combinations are simultaneously measurable, gross-positive and net-positive at any fee this venue offers. Nothing here is expected to change that. Worse, this rule's own mechanism cuts trade count, and the rho=-0.77 bind between edge and sample size says the rules with enough trades to promote are the ones without edge -- a gate that trades less walks into it. It ships to BE MEASURED, not to be believed, and nothing is promoted by this. `SEARCH_SPACES` gained a fourth family without anyone editing `research/tuning.py` -- since #528 it reads the rules' own `param_space()`, so declaring a space is what enrols a rule. The pin is updated with that reason written down, because enrolment is now a side effect of a declaration in another file. 9 mutants, 9 killed, including the reset, the stale-event carry-forward, the friction scaling, and the granularity fallback. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzuKAe2RVrPt9acVAWjRyL
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Research brief B-1, prerequisite-gated on #339's honest-cost re-measurement — which closed 2026-08-17. A fifth
RULE_REGISTRYkind: a deterministic trading-core change, filed as an issue first, exactly as the registry's own comment requires.What it does differently
The three shipped signal rules evaluate a condition on every bar. This one asks a prior question: has price moved far enough since the last event to be worth evaluating at all? A symmetric CUSUM filter accumulates returns from a rolling anchor and fires only when the running sum crosses a threshold.
The threshold is a multiple of friction, not a percentage
This is the whole design ask of the issue. The source's 2.0–2.5% threshold is not conservative here — it is almost exactly one round trip on this venue (2 × 1.2% taker + 2 × 0.05% slippage = 2.5%). So "a 2.5% move" names a move that pays for the trade and leaves nothing.
threshold_friction_multputs that in the knob itself:1.0reproduces the paper and says out loud that it is break-even before the order is placed. Default is2.ROUND_TRIP_FRICTION_PCTis imported fromstrategy.backtestrather than restated, so a rule defined as a multiple of friction cannot drift from the number the backtest charges it. The percentage is a derived property, never persisted — a stored 2.5% would keep answering 2.5% after a fee change that made 2.5% mean something else.The reset is the load-bearing part, and my first draft didn't have it
Without resetting the crossing side,
S+climbs monotonically through a rally and stays above the threshold for every later bar — so the rule fires on every bar of the move. That is the every-bar evaluation it exists to replace, wearing a threshold.It was caught by the test fixture: a rally with "comfortably more" rise than the threshold does not fire, because the crossing already happened earlier in the window and the anchor moved. The fixtures are built to cross on the final bar, and that constraint is the reset property made concrete.
Both sides are tracked though keel can act only on the upward one — a one-sided filter accumulates an unbounded downward sum that never resets and then mis-times the next upward event. The exit is the same filter on the other side, symmetric on purpose: a different exit threshold would be a second free parameter with no evidence behind it. #342's triple-barrier exits are deliberately not smuggled in — this rule must be measurable alone before it is combined.
Zero of ninety asset-rule-parameter combinations are simultaneously measurable (n≥100), gross-positive and net-positive at any fee this venue offers. Nothing here is expected to change that.
Worse: this rule's own mechanism cuts trade count, and the ρ=−0.77 bind between edge and sample size says the rules with enough trades to promote are the ones without edge. A gate that trades less is walking into it.
It ships to be measured, not to be believed. Nothing is promoted by this PR; no rule row is added; the live allowlist and config are untouched.
One thing that moved on its own
SEARCH_SPACESgained a fourth family without anyone editingresearch/tuning.py— since #528 it reads the rules' ownparam_space(), so declaring a space is what enrols a rule. I updated that pin with the reason written down, because enrolment is now a side effect of a declaration made in another file, and a rule that gained a space by accident would otherwise appear in a sweep budget silently.Verification
9 mutants, 9 killed — including the reset, the stale-event carry-forward, the friction scaling, the dropped downward side, and the granularity fallback.
Full suite 5210 passed / 3 skipped; ruff and mypy clean across 121 source files.