diff --git a/README.md b/README.md index 60ec52d0..967fd052 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,11 @@ applies the confirm/autonomy gate, then places and logs. There is deliberately n `pullback_continuation`, `rsi_meanrev`). A rule must walk `candidate → paper → live`, and promotion clears a two-part gate: performance floors *and* an overfitting check (PBO/CSCV). A rule that clears four floors on one in-sample parameter set is exactly what - the second gate exists to be suspicious of. + the second gate exists to be suspicious of. The sample-size floor keeps its 100-trade bar + but may be met two ways: the rule's own backtest, or — when its own sample is short — the + same parameters pooled across other products in paper, provided at least five products + each contribute ten trades (a diversity floor: crypto assets correlate, and a pool of + correlated samples overstates its power). - **The rails** (`keel/execution/guards.py`) — eighteen deterministic checks no order can skip and nothing can override, not even autonomy: the halal allowlist, per-order and per-day spend caps, exposure and concentration caps, correlation-aware sizing, a diff --git a/keel/commands/rules.py b/keel/commands/rules.py index e37c30cc..8d4c58ca 100644 --- a/keel/commands/rules.py +++ b/keel/commands/rules.py @@ -249,6 +249,15 @@ def rules_promote( parameter set is exactly what PBO exists to be suspicious of, so "nobody checked" is reported as a failing reason rather than quietly treated as fine (#247). + The sample-size axis has a SECOND, pooled reading (#338): when the same parameters + already run as `paper` rules on other products, their backtests are pooled with this + rule's own (pooled n >= 100 AND a diversity floor of at least 5 products contributing + >= 10 trades each). The pooled reading is printed BESIDE the per-rule one -- both + readings, always -- and a promotion that clears on one product alone is judged + exactly as before, untouched by the pool. `min_trades` itself is unchanged: 100 + stays 100; what changed is the unit of evaluation, with the operator's 2026-08-17 + agreement (see #338). + With `--force`, SKIPS the backtest/gate entirely and advances the rule one lifecycle step directly. This exists for a low-frequency trend-follower (or any rule) whose backtest can NEVER produce `min_trades` (default 100) trades -- without a bypass such a rule could never @@ -293,6 +302,38 @@ def rules_promote( f"rule {rule_id} ({row['kind']}): gate priced at {_describe_fee(fee_pct, fee_source)}" ) + # #338: the sample-size axis also has a POOLED reading -- the same parameters' + # evidence on other products. Siblings are `paper` rows with identical params + # (minus the product), one per product; each is backtested against its own + # product's candles at the same fee, and this rule's own reading joins the pool + # exactly once. A rule with no siblings is judged exactly as before, with no pooled + # lines in the output at all. + candidate_product = (row["params"] or {}).get("product_id") + pooled_samples: list[promotion_mod.ProductSample] | None = None + sibling_rows = ( + promotion_mod.paper_sibling_rows(repo, row["kind"], row["params"]) + if candidate_product + else [] + ) + if sibling_rows: + samples = [promotion_mod.ProductSample(str(candidate_product), stats)] + for sib in sibling_rows: + sib_product = (sib["params"] or {}).get("product_id") + sib_rule = agent._build_rule(sib) + if _resolve_granularity(sib_rule, granularity) is None: + click.echo( + f"warning: pooled sibling rule {sib['id']} ({sib_product}) has no " + "granularity to backtest against; excluded from the pool", + err=True, + ) + continue + sib_stats = _run_backtest(ctx, repo, sib_rule, granularity, fee_pct) + samples.append(promotion_mod.ProductSample(str(sib_product), sib_stats)) + # A pool of one (every sibling skipped) is no pool: judge the rule alone rather + # than printing a "diversity 1 < 5" failure the operator cannot act on. + if len(samples) > 1: + pooled_samples = samples + promo_cfg = promotion_mod.PromotionConfig( min_trades=config.promotion.min_trades, min_expectancy=config.promotion.min_expectancy, @@ -302,12 +343,39 @@ def rules_promote( pbo_result = _load_pbo(ctx, pbo_session, pbo_blocks) gate = promotion_mod.pbo_gate_from_config(config.research) - decision = promotion_mod.can_promote(stats, promo_cfg, pbo_result, gate) + decision = promotion_mod.can_promote(stats, promo_cfg, pbo_result, gate, pooled_samples) + + # BOTH readings, whenever a pool existed -- the operator approving the promotion + # is entitled to see which path carried it, and a pooled failure to see why. + if decision.pooled is not None: + reading = decision.pooled + census = ", ".join(f"{product}={n}" for product, n in reading.per_product) + click.echo( + f"rule {rule_id} ({row['kind']}): sample readings -- per-rule " + f"n_trades={stats.n_trades}, pooled n_trades={reading.n_pooled} across " + f"{len(reading.per_product)} products" + ) + click.echo( + f" pooled census (diversity floor {promotion_mod.MIN_POOLED_PRODUCTS} " + f"products x >= {promotion_mod.MIN_TRADES_PER_PRODUCT_POOLED} trades): " + f"{census} -- {reading.products_contributing} products contribute, " + f"min contribution {reading.min_contribution}" + ) + # Say in WORDS when the pooled path carried the promotion: a log auditor should + # not have to infer it from n_trades being below the floor on the line above. + if decision.promotable and stats.n_trades < promo_cfg.min_trades: + click.echo( + " promotion carried by the POOLED reading " + "(the rule's own sample is below min_trades)" + ) + click.echo(f"rule {rule_id} ({row['kind']}): overfitting check = {decision.overfitting}") for reason in decision.reasons: click.echo(f" - {reason}") - new_status = promotion_mod.transition(repo, row["kind"], stats, promo_cfg, pbo_result, gate) + new_status = promotion_mod.transition( + repo, row["kind"], stats, promo_cfg, pbo_result, gate, pooled_samples, rule_id + ) click.echo(f"rule {rule_id} ({row['kind']}): status -> {new_status}") diff --git a/keel/strategy/promotion.py b/keel/strategy/promotion.py index da63eabf..a7a37bfd 100644 --- a/keel/strategy/promotion.py +++ b/keel/strategy/promotion.py @@ -21,6 +21,18 @@ (from a backtest or from paper trading) can drive `can_promote`/`should_demote`/ `transition` identically. +**Cross-product pooling of the sample-size axis (#338).** `min_trades` used to be judged +per rule per product, which at this project's measured daily rates (1.19–3.20 +trades/asset-year) made the floor a 31–84-year wait. The operator-approved change +(2026-08-17, the agreement a gate change requires) is to the gate's UNIT OF EVALUATION, +not its floors: the sample-size axis may be cleared EITHER by the rule's own backtest +exactly as before OR by the same parameter set's pooled evidence across products in +paper — pooled n ≥ min_trades AND a diversity floor (see `MIN_POOLED_PRODUCTS`). +`min_trades = 100` itself is untouched: the win-rate axis was relaxed ALONE once, and +axes move only with their own justification. The G4/overfitting gate is NOT pooled: it +judges the parameter SELECTION (the trial matrix behind `--pbo-session`), which is +per-parameter-set evidence already, and this change does not alter its scope. + **Rules-table access:** `data/repository.py`'s `Repository` exposes typed `rules`-table methods (`insert_rule`/`get_rules`/`update_rule_status`, P3 Task 1); this module drives the lifecycle transitions purely through that surface. The `rules` table (see @@ -31,8 +43,10 @@ from __future__ import annotations +from collections.abc import Sequence from dataclasses import dataclass from decimal import Decimal +from typing import Any from keel_core.config import ResearchConfig @@ -169,6 +183,234 @@ def check_floors(stats: BacktestResult, cfg: PromotionConfig) -> tuple[bool, lis return (len(reasons) == 0, reasons) +# -- Cross-product pooling of the sample-size axis (#338) ----------------------- +# +# `min_trades` judged per rule per product made the floor unreachable in a human +# timespan at this project's measured daily rates (1.19-3.20 trades/asset-year: the +# go-live runbook's own table puts a single product at 31-84 years to 100 trades). +# The operator approved (2026-08-17) changing the gate's UNIT OF EVALUATION -- the same +# parameter set's evidence may be POOLED across products in paper -- not its floors: +# 100 stays 100. The recorded discipline above (the win-rate axis was relaxed ALONE; +# axes move only with their own justification) is respected: nothing here edits +# `PromotionConfig`. +# +# The diversity floor is the honest discount on pooling: crypto assets correlate, so a +# pool of correlated samples carries less information than its trade count claims -- +# pooled-but-correlated evidence overstates its statistical power. Requiring breadth +# (several products each with an independently meaningful sample) is how the pooled +# path pays for the larger n instead of just collecting it. +MIN_POOLED_PRODUCTS = 5 +MIN_TRADES_PER_PRODUCT_POOLED = 10 + + +@dataclass(frozen=True) +class ProductSample: + """One product's evidence for a shared parameter set: its id and its stats. + + `product_id` rides beside the `BacktestResult` (which is product-agnostic) because + the pooled path's diversity floor is a claim about DISTINCT PRODUCTS, not about + rows or results. + """ + + product_id: str + stats: BacktestResult + + +@dataclass(frozen=True) +class PooledReading: + """The pooled half of the gate's two readings, for reporting. + + `n_pooled` is the pool's total trades. `per_product` is the census (one entry per + distinct product, its total contribution). `products_contributing` counts only + products meeting `MIN_TRADES_PER_PRODUCT_POOLED` -- the diversity floor's question + is how many products have independently meaningful samples, not how many exist. + `min_contribution` is the smallest per-product total in the pool (the weakest link, + whatever its size). + """ + + n_pooled: int + per_product: tuple[tuple[str, int], ...] + products_contributing: int + min_contribution: int + + +def pool_stats(samples: Sequence[ProductSample]) -> tuple[BacktestResult, PooledReading]: + """Pool per-product stats into one aggregate plus the diversity census. + + The pooling arithmetic, stated field by field. Everything is recomputed from the + per-result aggregates `BacktestResult` carries; the underlying trade lists are NOT + merged (see the note on path-dependent fields below): + + - pooled ``n_trades`` = ``Σ n_i``. + - per-result wins = ``round(n_i * win_rate_i)``: `win_rate` is stored as the + float ``wins / n_trades``, whose round-trip error (~1e-16) + is far below 0.5, so this recovers the integer win count. + - pooled ``win_rate`` = ``Σ wins_i / Σ n_i``. + - pooled ``avg_win`` = ``Σ(wins_i * avg_win_i) / Σ wins_i`` -- the win-weighted + mean, i.e. exactly the avg_win of the union's winning + trades (0 when the union has no wins). + - pooled ``avg_loss`` = ``Σ(losses_i * avg_loss_i) / Σ losses_i`` with + ``losses_i = n_i - wins_i`` -- the same argument on the + loss side (0 when the union has no losses). ⚠️ One stated + inexactness: ``n_i - wins_i`` counts SCRATCH trades + (``outcome == "scratch"``, pnl exactly 0) as losses, while + ``stats.summarize`` excludes them from ``avg_loss``, so a + pool containing scratches overstates the loss side's weight. + A scratch requires pnl exactly zero net of taker fees, which + the corpus does not produce; stated rather than papered over. + - pooled ``expectancy``= ``Σ(n_i * expectancy_i) / Σ n_i`` -- the trade-weighted + mean; a size-weighted mean of means IS the overall mean, + so this is exactly the union's expectancy. + - pooled ``profit_factor`` = ``Σ(wins_i * avg_win_i) / |Σ(losses_i * avg_loss_i)|``, + with `stats.summarize`'s Infinity / 0 conventions. + - pooled ``avg_mfe``/``avg_mae`` = trade-weighted means, same as expectancy. + - ``trades``/``max_drawdown``/``max_losing_streak`` are NOT pooled: drawdown and + streak are path-dependent -- they depend on the ORDER of + trades across the union, which no per-result aggregate + carries -- so any value would be fabricated. They are set + to empty/0, and the promotion gate reads none of them; a + caller wanting real cross-product drawdown must pool + equity curves, not stats. + """ + n_total = sum(s.stats.n_trades for s in samples) + wins = [round(s.stats.n_trades * s.stats.win_rate) for s in samples] + losses = [s.stats.n_trades - w for s, w in zip(samples, wins)] + wins_total = sum(wins) + losses_total = sum(losses) + + gross_win = sum((w * s.stats.avg_win for w, s in zip(wins, samples)), Decimal(0)) + gross_loss = sum( + (n_losses * s.stats.avg_loss for n_losses, s in zip(losses, samples)), Decimal(0) + ) + + counts: dict[str, int] = {} + for sample in samples: + counts[sample.product_id] = counts.get(sample.product_id, 0) + sample.stats.n_trades + + pooled_stats = BacktestResult( + trades=[], + n_trades=n_total, + win_rate=(wins_total / n_total) if n_total else 0.0, + avg_win=(gross_win / wins_total) if wins_total else Decimal(0), + avg_loss=(gross_loss / losses_total) if losses_total else Decimal(0), + expectancy=( + sum((s.stats.n_trades * s.stats.expectancy for s in samples), Decimal(0)) / n_total + if n_total + else Decimal(0) + ), + profit_factor=( + (gross_win / abs(gross_loss)) + if gross_loss != 0 + else (Decimal("Infinity") if gross_win > 0 else Decimal(0)) + ), + # path-dependent fields, not pooled -- see docstring; the gate reads neither + max_drawdown=Decimal(0), + max_losing_streak=0, + avg_mfe=( + sum((s.stats.n_trades * s.stats.avg_mfe for s in samples), Decimal(0)) / n_total + if n_total + else Decimal(0) + ), + avg_mae=( + sum((s.stats.n_trades * s.stats.avg_mae for s in samples), Decimal(0)) / n_total + if n_total + else Decimal(0) + ), + ) + + reading = PooledReading( + n_pooled=n_total, + per_product=tuple(sorted(counts.items())), + products_contributing=sum( + 1 for n in counts.values() if n >= MIN_TRADES_PER_PRODUCT_POOLED + ), + min_contribution=min(counts.values()) if counts else 0, + ) + return pooled_stats, reading + + +def _pooled_floors( + pooled: BacktestResult, reading: PooledReading, cfg: PromotionConfig +) -> tuple[bool, list[str]]: + """Path (b): pooled n ≥ `cfg.min_trades` AND the diversity floor AND the quality + floors judged on the POOLED aggregates. + + The floors themselves are the same `PromotionConfig` the per-rule path uses -- + 100 stays 100; what differs is which sample answers the sample-size question, and + the diversity requirement that pooled sample must additionally clear. Reasons name + their path ("pooled ..."), so an operator can tell a pooled failure from a per-rule + one at a glance. + """ + reasons: list[str] = [] + + if reading.n_pooled < cfg.min_trades: + reasons.append( + f"pooled n {reading.n_pooled} < min_trades {cfg.min_trades} across " + f"{len(reading.per_product)} products" + ) + + if reading.products_contributing < MIN_POOLED_PRODUCTS: + reasons.append( + f"pooled diversity {reading.products_contributing} products < required " + f"{MIN_POOLED_PRODUCTS} -- each contributing product must supply at least " + f"{MIN_TRADES_PER_PRODUCT_POOLED} trades (crypto assets correlate, so a " + "pool narrow enough to be correlated overstates its power; breadth is the " + "discount pooled evidence pays for its larger n)" + ) + + if pooled.expectancy <= cfg.min_expectancy: + reasons.append( + f"pooled expectancy {pooled.expectancy} <= min_expectancy {cfg.min_expectancy}" + ) + + rr = _realized_rr(pooled) + if rr < cfg.min_rr: + reasons.append(f"pooled rr {rr} < min_rr {cfg.min_rr}") + + if pooled.win_rate < cfg.min_win_rate: + reasons.append(f"pooled win_rate {pooled.win_rate} < min_win_rate {cfg.min_win_rate}") + + return (len(reasons) == 0, reasons) + + +def paper_sibling_rows( + repo: Repository, kind: str, params: dict[str, Any] +) -> list[dict[str, Any]]: + """The `rules` rows that count as pooled evidence for the (kind, params) being + promoted: same `kind`, params IDENTICAL to `params` after dropping `product_id`, + a DIFFERENT `product_id`, and status `paper`. + + Exact equality on the stored JSON-plain form (`"2"` vs `2` is a real difference -- + they rebuild differently), because pooling another parameter set's trades would + launder a different experiment's sample into this one's evidence. Status `paper` + because the pooled path exists to count out-of-sample paper track record, which is + what that status means. + + One row per product, the most recently inserted (like `_fetch_rule`): two rows for + one (params, product) are one rule observed twice, and pooling both would + double-count its trades. The candidate's OWN product never appears here -- its + trades enter the pool through the candidate's own stats, exactly once. + """ + own_product = (params or {}).get("product_id") + fingerprint = {k: v for k, v in (params or {}).items() if k != "product_id"} + + by_product: dict[str, dict[str, Any]] = {} + for row in repo.get_rules(status="paper"): + if row["kind"] != kind: + continue + row_params = row["params"] or {} + product = row_params.get("product_id") + if product is None or product == own_product: + continue + row_fingerprint = {k: v for k, v in row_params.items() if k != "product_id"} + if row_fingerprint != fingerprint: + continue + current = by_product.get(product) + if current is None or row["id"] > current["id"]: + by_product[product] = row + return list(by_product.values()) + + # -- G4: PBO overfitting gate (spec §7, KB §78) -------------------------------- @@ -239,12 +481,17 @@ class PromotionDecision: `promotable` is the only field that authorises a status change. `floors_pass` is reported separately because a rule can be perfect on performance and still un-promotable for want of an overfitting check -- an operator needs to see which of the two they are looking at. + + `pooled` is the pooled half of the sample-size reading (#338) when pooled samples were + supplied, else `None`. It is REPORTING, not authorisation: it is present whenever a pool + existed, whether or not the pooled path carried the decision. """ promotable: bool reasons: list[str] floors_pass: bool overfitting: str + pooled: PooledReading | None = None def can_promote( @@ -252,6 +499,7 @@ def can_promote( cfg: PromotionConfig, pbo: PBOResult | None = None, gate: PBOGate | None = None, + pooled_samples: Sequence[ProductSample] | None = None, ) -> PromotionDecision: """The promotion decision: performance floors (G2) **and** the overfitting gate (G4). @@ -260,10 +508,30 @@ def can_promote( `keel trials pbo`). `gate` supplies the thresholds; omit it to use `PBOGate()`'s defaults, or build one from the deployment's own config with `pbo_gate_from_config`. + `pooled_samples` (#338) is the cross-product evidence pool for this rule's parameter + set — the candidate's own sample PLUS one `ProductSample` per same-parameter `paper` + sibling (see `paper_sibling_rows`). It changes the UNIT OF EVALUATION for the + sample-size axis, never the floors: + + - With no pool (`pooled_samples=None` or empty), the decision is byte-for-byte the + pre-#338 one: `check_floors(stats, cfg)` alone answers the floors. + - With a pool, the per-rule reading is computed exactly as before, and — only when + the rule's OWN sample is short (`n_trades < cfg.min_trades`) — the pooled path is + also evaluated: pooled n ≥ `cfg.min_trades` AND the diversity floor + (`MIN_POOLED_PRODUCTS` products each contributing `MIN_TRADES_PER_PRODUCT_POOLED` + trades) AND the quality floors judged on the POOLED aggregates. If the pooled path + clears, IT carries the decision and the per-rule sample-size/quality failures do + not count against it. A rule whose own backtest already clears `min_trades` is + judged on its own stats exactly as before: this is a unit change for rules that + lack a sample, not a loosening for rules that have one. + + The G4/overfitting gate is NOT pooled: `pbo` judges the parameter selection (the + trial matrix), which is per-parameter-set evidence, and its scope is unchanged. + **`pbo=None` is not a pass.** It is `NOT_RUN`, it appears in `reasons`, and it makes `promotable` False. This is the entire point of the function: the four floors and the G4 thresholds both already existed, `config.research.pbo_max`/`slope_floor` shipped in every - config, and nothing ever called `g4_pbo_gate` — so promotion was decided on performance + config, and nothing ever called either — so promotion was decided on performance alone while the overfitting gate sat dormant, which is indistinguishable from having no gate except that it looked like having one. @@ -280,6 +548,23 @@ def can_promote( """ floors_pass, reasons = check_floors(stats, cfg) + pooled: PooledReading | None = None + if pooled_samples: + pooled_stats, pooled = pool_stats(pooled_samples) + # The pooled path is reached only when the rule's OWN sample is short: a rule + # whose backtest clears min_trades has its own adequate sample and is judged on + # it, exactly as before. Anything else would turn a unit change for the + # sample-starved into a quality rescue for the sample-rich. + if stats.n_trades < cfg.min_trades: + pooled_ok, pooled_reasons = _pooled_floors(pooled_stats, pooled, cfg) + if pooled_ok: + # The pooled path carries the decision; `reasons` on a pass is [] by + # the same invariant `check_floors` keeps. The per-rule reading stays + # visible to the caller through `stats` and `decision.pooled`. + floors_pass, reasons = True, [] + else: + reasons = reasons + pooled_reasons + if pbo is None: overfitting = NOT_RUN reasons = reasons + [ @@ -299,6 +584,7 @@ def can_promote( reasons=reasons, floors_pass=floors_pass, overfitting=overfitting, + pooled=pooled, ) @@ -319,8 +605,27 @@ def should_demote(rolling_stats: BacktestResult, cfg: PromotionConfig) -> bool: return False -def _fetch_rule(repo: Repository, rule_name: str) -> tuple[int, str]: - """The most recently inserted `rules` row for `kind == rule_name` (id/status).""" +def _fetch_rule(repo: Repository, rule_name: str, rule_id: int | None = None) -> tuple[int, str]: + """The `rules` row to act on (id/status): the row with `rule_id` when one is named, + else the most recently inserted row for `kind == rule_name`. + + The kind-level fallback predates multi-row kinds (`rules seed` writes one row per + (kind, product)); with several rows of one kind it targets the NEWEST, which is + only right for the single-row case it was written for. A caller that knows which + row it means -- `keel rules promote ` always does -- must not have that guess + made for it: promoting "the latest row of this kind" would advance a sibling the + operator never named, which is exactly the wrong row now that pooling (#338) makes + same-kind sibling rows the normal shape of the table. + """ + if rule_id is not None: + for r in repo.get_rules(): + if r["id"] == rule_id: + if r["kind"] != rule_name: + raise ValueError( + f"rule id {rule_id} is kind {r['kind']!r}, not {rule_name!r}" + ) + return r["id"], r["status"] + raise ValueError(f"no rule found in the rules table with id={rule_id}") matches = [r for r in repo.get_rules() if r["kind"] == rule_name] if not matches: raise ValueError(f"no rule found in the rules table for kind={rule_name!r}") @@ -335,32 +640,41 @@ def transition( cfg: PromotionConfig, pbo: PBOResult | None = None, gate: PBOGate | None = None, + pooled_samples: Sequence[ProductSample] | None = None, + rule_id: int | None = None, ) -> str: """Advance (or demote) `rule_name`'s lifecycle status in the `rules` table given fresh `stats`, and return the resulting status. - `candidate`/`paper`: promotes one step (to `paper`/`live`) if `can_promote(stats, cfg, - pbo, gate)` passes, else stays put. **Without `pbo` it never promotes** -- see + pbo, gate, pooled_samples)` passes, else stays put. `pooled_samples` is the same + cross-product pool the caller showed the operator (#338) — passing it here is what + keeps the decision the DB obeys identical to the decision the operator read. + **Without `pbo` it never promotes** -- see `can_promote` for why an unrun overfitting check blocks rather than waves through. - `live`: demotes to `disabled` if `should_demote(stats, cfg)`, else stays `live`. - `disabled`: terminal; always stays `disabled`. + `rule_id`, when supplied, is the exact row to act on (see `_fetch_rule`); omit it for + the historical kind-level lookup. The CLI always names the row, so a promotion lands + on the rule the operator typed, never on a same-kind sibling that happens to be newer. + Demotion deliberately does NOT consult `pbo`, and the asymmetry is the safety property: missing evidence must block a rule moving toward real money and must never block pulling one back from it. """ - rule_id, status = _fetch_rule(repo, rule_name) + target_id, status = _fetch_rule(repo, rule_name, rule_id) if status in _PROMOTE_NEXT: - if can_promote(stats, cfg, pbo, gate).promotable: + if can_promote(stats, cfg, pbo, gate, pooled_samples).promotable: new_status = _PROMOTE_NEXT[status] - repo.update_rule_status(rule_id, new_status) + repo.update_rule_status(target_id, new_status) return new_status return status if status == "live": if should_demote(stats, cfg): - repo.update_rule_status(rule_id, "disabled") + repo.update_rule_status(target_id, "disabled") return "disabled" return status diff --git a/tests/strategy/test_promotion.py b/tests/strategy/test_promotion.py index 7a088f6f..7da3a768 100644 --- a/tests/strategy/test_promotion.py +++ b/tests/strategy/test_promotion.py @@ -8,6 +8,7 @@ from __future__ import annotations +import json from decimal import Decimal import pytest @@ -20,17 +21,22 @@ from keel.strategy.promotion import ( DEFAULT_CLASS, FAILED, + MIN_POOLED_PRODUCTS, + MIN_TRADES_PER_PRODUCT_POOLED, NOT_RUN, PASSED, TREND_FOLLOW, PBOGate, + ProductSample, PromotionConfig, can_promote, check_floors, floor_for_class, g4_pbo_gate, next_status, + paper_sibling_rows, pbo_gate_from_config, + pool_stats, promotion_class_of, should_demote, transition, @@ -71,10 +77,12 @@ def repo() -> Repository: return Repository(conn) -def _insert_rule(repo: Repository, kind: str, status: str = "candidate") -> int: +def _insert_rule( + repo: Repository, kind: str, status: str = "candidate", params: dict | None = None +) -> int: cursor = repo._conn.execute( "INSERT INTO rules (kind, params, status, created_at) VALUES (?, ?, ?, ?)", - (kind, "{}", status, 1_700_000_000), + (kind, json.dumps(params or {}), status, 1_700_000_000), ) repo._conn.commit() assert cursor.lastrowid is not None @@ -533,3 +541,423 @@ def test_g4_default_thresholds_match_the_shipped_config(): defaults = ResearchConfig() assert gate.pbo_max == defaults.pbo_max assert gate.slope_floor == defaults.slope_floor + + +# -- cross-product pooling of min_trades (#338) --------------------------------- +# +# The gate's unit of evaluation, not its floors: `min_trades` stays 100, but the +# sample-size axis may be cleared EITHER by the rule's own backtest exactly as +# before OR by the same parameter set's pooled evidence across products in paper, +# discounted by a diversity floor (crypto assets correlate; a pool concentrated in +# few products overstates its power). Operator-approved 2026-08-17 (see #338) -- +# the agreement a gate change requires. + + +def _pool( + own_product: str, + own_stats: BacktestResult, + siblings: list[tuple[str, BacktestResult]], +) -> list[ProductSample]: + """The full pooled sample: the candidate's own reading plus one per sibling.""" + return [ProductSample(own_product, own_stats)] + [ + ProductSample(pid, stats) for pid, stats in siblings + ] + + +def _seven_paper_siblings() -> list[tuple[str, BacktestResult]]: + """7 healthy same-parameter readings on other products, 16 trades each. + + 16 is chosen so the pooled arithmetic is exact in floats and Decimals alike: + win_rate 0.625 of 16 = 10 wins; pooled with the candidate's 4 wins in 16, + that is 74/128 = 0.578125 -- above the 0.55 floor the candidate alone fails. + """ + return [ + (f"ASSET-{i}-USD", _stats(n_trades=16, win_rate=0.625)) for i in range(1, 8) + ] + + +def test_pool_stats_field_arithmetic_is_the_documented_weighting() -> None: + """The docstring's field-by-field claims, asserted directly -- not only through the + gate's pass/fail, which reads n/win-rate/expectancy and could stay green while + avg_win/avg_loss/rr pool wrongly (a mis-weighted loss side that never crosses a + floor is still a lie in the census an operator reads). + + Sibling: n=16, 10 wins (0.625), avg_win 30, avg_loss -10. + Candidate: n=16, 4 wins (0.25), avg_win 60, avg_loss -20, expectancy -2. + Pooled: n=32; wins 14; win_rate 14/32 = 0.4375; avg_win (10*30 + 4*60)/14 = 540/14; + avg_loss (6*-10 + 12*-20)/18 = -300/18; gross_win 540, gross_loss 300, PF 1.8; + expectancy (16*-2 + 16*14)/32 = 6 -- the trade-weighted mean, exact. + """ + own = _stats( + n_trades=16, win_rate=0.25, avg_win=Decimal("60"), + avg_loss=Decimal("-20"), expectancy=Decimal("-2"), + ) + pooled, reading = pool_stats( + _pool("BTC-USD", own, _seven_paper_siblings()[:1]) + ) + assert reading.n_pooled == 32 + assert dict(reading.per_product) == {"BTC-USD": 16, "ASSET-1-USD": 16} + assert pooled.n_trades == 32 + assert pooled.win_rate == 0.4375 + assert pooled.avg_win == Decimal("540") / 14 + assert pooled.avg_loss == Decimal("-300") / 18 + assert pooled.expectancy == Decimal("6") + assert pooled.profit_factor == Decimal("1.8") + + +def test_no_pool_supplied_is_exactly_todays_decision() -> None: + """Without pooled samples the decision carries no pooled reading -- the single-product + path is byte-for-byte the pre-#338 behavior, not a special case of the pooled one.""" + ok = can_promote(_stats(), PromotionConfig(), pbo=_pbo()) + assert ok.promotable is True + assert ok.reasons == [] + assert ok.pooled is None + + failing = can_promote(_stats(n_trades=10), PromotionConfig(), pbo=_pbo()) + assert failing.promotable is False + assert failing.reasons == ["n_trades 10 < min_trades 100"] + assert failing.pooled is None + + +def test_pooled_pass_when_the_rule_alone_is_short_but_the_parameter_set_is_not() -> None: + """THE motivating case: a new asset's rule has 16 of its own trades, and the same + parameters already have a paper track record on 7 other products. + + The per-rule reading fails on sample size AND on win rate (0.25 < 0.55) and + expectancy -- and the pooled reading clears everything: 8 products x 16 = 128 + trades, 8 products each >= 10 trades, pooled win rate 74/128 = 0.578125, + pooled expectancy (16*-2 + 112*14)/128 = 12. Quality is judged on the POOLED + stats on this path, which is the point: the edge belongs to the parameter set, + and the unit of evaluation is what #338 changed. + """ + own = _stats(n_trades=16, win_rate=0.25, expectancy=Decimal("-2")) + samples = _pool("BTC-USD", own, _seven_paper_siblings()) + + decision = can_promote(own, PromotionConfig(), pbo=_pbo(), pooled_samples=samples) + + assert decision.promotable is True + assert decision.floors_pass is True + assert decision.reasons == [] + assert decision.pooled is not None + assert decision.pooled.n_pooled == 128 + assert decision.pooled.products_contributing == 8 + assert decision.pooled.min_contribution == 16 + assert len(decision.pooled.per_product) == 8 + + +def test_pooled_fail_on_total_n_names_its_path_and_the_census() -> None: + """8 products x 10 trades: every product clears the per-product bar, but the pool + totals 80 < 100. The reason must say WHICH reading failed and across how many + products, so an operator knows the fix is more trades, not more assets.""" + own = _stats(n_trades=10, win_rate=0.6) + siblings = [(f"ASSET-{i}-USD", _stats(n_trades=10, win_rate=0.6)) for i in range(1, 8)] + + decision = can_promote( + own, PromotionConfig(), pbo=_pbo(), pooled_samples=_pool("BTC-USD", own, siblings) + ) + + assert decision.promotable is False + assert any("pooled n 80 < min_trades 100 across 8 products" in r for r in decision.reasons) + # the per-rule failure is still visible alongside the pooled one + assert any("n_trades 10 < min_trades 100" in r for r in decision.reasons) + assert decision.pooled is not None + assert decision.pooled.n_pooled == 80 + + +def test_pooled_fail_on_diversity_even_with_120_total_trades() -> None: + """4 products x 30 = 120 trades clears the total, but 4 < MIN_POOLED_PRODUCTS. + + This is the correlation discount doing its job: 120 trades from 4 correlated + assets are not 120 independent observations, and the floor refuses to price + them as though they were. + """ + own = _stats(n_trades=30) + siblings = [(f"ASSET-{i}-USD", _stats(n_trades=30)) for i in range(1, 4)] + + decision = can_promote( + own, PromotionConfig(), pbo=_pbo(), pooled_samples=_pool("BTC-USD", own, siblings) + ) + + assert decision.promotable is False + assert any( + f"pooled diversity 4 products < required {MIN_POOLED_PRODUCTS}" in r + for r in decision.reasons + ) + assert decision.pooled is not None + assert decision.pooled.n_pooled == 120 # the total is NOT the problem + assert decision.pooled.products_contributing == 4 + + +def test_a_rule_with_its_own_full_sample_is_still_judged_on_its_own_stats() -> None: + """Path selection: the pooled path is reached only when the per-rule SAMPLE is short. + + A rule whose own backtest clears min_trades has its own adequate sample, so its + quality floors are judged on its own stats exactly as before -- a pool of healthy + siblings must not rescue a rule that is itself unprofitable on 150 trades. This + keeps the change a change of UNIT for rules that lack a sample, not a loosening + for rules that have one. + """ + own = _stats(n_trades=150, win_rate=0.40) # own sample clears n; quality fails + decision = can_promote( + own, + PromotionConfig(), + pbo=_pbo(), + pooled_samples=_pool("BTC-USD", own, _seven_paper_siblings()), + ) + + assert decision.promotable is False + assert any("win_rate 0.4 < min_win_rate 0.55" in r for r in decision.reasons) + # the pooled reading is still REPORTED, it just does not carry the decision + assert decision.pooled is not None + assert decision.pooled.n_pooled == 150 + 7 * 16 + + +def test_pooled_reading_is_reported_even_when_the_rule_passes_alone() -> None: + """Default behavior for promotions that already pass on one product is unchanged -- + and the cross-product reading is printed alongside, not hidden, because an operator + approving the promotion is entitled to both readings.""" + own = _stats() # 150 trades, clears everything alone + decision = can_promote( + own, + PromotionConfig(), + pbo=_pbo(), + pooled_samples=_pool("BTC-USD", own, _seven_paper_siblings()), + ) + + assert decision.promotable is True + assert decision.reasons == [] + assert decision.pooled is not None + assert decision.pooled.n_pooled == 150 + 7 * 16 + + +def test_pooled_quality_floors_are_judged_on_the_pooled_stats() -> None: + """The pooled path checks expectancy/rr/win-rate on the POOLED aggregates, so a pool + that is collectively under water is refused even with n and diversity both clear.""" + own = _stats(n_trades=16) + # 7 siblings whose pooled expectancy is negative: 16*-2 + 112*(-3) = -368 over 128 + siblings = [ + (f"ASSET-{i}-USD", _stats(n_trades=16, expectancy=Decimal("-3"), win_rate=0.30)) + for i in range(1, 8) + ] + decision = can_promote( + own, PromotionConfig(), pbo=_pbo(), pooled_samples=_pool("BTC-USD", own, siblings) + ) + + assert decision.promotable is False + assert any(r.startswith("pooled expectancy") for r in decision.reasons), decision.reasons + assert any(r.startswith("pooled win_rate") for r in decision.reasons), decision.reasons + + +def test_diversity_counts_only_products_meeting_the_per_product_bar() -> None: + """A 3-trade product still contributes its 3 trades to the pooled total (honest + pooling -- evidence is evidence), but it does not count toward the diversity floor: + the floor's question is how many products have independently meaningful samples.""" + own = _stats(n_trades=40) + # 4 substantive siblings (20 each) + 3 token ones (3 each): pooled = 40 + 80 + 9 = 129 + siblings = [(f"HEAVY-{i}-USD", _stats(n_trades=20)) for i in range(1, 5)] + siblings += [(f"THIN-{i}-USD", _stats(n_trades=3)) for i in range(1, 4)] + + decision = can_promote( + own, PromotionConfig(), pbo=_pbo(), pooled_samples=_pool("BTC-USD", own, siblings) + ) + + assert decision.pooled is not None + assert decision.pooled.n_pooled == 129 + # own + the 4 heavy siblings clear MIN_TRADES_PER_PRODUCT_POOLED; the 3-trade rows do not + over_bar = [n for _, n in decision.pooled.per_product if n >= MIN_TRADES_PER_PRODUCT_POOLED] + assert decision.pooled.products_contributing == 5 == len(over_bar) + assert decision.pooled.min_contribution == 3 + assert decision.promotable is True # 129 >= 100 and exactly 5 products clear the bar + + +# -- paper_sibling_rows: which stored rows count as pooled evidence -------------- + + +_CANDIDATE_PARAMS = {"entry_lookback": 55, "product_id": "BTC-USD"} + + +def test_paper_sibling_rows_matches_same_params_across_products(repo: Repository) -> None: + _insert_rule(repo, "pullback_continuation", status="candidate", params=_CANDIDATE_PARAMS) + _insert_rule( + repo, + "pullback_continuation", + status="paper", + params={"entry_lookback": 55, "product_id": "ETH-USD"}, + ) + + rows = paper_sibling_rows(repo, "pullback_continuation", _CANDIDATE_PARAMS) + + assert len(rows) == 1 + assert rows[0]["params"]["product_id"] == "ETH-USD" + + +def test_params_mismatch_is_not_a_sibling(repo: Repository) -> None: + """`entry_lookback: 20` is a DIFFERENT parameter set -- pooling its trades would + launder another experiment's sample into this one's evidence.""" + _insert_rule(repo, "pullback_continuation", status="candidate", params=_CANDIDATE_PARAMS) + _insert_rule( + repo, + "pullback_continuation", + status="paper", + params={"entry_lookback": 20, "product_id": "ETH-USD"}, # different params + ) + _insert_rule( # wrong kind, same params + repo, "rsi_meanrev", status="paper", params={"entry_lookback": 55, "product_id": "SOL-USD"} + ) + _insert_rule( # the docstring's own example: "55" (str) vs 55 (int) is a real mismatch + repo, + "pullback_continuation", + status="paper", + params={"entry_lookback": "55", "product_id": "LINK-USD"}, + ) + + assert paper_sibling_rows(repo, "pullback_continuation", _CANDIDATE_PARAMS) == [] + + +def test_only_paper_siblings_count(repo: Repository) -> None: + """A `live` row's stats are not paper evidence for a pooled promotion, and neither + is another candidate's: the pooled path exists to count out-of-sample PAPER + track record, which is what those statuses mean.""" + _insert_rule(repo, "pullback_continuation", status="candidate", params=_CANDIDATE_PARAMS) + for status in ("candidate", "live", "disabled"): + _insert_rule( + repo, + "pullback_continuation", + status=status, + params={"entry_lookback": 55, "product_id": f"{status.upper()}-USD"}, + ) + + assert paper_sibling_rows(repo, "pullback_continuation", _CANDIDATE_PARAMS) == [] + + +def test_candidates_own_product_is_counted_once_never_as_a_sibling(repo: Repository) -> None: + """A duplicate paper row on the candidate's OWN product is not a sibling: its trades + are the same trades the candidate's own backtest already put into the pool, and + counting them twice would inflate pooled n out of nothing.""" + _insert_rule(repo, "pullback_continuation", status="candidate", params=_CANDIDATE_PARAMS) + _insert_rule( # duplicate on the candidate's own product, now paper + repo, + "pullback_continuation", + status="paper", + params={"entry_lookback": 55, "product_id": "BTC-USD"}, + ) + + assert paper_sibling_rows(repo, "pullback_continuation", _CANDIDATE_PARAMS) == [] + + +def test_duplicate_sibling_rows_on_one_product_pool_once(repo: Repository) -> None: + """Two paper rows for the same (params, product) are one rule observed twice -- the + most recent row is the sibling, and its trades enter the pool exactly once.""" + _insert_rule(repo, "pullback_continuation", status="candidate", params=_CANDIDATE_PARAMS) + first = _insert_rule( + repo, + "pullback_continuation", + status="paper", + params={"entry_lookback": 55, "product_id": "ETH-USD"}, + ) + second = _insert_rule( + repo, + "pullback_continuation", + status="paper", + params={"entry_lookback": 55, "product_id": "ETH-USD"}, + ) + + rows = paper_sibling_rows(repo, "pullback_continuation", _CANDIDATE_PARAMS) + + assert len(rows) == 1 + assert rows[0]["id"] == second # the most recently inserted, like _fetch_rule + assert rows[0]["id"] != first + + +# -- transition under pooling ---------------------------------------------------- + + +def test_transition_promotes_via_the_pooled_path(repo: Repository) -> None: + """The DB-writing path makes the same pooled decision the CLI prints -- a rule whose + own backtest is short promotes on the parameter set's cross-product evidence.""" + rule_id = _insert_rule(repo, "pullback_continuation", status="candidate") + own = _stats(n_trades=16, win_rate=0.25, expectancy=Decimal("-2")) + + new_status = transition( + repo, + "pullback_continuation", + own, + PromotionConfig(), + pbo=_pbo(), + pooled_samples=_pool("BTC-USD", own, _seven_paper_siblings()), + ) + + assert new_status == "paper" + assert _rule_status(repo, rule_id) == "paper" + + +def test_transition_stays_put_when_the_pool_is_too_thin(repo: Repository) -> None: + rule_id = _insert_rule(repo, "pullback_continuation", status="candidate") + own = _stats(n_trades=16) + siblings = [(f"ASSET-{i}-USD", _stats(n_trades=10)) for i in range(1, 4)] # 4 products total + + new_status = transition( + repo, + "pullback_continuation", + own, + PromotionConfig(), + pbo=_pbo(), + pooled_samples=_pool("BTC-USD", own, siblings), + ) + + assert new_status == "candidate" + assert _rule_status(repo, rule_id) == "candidate" + + +def test_pooled_pass_still_requires_the_overfitting_check(repo: Repository) -> None: + """Pooling widens the SAMPLE-SIZE axis only. The G4 gate is untouched by it -- it + judges the parameter SELECTION (the trial matrix), not the sample, and it blocks + exactly as before when no CSCV result was supplied.""" + rule_id = _insert_rule(repo, "pullback_continuation", status="candidate") + own = _stats(n_trades=16, win_rate=0.25, expectancy=Decimal("-2")) + + new_status = transition( + repo, + "pullback_continuation", + own, + PromotionConfig(), + pooled_samples=_pool("BTC-USD", own, _seven_paper_siblings()), + ) + + assert new_status == "candidate" + assert _rule_status(repo, rule_id) == "candidate" + + +def test_transition_with_rule_id_targets_the_named_row_not_the_newest_sibling( + repo: Repository, +) -> None: + """`keel rules promote ` names a row; with same-kind sibling rows now the normal + shape of the table (#338's pools are made of them), the kind-level "newest row" + lookup would advance a sibling the operator never typed. `rule_id` pins the target; + omitting it keeps the historical kind-level lookup for library callers.""" + named = _insert_rule(repo, "pullback_continuation", status="candidate") + newer_sibling = _insert_rule(repo, "pullback_continuation", status="paper") + + status = transition( + repo, + "pullback_continuation", + _stats(), + PromotionConfig(), + pbo=_pbo(), + rule_id=named, + ) + + assert status == "paper" + assert _rule_status(repo, named) == "paper" + assert _rule_status(repo, newer_sibling) == "paper" # untouched: candidate->paper only + + +def test_transition_without_rule_id_keeps_the_kind_level_lookup(repo: Repository) -> None: + """The pre-existing behavior, pinned: no `rule_id` means the newest row of the kind.""" + _insert_rule(repo, "pullback_continuation", status="candidate") + newest = _insert_rule(repo, "pullback_continuation", status="paper") + + status = transition(repo, "pullback_continuation", _stats(), PromotionConfig(), pbo=_pbo()) + + assert status == "live" # the newest row (paper) promoted, not the older candidate + assert _rule_status(repo, newest) == "live" diff --git a/tests/test_cli.py b/tests/test_cli.py index 14fb240c..26a8a484 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -644,6 +644,155 @@ def test_rules_promote_errors_rather_than_downgrading_an_unusable_pbo_session( assert "no usable trial columns" in result.output +# -- rules promote: cross-product pooling of min_trades (#338) ------------------ +# +# The gate's unit of evaluation, not its floors: the sample-size axis may be cleared +# by the same parameters' pooled PAPER evidence on other products, discounted by a +# diversity floor. The command must show BOTH readings -- the per-rule number and the +# pooled census -- so the operator approving the promotion sees which path carried it. + + +def _pbo_pass(): + """A clean CSCV result, so these tests exercise the SAMPLE-SIZE axis without the + overfitting axis also blocking (its wiring is covered by its own tests above).""" + from keel.research.cscv import PBOResult + + return PBOResult( + pbo=Decimal("0.01"), + n_combinations=20, + n_columns=12, + n_blocks=16, + rows_used=800, + rows_dropped=0, + logits=[], + is_performance=[], + oos_performance=[], + degradation_slope=Decimal("-0.2"), + ) + + +def test_rules_promote_reports_both_readings_and_promotes_via_the_pooled_path( + tmp_path, valid_config_path, monkeypatch +): + """A rule with 16 of its own trades and 7 same-parameter paper siblings promotes, + and the output names BOTH readings: per-rule n, pooled n, and the diversity census. + + The backtest and PBO seams are faked (each has its own owning tests): this test is + about what the COMMAND counts, decides, and prints. BTC-USD's reading fails the + per-rule floors outright (16 trades, 25% win rate, negative expectancy); the seven + siblings' pooled reading clears everything -- pooled n 128 across 8 products, 8 + products each >= 10 trades, pooled win rate 74/128 -- so the promotion carries on + the pooled path and says so. + """ + from keel.commands import rules as rules_cmd + from keel.strategy.backtest import BacktestResult + + db_path = tmp_path / "test.db" + repo = _repo_at(db_path) + rule_id = repo.insert_rule("pullback_continuation", {"product_id": "BTC-USD"}) + for product in ("ETH-USD", "SOL-USD", "ADA-USD", "XLM-USD", "PAXG-USD", "LTC-USD", "DOGE-USD"): + repo.insert_rule("pullback_continuation", {"product_id": product}, status="paper") + + def fake_backtest(rule, candles, **kwargs): + return BacktestResult( + trades=[], + n_trades=16, + win_rate=0.25 if rule.product_id == "BTC-USD" else 0.625, + avg_win=Decimal("30"), + avg_loss=Decimal("-10"), + expectancy=Decimal("-2") if rule.product_id == "BTC-USD" else Decimal("14"), + profit_factor=Decimal("2"), + max_drawdown=Decimal("50"), + max_losing_streak=4, + avg_mfe=Decimal("20"), + avg_mae=Decimal("8"), + ) + + monkeypatch.setattr(rules_cmd.backtest_mod, "backtest", fake_backtest) + monkeypatch.setattr(rules_cmd, "_load_pbo", lambda ctx, session, blocks: _pbo_pass()) + + result = CliRunner().invoke( + cli, + ["--db", str(db_path), "--config", str(valid_config_path), + "rules", "promote", str(rule_id)], + ) + + assert result.exit_code == 0, result.output + assert "per-rule n_trades=16" in result.output + assert "pooled n_trades=128 across 8 products" in result.output + assert "pooled census" in result.output + assert "8 products contribute" in result.output + assert "BTC-USD=16" in result.output and "DOGE-USD=16" in result.output + assert "status -> paper" in result.output + row = {r["id"]: r for r in repo.get_rules()}[rule_id] + assert row["status"] == "paper" + + +def test_rules_promote_names_the_diversity_failure_when_the_pool_is_too_narrow( + tmp_path, valid_config_path, monkeypatch +): + """4 products of 30 trades each: the pooled total clears 100, the diversity floor + (5 products) does not, and the failure reason says which path and which axis.""" + from keel.commands import rules as rules_cmd + from keel.strategy.backtest import BacktestResult + + db_path = tmp_path / "test.db" + repo = _repo_at(db_path) + rule_id = repo.insert_rule("pullback_continuation", {"product_id": "BTC-USD"}) + for product in ("ETH-USD", "SOL-USD", "ADA-USD"): + repo.insert_rule("pullback_continuation", {"product_id": product}, status="paper") + + def fake_backtest(rule, candles, **kwargs): + return BacktestResult( + trades=[], + n_trades=30, + win_rate=0.6, + avg_win=Decimal("30"), + avg_loss=Decimal("-10"), + expectancy=Decimal("14"), + profit_factor=Decimal("2"), + max_drawdown=Decimal("50"), + max_losing_streak=4, + avg_mfe=Decimal("20"), + avg_mae=Decimal("8"), + ) + + monkeypatch.setattr(rules_cmd.backtest_mod, "backtest", fake_backtest) + monkeypatch.setattr(rules_cmd, "_load_pbo", lambda ctx, session, blocks: _pbo_pass()) + + result = CliRunner().invoke( + cli, + ["--db", str(db_path), "--config", str(valid_config_path), + "rules", "promote", str(rule_id)], + ) + + assert result.exit_code == 0, result.output + assert "pooled n_trades=120 across 4 products" in result.output + assert "pooled diversity 4 products < required 5" in result.output + assert "status -> candidate" in result.output + + +def test_rules_promote_with_no_siblings_prints_no_pooled_reading( + tmp_path, valid_config_path +): + """Default behavior for a single-product promotion is unchanged: no siblings in the + table means no pooled reading in the output -- the per-rule decision, alone, exactly + as before #338.""" + db_path = tmp_path / "test.db" + repo = _repo_at(db_path) + rule_id = repo.insert_rule("pullback_continuation", {"product_id": "BTC-USD"}) + + result = CliRunner().invoke( + cli, + ["--db", str(db_path), "--config", str(valid_config_path), + "rules", "promote", str(rule_id)], + ) + + assert result.exit_code == 0, result.output + assert "pooled" not in result.output + assert "status -> candidate" in result.output + + def test_rules_demote_steps_back_one_stage(tmp_path): db_path = tmp_path / "test.db" repo = _repo_at(db_path)