From b2f71a376be937bbaa301b1a873f52e412d93fb0 Mon Sep 17 00:00:00 2001 From: Elmehdi Aitbrahim Date: Sun, 23 Aug 2026 10:58:43 -0400 Subject: [PATCH 1/2] feat(db): record the stop a tranche was sized against, because the current one ratchets away from it (#520) v12 adds positions.initial_stop, written at tranche open and never rewritten. The break-even arm of exit_policy.next_stop computes its threshold from the trade's ORIGINAL per-unit risk: entry + be_roll_rr * (entry - initial_stop). Live state carried entry_fill (the ledger) and open_stop: (the CURRENT, already-ratcheted stop) and nothing else -- so the number that threshold is most sensitive to was simply absent. Substituting the current stop is not an approximation, it is a DIFFERENT POLICY. The current stop rises on every ratchet, shrinking (entry - stop), so the threshold creeps toward entry and the arm fires earlier each time, drifting further from the measured policy the longer a trade runs. Live and sim would then encode two different break-even rules while appearing to share exit_policy's functions -- the exact failure sharing them was meant to prevent. NULL means UNKNOWN and readers must switch the break-even arm OFF for that tranche rather than guess. Zero would be a real number -- a stop 100% below entry -- and a catastrophic one to compute a threshold from, so the column is nullable and _position_row_to_dict is careful not to let _text_to_dec invent a zero. The trailing arm is unaffected either way: it needs no original risk, so a tranche without one keeps trailing and simply never break-even-rolls. NO BACKFILL, deliberately: the honest value for every pre-v12 tranche is NULL. Idempotent by the v8/v11 PRAGMA table_info guard, because a database stamped at v11 got positions from v4's DDL and CREATE TABLE IF NOT EXISTS never adds a column. DCA passes None and that is legitimate, not a degraded case -- it carries no stop by design, and requiring one would refuse a real tranche. The three schema-version tripwires were bumped 11 -> 12 consciously, which is what they exist to force. Gates: pytest 4559 passed / 3 skipped; ruff check keel tests packages clean; mypy clean across 354 source files. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NyeggYtojNXCTHeD3JHxb6 --- keel/agent.py | 22 ++++++- keel/data/db.py | 33 +++++++++- keel/data/repository.py | 26 ++++++-- tests/data/test_db.py | 4 +- tests/data/test_initial_stop.py | 105 ++++++++++++++++++++++++++++++ tests/data/test_migrations.py | 2 +- tests/data/test_trade_outcomes.py | 4 +- 7 files changed, 183 insertions(+), 13 deletions(-) create mode 100644 tests/data/test_initial_stop.py diff --git a/keel/agent.py b/keel/agent.py index 57dbaf9..79d954c 100644 --- a/keel/agent.py +++ b/keel/agent.py @@ -320,9 +320,16 @@ def _open_tranche( order: dict[str, Any] | None, result: ExecutionResult, now_ts: int, + initial_stop: Decimal | None = None, ) -> None: """Record the newly opened tranche in the `positions` ledger and point it at its bracket. + `initial_stop` is the stop this tranche was SIZED against (#520). It is recorded at OPEN and + never rewritten, because that is the whole point: `open_stop:` already tracks the + current, ratcheting stop, and the break-even threshold needs the ORIGINAL per-unit risk. + `None` is legitimate and means unknown -- DCA carries no stop by design -- and readers must + disable the break-even arm for such a tranche rather than substitute the current stop. + The ledger is what a later exit attributes P&L against, so a tranche whose entry price or qty could not be read is NOT recorded: `record_closed_trade` already refuses to guess a missing entry price, and a ledger row carrying `None` would either crash the arithmetic or fabricate @@ -366,6 +373,7 @@ def _open_tranche( # Nothing else preserves it: the exit's order row knows only its own fee. entry_fee=order["fee"] or Decimal("0"), entry_fill=entry_fill, + initial_stop=initial_stop, ) if result.bracket_order_id is not None: repo.set_position_bracket(position_id, result.bracket_order_id) @@ -1640,7 +1648,19 @@ def run_once( f"position_rule:{product_id}", {"rule_name": signal.rule_name, "opened_at": now_ts}, ) - _open_tranche(repo, product_id, signal.rule_name, order, result, now_ts) + _open_tranche( + repo, + product_id, + signal.rule_name, + order, + result, + now_ts, + # #520: the stop the position was SIZED against, captured at open + # because nothing else preserves it -- `open_stop:` starts + # here and then ratchets away from it. `None` for DCA, which has no + # stop by design. + initial_stop=signal.setup.stop if signal.setup is not None else None, + ) cycle_result = LoopResult( ts=now_ts, diff --git a/keel/data/db.py b/keel/data/db.py index 1f14aef..cbdf901 100644 --- a/keel/data/db.py +++ b/keel/data/db.py @@ -19,7 +19,7 @@ from pathlib import Path from typing import Any -SCHEMA_VERSION = 11 +SCHEMA_VERSION = 12 # Creation order matters for readability (and for backends that validate FK targets eagerly); # SQLite itself only checks FK targets at DML time, but we still declare referenced tables first. @@ -84,6 +84,7 @@ qty TEXT NOT NULL, entry_fill TEXT NOT NULL, entry_fee TEXT NOT NULL, + initial_stop TEXT, bracket_order_id INTEGER, status TEXT NOT NULL DEFAULT 'open', FOREIGN KEY (bracket_order_id) REFERENCES orders(id) @@ -472,6 +473,35 @@ def _migrate_v11_orders_filled_quantity(conn: sqlite3.Connection) -> None: conn.execute("ALTER TABLE orders ADD COLUMN filled_quantity TEXT") +def _migrate_v12_positions_initial_stop(conn: sqlite3.Connection) -> None: + """v12 adds `positions.initial_stop` -- the stop the tranche was SIZED against (#520). + + The break-even arm of `exit_policy.next_stop` computes its threshold from the trade's + ORIGINAL per-unit risk: `entry + be_roll_rr * (entry - initial_stop)`. Live state carries + `entry_fill` (this ledger) and `open_stop:` (the CURRENT, already-ratcheted stop) + and nothing else -- so the number the threshold is most sensitive to was simply absent. + + Substituting the current stop is not an approximation, it is a DIFFERENT POLICY: the current + stop rises on every ratchet, shrinking `(entry - stop)` so the threshold creeps toward entry + and the arm fires earlier each time, drifting further from the measured policy the longer a + trade runs. Live and sim would then encode two different break-even rules while appearing to + share `exit_policy`'s functions -- the exact failure sharing them was meant to prevent. + + Idempotent by the v8/v11 `PRAGMA table_info` guard: a database already stamped at v11 got + `positions` from v4's DDL, which has no such column, and `CREATE TABLE IF NOT EXISTS` never + adds one. + + **NO BACKFILL, deliberately.** The honest value for every pre-v12 tranche is NULL -- nobody + recorded it — and readers must treat NULL as "unknown" and disable the break-even arm for + that tranche rather than guess. Inventing a value here would fabricate the one input the + policy is most sensitive to. The trailing arm is unaffected: it needs no original risk, so an + old tranche keeps trailing and simply never break-even-rolls. + """ + columns = {row["name"] for row in conn.execute("PRAGMA table_info(positions)")} + if "initial_stop" not in columns: + conn.execute("ALTER TABLE positions ADD COLUMN initial_stop TEXT") + + _MIGRATIONS: dict[int, Callable[[sqlite3.Connection], None]] = { 2: _migrate_v2_broker_subscriptions, 3: _migrate_v3_trade_outcomes, @@ -483,6 +513,7 @@ def _migrate_v11_orders_filled_quantity(conn: sqlite3.Connection) -> None: 9: _migrate_v9_screen_exceptions, 10: _migrate_v10_instrument_attestations, 11: _migrate_v11_orders_filled_quantity, + 12: _migrate_v12_positions_initial_stop, } diff --git a/keel/data/repository.py b/keel/data/repository.py index de1474d..67ceb09 100644 --- a/keel/data/repository.py +++ b/keel/data/repository.py @@ -455,9 +455,7 @@ def upsert_broker_subscription(self, record: BrokerSubscription) -> None: def list_broker_subscriptions(self) -> list[BrokerSubscription]: """Every attested subscription, ordered by venue -- what `keel subscription show` renders.""" - rows = self._conn.execute( - "SELECT * FROM broker_subscriptions ORDER BY venue" - ).fetchall() + rows = self._conn.execute("SELECT * FROM broker_subscriptions ORDER BY venue").fetchall() return [_subscription_from_row(row) for row in rows] # -- trade outcomes (closed round-trips; rails 11 and 16) --------------- @@ -529,15 +527,24 @@ def open_position( qty: Decimal, entry_fill: Decimal, entry_fee: Decimal, + initial_stop: Decimal | None = None, bracket_order_id: int | None = None, ) -> int: - """Record a newly opened tranche and return its id.""" + """Record a newly opened tranche and return its id. + + `initial_stop` is the stop this tranche was SIZED against (#520) -- the original + per-unit risk the break-even threshold is computed from. `None` is a legitimate value + and means "unknown", not "zero": DCA has no stop by design, and every tranche opened + before v12 predates the column. Readers must disable the break-even arm for such a + tranche rather than substitute the current stop, which is a different policy (see + `db._migrate_v12_positions_initial_stop`). + """ cursor = self._conn.execute( """ INSERT INTO positions (product_id, rule_name, opened_at, qty, entry_fill, entry_fee, - bracket_order_id, status) - VALUES (?, ?, ?, ?, ?, ?, ?, 'open') + initial_stop, bracket_order_id, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'open') """, ( product_id, @@ -546,6 +553,7 @@ def open_position( _dec_to_text(qty), _dec_to_text(entry_fill), _dec_to_text(entry_fee), + None if initial_stop is None else _dec_to_text(initial_stop), bracket_order_id, ), ) @@ -607,6 +615,12 @@ def _position_row_to_dict(self, row: sqlite3.Row) -> dict[str, Any]: d = dict(row) for field in ("qty", "entry_fill", "entry_fee"): d[field] = _text_to_dec(d[field]) + # `initial_stop` (#520) decodes the same way but is NULLABLE, and the distinction is + # load-bearing: `None` means "nobody recorded it" -- DCA, or a tranche predating v12 -- + # and the break-even arm must switch OFF for it rather than substitute a value. + # `_text_to_dec` is not asked to invent a zero. + raw_initial_stop = d.get("initial_stop") + d["initial_stop"] = None if raw_initial_stop is None else _text_to_dec(raw_initial_stop) return d # -- profile (the user's own settings) ------------------------------------ diff --git a/tests/data/test_db.py b/tests/data/test_db.py index 0cf83e7..10c20a0 100644 --- a/tests/data/test_db.py +++ b/tests/data/test_db.py @@ -103,11 +103,11 @@ def test_agent_state_table_has_key_primary_key(): assert pk_columns == {"key"} -def test_schema_version_is_11(): +def test_schema_version_is_12(): """Deliberate tripwire: bump this literal consciously on every schema change.""" from keel.data.db import SCHEMA_VERSION - assert SCHEMA_VERSION == 11 + assert SCHEMA_VERSION == 12 def test_a_v6_database_migrates_up_and_gains_the_profile_table(tmp_path): diff --git a/tests/data/test_initial_stop.py b/tests/data/test_initial_stop.py new file mode 100644 index 0000000..9b823cd --- /dev/null +++ b/tests/data/test_initial_stop.py @@ -0,0 +1,105 @@ +"""`positions.initial_stop` — the stop a tranche was SIZED against (#520). + +The break-even arm of `exit_policy.next_stop` needs the trade's ORIGINAL per-unit risk +(`entry + be_roll_rr * (entry - initial_stop)`). Live state carried `entry_fill` and the current, +ratcheting `open_stop:` and nothing else, so the number the threshold is most +sensitive to was simply absent. + +The distinction these tests defend: `None` means UNKNOWN, and a reader must disable the +break-even arm for that tranche rather than substitute the current stop. Substituting is not an +approximation — the current stop rises on every ratchet, so the threshold would creep toward +entry and the arm would fire earlier each time, drifting further from the measured policy the +longer a trade runs. +""" + +from __future__ import annotations + +from decimal import Decimal + +from keel.data import db +from keel.data.repository import Repository + + +def _repo() -> Repository: + conn = db.connect(":memory:") + db.migrate(conn) + return Repository(conn) + + +def _open(repo: Repository, **overrides): # noqa: ANN003, ANN202 + kwargs = dict( + product_id="BTC-USD", + rule_name="pullback_continuation", + opened_at=1_787_000_000, + qty=Decimal("0.01"), + entry_fill=Decimal("50000"), + entry_fee=Decimal("0.5"), + ) + kwargs.update(overrides) + return repo.open_position(**kwargs) + + +def test_a_recorded_initial_stop_round_trips_as_a_decimal() -> None: + repo = _repo() + _open(repo, initial_stop=Decimal("49000")) + + position = repo.get_open_positions("BTC-USD")[0] + assert position["initial_stop"] == Decimal("49000") + assert isinstance(position["initial_stop"], Decimal) + + +def test_an_unrecorded_initial_stop_reads_as_None_not_zero() -> None: + """The load-bearing distinction. Zero would be a stop 100% below entry -- a real number, and + a catastrophic one to compute a break-even threshold from. `None` says 'nobody recorded it'.""" + repo = _repo() + _open(repo) # no initial_stop -- DCA, or any caller that has none + + position = repo.get_open_positions("BTC-USD")[0] + assert position["initial_stop"] is None + + +def test_dca_style_open_without_a_stop_is_accepted_not_rejected() -> None: + """DCA has no stop BY DESIGN. Requiring one would refuse a legitimate tranche.""" + repo = _repo() + position_id = _open(repo, rule_name="dca", initial_stop=None) + + assert position_id is not None + assert repo.get_open_positions("BTC-USD")[0]["initial_stop"] is None + + +def test_the_column_exists_on_a_freshly_migrated_database() -> None: + conn = db.connect(":memory:") + db.migrate(conn) + columns = {row["name"] for row in conn.execute("PRAGMA table_info(positions)")} + assert "initial_stop" in columns + + +def test_the_migration_is_idempotent_and_does_not_backfill() -> None: + """A database stamped before v12 got `positions` from v4's DDL, which has no such column, and + `CREATE TABLE IF NOT EXISTS` never adds one -- hence the `PRAGMA table_info` guard. + + The honest value for a pre-v12 tranche is NULL. A backfill would fabricate the one input the + policy is most sensitive to. + """ + conn = db.connect(":memory:") + db.migrate(conn) + repo = Repository(conn) + _open(repo, initial_stop=Decimal("49000")) + + db.migrate(conn) # running it again must not raise, duplicate the column, or rewrite data + + columns = [row["name"] for row in conn.execute("PRAGMA table_info(positions)")] + assert columns.count("initial_stop") == 1 + assert repo.get_open_positions("BTC-USD")[0]["initial_stop"] == Decimal("49000") + + +def test_initial_stop_is_not_rewritten_when_the_bracket_moves() -> None: + """`open_stop:` ratchets; this does not. That separation IS the fix -- if the ledger + tracked the current stop there would be nothing to compute the original risk from.""" + repo = _repo() + position_id = _open(repo, initial_stop=Decimal("49000")) + + repo.set_position_bracket(position_id, None) + repo.set_state("open_stop:BTC-USD", Decimal("50500")) # a ratchet moved the live stop + + assert repo.get_open_positions("BTC-USD")[0]["initial_stop"] == Decimal("49000") diff --git a/tests/data/test_migrations.py b/tests/data/test_migrations.py index fc3bb9e..4ec0c63 100644 --- a/tests/data/test_migrations.py +++ b/tests/data/test_migrations.py @@ -46,7 +46,7 @@ def test_fresh_database_is_stamped_at_the_current_version() -> None: conn = db.connect(":memory:") db.migrate(conn) version = conn.execute("SELECT version FROM schema_version").fetchone()["version"] - assert version == db.SCHEMA_VERSION == 11 + assert version == db.SCHEMA_VERSION == 12 def test_fresh_database_gets_no_subscription_row() -> None: diff --git a/tests/data/test_trade_outcomes.py b/tests/data/test_trade_outcomes.py index 43c2599..1b6e68d 100644 --- a/tests/data/test_trade_outcomes.py +++ b/tests/data/test_trade_outcomes.py @@ -35,11 +35,11 @@ def _outcome(**overrides: object) -> dict: return base -def test_schema_is_at_version_11() -> None: +def test_schema_is_at_version_12() -> None: conn = db.connect(":memory:") db.migrate(conn) version = conn.execute("SELECT version FROM schema_version").fetchone()["version"] - assert version == db.SCHEMA_VERSION == 11 + assert version == db.SCHEMA_VERSION == 12 def test_fresh_database_has_no_outcomes() -> None: From bdb204b2ed368df5d4e171ca29f52d545e9a2c89 Mon Sep 17 00:00:00 2001 From: Elmehdi Aitbrahim Date: Sun, 23 Aug 2026 14:22:03 -0400 Subject: [PATCH 2/2] docs(prd): what keel can learn from Jesse, which is not its strategies (#528, #529) Compares keel's Rule ABC against the Jesse framework's Strategy API and its published example strategies (jesse-ai/example-strategies -- jesse.trade is Cloudflare-protected, and reading the source is the better record anyway: what matters is what the framework lets a strategy SAY, not what its strategies claim to earn). The conclusion is deliberately narrow. keel is cost-bound, not signal-bound -- round-trip friction ~2.5%, no shipped rule family net-positive, turtle_breakout negative on all 24 assets measured -- so porting DUAL_THRUST, KDJ, Bollinger and the rest is the known dead end. A rule negative on 24 assets is not fixed by a 25th signal. What Jesse's API can express that keel's cannot: conditional entry at a chosen price, a per-bar update_position hook, pyramiding, partial exits, fill-event hooks, per-strategy state, and a declared hyperparameter space. Four of those are ALREADY keel's open issues (#333, #447, #502) -- an external framework independently re-deriving the same gaps is corroboration of the roadmap, not an addition to it, and the PRD explicitly declines to re-file them. Two things are genuinely new, and are filed: - #528: Jesse declares each strategy's parameter space. keel already consumes n_trials in research/deflate.py and reasons about the trials budget in source comments, but the number itself is hand-recorded. Declaring the space makes the input to keel's own overfitting correction derivable rather than remembered. Explicit non-goal: no optimiser -- running one against a cost-bound engine manufactures exactly what the deflated-Sharpe machinery exists to detect. - #529: Jesse strategies name their entry price and assume the fill. keel priced that assumption when #258 corrected the fill model and pullback_continuation's gross PF fell 0.92 -> 0.77. Any foreign strategy ported naively inherits an optimism this project has already measured. The document also records what keel does that Jesse does not -- rails, a detect() with no account access, fill fidelity, trials discipline -- so the comparison reads as a trade rather than a wishlist. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NyeggYtojNXCTHeD3JHxb6 --- ...6-08-23-strategy-api-expressiveness-prd.md | 179 ++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-23-strategy-api-expressiveness-prd.md diff --git a/docs/superpowers/specs/2026-08-23-strategy-api-expressiveness-prd.md b/docs/superpowers/specs/2026-08-23-strategy-api-expressiveness-prd.md new file mode 100644 index 0000000..e3b25d0 --- /dev/null +++ b/docs/superpowers/specs/2026-08-23-strategy-api-expressiveness-prd.md @@ -0,0 +1,179 @@ +# PRD — Strategy-API expressiveness, learned from Jesse + +**Date:** 2026-08-23 · **Status:** proposal, not accepted · **Source:** comparison against the +[Jesse](https://github.com/jesse-ai/jesse) framework's strategy API and its published example +strategies (`jesse-ai/example-strategies`). + +--- + +## 0. The filter this whole document is written through + +**keel is cost-bound, not signal-bound.** That is measured, not asserted: + +- Round-trip friction on this venue is **~2.5% of notional** (1.2% taker per leg + 5bp slippage + each way) — the same order of magnitude as the per-trade edge of everything ever measured. +- **No shipped rule family is net-positive** at that cost. Zero of 90 in the shipped-defaults + intersection; zero of 82 in the `rsi_meanrev` grid. +- `turtle_breakout` is negative on **all 24 assets** measured. Seven assets showed gross PF > 1.0 + and **all seven died at the maker rate**, before the taker rate was reached. +- A 144-cell hourly sweep across six assets produced **8 winning cells out of 864 trials**, all in + one asset, in a liquidity surge. + +**Therefore: the lesson from Jesse is NOT its strategies.** Jesse ships DUAL_THRUST, KDJ, +MACD_EMA, SMACrossover, SimpleBollinger, RSI2, IFR2 and others that keel does not have. Adding +them is the known dead end — a rule negative on 24 assets is not fixed by a 25th signal, and +signal count has never been the constraint. + +**The lesson is the API.** Jesse's `Strategy` interface can express things keel's `Rule` cannot, +and those specific expressive gaps are — independently — already keel's open issues. That +convergence is the finding. + +--- + +## 1. What was examined + +`jesse.trade/strategies` is Cloudflare-protected and could not be read. The analysis is instead +from the **source of record**: the `jesse-ai/example-strategies` repository (DUAL_THRUST, Donchian, +IFR2, KDJstrategy, MACD_EMA, MAGen, RSI2, SMACrossover, SimpleBollinger, TradingView_RSI, +TurtleRules) and the API surface those strategies exercise. + +Reading the code rather than a marketing page is the better source anyway: what matters here is +what the framework lets a strategy *say*, not what its strategies claim to earn. + +--- + +## 2. The API comparison + +### What Jesse can express that keel cannot + +| Jesse | keel today | +|---|---| +| `self.buy = qty, entry` — a **conditional entry at a chosen price** | `Setup(entry, stop, target)` is advisory; the executor places a **market IOC** and `backtest()` fills at the next bar's open | +| `update_position()` — a per-bar hook on an **open** position | no live equivalent; exits are a boolean `exit_signal()` | +| Pyramiding — `self.buy = ...` again inside `update_position` | a second entry is a separate tranche; no rule can request one | +| Partial exits — a list of `(qty, price)` take-profits | `scale_out()` exists but has **no caller**, pinned by a tripwire test | +| `on_increased_position(order)`, `on_stop_loss(order)` — **fill-event hooks** | no rule-level event hooks at all | +| `self.vars` — per-strategy mutable state across bars | `Rule.detect()` is a pure function of candles | +| `hyperparameters()` — a **declared search space** with min/max/default | parameters are constructor kwargs; the search space lives in ad-hoc sweep scripts | +| `should_cancel_entry()` | no equivalent | + +### What keel does that Jesse does not — stated so this is a trade, not a wishlist + +| keel | Jesse | +|---|---| +| 18 un-overridable rails run before **every** order | strategy-level risk only | +| `detect()` has **no account, balance or venue access** — a rule physically cannot size a position | `self.balance` is available inside the strategy | +| Compliance screening, attestation with provenance, `qabd` possession as an executable check | none | +| Fill model deliberately matches what the executor actually places | strategies assume their chosen price | +| Deflated Sharpe / PBO / trials budget as first-class discipline | optimisation is offered; the multiple-testing correction is the user's problem | + +**keel's statelessness is a deliberate property, not a deficiency.** `detect()` being a pure +function of candles is what makes rules testable with no fixtures, and what guarantees a rule +cannot size its own position. Any expressiveness added must not trade that away wholesale. + +--- + +## 3. The convergence — most of these gaps are already filed + +This is the honest headline. The comparison did not reveal a missing roadmap; it **independently +re-derived the one keel already has**: + +| Jesse capability | keel issue | +|---|---| +| conditional entry at a price | **#333** — "Route a rule's conditional entry as a genuine resting order (limit/stop)" | +| `update_position()` per-bar management | **#502** stage 3 — the live stop-management step | +| partial exits | **#502** — `scale_out`'s two prerequisites | +| pluggable strategies | **#447** — "Rules are not pluggable, though brokers are" | + +An external framework arriving at the same four gaps is evidence those issues are correctly +prioritised. **No new issue is warranted for any of them.** + +--- + +## 4. What IS genuinely new + +### 4.1 A declared hyperparameter search space (the strongest finding) + +Jesse's `hyperparameters()` returns the space itself: + +```python +def hyperparameters(self): + return [ + {'name': 'stop_loss_atr_rate', 'type': float, 'min': 0.1, 'max': 2.0, 'default': 2}, + {'name': 'up_length', 'type': int, 'min': 3, 'max': 30, 'default': 21}, + ... + ] +``` + +keel already has the machinery this feeds: + +- `research/deflate.py::expected_max_sharpe(n_trials)` — the Sharpe expected from the luckiest of + N zero-skill trials. +- `research/independence.py` — "two rules that fire together are one rule counted twice, + **consuming trials budget twice**". +- A **hand-maintained** trials ledger (`keel/research/ledger.py`, recorded through the CLI with + explicit `DECISIONS`/`PROVENANCE`), plus source comments reasoning about whether a given choice + "increments the trials budget (§73.12)". + +**So `n_trials` — the input to keel's own overfitting correction — is currently a number a human +remembers to record.** Declaring the search space on the rule makes it *derivable*: the size of +the grid a sweep could explore is a property of the rule, not of the operator's diligence. + +This is the one place Jesse's design is straightforwardly better for a discipline keel already +cares about more than Jesse does. + +### 4.2 A warning worth encoding, not a feature + +Jesse strategies routinely do `self.buy = qty, entry` and assume the fill. keel measured what that +assumption costs: correcting `pullback_continuation` to a market fill **dropped its gross profit +factor from 0.92 to 0.77** and doubled its trade count, because a market order takes the trades +the strategy meant to decline. + +**Any Jesse strategy ported naively inherits an optimism keel has already priced.** That belongs in +the docs as a stated hazard, so the next person evaluating an external strategy knows to check the +fill model before the returns. + +--- + +## 5. Proposal + +**Adopt one thing, document one thing, decline the rest.** + +1. **Adopt:** a declared parameter space on `Rule` (§4.1), wired to the trials budget. +2. **Document:** the fill-model hazard when evaluating any externally-sourced strategy (§4.2). +3. **Decline, explicitly:** porting Jesse's signal families. Not because they are bad, but because + signal count is not the binding constraint and importing them would consume review effort on + the axis already measured closed. +4. **Do not re-file** #333 / #447 / #502 — the comparison validates them; it does not add to them. + +### Non-goals + +- Adopting Jesse's stateful `Strategy` base class wholesale. Its `self.vars` and `self.balance` + access would give a rule the ability to size its own position, which is precisely the separation + keel's architecture is built on. +- Adding pyramiding. It is expressible today as a second tranche, and rail 8 already governs + averaging up. +- A hyperparameter *optimiser*. Declaring the space is cheap and improves an existing correction; + running an optimiser against a cost-bound engine would manufacture exactly the overfitting the + deflated-Sharpe machinery exists to detect. + +--- + +## 6. Risks + +- **The declared space becomes a licence to search it.** The point is to make the trials budget + honest, not to encourage sweeps. Mitigation: the field is consumed by the *correction*, and any + sweep still records to the ledger with its provenance. +- **A declared space that drifts from the real one.** If a sweep script explores values outside the + declaration, the correction under-counts. Mitigation: the sweep should read the declaration + rather than restate it. +- **Scope creep toward Jesse's model.** Each capability in §2 is individually reasonable and + collectively a different architecture. The rails, the pure `detect()`, and the fill fidelity are + the things not to trade. + +## 7. Success criteria + +- [ ] `n_trials` for any rule family is derivable from the rule itself, not from a human's memory. +- [ ] The fill-model hazard is documented where someone evaluating an external strategy will meet it. +- [ ] No new signal family is added as a result of this comparison. +- [ ] #333, #447 and #502 are unchanged — the comparison is recorded as corroboration, not new scope.