Repository navigation
keel v0.16.0
Built from c86de77. Version binds to this hash:
keel --version reports keel 0.16.0+c86de7738d05 [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.16.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.
Other changes
fix(execution): quantize bracket prices to the venue tick, in opposite directions (#802) (#804)
Closes #802.
The bug
Sizes were quantized (#513/#516). Prices were not.
2026-09-16 03:44Z, keel-live.db order id 5
executor.order_rejected PAXG-USD SELL error='Too many decimals in order price'
_bracket_spec floored base_size and passed stop/target straight through, so
4521.76390215979454 went on the wire. sizing.quote_increment_for looks like it covers this
and does not — its own docstring says "the venue's finest acceptable quote_size", it is
keyed on the CURRENCY not the product, and it is applied in one place: a BUY's notional.
Every protective bracket was rejected. All three positions on the live deployment carry
bracket_order_id = NULL, and the stop-loss/take-profit machinery has never fired there. Nothing
distinguished that from DCA's legitimate bracket-lessness, in any surface.
Direction is the substance, not a detail
quantize_down is documented as the safe direction for sizes — rounding a size up spends
more than guards.check authorised. That reasoning does not transfer to a protective stop:
rounding a long's stop DOWN moves it further from price and widens the loss the position was
sized against, silently, on every bracket.
So:
| price | direction | why |
|---|---|---|
stop_trigger_price |
up | toward safety — never widen the risk the position was sized on |
take_profit_price |
down | toward reachability |
quantize_up is a separate function, not a flag on quantize_down, because the two are
justified by different arguments and a flag invites picking the wrong one by habit.
Note Decimal.__floordiv__ truncates toward zero rather than flooring, so the usual
-((-v) // i * i) ceiling idiom silently returns the floor. It steps up explicitly instead, with
a comment saying why.
A collapsed pair is refused, not sent
The two prices round toward each other, so a coarse tick can invert a pair that was valid before
it. That raises BracketPricesUnplaceable rather than reaching the venue.
And the #799 shape is closed while we're here
_bracket_spec was an inline argument to _run_order, so anything it raised left
place_bracket past the if not result.placed recovery — exactly how a filled PAXG entry was
stranded for three weeks. It is now built inside a try, and a bracket that cannot be BUILT is
the same event as one the venue REFUSES: levels recorded to unbracketed:, a WARNING, a retry
next cycle. There is a test asserting the retry record holds the RULE's unrounded levels.
Where the tick comes from
Instrument.quote_increment, the field its own docstring anticipated ("would sit here naturally,
but nothing reads them yet, and a field no caller reads is a field no test meaningfully checks").
It rides the existing per-product fetch and the existing cache record, so no extra venue
round-trip enters the order-placement path. A pre-#802 cache record simply reads as unknown until
it expires, which is correct — nothing knew the tick when it was written.
Unknown stays unknown: prices go unrounded, never refused. Refusing would leave a filled position
with no stop at all, which is the #799 outcome.
Verification
Mutation: reusing quantize_down for the stop — the fix that still puts prices on the tick and
would pass a naive "is it rounded?" check — is killed by three tests.
ruff check keel tests packages -> All checks passed!
ruff format --check . -> 503 files already formatted
mypy -> clean (465 files; pre-existing PyYAML stub note only)
pytest -> 6651 passed, 3 skipped
Deployment note
The live PAXG position is currently retrying its bracket every cycle and escalating
reconcile.position_unprotected at CRITICAL. This is what lets that retry succeed.
fix(ledger): carry the owning rules.id on a position so exits are attributed (#803) (#805)
Closes #803.
The bug
SELL PAXG-USD rejected unattributed 0.01320427
OrderIntent carries two different facts in two fields (guards.py:194-200): rule_kind, a
KIND ("dca", "turtle_breakout"), and rule_id, the rules.id row. Entries thread the id
from signal.rule_id. Both exit paths build their intent from a POSITION, not a signal — and
positions stored only the kind, so there was no id to thread and every protective SELL was
written with orders.rule_id NULL.
Not cosmetic: per-rule accounting reading orders.rule_id sees entries attributed and exits
anonymous, which for a trend rule drops precisely the leg the outcome lands on.
Not caused by the demotion, which is the natural first guess — rule 3 went live -> paper
shortly before this order, which looks exactly like a lookup that stopped resolving. The id was
never on the intent; the row would read unattributed with the rule live. The migration
docstring records that, so the next reader doesn't re-derive it.
The change
- v21:
positions.rule_id INTEGER, nullable. _open_trancheinherits it from the ENTRY order rather than taking a new argument. That
order was already written withsignal.rule_id, so every caller gets the attribution without
one more parameter to forget.place_bracketandscale_outtake an optionalrule_idand put it on the intent; the four
call sites passposition["rule_id"]orsignal.rule_id.
No backfill
The call v12 made for initial_stop. A tranche records its bracket order, never its
entry order, so there is no join back to the id. Matching on (product_id, rule_name) would
re-attribute by guess and be wrong wherever a rule row was replaced. NULL means "nobody recorded
it", and there is a test asserting a pre-v21 row reads NULL rather than acquiring an owner.
rule_id is optional everywhere, deliberately
There is a test for that on its own: a missing id must never cost a position its stop. A
pre-v21 tranche has none, and refusing to protect it because the bookkeeping is incomplete would
trade a reporting gap for an unprotected position.
Verification
Mutation: dropping rule_id=rule_id from place_bracket's intent — which puts live order id 5
back in exactly the state that prompted this — fails the end-to-end test and nothing else.
Nine hardcoded SCHEMA_VERSION == 20 pins across three test files moved to 21, and three new
migration tests cover the fresh schema, the no-backfill path, and idempotency.
ruff check keel tests packages -> All checks passed!
mypy -> clean (465 files; pre-existing PyYAML stub note only)
pytest -> 6643 passed, 3 skipped
Note for the deployment
This is a schema change: keel migrate must run on all four databases before the new build
trades. The ALTER is guarded by PRAGMA table_info and adds a nullable column, so it is
non-destructive and idempotent.
Stacked behind #804 (#802) in spirit but independent in code — they touch different files and can
land in either order.
chore(release): 0.16.0 -- brackets reach the venue, and exits name their rule (#806)
Version bump only. No behaviour change in this PR.
Why MINOR and not a patch
#805 ships schema v21 (positions.rule_id). A deployment must run keel migrate on every
database before the new build trades, and the version number is the only signal an operator gets
before they read the notes. 0.15.1 was a pure bug fix and was numbered accordingly; this is not.
What lands
| #804 (#802) | Bracket prices quantized to the venue tick — stop up, target down. Until now every protective bracket was rejected with Too many decimals in order price, so the stop-loss and take-profit machinery had never fired on the live deployment. Nothing distinguished that from DCA's legitimate bracket-lessness. |
| #805 (#803) | positions.rule_id, so the protective SELL exiting a tranche names its owning rule. Both exit paths build their intent from a position, so every bracket, roll and scale-out was written unattributed while its entry carried the name. |
Both PRs were reviewed and both reviews found a real defect the original missed — an unpatched
second _bracket_spec call site in one, an untested inheritance line in the other. Those fixes
are included.
What moved
- eight workspace
pyproject.tomlversionfields,0.15.1→0.16.0 - fourteen inter-package
==0.15.1pins →==0.16.0(these are what akeel versions
PARTIAL INSTALL comes from when left behind — this deployment has hit that before) uv.lock, refreshed viauv lock
Verified locally
grep -rn '0\.15\.1' pyproject.toml packages/*/pyproject.toml -> (none)
ruff check keel tests packages -> All checks passed!
ruff format --check . -> 504 files already formatted
keel/templates/config.live.yaml -> mode: confirm
keel/data/db.py -> SCHEMA_VERSION = 21
pytest tests/execution tests/data tests/test_agent.py -> 1134 passed
After merge — the order matters
- Actions → Release → Run workflow, entering
0.16.0 - Install the five production wheels by path into
~/keel keel migrateon all four databases — new column; the ALTER is guarded and nullable, so
it is non-destructive and idempotent, but the build should not trade before it runs- Restart the four console daemons
The live deployment has a PAXG position retrying its bracket every cycle and escalating
reconcile.position_unprotected at CRITICAL. #804 is what lets that retry finally succeed.