keel v0.5.5
Built from 7b02bfd. Version binds to this hash:
keel --version reports keel 0.5.5+7b02bfd8fbbc [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.5.5-py3-none-any.whl
keel --version
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.
Other changes
feat(rules): keel rules add -- a proposed parameter set's path to a backtest (#182)
A scout produces rule-parameter proposals (proposals/*-param-proposals.json: {"kind": "turtle_breakout", "params": {"entry_lookback": 55}}). Until now none of them could be evaluated. keel rules seed builds rows only from each kind's constructor defaults, and no CLI accepted custom params, so putting a proposal in front of keel rules backtest meant hand-writing Python against Repository.insert_rule. A proposal that cannot be measured is one that gets adopted on argument instead of on evidence.
keel rules add --kind turtle_breakout --product BTC-USD --params '{"entry_lookback": 55}'
added rule 3: turtle_breakout BTC-USD status=candidate
params: {"adx_period": 14, ..., "entry_lookback": 55, ..., "target_rr": "6"}
note: 1 other rule(s) already exist for turtle_breakout/BTC-USD -- this is allowed
(comparing parameter sets is the point), but backtest the right one:
[1] status=candidate entry_lookback=40
next: keel rules backtest 3
candidate, always, with no flag to say otherwise
The row is inserted at candidate and there is deliberately no --status. candidate is the lifecycle floor: the row must still clear rules backtest and rules promote before it can reach paper, let alone live. That single property is what makes it safe to let un-vetted JSON — from an operator's keyboard or a scout's file — into the rules table of a system trading real money. rules seed --status live exists for the supervised live-order test; a command whose entire input is un-vetted parameters must not have its equivalent. Asserted two ways: the command exposes no status parameter, and --status live is rejected.
One coercion boundary, not two
rules.params round-trips through json.dumps/json.loads, so a stored param is JSON-plain while the constructors want Decimals, a Granularity and tuples. agent._build_rule already owned that conversion. --params JSON has the identical problem in the identical direction, so rather than grow a second table, _build_rule was split:
agent.build_rule_from_params(kind, params)— the coercion + construction boundary,agent._build_rule(row)— a thin DB-row adapter over it that addsrules.id.
rules add calls the same function the agent cycle and the backtester call. Two tables that can drift is the failure mode being avoided: the symptom of the drift is a Decimal/float TypeError raised deep inside a rule's arithmetic, mid-backtest, far from the params that caused it.
Refused before anything is written
Everything below the first line of the command validates; insert_rule is the last statement that can run. Nothing is written on any refusal path.
| input | outcome |
|---|---|
--product XLM-28AUG26-CDE / BTC-PERP-USD |
refused — rail 19 shape (via parse_products_option, exactly as rules seed) |
--product BTC-EUR |
refused — "settles in EUR", rail 18 |
--product btc-USD |
refused — "did you mean BTC-USD?", never silently uppercased |
--kind turtle |
refused — naming RULE_REGISTRY's kinds |
{"cadance_days": 7} |
refused — the constructor's TypeError, plus the accepted kwargs read off its signature |
{"cadence_days": 0} |
refused — Dca's own ValueError, "cadence_days must be positive" |
not JSON / [7] / a product_id disagreeing with --product |
refused |
The params are validated by construction: RULE_REGISTRY[kind](product_id=..., **params) is actually built, and what is stored is .describe()'s params — not the raw JSON — so the row is exactly what agent._build_rule reconstructs. A row that stores but cannot rebuild is worse than a refusal: it fails later, inside a backtest or a cycle.
Construction is not enough, and that is not a theory
Two of the four rule kinds (RsiMeanReversion, PullbackContinuation) validate nothing in their constructors, so "validate by construction" is not a filter for them at all. Five shapes were found that construct, store, rebuild — and only then fail, inside the backtest the row was added for. Each was reproduced before being closed:
| params | what construction did | what happened next |
|---|---|---|
{"oversold": "10.0"} |
accepted (plain dataclass, no validation) | TypeError: can only concatenate str (not "int") to str in detect() |
{"lookback_days": 90.5} / 1e400 |
accepted, > 0 passes (inf is positive) |
TypeError: slice indices must be integers |
{"oversold": null} |
accepted — coercion passes None through |
float < None |
{"ema_periods": "abc"} / ["8","20","50"] |
tuple("abc") char-splits to ('a','b','c'); quoted numbers survive as strings |
TypeError in ema_fan |
{"budget_usd": Infinity} |
Decimal('Infinity') <= 0 is False, so Dca's budget guard passes it |
no meaningful backtest; the stored row is not valid JSON |
{"granularity": "ONE_DAY"} (pullback) |
accepted, then dropped — describe() does not carry it |
rebuilds at the ONE_HOUR default: backtest numbers for a rule on a different candle series |
All six now refuse, naming the offending params, before anything is written. Each check is derived from the rule itself — the constructor's own default type, its own describe(), and agent.coerced_param_keys for "may this one be quoted?" — so a rule that changes a field's type or starts persisting one needs no edit here. Quoting is the likeliest typo in hand-copied JSON precisely because it is correct for the Decimal params.
Deliberately not checked: whether a value makes economic sense. A negative atr_stop_mult is still accepted. That judgement belongs in the rule's constructor, where every caller gets it (Dca already validates its own three), and re-stating it in a CLI command is exactly the second, drifting copy this change is otherwise careful not to create. A candidate rule with a nonsense parameter answers for itself in the backtest it has to pass.
Duplicates are the use case; the allowlist is a note, not a veto
No idempotency skip — comparing two parameter sets for one (kind, product) is the entire point. Existing rules for the pair are reported with their ids, statuses, and only the params that differ from the new row (rules list remains there for the full picture; making the operator eye-diff thirteen identical params at the moment they must pick an id is how the wrong id gets backtested).
A product outside config.allowlist is added and said out loud, not refused — backtesting SOL before deciding whether to admit it is the intended workflow, rail 1 still stands between it and any order, and a candidate rule never trades regardless. The note quotes rail 1's own _asset key so it cannot disagree with the rail it is previewing.
Verification
./.venv/bin/ruff check keel tests packages clean, mypy keel packages clean, 2122 tests pass (2085 → +37). Every new test was watched fail for its own reason — against a stub command with the right option surface, so each one failed on its own assertion rather than on an import error.
The refactor of _build_rule is a pure extraction, checked line by line against the original and re-run against all four of its existing callers (agent.run_once, cli.simulate, insights.build_rule_track_record, rules backtest): same coercions, same order, same ValueError. The branch was also put through an adversarial review pass that hunted specifically for rows that store but do not run; the last two rows of the table above came out of it.
Read-only w.r.t. the exchange: no network call, no broker, no confirmation gate — one local rules row, exactly like rules seed.
fix(rules): refuse container params, typo'd Literal choices, and empty --params (#183)
Six findings from an independent adversarial review of #182, all reproduced on main before being fixed here. #182 itself stands — the candidate floor, the ~45 refusal paths, and the build_rule_from_params refactor were all verified solid. These are the gaps that review found.
F1 (HIGH) — _param_type_mismatches had two holes
Reproduced on main:
{'oversold': '10.0'} refused=True <- string caught
{'oversold': [1, 2]} refused=False <- LIST falls out of the elif chain
{'oversold': {'a': 1}} refused=False
{'adx_threshold': []} refused=False
{'stop_method': 'banana'} refused=False <- Literal choices never checked
{'entry_zone': 'banana'} refused=False
(a) A JSON container supplied for a scalar param fell out of the chain entirely. {"oversold": [1,2]} is the same param and the same failure as {"oversold": "10.0"} and {"oversold": null}, both already refused. End-to-end on a copy of a real DB: rules add --kind rsi_meanrev --params '{"oversold": [1,2]}' → added rule 28 → rules backtest 28 → TypeError: '<' not supported between instances of 'float' and 'list'.
(b) A param's declared Literal choices were never checked.
F2 (HIGH, raised from MEDIUM) — a typo'd Literal silently backtests a different rule
The worse of the two, because it does not crash. PullbackContinuation dispatches on == "ema_touch" and validates nothing, so an unknown value picks a branch by fallthrough. Measured on real BTC-USD hourly candles (4000-bar slice, this branch, rule constructed directly):
entry_zone |
trades | expectancy |
|---|---|---|
ema_touch (default) |
11 | -906.0013075636… |
ema_band |
7 | -901.8626775771… |
banana |
7 | -901.8626775771… |
stop_method |
trades | expectancy |
|---|---|---|
fixed (default) |
11 | -906.0013075636… |
atr |
13 | -1134.3810236435… |
banana |
13 | -1134.3810236435… |
Identical to the fallthrough branch to the last digit. An operator who fat-fingers ema_touch reads ema_band's numbers under the name they typed — and rules promote re-runs the backtest against that same stored row, so the row can advance toward live carrying a parameter nobody chose. Same class as the granularity finding #182 already refuses.
RsiMeanReversion does raise on an unknown stop_method — but from _compute_stop, i.e. at detect() time, on a row already written. #182's docstring claim that construction catches "a value its own validation rejects" was false as written; corrected here.
The choices come from typing.get_type_hints(rule_cls.__init__), never a list kept in the CLI. from __future__ import annotations leaves inspect.signature's annotations bare strings — mutating the fix to use inspect.signature instead kills 4 tests.
signal_patterns now declares its accepted names as SignalPattern, a Literal beside the matcher that implements them. That closes the inconsistency the review flagged: refusing signal_patterns: [] ("never signals, reads as a rule that does not work") while accepting ["nonexistent"], which is that same rule with extra steps. A drift test pins the Literal to the names _match_signal_pattern actually handles.
F3 (LOW-MED) — --params '' silently meant "use the defaults"
json.loads(params_json) if params_json else {} could not tell "flag absent" from "flag given but empty". --params "$(jq -c .params proposal.json)" yields "" when the key is missing or jq errors → a defaults row written, added rule 32 printed, and the operator backtests the stock rule believing it is their proposal. Now is None vs "", with an explicit refusal that names the likely cause.
F4 (LOW) — the Infinity refusal was factually wrong about its own worked example
budget_usd is a _DECIMAL_PARAMS param, so the stored value is the string "Infinity" — perfectly valid JSON, not the bare token the message claimed. And that is worse, not better: it rebuilds silently into Decimal('Infinity') that no positivity guard rejects and that propagates (size_usd=Decimal('Infinity') with nothing raising). Only float params (lookback_days: 1e400, volume_mult) emit the unparseable bare token. The refusal was right; the reason is now given per param and is true for each.
F5 (LOW) — _params_delta reported schema drift as parameter difference
Before, against a real pre-existing row:
[10] status=paper entry_lookback=40, min_volume_filter='<absent>', s1_filter='<absent>',
volume_ma_period='<absent>', volume_mult='<absent>'
Four of five "differences" are params that did not exist when row 10 was written, burying the one under test. After:
[10] status=paper entry_lookback=40 [+4 param(s) that row does not carry, so not comparable:
min_volume_filter, s1_filter, volume_ma_period, volume_mult]
F6 (TRIVIA) — coerced_param_keys' granularity branch had no test
Mutating it killed zero tests. Pinned rather than removed, and one correction to the finding: the branch is not "unreachable" — it executes for both rsi_meanrev and pullback_continuation. It was merely untested.
I first justified keeping it by claiming that dropping it would leave a correct "timeframe": "ONE_DAY" refused. I checked that claim and it is false — Granularity subclasses str, so the value passes the string check either way, and removing the branch changes no accept/refuse outcome at all. (That is the same class of error as F4, so it did not get to survive in the fix for F4; see deb76a4.)
What the branch actually carries is operator-visible output. rules add prints this set verbatim as a quoted value is right only for [...], so a hint omitting timeframe would tell the operator the opposite of the truth about the one param whose whole job is to arrive quoted. That is now what is pinned, alongside a structural test against the tables the coercion itself reads. Removing the branch kills 3 tests.
Not done, deliberately
No economic range checks in the CLI. rsi_period: 0 still constructs (→ ZeroDivisionError at backtest) and atr_stop_mult may still be negative. Those belong in the rule constructors where every caller benefits, not in a CLI copy that would become the second drifting table the one-boundary requirement exists to prevent. Follow-up: TurtleBreakout and RsiMeanReversion should validate their own fields, as Dca already does.
Verification
Every fix mutation-tested; each is load-bearing:
| mutation | tests killed |
|---|---|
| container check disabled | 1 |
scalar Literal choices check disabled |
2 |
| sequence element choices check disabled | 1 |
--params reverted to truthiness |
1 |
_params_delta reverted to key-union |
1 |
coerced_param_keys granularity branch removed |
2 |
a SignalPattern name dropped |
1 |
Infinity reason collapsed to one blanket claim |
1 |
| float/Decimal reasons swapped | 2 |
get_type_hints → inspect.signature |
4 |
coerced_param_keys granularity branch (after the F6 correction) |
3 |
Gates: ruff clean, mypy clean (203 files), pytest 2134 passed (2122 on main, +12).
chore: bump to 0.5.5 for keel rules add (#184)
Version bump only — no code changes. Ships #182 and #183.
Patch: no schema change, no rail change, nothing that alters what the agent trades or when.
What it ships
keel rules add --kind --product --params inserts one rule row at status candidate so a scout-produced parameter set can reach keel rules backtest / keel simulate. Before this, rules seed built rows only from each kind's constructor defaults and no CLI accepted params — a proposal had no path to evidence without hand-writing Python against Repository.insert_rule.
Status is a literal at the single insert — no flag, no override. A row this command creates must still clear rules backtest and rules promote before it can reach paper or live. That is the property that makes a write path into the rules table safe to add at all; an independent review attacked it specifically (env vars, --params '{"status":"live"}', an UPDATE path) and could not break it, and mutating the literal kills three tests.
#183 closes the validation gap #182 shipped with. Container params ({"oversold": [1,2]}) and typo'd Literal choices (entry_zone: "banana") were accepted, stored and rebuilt cleanly, then either crashed the backtest the row exists for or — worse — silently selected a different code path. entry_zone: "banana" falls through to ema_band: 7 trades against ema_touch's 11, expectancy identical to ema_band to the last digit. Since rules promote re-runs the backtest against that same stored row, an operator could promote a rule believing it was ema_touch while every number they ever saw measured ema_band.
Deliberately not included
Economic range checks (a negative atr_stop_mult, rsi_period: 0) are still accepted. Those belong in the rule constructors where every caller benefits — Dca already validates its own fields this way. A CLI-side copy would be the second, drifting validation table the one-boundary design exists to prevent. Follow-up for TurtleBreakout / RsiMeanReversion.
uv.lock relocked in the same commit, per 0.5.0 through 0.5.4. Verified with uv sync --frozen, which accepted the lock and rewrote nothing beyond the five pyproject.toml bumps.
Gate: 2134 tests pass, ruff clean.