Skip to content

keel v0.3.2

Choose a tag to compare

@github-actions github-actions released this 30 Jul 18:50
· 195 commits to main since this release
712c976

Built from 712c976. Version binds to this hash:
keel --version reports keel 0.3.2+712c976b3f33 [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.3.2-py3-none-any.whl
keel --version

⚠️ 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.

Features

feat(engine): log why a rule declined, with the near-miss numbers (#158)

A cycle reporting signals=0 said nothing about why. Whether price was 1% or 40% away from
the trigger is the difference between "nearly fired" and "nowhere near", and answering it meant
replaying the rules against the database by hand.

Now the log answers it:

{"event": "engine.no_signal", "rule": "turtle_breakout", "product": "ETH-USD",
 "gate": "donchian_high", "close": 1919.9, "entry_level": 1979.0, "gap_pct": 3.08}

How

Rule.last_rejection (additive, defaults None) records the gate a bar tripped and the numbers
behind it. engine.evaluate folds it into the engine.no_signal event it already emits per
declining rule, along with the rule's product.

No new event and no new call site — that is the constraint that shaped this. strategy.backtest
and sim.portfolio_sim drive detect() once per bar, so logging from inside the rule would emit
millions of events in a sim. The rule only ever writes an attribute; the engine, which already
logs exactly once per declining rule, does the logging.

product comes from the rule rather than a Setup (there isn't one) and is omitted entirely when
a rule declares none — every turtle rule is named turtle_breakout, so without it the five lines
a five-product cycle emits are indistinguishable.

What TurtleBreakout records

Every decline path: insufficient_history, donchian_high, adx, macd_histogram,
min_volume, atr, stop_not_below_entry, s1_filter. From donchian_high down, each carries
close / entry_level / gap_pct — so the interesting case, price clearing the channel and
something else declining it, is visible rather than inferred. That is exactly ETH-USD on
2026-07-28: it closed above its 40-day high and only the ADX filter (21.3 < 25) declined it,
which signals=0 gave no hint of.

A bar that fires clears the field, so a stale reason can never misreport a firing rule.

Scope

Purely diagnostic — no gate, guard or sizing path reads last_rejection, and a rule that records
nothing logs the bare event it logged before (pinned by a test). Only turtle_breakout records
reasons so far; the other three rules are unaffected and log exactly as before.

Verified against the live paper database: the emitted numbers reproduce the by-hand analysis
exactly (BTC 4.82% below channel, ETH 3.08%, PAXG 7.49%, XLM 44.68%, ADA 23.49%).

Fixes

fix(tui): toast on 'r' so refresh is distinguishable from a dead key (#159)

Pressing r in the TUI appeared to do nothing. It was working — it just never said so.

What was wrong

The handler set no message:

if ch == ord("r"):
    last_balance_ts = 0  # force the balance to re-fetch on the next iteration too
    continue

Three things stacked to make a working key look dead:

  1. No toast. a (autonomy) and f (fetch) both assign message; r didn't.
  2. The repaint is byte-identical. r re-reads the same local DB, which only changes when an
    agent cycle runs — once a day on a daily-Turtle deployment.
  3. Its one visible effect is deferred. The balance re-read sits after _paint on purpose
    (so the first frame never blocks on a network call), so the new value appears on the next
    repaint, not the one r triggered.

The fix

r now toasts, and the wording also answers the question the silence provoked — what r is for
compared to f:

refreshed local state -- balance updating (no candle fetch; use [f] for that)

Deliberate wording choices:

  • "balance updating", not a claim it is already showing — it lands on the following frame.
  • "no candle fetch" — the real distinction. r pulls no market data at all; f backfills 5
    years for every allowlisted product. The footer calling both "refresh"/"fetch" gave no hint.
  • 77 chars, so it fits an 80-column terminal.
  • Styles as ok — a routine success must not borrow the alert colour that _message_style
    reserves for failures and for arming autonomy ON.

Tests

  • r paints a toast, driven through run_live with the existing _KeySequenceStdscr +
    fake-curses harness, so it exercises the real key handler rather than a stand-in.
  • the toast styles as ok, guarding against a future rewording that trips _message_style's
    "failed" / "cancelled" keywords.

No behaviour change beyond the message: r does exactly what it did before.

Docs, CI & tooling

docs(readme): how to deploy a release, and ignore the operator wrappers (#157)

Two small chores that had been sitting outside the repo.

Deploying a release into a deployment

docs/RELEASING.md covers cutting a release but stops at the published artifacts. How to get
one into a deployment (~/keel) lived only in shell history, which is how a deployment ends up
a version behind without anyone noticing. New Deploying a new version section in the README
documents the four commands, plus the parts that are easy to get wrong:

  • run them from the deployment directory — every path (Release/, .venv) is relative to it
  • --find-links Release is what lets the single keel_trader wheel resolve its keel-core /
    keel-broker-* siblings, which is why step 1 downloads all the wheels
  • installing by path rather than by bare name is deliberate: keel on PyPI is an unrelated
    project, so pip install keel fetches a stranger's code
  • keel --version is the check that matters — it must report [release] bound to a commit, and a
    (DIRTY) or [checkout] build corresponds to no commit and must not be run against live funds
  • a new build takes effect on the next scheduled cycle with nothing to restart, since each cycle is
    a fresh process — but a long-running keel tui keeps the build it started with

The four commands were run against the live deployment as written; keel --version returns exactly
the line quoted in the README.

Ignore the operator wrappers

keel-live and keel-paper are deployment artifacts with config and database paths pinned
together, copied to ~/keel rather than tracked here — the same treatment their sibling scripts
already get. They were showing up as untracked in every git status.