From 8a54a4a5b66f430da95da22664dd4e63d38a5790 Mon Sep 17 00:00:00 2001 From: Elmehdi Aitbrahim Date: Tue, 1 Sep 2026 19:19:08 -0400 Subject: [PATCH] feat(rules): CUSUM event gating, with the threshold priced in round trips (#341) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01NzuKAe2RVrPt9acVAWjRyL --- keel/agent.py | 7 + keel/strategy/rules/cusum_event.py | 291 +++++++++++++++++++++++++++ tests/research/test_tuning.py | 13 +- tests/strategy/rule_conformance.py | 2 +- tests/strategy/test_cusum_event.py | 250 +++++++++++++++++++++++ tests/strategy/test_rule_contract.py | 2 +- 6 files changed, 561 insertions(+), 4 deletions(-) create mode 100644 keel/strategy/rules/cusum_event.py create mode 100644 tests/strategy/test_cusum_event.py diff --git a/keel/agent.py b/keel/agent.py index 875ed8eb..7d9235f4 100644 --- a/keel/agent.py +++ b/keel/agent.py @@ -90,6 +90,7 @@ from keel.strategy.exit_policy import EXIT_POLICY_OFF, next_stop, policy_for, trailing_atr from keel.strategy.paper import PaperTrader from keel.strategy.rules.base import Action, Rule, Setup, Signal +from keel.strategy.rules.cusum_event import CusumEvent from keel.strategy.rules.dca import Dca from keel.strategy.rules.pullback_continuation import PullbackContinuation from keel.strategy.rules.rsi_meanrev import RsiMeanReversion @@ -129,6 +130,12 @@ "rsi_meanrev": RsiMeanReversion, "dca": Dca, "turtle_breakout": TurtleBreakout, + # #341. Entry GATING rather than a signal: it asks whether price has moved enough since + # the last event to be worth evaluating, and its threshold is stated as a multiple of round + # -trip friction so the knob cannot quietly mean "break-even" the way the source's 2.5% + # does here. Registered with the honest prior on the record: at 0 of 90 measured, it is + # expected to join the null, and it ships to BE measured rather than to be believed. + "cusum_event": CusumEvent, } # The per-kind coercion tables that used to live here -- `_DECIMAL_PARAMS`, `_GRANULARITY_PARAMS` diff --git a/keel/strategy/rules/cusum_event.py b/keel/strategy/rules/cusum_event.py new file mode 100644 index 00000000..ad660c9e --- /dev/null +++ b/keel/strategy/rules/cusum_event.py @@ -0,0 +1,291 @@ +"""CUSUM event-driven entry gating (#341). + +**The idea, and what makes it different from every other rule here.** The three shipped signal +rules evaluate a condition on EVERY bar and enter whenever it holds. 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, resetting that side when it does. Bars where nothing much happened do +not produce a decision, so the rule trades on EVENTS rather than on a clock. + +Source: Grądzki et al., *Financial Innovation*, 2025-12-15 +(https://jfin-swufe.springeropen.com/articles/10.1186/s40854-025-00866-w) -- BTC+ETH, +walk-forward, 2,700 runs disclosed; CUSUM sampling with wide barriers beat next-bar labeling, +with "excessive trading incurs a lot of costs" as the stated mechanism. **NOT independently +replicated**, and its fees were 0.1% per leg -- about twelve times lighter than what this +account pays. + +⚠️ **THE HONEST PRIOR, WHICH IS THAT THIS WILL JOIN THE NULL.** The shipped rule library has +been measured to exhaustion (`docs/experiments/2026-08-13-restated-under-a-production-faithful- +engine.md`): 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, and this rule exists to be MEASURED rather than to be believed. Its own +mechanism cuts trade count, and the ρ=−0.77 bind between edge and sample size in +`2026-08-12-fee-curve-and-rsi-meanrev.md` says the rules with enough trades to promote are the +ones without edge. A gate that trades less is walking straight into that. + +**THE THRESHOLD IS A MULTIPLE OF FRICTION, NOT A PERCENTAGE, and that is the whole design ask +of #341.** 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" event +names a move that pays for the trade and nothing more. Expressing the knob as +`threshold_friction_mult` makes that visible in the parameter itself: `1.0` reproduces the +paper's setting AND says out loud that it is break-even before the trade is even placed. The +default is `2` -- price must move twice what the round trip costs before an entry is considered. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal + +from keel.analysis.indicators import atr +from keel.strategy.backtest import SLIPPAGE_FLOOR_PCT, TAKER_FEE_PCT +from keel.strategy.rules.base import ParamSpec, Rule, Setup +from keel.types import Candle, Granularity + +#: What one round trip costs at this venue: two taker legs plus two slippage legs. Imported from +#: `strategy.backtest` rather than restated, so a rule whose threshold is DEFINED as a multiple +#: of friction cannot drift away from the number the backtest charges it. +ROUND_TRIP_FRICTION_PCT = 2 * TAKER_FEE_PCT + 2 * SLIPPAGE_FLOOR_PCT + + +@dataclass(frozen=True) +class CusumReading: + """What the filter says about the LAST bar of a window.""" + + #: Whether the upward sum crossed the threshold on the final bar -- the entry event. + fired_up: bool + #: Whether the downward sum crossed on the final bar -- the exit event. + fired_down: bool + #: The sums as they stand after the final bar, for the decline diagnostic. Both are + #: post-reset: a side that just fired reads zero, which is the honest number -- the anchor + #: has moved. + s_plus: Decimal + s_minus: Decimal + + +def cusum_read(closes: list[Decimal], threshold: Decimal) -> CusumReading: + """Run the symmetric CUSUM filter over `closes` and report the final bar. + + `S+ = max(0, S+ + r)`, `S- = min(0, S- + r)` over simple returns -- **and the crossing side + RESETS TO ZERO when it fires**, which is the part that makes this an event filter rather + than a trend detector. + + That reset is not a detail. Without it `S+` climbs monotonically through a trending window + and stays above the threshold for every subsequent bar, so a rule reading "is `S+` above + the threshold" would fire on EVERY bar of the move -- exactly the every-bar evaluation this + rule exists to replace, wearing a threshold. What the filter actually says is "an event + happened HERE", once, after which the anchor moves to the current price and the next move + is measured from there. + + Both sides are tracked even though keel can only act on the upward one: a one-sided filter + accumulates an unbounded downward sum that never resets and then mis-times the next upward + event. + + Pure and stateless. `detect()` is required to be pure, so the filter cannot live on the + instance and is replayed over a bounded window every call -- see `CusumEvent.detect`. + """ + s_plus = Decimal("0") + s_minus = Decimal("0") + fired_up = False + fired_down = False + for index, (previous, current) in enumerate(zip(closes, closes[1:])): + last = index == len(closes) - 2 + if previous <= 0: + continue + change = (current - previous) / previous + s_plus = max(Decimal("0"), s_plus + change) + s_minus = min(Decimal("0"), s_minus + change) + fired_up = s_plus >= threshold + fired_down = s_minus <= -threshold + if fired_up: + s_plus = Decimal("0") + if fired_down: + s_minus = Decimal("0") + if not last: + # Only the FINAL bar's crossing is an event this call may act on. An earlier + # crossing has already moved the anchor, which the reset above records; carrying + # its flag forward would report a stale event on every later bar. + fired_up = False + fired_down = False + return CusumReading( + fired_up=fired_up, fired_down=fired_down, s_plus=s_plus, s_minus=s_minus + ) + + +class CusumEvent(Rule): + """Enter long when the upward CUSUM sum crosses a friction-scaled threshold. + + `promotion_class` stays `"default"`: this is an entry FILTER over ordinary momentum, not a + trend-follower, and claiming the low-win/high-R:R floor would hand it a gentler admission + bar than its own mechanism earns. + """ + + decimal_params = ("threshold_friction_mult", "atr_stop_mult", "target_rr") + granularity_param = "granularity" + + PARAM_DOCS = { + "granularity": "Bar size the filter accumulates over.", + "lookback": ( + "Bars the filter is replayed across each call. Bounds the work and, because the " + "filter restarts at zero, DEFINES the state -- it is not a performance knob." + ), + "threshold_friction_mult": ( + "Event threshold as a multiple of one round trip (2 taker + 2 slippage legs). 1.0 " + "is the source's own setting and is exactly break-even before the trade is placed." + ), + "atr_period": "ATR length the stop is sized from.", + "atr_stop_mult": "Stop distance in ATRs below the entry.", + "target_rr": "Nominal take-profit as a multiple of the stop distance.", + } + + def __init__( + self, + product_id: str, + granularity: Granularity = Granularity.ONE_HOUR, + lookback: int = 168, + threshold_friction_mult: Decimal = Decimal("2"), + atr_period: int = 20, + atr_stop_mult: Decimal = Decimal("2"), + target_rr: Decimal = Decimal("3"), + name: str = "cusum_event", + ) -> None: + if lookback <= 1: + raise ValueError("lookback must be greater than 1 -- a filter needs a return to sum") + if threshold_friction_mult <= 0: + raise ValueError("threshold_friction_mult must be positive") + if atr_period <= 0: + raise ValueError("atr_period must be positive") + if atr_stop_mult <= 0: + raise ValueError("atr_stop_mult must be positive") + if target_rr <= 0: + raise ValueError("target_rr must be positive") + + self.name = name + self.product_id = product_id + self.granularity = granularity + self.params: dict = { + "granularity": granularity.value, + "lookback": lookback, + "threshold_friction_mult": threshold_friction_mult, + "atr_period": atr_period, + "atr_stop_mult": atr_stop_mult, + "target_rr": target_rr, + } + + @property + def threshold_pct(self) -> Decimal: + """The event threshold as a fraction of price -- the friction multiple, made concrete. + + A property rather than a stored param so the two can never disagree: the stored knob is + the MULTIPLE, and the percentage it implies is derived from the same constants the + backtest charges. A rule that persisted the percentage would keep answering 2.5% after + a fee change that made 2.5% mean something else. + """ + return self.params["threshold_friction_mult"] * ROUND_TRIP_FRICTION_PCT + + def param_space(self) -> tuple[ParamSpec, ...]: + return ( + ParamSpec("threshold_friction_mult", "decimal", 1.0, 4.0, Decimal("0.5")), + ParamSpec("lookback", "int", 48, 336, Decimal(48)), + ParamSpec("atr_stop_mult", "decimal", 1.5, 3.0, Decimal("0.5")), + ParamSpec("target_rr", "decimal", 2.0, 6.0, Decimal("1")), + ) + + def _decline(self, gate: str, **numbers: object) -> Setup | None: + """Record WHY this bar declined, and return `None` for `detect()`. Never logs -- see + `TurtleBreakout._decline` for the reasoning (this runs once per bar in a sim).""" + self.last_rejection = {"gate": gate, **numbers} + return None + + def _series(self, candles_by_tf: dict[Granularity, list[Candle]]) -> list[Candle]: + """The declared granularity's series, or empty. An absent key declines as insufficient + history rather than falling back -- a rule configured for hourly must never quietly + decide on daily bars.""" + return candles_by_tf.get(self.granularity, []) + + def detect(self, candles_by_tf: dict[Granularity, list[Candle]]) -> Setup | None: + """An upward CUSUM event, then an ATR stop and a nominal target. + + Pure: the filter is replayed from zero over the last `lookback` bars every call, so the + same candles always produce the same answer whether this is the live cycle, the edge + backtest or the account sim. + """ + series = self._series(candles_by_tf) + lookback = self.params["lookback"] + atr_period = self.params["atr_period"] + + needed = max(lookback, atr_period * 4) + 1 + if len(series) < needed: + return self._decline("insufficient_history", bars=len(series), bars_needed=needed) + + threshold = self.threshold_pct + reading = cusum_read([c.close for c in series[-lookback:]], threshold) + # Carried on every decline from here down: how far the sum sat from firing is the whole + # diagnostic value of an event filter, and it is invisible from `signals=0` alone. + event = { + "s_plus": float(reading.s_plus), + "s_minus": float(reading.s_minus), + "threshold_pct": float(threshold), + "friction_mult": float(self.params["threshold_friction_mult"]), + } + if not reading.fired_up: + return self._decline("cusum_threshold", **event) + + work = series[-(atr_period * 4) :] + atr_now = Decimal(str(atr(work, atr_period)[-1])) + if atr_now <= 0: + return self._decline("atr", atr=float(atr_now), **event) + + current = series[-1] + entry = current.close + stop = entry - self.params["atr_stop_mult"] * atr_now + if stop >= entry: + return self._decline("stop_not_below_entry", stop=float(stop), **event) + + risk = entry - stop + target = entry + self.params["target_rr"] * risk + + self.last_rejection = None # this bar FIRED -- a stale reason would misreport it + return Setup( + product_id=self.product_id, + direction="long", + entry=entry, + stop=stop, + target=target, + context={ + "rule_class": "event_gated", + "s_plus_before_reset": float(threshold), + "threshold_pct": float(threshold), + "friction_mult": float(self.params["threshold_friction_mult"]), + "atr": float(atr_now), + "atr_stop_mult": self.params["atr_stop_mult"], + "lookback": lookback, + }, + ts=current.ts, + ) + + def exit_signal(self, held: Setup, candles_by_tf: dict[Granularity, list[Candle]]) -> bool: + """The same filter, the other side: a downward event of equal size closes the position. + + Symmetric on purpose. The entry's claim is that a move of this size is the smallest one + worth paying for; the identical claim in reverse is the smallest one worth exiting on, + 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, deliberately not + smuggled in here -- this rule must be measurable on its own before it is combined. + + `held` is unused: the stop and the nominal target are the backtester's and the account + sim's to enforce, exactly as for `TurtleBreakout`. + """ + del held + series = self._series(candles_by_tf) + lookback = self.params["lookback"] + if len(series) < lookback + 1: + return False + return cusum_read([c.close for c in series[-lookback:]], self.threshold_pct).fired_down + + def describe(self) -> dict: + return { + "name": self.name, + "params": self.params, + "param_space": [spec.plain() for spec in self.param_space()], + } diff --git a/tests/research/test_tuning.py b/tests/research/test_tuning.py index 80b1c2e0..dd382813 100644 --- a/tests/research/test_tuning.py +++ b/tests/research/test_tuning.py @@ -140,12 +140,21 @@ def _consistent_columns(values: list[float], t: int = 32) -> list[list[Decimal]] # -- 1-3. SEARCH_SPACES integrity -------------------------------------------------------------- -def test_search_spaces_pin_exactly_the_three_families() -> None: - """The three tradable families with a stop (dca is out of scope -- it has none).""" +def test_search_spaces_pin_exactly_the_families_with_a_stop() -> None: + """Every tradable family with a stop. `dca` is out of scope because it has none. + + `cusum_event` joined at #341 and did so WITHOUT anyone editing this module: since #528 + `SEARCH_SPACES` reads the rules' own `param_space()` declarations, so declaring a space is + what enrols a rule here. That is the one-source-of-truth design working -- and it is also + why this pin is worth keeping: 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. + """ assert set(tuning.SEARCH_SPACES) == { "turtle_breakout", "rsi_meanrev", "pullback_continuation", + "cusum_event", } for family, space in tuning.SEARCH_SPACES.items(): assert 4 <= len(space) <= 7, family diff --git a/tests/strategy/rule_conformance.py b/tests/strategy/rule_conformance.py index 58f229bb..bd5929a1 100644 --- a/tests/strategy/rule_conformance.py +++ b/tests/strategy/rule_conformance.py @@ -14,7 +14,7 @@ def firing_candles(self) -> dict[Granularity, list[Candle]]: `packages/keel-broker-api/keel_broker_api/conformance/suite.py` ships the broker contract from an installable package because third-party broker adapters exist and need something to prove themselves against. Issue #447 decided the rule registry stays CURATED: `keel.agent. -RULE_REGISTRY` is a closed, hand-maintained dict of exactly the four rules this repository +RULE_REGISTRY` is a closed, hand-maintained dict of exactly the rules this repository ships, there is no `keel.rules` entry point, and there will not be one -- a rule is not a pluggable adapter, it is an investment decision this team made and is accountable for. A suite that shipped from `keel/` would imply the opposite: that some future third party is diff --git a/tests/strategy/test_cusum_event.py b/tests/strategy/test_cusum_event.py new file mode 100644 index 00000000..ab0b326a --- /dev/null +++ b/tests/strategy/test_cusum_event.py @@ -0,0 +1,250 @@ +"""#341 -- CUSUM event-driven entry gating. + +The rule trades on EVENTS rather than on a clock: it enters only when price has cumulatively +moved past a threshold since the last event, and the threshold is stated as a multiple of one +round trip rather than as a percentage. + +Two properties carry the design and everything here exists to hold them: + +* **The filter resets when it fires.** Without that it is a trend detector with extra steps — + `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 this rule exists + to replace. +* **The threshold is friction-scaled.** The source's 2.0–2.5% is not a conservative setting on + this venue, it is almost exactly one round trip (2 × 1.2% taker + 2 × 0.05% slippage). A knob + spelled as a percentage hides that; a knob spelled as a multiple cannot. +""" + +from __future__ import annotations + +from decimal import Decimal + +import pytest + +from keel.strategy.rules.base import Setup +from keel.strategy.rules.cusum_event import ( + ROUND_TRIP_FRICTION_PCT, + CusumEvent, + cusum_read, +) +from keel.types import Candle, Granularity +from tests.strategy.rule_conformance import RuleConformanceTests + +_HOUR = 3600 + + +def _candles(closes: list[float], *, start_ts: int = 1_700_000_000) -> list[Candle]: + """A series whose closes are exactly `closes`, with a small symmetric range each bar so + ATR is positive (a zero ATR declines before the filter is ever consulted).""" + out: list[Candle] = [] + for index, close in enumerate(closes): + price = Decimal(str(close)) + out.append( + Candle( + ts=start_ts + index * _HOUR, + open=price, + high=price * Decimal("1.004"), + low=price * Decimal("0.996"), + close=price, + volume=Decimal("100"), + ) + ) + return out + + +def _flat_then(rise_pct: float, *, bars: int, flat: int = 200) -> list[Candle]: + """`flat` bars at 100, then `bars` bars moving by `rise_pct` each. + + ⚠️ **The move must CROSS ON THE FINAL BAR, and building these fixtures is what makes that + concrete.** A rally that crossed earlier in the window has already fired and re-anchored, + so the last bar reads a small `S+` and correctly declines. A fixture with "comfortably + more" rise than the threshold therefore does NOT fire — which is how the first draft of + this file failed, and is exactly the reset property under test. + """ + closes = [100.0] * flat + price = 100.0 + for _ in range(bars): + price *= 1 + rise_pct / 100 + closes.append(price) + return _candles(closes) + + +def _firing() -> dict[Granularity, list[Candle]]: + """Crosses the default 5% threshold (2 × the 2.5% round trip) on the last bar: four bars + of 1.2% sum to 4.8% and decline, the fifth reaches 6.0% and fires.""" + return {Granularity.ONE_HOUR: _flat_then(1.2, bars=5)} + + +# -- the shared contract ------------------------------------------------------------------------ + + +class TestCusumEventConformance(RuleConformanceTests): + def rule(self) -> CusumEvent: + return CusumEvent(product_id="BTC-USD") + + def firing_candles(self) -> dict[Granularity, list[Candle]]: + return _firing() + + +# -- the filter itself -------------------------------------------------------------------------- + + +def test_the_threshold_is_a_multiple_of_one_round_trip() -> None: + """The design ask of #341, asserted against the constants the backtest actually charges.""" + assert ROUND_TRIP_FRICTION_PCT == Decimal("0.025") + rule = CusumEvent(product_id="BTC-USD", threshold_friction_mult=Decimal("2")) + assert rule.threshold_pct == Decimal("0.05") + + +def test_the_sources_own_setting_is_exactly_break_even_here() -> None: + """The finding that motivated expressing the knob this way. + + A 2.5% event threshold sounds conservative and is not: it is one round trip on this venue, + so the paper's setting names a move that pays for the trade and leaves nothing. Spelled as + a multiple, that is impossible to miss — `1.0` says it. + """ + paper = CusumEvent(product_id="BTC-USD", threshold_friction_mult=Decimal("1")) + assert paper.threshold_pct == ROUND_TRIP_FRICTION_PCT + + +def test_the_filter_fires_on_the_bar_that_crosses() -> None: + reading = cusum_read([Decimal("100"), Decimal("103"), Decimal("106")], Decimal("0.05")) + assert reading.fired_up + + +def test_the_filter_resets_and_does_not_fire_again_on_the_next_bar() -> None: + """**The load-bearing property.** Without the reset this is a trend detector. + + `S+` would climb monotonically through a rally and stay over the threshold for every later + bar, so the rule would fire on every bar of the move — the every-bar evaluation it exists + to replace, wearing a threshold. + """ + crossed = [Decimal("100"), Decimal("103"), Decimal("106")] + assert cusum_read(crossed, Decimal("0.05")).fired_up + + one_more_small_step = [*crossed, Decimal("106.1")] + after = cusum_read(one_more_small_step, Decimal("0.05")) + assert not after.fired_up, "the filter fired twice for one move — the reset is missing" + assert after.s_plus < Decimal("0.05") + + +def test_a_flat_series_never_fires() -> None: + assert not cusum_read([Decimal("100")] * 50, Decimal("0.05")).fired_up + + +def test_the_downward_side_fires_independently() -> None: + reading = cusum_read([Decimal("100"), Decimal("97"), Decimal("94")], Decimal("0.05")) + assert reading.fired_down + assert not reading.fired_up + + +def test_a_non_positive_close_is_skipped_rather_than_dividing_by_zero() -> None: + """A zero or negative close is not a price; it must not take the filter out with it.""" + reading = cusum_read( + [Decimal("0"), Decimal("100"), Decimal("106")], Decimal("0.05") + ) + assert reading.fired_up + + +# -- the rule ----------------------------------------------------------------------------------- + + +def test_a_rally_past_the_threshold_produces_a_long_setup() -> None: + rule = CusumEvent(product_id="BTC-USD") + + setup = rule.detect(_firing()) + + assert isinstance(setup, Setup) + assert setup.direction == "long" + assert setup.stop < setup.entry < setup.target + assert setup.context["friction_mult"] == 2.0 + + +def test_a_move_smaller_than_the_threshold_declines_and_says_how_far_off( ) -> None: + """`signals=0` alone cannot distinguish "nothing happened" from "almost fired".""" + rule = CusumEvent(product_id="BTC-USD") + + assert rule.detect({Granularity.ONE_HOUR: _flat_then(0.05, bars=4)}) is None + assert rule.last_rejection is not None + assert rule.last_rejection["gate"] == "cusum_threshold" + assert rule.last_rejection["threshold_pct"] == 0.05 + assert 0 < rule.last_rejection["s_plus"] < 0.05 + + +def test_raising_the_multiple_refuses_a_move_the_lower_one_took() -> None: + """The knob does what it says, in the direction that matters: a higher multiple demands a + bigger move before paying the same round trip.""" + # 0.6% x 5 bars = 3.0%: past one round trip (2.5%) and nowhere near four (10%), crossing + # on the final bar so the lower threshold genuinely fires rather than having fired earlier. + candles = {Granularity.ONE_HOUR: _flat_then(0.6, bars=5)} + + assert ( + CusumEvent(product_id="BTC-USD", threshold_friction_mult=Decimal("1")).detect(candles) + is not None + ) + assert ( + CusumEvent(product_id="BTC-USD", threshold_friction_mult=Decimal("4")).detect(candles) + is None + ) + + +def test_insufficient_history_declines_with_the_count() -> None: + rule = CusumEvent(product_id="BTC-USD") + + assert rule.detect({Granularity.ONE_HOUR: _candles([100.0] * 20)}) is None + assert rule.last_rejection is not None + assert rule.last_rejection["gate"] == "insufficient_history" + + +def test_a_rule_configured_for_hourly_never_decides_on_daily_bars() -> None: + """An absent key declines as insufficient history rather than falling back — a quiet + fallback would re-gate the rule on a clock nobody configured.""" + rule = CusumEvent(product_id="BTC-USD", granularity=Granularity.ONE_HOUR) + + assert rule.detect({Granularity.ONE_DAY: _flat_then(1.2, bars=5)}) is None + assert rule.last_rejection is not None + assert rule.last_rejection["gate"] == "insufficient_history" + + +def test_the_exit_is_the_same_filter_on_the_other_side() -> None: + """Symmetric on purpose: the entry's claim is that a move of this size is the smallest one + worth paying for, and the identical claim in reverse is the smallest worth exiting on. A + different exit threshold would be a second free parameter with no evidence behind it.""" + rule = CusumEvent(product_id="BTC-USD") + held = Setup( + product_id="BTC-USD", + direction="long", + entry=Decimal("100"), + stop=Decimal("95"), + target=Decimal("115"), + context={}, + ts=0, + ) + + falling = {Granularity.ONE_HOUR: _flat_then(-1.2, bars=5)} + assert rule.exit_signal(held, falling) is True + assert rule.exit_signal(held, _firing()) is False + + +@pytest.mark.parametrize( + ("kwargs", "message"), + [ + ({"lookback": 1}, "lookback"), + ({"threshold_friction_mult": Decimal("0")}, "threshold_friction_mult"), + ({"atr_period": 0}, "atr_period"), + ({"atr_stop_mult": Decimal("0")}, "atr_stop_mult"), + ({"target_rr": Decimal("0")}, "target_rr"), + ], +) +def test_a_nonsensical_parameter_is_refused_at_construction(kwargs, message) -> None: + with pytest.raises(ValueError, match=message): + CusumEvent(product_id="BTC-USD", **kwargs) + + +def test_the_threshold_percentage_is_derived_and_never_persisted() -> None: + """A rule that stored the percentage would keep answering 2.5% after a fee change that made + 2.5% mean something else. The stored knob is the MULTIPLE.""" + rule = CusumEvent(product_id="BTC-USD") + + assert "threshold_pct" not in rule.describe()["params"] + assert "threshold_friction_mult" in rule.describe()["params"] diff --git a/tests/strategy/test_rule_contract.py b/tests/strategy/test_rule_contract.py index 5f80fc83..cb5765b7 100644 --- a/tests/strategy/test_rule_contract.py +++ b/tests/strategy/test_rule_contract.py @@ -315,7 +315,7 @@ def test_a_stored_rule_row_rebuilds_with_real_decimals_on_its_attributes(kind: s principle be satisfied by a rule that stored strings and stringified them back on the way out. This reads the ATTRIBUTES the rule's arithmetic actually uses. - `params` is checked alongside because three of the four rules build that dict in their + `params` is checked alongside because most rules build that dict in their constructor from the coerced kwargs, and it is what `exit_policy` reads for `trail_atr_mult` (`keel/strategy/exit_policy.py`'s own docstring relies on those arriving coerced). """