Skip to content

feat(rules): CUSUM event gating, with the threshold priced in round trips (#341) - #683

Merged
eaitbrahim merged 1 commit into
mainfrom
feat-341-cusum
Sep 1, 2026
Merged

feat(rules): CUSUM event gating, with the threshold priced in round trips (#341)#683
eaitbrahim merged 1 commit into
mainfrom
feat-341-cusum

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

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.

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_mult puts that in the knob itself: 1.0 reproduces the paper and says out loud that it is break-even before the order is placed. Default is 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. 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.

⚠️ The honest prior, in the module and in the registry

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_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. 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.

…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
@eaitbrahim eaitbrahim self-assigned this Sep 1, 2026
@eaitbrahim
eaitbrahim merged commit 1f87adf into main Sep 1, 2026
4 checks passed
@eaitbrahim
eaitbrahim deleted the feat-341-cusum branch September 1, 2026 23:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant