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). """