Repository navigation
keel v0.18.0
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.
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 simulaterun 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.detectpriced 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.TurtleBreakoutalready guarded against this with_completed_days;Dcahad no guard. - The edge table showed DCA as N 0.
Dcadeclares no timeframe, sobacktest()gave it only hourly candles. It needs daily candles, so it never fired. Declaring a daily timeframe would not have fixed it. Itstarget=entry/stop=0sentinels 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_pnlandtrade_countcome from closed trades only, so the sleeve was insideending_valueand 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.pyascompleted_days, with its docstring.TurtleBreakoutimports it under its old private name, so every existing reference still resolves.Dca.detectnow 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 inSimResult.dca_buys(decision time, fill time, qty, notional, and cash cost including fees).SimResult.final_pricesrecords each asset's last close. - Edge table. Rules with the new
Rule.accumulatesflag, which today meansDca, are left out ofedge_table. So they are not in__pooled__orgroup_trades_by_class, and G2 gets neither fake round trips nor a zero-trade sample. The newreport.accumulation_tableruns 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_metricsnow returnsdca_sleeve, built byportfolio_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--forceis 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:BTCround-trip key; the other tests failed on missing names or keys.tests/sim/test_dca_sleeve.py, all using the realDca: (a) total spend equals budget × cadence days; (d) accumulation row plus a pooled result byte-identical with or without DCA, andgroup_trades_by_classis 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 frombacktest().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_tableand the pool (2 failed) - sleeve marked at cost instead of the last close (1 failed)
completed_daysdrops 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 testsis clean,uv run ruff format --check keel testsis clean, anduv run mypyreports 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_statsand_pooled_floorsall 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.
- The R denominator was signed,
What changed
-
Trades carry their risk.
Trade.initial_riskandSimTrade.initial_riskhold 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 toNone, so existing constructors keep working. -
One R formula.
rules.base.r_multiple_of(pnl, initial_risk, qty) = pnl / (initial_risk × qty). It returnsNonewhen 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.
BacktestResultgains:expectancy_r,avg_win_r,avg_loss_r,profit_factor_rmax_drawdown_r: the drawdown of cumulative Ravg_mfe_r,avg_mae_rn_excluded_no_riskn_wins_r/n_losses_r, which letpool_statspool 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
Nonefor 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, andshould_demote(whose docstring says it mirrorscheck_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_tradesandmin_win_rateare unchanged.min_expectancyis now an R threshold; every shipped config has it at 0.0.
-
Paper history.
paper.track_recordrecoversinitial_riskfor 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 recordinitial_riskin the payload. -
keel rules backtestnow printsexpectancy_randmax_drawdown_rfirst, thenexpectancy_pxandmax_drawdown_pxwith a legend "(px = price units, 1-coin notional)". Before, it printed bareexpectancy=andmax_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), orno 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 fromcheck_floors, which now judges R, so the blocking reasons name R.
Test fixtures that changed
- The hand-built
BacktestResultfixtures intest_promotion._stats,test_report._pooled_resultandtest_cli's pooledfake_backtestnow 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_recordstoredr_multiple="2.5"on a trade whose own pnl and stop gave 1.0R.track_recordnow 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.jsonandtests/baseline/serialize.pyare unchanged, and the baseline test passes. The baseline has no gap-below-stop entries, so every pinnedr_multipleis 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.45and1.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_rwas written after the implementation. It fails on the old code, where G2 passed on price units, and a mutant kills it (M7 below). - The backtest, paper and portfolio_sim gap tests failed on the sign:
-
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 testsM2a summarizereads pnl instead of Rtest_r_aggregates_are_identical_across_a_btc_scale_and_an_xlm_scale_sampleM2b check_floorsexpectancy in price unitstest_check_floors_judges_expectancy_and_rr_in_rM2c _realized_rrin price unitstest_check_floors_judges_expectancy_and_rr_in_rM3 no-risk trades counted as 0R (no exclusion) test_trades_with_no_or_zero_risk_are_excluded_from_r_and_countedM4 no sort by exit time test_edge_table_pools_in_exit_time_order_so_max_drawdown_is_chronologicalM5 risk measured from the managed stop test_a_managed_stop_still_measures_r_against_the_original_stopM6 pool_statsdoesn't pool Rtest_pooled_pass_when_the_rule_alone_is_short_but_the_parameter_set_is_notM7 no R passes the floors test_g2_refuses_a_pooled_sample_with_no_rM8 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 -qgives 6755 passed, 3 skipped, including the 34 new tests. -
Lint and types:
uv run ruff check keel testspasses.uv run ruff format --check keel testsfinds all 426 files formatted. Bareuv run mypyreports 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_usdstays 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 askeel 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 indocs/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.pypasses: the record points at the per-product restatement.ruffis 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 simulatebuys 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
- Run Actions → Release with version
0.18.0. - Install into
~/keelusing the runbook's steps: back up each database, install the five wheels, runkeel migrate(nothing to apply), thenkeel versions(must show[release]) andkeel status. - Restart the three running
keel serveconsoles. The hourly console is decommissioned.