Skip to content

keel v0.18.0

Choose a tag to compare

@github-actions github-actions released this 26 Sep 22:10
· 80 commits to main since this release
af9db81

Built from af9db81. Version binds to this hash:
keel --version reports keel 0.18.0+af9db817b3c8 [release].

Install

Download all wheels from this release into one directory, then install the
keel_trader wheel by path:

pip install --find-links . ./keel_trader-0.18.0-py3-none-any.whl
keel versions

keel versions — not keel --version — is the check: it reports every
keel distribution in the venv and exits non-zero if a sibling was left behind at
an older version, which --version cannot see. Upgrading an existing
deployment: see "Deploying a new version" in the README.

⚠️ Never install by bare name. The distribution is keel-trader; the name
keel on PyPI belongs to an unrelated project, so pip install keel fetches
someone else's package. A build reporting DIRTY or [checkout] is not this
release and must not be run against live funds.

Configure

config.yaml is attached to this release: the production config, in
auto_trade.mode: confirm — keel previews every order and waits for your
approval. Drop it beside the install (or run keel init-config --live), put
your CDP key in a git-ignored .env, then:

keel migrate     # existing database: apply schema migrations
keel init        # fresh deployment: write config + seed candidate rules

Seeded rules start as candidate and trade nothing until you promote them.

Fixes

