Skip to content

keel v0.13.2

Choose a tag to compare

@github-actions github-actions released this 01 Sep 12:26
· 227 commits to main since this release
6ca893a

Built from 6ca893a. Version binds to this hash:
keel --version reports keel 0.13.2+6ca893a3d4e5 [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.13.2-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.

Desktop app (macOS / Windows)

⚠️ These builds are not code-signed, so your computer will warn you.
Apple's certificate is $99/yr and Azure Trusted Signing is ~$120/yr, and
keel cannot currently afford either. There is no cheaper tier and no free
open-source option on either platform. (Windows is worse value than it looks:
since 2024 even an EV certificate no longer grants an instant SmartScreen
pass — reputation is earned from download volume over time.)

Prefer no warning at all? Install from the wheels above instead — nothing
is downloaded as an application, so nothing objects. Needs a terminal and
Python 3.14+.

Full explanation, including how to check what you downloaded:
https://github.com/CodeGateSoftware/keel/blob/main/docs/desktop-install.md

macOS — the first open is refused. Open System Settings → Privacy &
Security
, scroll to the message about keel, and choose Open Anyway.
You will only do this once.

Windows — SmartScreen shows "Windows protected your PC". Choose More
info → Run anyway
.

Verify what you downloaded before you run it. Every artifact carries a
GitHub build attestation binding it to this repository, this workflow and this
commit — which is the question a code-signing certificate answers too:

gh attestation verify <file> --repo CodeGateSoftware/keel

A SHA256SUMS-<platform>-<arch>.txt file per build is attached as well
(SHA256SUMS-macOS-arm64.txt, SHA256SUMS-macOS-x86_64.txt,
SHA256SUMS-Windows-x86_64.txt) -- pick the one matching what you
downloaded. If either check fails, do not run it.

The app opens keel in your browser; there is no terminal involved. Your
config, database and credentials live in your user Application Support /
LocalAppData folder and are never touched by an update.

Other changes

docs(fiqh): long-only is bay' ma la yamlik, and it is not closed at the venue boundary (#669)

docs/fiqh-basis.md had no section on long-only at all. The rails table covered the prudential rails, rails 18/19 covered the spot-only charter, and the one ruling that is neither — keel never sells what it does not hold — was left implicit in four places that enforce it and named in none.

What the section says

Long-only is a fiqh-derivation, not a charter, and it descends from Ayub Ch 5.4.2 (§65.11) and Ch 6.5.1 (§65.4): bay' ma la yamlik. That anchor is more direct than riba for this class of failure, and the difference is load-bearing — a sale of what the seller does not hold is impermissible with no interest anywhere in the transaction, so a riba-framed defence does not reach it.

The four enforcement points are named with the source text that carries them: direction: Literal["long"] plus its __post_init__ runtime check (#447), side=Side.BUY unconditional in the engine, rail 10, rails 18/19.

One qualification, stated because §65.4 is invoked here for nearly the opposite of what it was read for. §65.4's own point is that possession is CONSTRUCTIVE — "what is meant by possession here is the inability to deliver the goods" — which is exactly how exchange-held balances qualify as valid qabd. That lenient reading does not weaken the long-only claim; it sharpens it. In every failure mode below the asset is not there at all, so selling it fails the prohibition on the lenient reading, not merely the strict one.

The hadith is recorded honestly. Hakim ibn Hizam — "Do not sell what is not with you", Sunan an-Nasa'i 4613 / Abu Dawud 3503 — is marked as not extracted in this repository's knowledge base. It carries no §N.x row and is not disguised as one. A test pins the disclaimer and its truth: if a source file ever extracts the hadith, the disclaimer has become false and the test says so.

The new open question

The ruling is sound where keel decides and open where it settles:

  • A SELL is never clamped to the account's available base — fee dust, an unrecorded partial fill (filled_quantity is NULL on every filled live order), or an out-of-band transfer can send an order for more than is held (#667).
  • A resting stop leg can outlive the position that justified it; no sweep cancels a SELL whose position is gone (#668).
  • On a cash account each is a rejection. On a margin-enabled account the venue fills the difference as a short — and there is no cash-account posture check on Coinbase; verify_cash_account exists only on the Alpaca adapter (#666).

Named in the doc rather than left for a reader to discover.

Pins

Four tests, all two-sided, 12 mutants and 12 killed.

The base.py pin matches the field line by regex rather than the file, because __post_init__'s docstring quotes the annotation too and a prose mention is not a contract — an earlier version of this pin passed against direction: str. It also pins the runtime check separately, since the annotation is a promise to mypy and a foreign rule never runs mypy.

The venue-boundary pin is anchored to _sell_base_size's own stated contract, so when #667 lands this test fails — the doc gets updated with the code, not after it.

Full suite: 5059 passed, 3 skipped. ruff and mypy clean; no new ruff format deltas.

Closes nothing — it records what #666/#667/#668 are for.

fix(execution): a SELL is clamped to what the venue holds, and refused when it holds nothing (#667) (#670)

Closes #667.

_sell_base_size quantized the exit down to the venue's increment and did nothing else. No SELL path read a base balance — rail 13's own comment recorded the asymmetry: "SELL intents never fetch a balance (the rail exempts them)."

So keel sold the quantity its ledger believed it held, and the ledger runs high in three ways it cannot see from its own records:

  • a venue that takes its taker fee out of the received base leaves 0.9985 where the order said 1.0000;
  • a partial fill leaves less still, and orders.filled_quantity is written only when the venue's post-fill status was observable;
  • an operator who moves coins out of the account tells keel nothing at all.

On a cash account each is a rejected exit — a position that wanted out, still in, unprotected. On a margin-enabled account (#666) the venue fills the difference by opening a short. That is bay' ma la yamlik arrived at by arithmetic, reachable without a single line of the strategy layer proposing one.

Two mechanisms, deliberately split

what it does when
executor._clamp_to_held shrinks the order to the holding a position that really exists, just smaller than the books say
rail 21 (base_balance) refuses the order the venue affirmatively reports holding nothing

_build_intent already returns None when the ledger says zero, so reaching rail 21 at all means the two disagree. Refusing there sells nothing and traps nothing, because there is nothing to trap.

Neither cancels a protective order — which is why _record_observed_fill_quantity's refusal to auto-resize stands beside them unchanged. What that refusal declines is cancelling a resting bracket on the strength of a settling snapshot. Nothing here cancels anything; it only declines to ask for more than is there, which no snapshot ambiguity can make wrong.

Balance.total, never Balance.available

The load-bearing line of the change. available excludes base committed to resting orders, and keel's own bracket commits the entire position — so for exactly the products keel is protecting, available reads ~0. A clamp on that number would shrink every exit to nothing and rail 21 would then veto it, refusing every stop roll and every exit keel has ever placed. total is available + hold; ownership, not encumbrance, is what the prohibition asks about.

It also makes _roll_stop's cancel-then-place ordering irrelevant to the number. On available the holding would jump from ~0 to the full position across the cancel, and a roll's fate would depend on which side of that line it was read from. There is a test for this specifically.

Rail 21 fails OPEN on unknown

The deliberate inverse of rails 12/13/17, and the one place in this engine where "unknown is a rejection" would do more harm than the hole it closes. A refused BUY costs nothing; a refused SELL strands a position that wanted out. It is _sell_base_size's asymmetry table applied to a balance instead of an increment, and the comment says not to fix it into consistency.

Rail 21 joins LIVE_STATE_RAILS, so paper skips it and reports the skip — a paper track record that silently skipped a live-state rail would score a strategy on evidence live trading would have refused.

Two more things

filled_quantity was coupled to the price. One fill <= 0 guard returned before the quantity was recorded, so a venue answering with a filled size and no average price lost the size too — and the size is the half that measures the drift. The quantity observation now runs first. The guard itself is kept, because a zero overwriting a usable price estimate was always the right thing to decline.

Drift is surfaced, not absorbed. Recorded under balance_drift:<product> and reported by keel doctor as balance.drift — WARN, not FAIL, because every cause is legitimate. A log line alone requires an operator to know to grep for it, and the drift outlives the order that discovered it: the next exit is clamped by the same amount.

Costs and behaviour changes

  • One extra get_balances per SELL-ish order. An entry now costs two (rail 13's quote read, then the bracket's base read).
  • Two existing tests changed premise, not implementation. test_exit_signal_never_fetches_a_balance asserted get_balances_calls == 0 and was right for as long as the only balance question was rail 13's. #667 asks a different question of the same endpoint, and it is the exit that must ask it.

Verification

21 mutants, 21 killed. One survivor was worth the exercise: every rail-21 test built its OrderIntent by hand, so dropping available_base= from the intent the executor actually builds left all of them green. The end-to-end tests through execute, place_bracket, scale_out and roll_to_break_even exist because of that survivor.

Full suite: 5081 passed, 3 skipped. ruff and mypy clean across 120 source files.

Note on ordering with #669

The rail count moved from nineteen to twenty in guards.py and five docs, and docs/fiqh-basis.md gains a "rail 21 is the second fiqh rail" section. #669 (the bay' ma la yamlik amendment) edits the same file on a branch cut from the same base, so whichever merges second needs a rebase — I'll reconcile them rather than let either land stale.

fix(execution): cancel a resting SELL whose position has left the account (#668) (#671)

Closes #668. Stacked on #670 (targets fix-667-sell-clamp) — the sweep reads executor._held_base, and a second reader of the same venue fact would be two things to keep in agreement.

Why nothing caught this

_clear_resting_bracket fails closed before an exit. reconcile_unbracketed_positions heals a position that has no bracket. Both are position-driven — they start from a tranche and ask what protects it. A protective order whose position has left the account has no tranche to be walked from, so neither can reach it. That is the structural reason the gap existed, not an oversight in either sweep.

sweep_orphan_brackets runs the other way: from keel's own resting SELLs to the account.

How an orphan appears

The shape is not obvious on a venue with native brackets, so it is worth being concrete:

  • the operator sells or transfers the asset at the venue — keel's bracket keeps resting against inventory that left without telling it;
  • scale_out and _roll_stop both cancel before they place — a process dying inside that window can leave a replacement resting over a resolved position;
  • a venue without a native trigger-bracket needs two legs, and a filled target leaves a live stop behind. Not reachable on the venue that trades live today, where BracketGTC is one order — and reachable the moment one is not.

Left alone, the market reverses through the stop and the venue is asked to sell an asset that is not there. Cash account: a rejection. Margin-enabled (#666): a short, produced entirely by a missing cancel.

The venue decides, never the ledger

This is a safety property, not a preference. Cancelling a protective order over a position that really exists strips a live holding of its only stop, so the trigger has to be the account's own statement about itself. keel's ledger can be stale in exactly the dangerous direction — saying zero while the venue holds the position — and a sweep driven from it would cancel precisely the brackets that were working.

An unreadable balance therefore cancels nothing. A venue that omits empty accounts is saying nothing, not saying zero.

Balance.total, not Balance.available, for the same reason as #667's clamp: a resting SELL holds the base it commits, so available reads ~0 for exactly the products this sweep looks at. Reading it would cancel every protective order keel has ever placed, on the first cycle, and call the result a fix. There is a test for that specific inversion.

Dust

Strictly below base_increment counts as nothing held — a residue the venue cannot express as a size is not a position. Exactly one increment is the smallest real position there is and keeps its bracket. With no increment known there is no threshold and none is invented: _base_increment_for returns None for a venue error as much as for an unknown product, and a guessed floor would cancel protective orders over a number nobody supplied.

Where it runs, and why there

Last of the three reconciliation passes. reconcile_unbracketed_positions will place a bracket for a tranche whose position left out of band, so sweeping afterwards makes the cancel the cycle's final word and nothing re-creates within one cycle what it just removed. Running it first would leave a fresh orphan resting for a full day — this deployment cycles once per UTC day.

#667's rail 21 stops that re-place at source, which makes the churn rare rather than the ordering optional: the rail refuses to place into an empty holding, this cancels what already rests, and neither subsumes the other.

What it deliberately does not do

The tranche is left open. Closing it would book a realized outcome at a price nobody observed — what the position was worth when it left the account is a question only an operator can answer. doctor's new bracket.orphan finding and the existing ledger.unbooked_exit both surface it.

It also never raises into the cycle: per-row isolation, an accepted-but-unsettled cancel is not recorded as done, and a refused cancel leaves the local row untouched (a venue that refuses may have already filled it).

Verification

11 mutants, 11 killed — including the wiring (a sweep nothing calls reports nothing) and its position in the cycle, which a presence-only test would miss.

Full suite: 5105 passed, 3 skipped. ruff and mypy clean.

docs(fiqh): the venue boundary is closed; what is open is the premise underneath it (#672)

Follow-up to #669/#670/#671, which all merged cleanly and left docs/fiqh-basis.md internally inconsistent.

What was wrong

#669 landed a "Known open questions" bullet describing the venue-boundary gap. #670 and #671 then closed it. Git merged all three without a conflict, so the doc now claims — three paragraphs below the section describing the machinery that fixes it — that:

  • a SELL is never clamped to the account's holding (now false — _clamp_to_held)
  • filled_quantity is recorded nowhere on this deployment (now written before the price guard)
  • no sweep cancels a SELL whose position is gone (now false — sweep_orphan_brackets)

The pin that failed to fire

This is the part worth recording. #669 anchored that section to _sell_base_size's docstring sentence — "it can only ever make an exit MORE likely to be accepted" — reasoning that #667 landing would break it and force the doc forward with the code.

It didn't. #667 clamped at intent construction and left that docstring standing, correctly, because the function's own contract never changed. So the pin held while the paragraph it guarded went stale.

A proxy is only a pin while the thing it stands for and the thing it matches move together. That one stopped, silently, and the test stayed green through exactly the change it existed to catch.

What replaces it

The question that actually remains open, and it is a better one than the bullet it retires.

_clamp_to_held, rail 21 and sweep_orphan_brackets all fail open on an unreadable balance — deliberately and correctly, since refusing a SELL over a balance endpoint that went quiet would strand positions that wanted out. So a venue that stops answering does not produce a refusal; it produces the old behaviour, an exit sized from keel's own ledger.

That is survivable only while the account cannot go short, and keel does not check that it cannot. verify_cash_account exists on the Alpaca adapter alone. The layered defence is sound whenever the venue answers, and rests on an unverified premise whenever it does not — which is #666.

Re-anchored

Two-sided against the premise itself rather than a sentence about it: the coinbase adapter must still have no posture read, and the alpaca adapter must still have one. When #666 lands, this test fails because the fact changed, not because someone reworded a paragraph.

4 mutants, 4 killed. Full suite: 5109 passed, 3 skipped. ruff and mypy clean.

chore(release): 0.13.2 -- the venue boundary, closed (#673)

Version bump only — 8 pyproject.toml files plus uv.lock, the same nine-file shape as the 0.13.1 bump. No code, no migration; schema stays at v16.

What 0.13.2 carries to the deployment

~/keel is on 0.13.1 (9c11ead), which is exactly the commit #669–#672 were cut from. Everything below is currently unreleased:

#667 _clamp_to_held reduces a SELL to what the venue reports holding — down only, never up, unchanged when unreadable. Rail 21 refuses an order against an affirmatively empty holding.
#668 sweep_orphan_brackets cancels a resting SELL whose position has left the account — order-driven, where every existing sweep is position-driven and so could not reach it.
#669/#672 docs/fiqh-basis.md records rail 21 as the second fiqh rail, anchored in bay' ma la yamlik rather than riba. Rail count nineteen → twenty across guards.py and five docs.

All three mechanisms read Balance.total, never Balance.available — a resting bracket holds the base it commits, so available reads ~0 for exactly the products keel is protecting.

keel doctor gains balance.drift and bracket.orphan, both WARN. The live deployment has neither finding today; they exist so the next divergence isn't found by asking an unrelated question.

Still open

#666. All three mechanisms fail open on an unreadable balance — correctly, since refusing a SELL over a quiet endpoint strands positions that wanted out. That is survivable only while the account cannot go short, and verify_cash_account exists on the Alpaca adapter alone. tests/test_fiqh_basis.py pins that two-sided, so it fails when #666 lands.

Verification

Full suite 5109 passed / 3 skipped; tests/test_packaging.py green, which is the check that catches a root-only bump with stale sibling pins.

After merge: Actions → Release → Run workflow, 0.13.2, desktop: publish-unsigned for the DMG/installer artifacts. The head -1 closed-pipe bug that killed every desktop leg on v0.13.0 was fixed in 0.13.1 and is on main.