fix(sim): DCA buys once per completed day, and the report shows its sleeve (#821) (#825)

What was wrong

keel simulate got the DCA rule wrong in four ways, and the report hid all of them.

  • The simulator bought DCA about 24 times per cadence day. It evaluates rules on every hourly bar. A DCA rule decides on daily candles, so its answer is the same on all 24 bars of a day, and each of those bars bought. On a synthetic 60-day repro, 9 cadence days at $50 spent about $10,768 instead of $450. Every earlier keel simulate run with a DCA rule has an oversized DCA sleeve. That inflates or distorts its ending value, return, drawdown, caps headroom and rail-14 trading volume.
  • It read the day before the day had happened. The simulator's daily window includes the day still in progress, and that bar is stored with the completed day's OHLC. From 00:00 UTC, Dca.detect priced its buy at that day's close and measured the dip against that day's high. Live only has closed candles, so live DCA sees yesterday, and the simulator's cadence day was one day ahead of live. TurtleBreakout already guarded against this with _completed_days; Dca had no guard.
  • The edge table showed DCA as N 0. Dca declares no timeframe, so backtest() gave it only hourly candles. It needs daily candles, so it never fired. Declaring a daily timeframe would not have fixed it. Its target=entry / stop=0 sentinels would turn each buy into a fee-only round trip closed on its fill bar, which is a fake loss and not an edge.
  • The sleeve was bought but never shown. DCA lots are never closed by design. per_asset_pnl and trade_count come from closed trades only, so the sleeve was inside ending_value and appeared nowhere else in the Markdown report or the HTML artifact.

What changed

  • Completed days only. The forming-day guard now lives in keel/strategy/rules/base.py as completed_days, with its docstring. TurtleBreakout imports it under its old private name, so every existing reference still resolves. Dca.detect now uses it. Live behaviour is unchanged: when the last daily bar is closed and the hourly series is past it, that bar is kept. A test pins this.
  • One DCA decision per UTC day, per (asset, rule) in portfolio_sim. The day is marked when the decision is taken, whether the buy fills or is vetoed. That matches live, where the agent trades once per UTC day. Each buy is logged in SimResult.dca_buys (decision time, fill time, qty, notional, and cash cost including fees). SimResult.final_prices records each asset's last close.
  • Edge table. Rules with the new Rule.accumulates flag, which today means Dca, are left out of edge_table. So they are not in __pooled__ or group_trades_by_class, and G2 gets neither fake round trips nor a zero-trade sample. The new report.accumulation_table runs them over daily candles, using the same completed-day and once-per-day rules. It reports buys, qty, cost basis including fees, last close, marked value and unrealized P&L. The report renders it as its own section, "DCA accumulation (not round trips)", right after the edge table. The edge table's own columns are untouched, so #820's R-multiple change should merge cleanly.
  • Account report. build_account_metrics now returns dca_sleeve, built by portfolio_sim.dca_sleeve: per asset, the buys, qty, cost basis, value at the last close, and unrealized P&L. The Markdown report shows it under "DCA sleeve (accumulation, marked to market)". The per-asset list is now labelled "Per-asset realized rule P&L (closed round trips; the DCA sleeve is below)". The HTML artifact gets the same sleeve table, and its chart is retitled "Per-asset realized rule P&L".
  • keel rules promote: nothing changes. Its docstring says a DCA rule produces no backtest trades, so --force is its only path to paper. That is still true. backtest() is unchanged, a test pins N 0, and the promotion path never reads the accumulation row.

Before and after (synthetic repro: 60 days of flat-ish hourly and daily candles, cadence 7, $50)

Before After
Account-sim DCA buys ~24 per cadence day 8, one per cadence day
Account-sim DCA spend ~$10,768 $400.00 notional ($405.95 cash with taker fees and slippage)
Day the buy is decided on the cadence day itself, reading its close from 00:00 the day after the cadence day closes, as live
Edge table dca:BTC row with N 0 no round-trip row; accumulation row with 8 buys, $400.90 cost, $428.93 value, +$28.03 unrealized
Account report sleeve absent; per_asset_pnl {} DCA sleeve table: 8 buys, 3.7572 qty, $405.95 cost, $429.79 value, +$23.84 unrealized

The window has 9 cadence days, but the ninth is its last day. That day's decision falls on the next day, outside the window, so the result is 8 buys, not 9.

Verification

  • TDD: each new test was written first and seen failing for the right reason. The sim test got qty 96, which is 0.5 × 8 cadence days × 24; the forming-day tests got a buy priced off the forming day; the edge test found a dca:BTC round-trip key; the other tests failed on missing names or keys.
    • tests/sim/test_dca_sleeve.py, all using the real Dca: (a) total spend equals budget × cadence days; (d) accumulation row plus a pooled result byte-identical with or without DCA, and group_trades_by_class is empty; (e) sleeve value equals qty × last close and unrealized equals value − cost; (f) the Markdown tables are parsed row by row. Also: the realized-P&L label, and N 0 from backtest().
    • tests/strategy/test_dca.py: (b) at hour 0 the forming day's close and high are ignored, and a forming cadence day does not fire; (c) live-shaped input still fires on the closed day. (c) is a regression pin, so it passes both before and after the change.
    • tests/sim/test_artifact.py: the sleeve table header and cells, and no table when there is no sleeve.
  • Mutation run: each mutant was applied alone, the source was checked to confirm it changed, and the file was restored from a saved copy. All five were killed:
    • once-per-day guard removed (3 tests failed)
    • completed-day drop removed from Dca (5 failed)
    • DCA routed back into edge_table and the pool (2 failed)
    • sleeve marked at cost instead of the last close (1 failed)
    • completed_days drops the last day whenever hourly data exists, the live regression (1 failed)
  • uv run pytest -q: 6735 passed, 3 skipped. uv run ruff check keel tests is clean, uv run ruff format --check keel tests is clean, and uv run mypy reports no issues. No baseline fixtures changed.

Closes #821

fix(research): the edge table, G2 and promotion judge R, not price units (#820) (#826)

What was wrong

The simulator's edge table said its numbers were "unit-less R-multiples". They weren't. Every trade already stored the correct R (Trade.r_multiple), but stats.summarize never used it. Expectancy, average win and loss, profit factor, max drawdown and MFE/MAE were all built from pnl, which is price units for a one-coin position. A BTC trade and an XLM trade differ by five orders of magnitude in price, so:

  • The pooled row was decided by BTC. One BTC trade that lost 1R (−2000) outweighed any number of XLM trades that won 2R (+0.004 each).
  • G2 in keel simulate, and #338's pooled promotion, read those same numbers. check_floors, _realized_rr, pool_stats and _pooled_floors all worked in price units.
  • Two more defects sat on the same path:
    • The R denominator was signed, (entry_fill − stop) × qty. When the fill gapped below the stop, the risk went negative and a losing trade got a positive R. This happened in all three close paths: backtest, paper and portfolio_sim.
    • The pooled max drawdown followed the trades in rule order (rule A's whole history, then rule B's). That sequence never happened.

What changed

  • Trades carry their risk. Trade.initial_risk and SimTrade.initial_risk hold the per-unit |entry_fill − setup.stop|: the fill the trade actually got, against the setup's ORIGINAL stop, never a trailed or break-even stop. Both fields are last and default to None, so existing constructors keep working.

  • One R formula. rules.base.r_multiple_of(pnl, initial_risk, qty) = pnl / (initial_risk × qty). It returns None when the risk is missing or zero. All three close paths use it, so R's sign now always matches the P&L's.

  • R versions of the stats, next to the money ones. BacktestResult gains:

    • expectancy_r, avg_win_r, avg_loss_r, profit_factor_r
    • max_drawdown_r: the drawdown of cumulative R
    • avg_mfe_r, avg_mae_r
    • n_excluded_no_risk
    • n_wins_r / n_losses_r, which let pool_stats pool R exactly

    A trade with no risk is left out of the R stats and counted, never treated as 0R. A sample with no R at all gives None for every R field.

    The money fields are unchanged. insights, the web payload, tuning and walk-forward still read them.

  • Edge table. The pooled row and the per-class pools (group_trades_by_class) are sorted by exit time before they are summarised. The table shows R columns under a label that is now true, and adds an "Excluded from R" line when any trades were left out.

  • Promotion.

    • check_floors, _realized_rr, pool_stats/_pooled_floors, and should_demote (whose docstring says it mirrors check_floors) now judge expectancy and R:R in R.
    • No R at all means a refusal, not a pass. For demotion, no R demotes the rule, following transition's rule that missing evidence must never block pulling a rule back from real money.
    • min_trades and min_win_rate are unchanged. min_expectancy is now an R threshold; every shipped config has it at 0.0.
  • Paper history. paper.track_record recovers initial_risk for exits written before this change from the entry payload's fill and stop. It then recomputes their R instead of trusting a value the old signed formula stored. New exits record initial_risk in the payload.

  • keel rules backtest now prints expectancy_r and max_drawdown_r first, then expectancy_px and max_drawdown_px with a legend "(px = price units, 1-coin notional)". Before, it printed bare expectancy= and max_drawdown=.

Behaviour changes to know about

  • G2 and pooled promotion can change verdict. A multi-asset pool whose sign in price units was set by BTC is now judged by its R. It can flip either way: a pool whose BTC trades lost R while cheaper assets won R now passes, and the reverse now fails. Refusal reasons now read expectancy_r … (in R), rr_r … (in R), or no R: … refusing rather than passing.
  • A single-asset rule can also move. R:R was a ratio of average pnl and is now a ratio of average R, and the two differ whenever risk per trade varies.
  • insights' gate distance still displays money expectancy and R:R, as it should. Its pass/block verdict comes from check_floors, which now judges R, so the blocking reasons name R.

Test fixtures that changed

  • The hand-built BacktestResult fixtures in test_promotion._stats, test_report._pooled_result and test_cli's pooled fake_backtest now also fill the R fields with the same numbers. Without R, the floors correctly refuse them.
  • test_journal_report_enriches_r_multiple_from_paper_track_record stored r_multiple="2.5" on a trade whose own pnl and stop gave 1.0R. track_record now recomputes R from the journalled fill and stop, so the fixture was made self-consistent (pnl 25 on a risk of 10).
  • Baseline: tests/fixtures/baseline_backtest.json and tests/baseline/serialize.py are unchanged, and the baseline test passes. The baseline has no gap-below-stop entries, so every pinned r_multiple is the same value, and the serializer doesn't emit the new fields.

Verification

  • TDD. 34 new tests. Each was run and seen failing before its implementation. Examples of the failure reasons:

    • The backtest, paper and portfolio_sim gap tests failed on the sign: 0.45 != -0.45 and 1.28… != −….
    • The chronological-pool test failed on the order: [18000, 10800, 25200] != [10800, 18000, 25200].
    • The rest failed on the missing fields and helper.

    One exception: test_g2_refuses_a_pooled_sample_with_no_r was written after the implementation. It fails on the old code, where G2 passed on price units, and a mutant kills it (M7 below).

  • Mutation. Each mutant was applied alone, checked to have changed the source, run, and restored from a saved copy. All 10 were killed:

    Mutant Killed by
    M1 signed risk test_the_shared_r_helper; also, separately, the backtest, paper (×2) and portfolio_sim gap tests
    M2a summarize reads pnl instead of R test_r_aggregates_are_identical_across_a_btc_scale_and_an_xlm_scale_sample
    M2b check_floors expectancy in price units test_check_floors_judges_expectancy_and_rr_in_r
    M2c _realized_rr in price units test_check_floors_judges_expectancy_and_rr_in_r
    M3 no-risk trades counted as 0R (no exclusion) test_trades_with_no_or_zero_risk_are_excluded_from_r_and_counted
    M4 no sort by exit time test_edge_table_pools_in_exit_time_order_so_max_drawdown_is_chronological
    M5 risk measured from the managed stop test_a_managed_stop_still_measures_r_against_the_original_stop
    M6 pool_stats doesn't pool R test_pooled_pass_when_the_rule_alone_is_short_but_the_parameter_set_is_not
    M7 no R passes the floors test_g2_refuses_a_pooled_sample_with_no_r
    M8 paper legacy exit trusts the stored signed R test_track_record_recovers_initial_risk_for_a_legacy_exit_from_its_entry
  • Full suite: uv run pytest -q gives 6755 passed, 3 skipped, including the 34 new tests.

  • Lint and types: uv run ruff check keel tests passes. uv run ruff format --check keel tests finds all 426 files formatted. Bare uv run mypy reports no issues in 468 source files.

Closes #820

Compliance & rails

chore(live): per-asset ceiling 0.50 -> 0.75 ($200 -> $300) (#819)

Mirrors a change already live in ~/keel/config.live-sandbox.yaml (applied 2026-09-26 at 01:16 UTC, before that day's 01:20 run), so the tracked copy stays byte-identical to the deployment. Before this edit the two files were identical.

Why

  • The weekly BTC DCA was vetoed on 2026-09-25 by per_asset_concentration_cap. BTC held $151.81 at cost, from three $50 buys. The fourth buy projected to $201.81, over the 0.50 × $400 = $200 limit.
  • Exposure is measured at cost, so a falling price never frees room. At 0.50 the DCA was permanently capped at three buys, regardless of the ~$257 in available cash.
  • At 0.75 the limit is $300, which admits three more weekly buys. The first projection over it is $301.81.

Unchanged

  • max_exposure_usd stays at 400. It's also the sizing proxy, and the config's own note says to raise it only alongside rail 14.
  • The total exposure after the next buy is about $263.51 (BTC $151.81, PAXG about $61.70 at cost, plus $50), still under $400.

A dated note was added to the caps history comment.

Research & validation

docs(adr): 0006 -- daily strategies are evaluated on the pooled sample, never per asset (#822) (#824)

Closes #822.

Adds docs/decisions/0006-pooled-evaluation-of-daily-strategies.md and its index entry.

  • Records the owner's decision of 2026-09-26 on the floor #427 left open: n ≥ 100 pooled.
  • Formalizes #338's pooled unit of evaluation, including its diversity floor of 5 products × 10 trades.
  • Per-product statistics become diagnostics only.
  • Evidence that per-asset n ≥ 100 is unreachable: a 5-year backtest gives the daily Turtle 5 to 18 trades per product, and 220 pooled.
  • Stated as a consequence, not hidden: at n = 100 pooled, n_eff ≈ 39, so any evaluation at the floor is descriptive and can only see an edge of about 20 points or more. A confirmatory claim still needs about 259 pooled.
  • Pooled expectancy must be unit-free, which ties this record to #820.

No code or config changes: min_trades: 100 keeps its value, and this record states its unit is the pool.

docs(experiments): the hourly turtle over five years -- 4,871 trades, 0 of 19 positive at the fee paid (#823) (#827)

Refs #823 (closed with its result). Records the run with its complete per-trade dataset and the fee-to-risk arithmetic behind it.

Files

  • docs/experiments/2026-09-26-hourly-turtle-5year-backtest.md: the record.
  • …-5year-backtest.py: the driver. It uses the same code path as keel simulate's edge pass (report.edge_table, simulate.load_sim_candles, per-product slippage), reads the database read-only, and replays the exact window of the original run.
  • …-5year-backtest.jsonl: all 4,871 trades, each with its R at the 1.20%, 0.60% and 0% fee levels, plus per-product and pooled summaries. It's 1.5 MB, the largest file in docs/experiments/. The README says bulk sweep output isn't committed; this is the run's recorded output rather than a sweep, and it was asked for as the complete dataset. Happy to drop it to summaries only if you'd rather not carry the size.
  • docs/experiments/README.md: the index entry.

Findings

  • The driver reproduces the #823 run exactly: N = 4,871, −1.178 R at 1.20% and −0.696 R at 0.60%. It asserts that the trade set is identical across fee levels.
  • 0 of 19 products are positive at 1.20% or 0.60%. At a measured 0% fee, only BTC, ETH and SOL are, the three products with 5–11 bp slippage. Pooled at 0% it's −0.215 R.
  • Mechanism: the 2×ATR hourly stop sits a median 2.75% from entry, so a 2.40% round trip costs about 0.87 R per trade, matching the measured 0%→1.20% difference. The daily stop sits a median 11.6% away, so the same round trip costs about 0.21 R there.
  • Relationship to earlier records: consistent with 2026-08-11. It reconciles 2026-08-12's "profitable at zero fee" (priced at a flat 5 bp) with per-product slippage.

Honesty notes

  • Not pre-registered: it records a run already made.
  • Corrects an earlier figure. My #823 comment's zero-fee number (about −0.2 R) was extrapolated; the record replaces it with the measured −0.215 R.
  • States what it doesn't show. Daily strategies have no demonstrated edge either. "Regime-filtered daily strategies" is a hypothesis this record motivates, not one it tests. Weekly DCA is accumulation, not an edge claim.
  • Records the decommissioning of the hourly paper account (2026-09-26): both launchd jobs unloaded, 19 rules disabled, one paper BCH position frozen.

Verification

  • Full suite: 6769 passed, 3 skipped.
  • tests/test_experiment_cost_pointers.py passes: the record points at the per-product restatement.
  • ruff is clean on the driver.

Other changes

chore(release): 0.18.0 -- evidence in R, DCA simulated once a day, pooled evaluation as policy (#828)

Version bump for 0.18.0: the eight workspace versions, the inter-package == pins, and uv.lock. Same shape as 0.17.0 (#818): 9 files, 25 lines in and 25 out. uv lock --check passes.

Why a minor bump: #826 changes what promotion decides. G2 and #338's pooled path now judge in R, min_expectancy is an R threshold, and a sample with no R is refused. #825 adds new report sections. No schema change since v0.17.0 (keel/data/ is untouched).

What's in it

  • #826 (#820): the backtest edge table, G2 and promotion work in R rather than price units. A losing trade that gapped below its stop now gets a negative R, and pooled rows are in exit-time order.
  • #825 (#821): keel simulate buys DCA once per completed day (it bought 24 times per cadence day), with no forming-day look-ahead, and reports the DCA holdings at market value.
  • #824 (#822): ADR 0006, daily strategies are evaluated on the pooled sample.
  • #827 (#823): experiment record for the hourly Turtle, 4,871 trades.
  • #819: the live per-asset ceiling config mirrored into the repo (0.50 → 0.75).

Live trading is unaffected. These changes touch offline tooling and promotion only. Replaying 14 days of real live candles through the new DCA code gave the same buy days as before.

After merge

  1. Run Actions → Release with version 0.18.0.
  2. Install into ~/keel using the runbook's steps: back up each database, install the five wheels, run keel migrate (nothing to apply), then keel versions (must show [release]) and keel status.
  3. Restart the three running keel serve consoles. The hourly console is decommissioned.