keel v0.6.0
Built from fbf7cc2. Version binds to this hash:
keel --version reports keel 0.6.0+fbf7cc284a96 [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.6.0-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.
Features
feat(brokers): add the robinhood crypto v2 adapter behind the broker port (#192)
What this adds
packages/keel-broker-robinhood/ — a first-party adapter implementing keel's Broker port
against the official Robinhood Crypto Trading API v2, registered under the keel.brokers
entry-point group and passing the shipped BrokerConformanceTests against a canned, zero-network
transport.
Structure mirrors keel-broker-coinbase: an injected Transport Protocol (defaulting to None
so capabilities() is answerable offline), translate.py as the single place keel's order model
becomes Robinhood's, and adapter.py holding the capability declaration.
BrokerCapabilities(
venue="robinhood",
supported_orders=frozenset({"market_ioc_base", "limit_gtc", "stop_limit_gtc"}),
supports_native_preview=False,
synthesizes_preview=True,
supports_fee_summary=True,
quote_currencies=frozenset({"USD"}),
asset_classes=frozenset({"spot"}),
)v2 exclusively, never v1. Two contract-level reasons: v1's cancel returns text/plain "Cancel
request was submitted", which acknowledges a request and cannot satisfy cancel_order's "return
True only when the venue confirms the cancellation for THIS order id"; and v1 carries neither
the per-order fee_charged get_order needs nor the fee_tier_status get_fee_summary is built
from.
The three capability gaps
1. No candles. The v2 API has no OHLC, historical, or candles endpoint at all — only
best_bid_ask and estimated_price. get_candles raises ValueError for every granularity,
which is the port's sanctioned way to say "I serve no bars" (_any_candles catches ValueError
per granularity and skips). It must not return []: an empty list reads downstream as a
statement about the market when the truth is a statement about the API. Robinhood is an
execution venue here; bars come from elsewhere.
2. No quote-sized market orders — so this adapter cannot open positions. Robinhood's
market_order_config accepts only asset_quantity. keel places entries as MarketIOCByQuote, so
market_ioc_quote is absent from supported_orders and raises UnsupportedOrder. Synthesising
it by dividing an estimated price is deliberately not implemented — it would accept an order
sized in one basis and place an order sized in another, on the live-money path, invisibly.
translate.to_order_body refuses it a second time as defence in depth. What the adapter can do
is exits (market_ioc_base), resting take-profit limits, and protective stop-limits.
market_ioc_base is declared even though Robinhood accepts no time_in_force on market orders.
That is a naming impedance, not a capability lie: a market order is immediate by construction and
there is no resting-market variant to confuse it with. The kind that would be a lie is the
quote-sized one, and it is not declared.
3. No sandbox. Robinhood ships no test environment. Every test runs against a canned
in-memory transport; the conformance suite is the only end-to-end signal short of real money.
This is also why RobinhoodAdapter() defaults transport=None — there is no "harmless"
configuration that talks to a real endpoint.
A fourth, smaller gap worth calling out at review time: get_fee_summary().fees_usd is always
Decimal("0"), because v2 exposes per-order fee_charged but no account-level fees-paid total.
FeeSummary's docstring says subscription-lapse detection leans on that field, so against this
venue the test is inert and detection falls back to attestation alone. Documented loudly in both
the method docstring and the README; closing it means paging order history and summing
fee_charged, which needs its own rate-limit design.
Not wired to the live path
Deliberately. keel/commands/_common.py still constructs CoinbaseClient directly and the
broker-port migration (Phase B) has not landed. Installing this package registers robinhood for
entry-point discovery and nothing more — no command, rule, or rail constructs a
RobinhoodAdapter. Stated in the README too.
…truncated — full description in #192.
feat(tui): add the v activity feed -- what keel has been DOING, cycle by cycle (#235)
The problem
keel has been running fine for weeks and has not traded. The dashboard makes that look like death — and it cannot help, because every one of its six panels reports state, and state is exactly what a correctly-idle deployment and a crashed one have in common: the same zeroes, the same empty position list, the same flat equity.
What is missing is the narrative, and the narrative already exists. Every cycle emits agent.cycle_start, agent.feed_polled, agent.signals_evaluated, engine.setup_detected/setup_rejected, guards.check_failed, agent.enter_evaluated into the structured JSONL engine log, all correlated by the cycle_id keel/agent.py binds via keel_core.telemetry.bind_cycle. Nothing surfaced it.
What this adds
v — a chronological activity feed, newest first, one row per engine cycle, expandable to the events inside it. A quiet cycle still gets a row: the run of quiet cycles is the answer to "is it alive", and a feed that omitted them would reproduce the very impression it exists to fix.
keel/commands/activity.py— new, pure, no curses: bounded tail read, parse, group bycycle_id, summarise, render to plain text. The whole testable core.keel/commands/tui.py— thevoverlay, styling what those renderers already produced, exactly asi/s/p/ddo forinsights/admission. New keyv(verified free), help section, footer row.
Real-data proof
Run against the actual deployment log, ~/keel/logs/keel.log (815 KB, read-only, untouched):
# /Users/…/keel/logs/keel.log (815357 bytes)
# read+group+summarise: 3.7 ms status=ok records=329 skipped=0 cycles=77
when mode sig blk ent exi err what happened
▸ 2026-08-11 09:00:09 paper 0 0 0 0 0 5 products / turtle_breakout gate rejected: choppy_regime (PAXG-USD)
▸ 2026-08-10 09:00:01 paper 0 0 0 0 0 5 products / turtle_breakout quiet -- looked, nothing to do
▸ 2026-08-09 09:00:04 paper 0 0 0 0 0 5 products / turtle_breakout quiet -- looked, nothing to do
▸ 2026-08-08 09:00:06 paper 1 1 0 0 0 5 products / turtle_breakout rail veto: per_asset_concentration_cap | rail veto: monthly_subscription_allowance | not placed: paper: vetoed by rails
▸ 2026-08-07 09:00:05 paper 0 0 0 0 0 5 products / turtle_breakout quiet -- looked, nothing to do
▸ 2026-08-06 09:00:03 paper 0 0 0 0 0 5 products / turtle_breakout quiet -- looked, nothing to do
▸ 2026-08-05 09:00:01 paper 0 0 0 0 0 5 products / turtle_breakout quiet -- looked, nothing to do
▸ 2026-08-05 08:12:58 -- 0 0 0 0 2 uncorrelated events (no cycle_id) error: cb_client.accounts_fetch_failed | error: executor.quote_fetch_failed
That is the answer to "why hasn't it traded", in eight lines: it looks every day, it found exactly one setup in a week, two rails vetoed it, and the account reads have been failing separately the whole time.
Enter on the 2026-08-08 row:
…truncated — full description in #235.
feat(tui): scope the activity feed to the current day by default (#237)
Follow-up to #235, which merged before this landed. #235 added the v activity feed; this scopes it.
The instruction
"make sure the activity displayed in the TUI is for the current day, don't display historical or old activities"
The feed as merged showed every cycle in the bounded read window — 77 rows on the real deployment, going back three weeks. It now shows today.
What "today" means
The local calendar day, midnight to now, derived through datetime.date in the same local clock _stamp renders the timestamps in. Not a rolling 24 hours: on a deployment that runs at 09:00, a rolling window would put yesterday's cycle on screen every morning and drop it every afternoon, so the same day's feed would change shape depending on when it was opened.
Going through date → naive midnight → .timestamp() uses the offset in force on that day, so a 23- or 25-hour DST day still starts where the civil day starts (a fixed now - 86400 would drift an hour twice a year). In the few zones whose transition happens at midnight, so 00:00 does not exist, the conversion resolves an hour early — deliberately the safe direction, since a boundary that errs early can only ever include a cycle that belongs to today, never exclude one.
The boundary is inclusive, so a cycle at exactly local midnight belongs to the day beginning then. There is deliberately no upper bound at now: in live use now is the current instant, so the only record an upper bound could exclude is one stamped in the future by a writer whose clock is ahead — and in this module of all modules, showing the anomalous row beats quietly shrinking the panel.
Where it lives
keel/commands/activity.py, the pure layer — apply_scope(feed, scope, now_ts=...), a separate pass over feed_from_lines's result rather than a parameter threaded through it. That keeps "parse the window" and "decide which days to show" independently testable, and keeps feed_from_lines the unscoped function every existing caller already relied on. now_ts is injected (defaulting to time.time() at exactly one seam), so no test in this change depends on the day it runs.
run_live passes the same now_fn the rest of the dashboard clocks itself with, so the day boundary can never disagree with the timestamps on screen.
The toggle: t
t cycles today → 7 days → all, shown in the header. Verified free — q Q h ? i r a f s p d v are the dashboard's keys, k/j/Enter/Space the in-overlay ones, and nothing bound t.
The scope resets to today on every open and every close. A widened view answers one question once; it never becomes tomorrow's default.
The empty state — the important part
The deployment runs once a day, at 09:00. "Today" therefore holds at most one row, and none at all before 09:00. A blank panel there would be strictly worse than the state-only dashboard this feature exists to fix, because a blank panel and a dead agent look identical.
So an empty day is never blank. Rendered from the real ~/keel/logs/keel.log exactly as the overlay would have shown it at 07:00 this morning — the log filtered to the records that existed at that moment:
keel tui -- activity
scope: today (2026-08-11) · 0 cycles · 76 older hidden · press t to widen
keel has not run yet today.
Last cycle: 2026-08-10 09:00:01 -- yesterday, 21h 59m ago.
Next cycle due today around 09:00 local -- in 2h 00m.
That one line is all the history this panel shows: it is scoped to today.
Press t to widen the scope: today -> 7 days -> all history in the window.
source: /Users/…/keel/logs/keel.log (315 records in window)
scope today (2026-08-11): 76 older cycle(s) in the window are hidden -- press t to widen
Three lines answer "is keel alive" before any question of what it did. The next-run estimate is inferred from the last cycle's own time of day, taken from the log rather than from a schedule setting this module would otherwise have to be taught about and could then disagree with. When that time has already passed, it reads differently, because it is different news:
keel has not run yet today.
Its usual start time today (09:00 local) passed 5h 00m ago.
If no row appears here shortly, check that the agent's schedule is still running.
…truncated — full description in #237.
Fixes
fix(brokers): close the review findings on the robinhood adapter (#194)
Follow-ups to #192, which merged before these review fixes landed. Every finding below was
raised against that PR; the adapter shipped to main with all four blockers live, so this is the
catch-up. Diff is fix-only — the package itself is already on main via the squash.
Not merging — that is the user's call.
Blockers
B1 — str(Decimal) emitted scientific notation into the order body
str(Decimal("0.00000001")) == "1E-8", and BTC's asset_increment is exactly 0.00000001 per
this repo's own tests/fixtures/rh_trading_pairs.json. One satoshi is therefore the smallest
order this venue accepts, not an edge case — it is the size a dust-sized exit produces. Robinhood's
asset_quantity / limit_price / stop_price have no exponent form, so that body is malformed.
The failure mode is the quiet kind: a rejected exit leaves a position open while the engine
records it closed, and a rejected stop-limit leaves a position unprotected while local state
says there is a stop. Every money and size field now renders through translate._render
(format(d, "f")), which is positional at every magnitude and neither rounds nor truncates.
The test that should have caught this asserted the property over 0.123456789 and 64000.10 —
two values that cannot trigger the exponent form. It is now parametrized over values that do
(0.00000001, 0.000000012345, 1E+2 → 100), plus a structural assertion that no rendered
field ever contains an e. Confirmed failing against the old implementation before fixing.
B2 — place_order reported success for orders the venue rejected
The old code checked only that an id came back, on the stated reasoning that "a placement that
comes back at all came back as an order." That is false here: Robinhood answers a rejected order
on the happy HTTP path — 200, with a real order object whose state reads failed.
A StopLimitGTC answered {"id": "...", "state": "failed"} was recorded as a protective stop that
does not exist at the venue. failed and canceled now return PlaceResult(success=False, ...)
with the state named in reason, and broker_order_id=None matching CoinbaseAdapter's failure
path.
An unrecognised state still reports success, deliberately — the asymmetry with get_order is
argued in the docstring: reporting failure for an order that is actually live invites the caller to
place it again, and a duplicate live order has no recovery, whereas success hands back the id and
lets reconciliation poll (where an unknown state maps to PENDING and stays under observation).
B3 — estimated_price namespace: the reviewer was wrong, the code was right
Resolved from the primary source, not by guessing: https://docs.robinhood.com/crypto/trading/.
The v2 API genuinely splits these two neighbouring reads across namespaces —
get/api/v2/crypto/trading/estimated_price/
get/api/v2/crypto/marketdata/best_bid_ask/
— which is exactly what transport.py already did. The instinct to "fix" it is understandable
because v1 is the consistent one (/api/v1/crypto/marketdata/estimated_price/). Required
query params confirmed as symbol, side (bid/ask/both), quantity, all three required.
No behaviour change. The path is now pinned by a test and anchored to the doc URL with the verbatim
quoted paths in a comment, so this cannot be re-litigated.
B4 — get_order omitted account_number; cancel_order correctly does not
get_order now sends the same account_number query param create_order sends, per Robinhood's
own v2 sample client. Omitting it risks a 404 — and a 404 here is quiet corruption, not a loud
failure: _request turns it into None, and adapter.get_order turns that into a terminal
FAILED with zeroed money for an order still resting at the venue.
Partially rejecting the finding: the review asked to make all three consistent. Robinhood
documents post/api/v2/crypto/trading/orders/{id}/cancel/ with a path parameter only and no
query-parameter section, and their own v2 sample omits it there while passing it for place/fetch.
Adding an undocumented param would be a guess, and every query byte is signed — so a guess the
venue rejects is a 401 on the cancel path. Left as-is, with the reasoning written down.
Should-fix
…truncated — full description in #194.
fix(rails): bracket a held tranche whose bracket was never placed, and page a human when it cannot (#205)
Closes #195. Found by the QuantCrawler teardown (#193, §1.1) and verified against source.
The hole
place_bracket runs after the entry has already filled, so a refusal leaves a real position with no stop at the exchange. keel/agent.py recorded that as unremarkable — "Neither is an error here — the tranche is real either way" — and nothing ever revisited it.
A rails veto cannot actually cause this. Rails 3/4/5/6/8/11/13/14/16/17 are is_buy-gated and never run on a protective SELL (rail 13: "SELL is exempt — it produces quote currency, it doesn't consume it"), and at initial placement the bracket's notional (qty × stop) is strictly smaller than the entry that just cleared the same caps.
But if not result.placed also catches a broker rejection — min-size, precision, a venue error — which no rail can prevent. And nothing healed it:
reconcile_open_ordersiteratesstatus="pending". A never-placed bracket writes no order row at all (the insert is after the guard gate); a rejected one writesrejected._rebracket_or_escalatesaw neither — it only heals a bracket that was accepted and later died.keel-live-run.shgates on a UTC day-stamp ("the trigger count is catch-up BREADTH, not cadence"), so the window is ~24h, not the hour the 24 launchd triggers suggest.keel killhalts new orders and closes nothing. There is no flatten.- The only signal was
WARNING executor.bracket_not_placed.
The fix
The retry needs levels to place from, and reconcile deliberately refuses to invent them ("would silently re-risk the position on a level no rule produced"). So:
place_bracketrecords{stop, target, qty}underunbracketed:<product>on failure, and clears it on success. Deliberately notopen_stop:/open_target:— those mean "resting at the exchange", and rail 9 readsopen_stopas its no-widening reference. Writing them for an order that does not exist would tell rail 9 a stop is protecting a position when none is.reconcile_unbracketed_positionssweeps thepositionsledger each cycle, re-places from the recorded levels, re-points the tranche, and escalates CRITICAL if it still cannot. Driven from the ledger because that is the only place that knows a tranche is held.- A tranche with no record is skipped silently. That is DCA's correct resting state — it carries no stop by design — and escalating it would fire a CRITICAL per DCA tranche per cycle.
- CRITICAL escalations now leave the machine.
WebhookAlertHandlerPOSTs toKEEL_ALERT_WEBHOOKas the same JSON object the log file gets. Off by default; no URL means no handler and no network call. Level is on the handler, so verbosity can't turn it into a firehose. Never raises into the caller and never logs its own failure through thekeellogger (that would recurse).
Explicitly rejected
- Exempting protective sells from the notional/exposure rails. They already are, so it buys nothing — and rail 2 is the only per-order magnitude check that runs on the sell side. Removing it would leave an oversized SELL from a qty bug with nothing between it and the exchange.
- Auto-flatten on reject. A market sell fired into the same book that just rejected the order, at the moment the broker API is degraded.
Tests
19 new, TDD — all 10 core ones written failing first. Notably test_run_once_brackets_a_tranche_whose_bracket_was_never_placed, verified to fail when the sweep is unwired: every reconcile test calls the pass directly and would pass in full while run_once never invoked it, leaving the exact hole open.
Covered: never-placed and rejected brackets both swept; DCA left alone; sold-out product not re-bracketed; already-protected tranche untouched; failed retry keeps its record for the next cycle; escalation fires; alert payload, CRITICAL-only filtering, unreachable endpoint, double-install, and env-over-.env resolution.
ruff check keel tests packages clean, mypy clean on the strict keel-core additions, 2226 passed / 1 skipped.
Note for the reviewer
…truncated — full description in #205.
fix(rails): let rail 9 see a protective bracket's own stop (#212)
Closes #206. Surfaced while fixing #195.
The gap
place_bracket carries the bracket's trigger price in entry and leaves stop=None:
intent = OrderIntent(
side=Side.SELL,
entry=stop, # the trigger price
stop=None, # -> rail 9 has nothing to check
...
)That shape is correct — the order is the stop, not an entry protected by one somewhere else. But rail 9 (no stop-loss widening) is guarded by intent.stop is not None, so it skipped every bracket keel has ever placed.
The ratchet-only invariant was still enforced, but only inside _roll_stop — which has no production caller (tests/execution/test_executor.py:1415 is an explicit tripwire asserting that). So on the live path, the only thing checking that a bracket's stop had not moved against the position was code that never runs.
The fix
OrderIntent.protective_stop, set by place_bracket and _roll_stop, read by rail 9 when stop is absent:
proposed_stop = intent.stop if intent.stop is not None else intent.protective_stopWhy a separate field and not just stop — this is the part worth reviewing. Rail 7 (min-move/anti-scalping) measures abs(entry - stop) / entry, and a bracket's entry and stop are the same price by construction. Populating stop would compute a 0% move and veto every protective order keel places on the anti-scalping floor. Two rails asking two different questions need two fields. test_a_protective_bracket_is_not_vetoed_by_the_min_move_floor pins this.
Rail 7 remains inert on brackets by design — there is no entry-to-stop distance to measure. The module docstring now says so rather than implying coverage that was never there, which was the original complaint in #206.
The comparison stays strictly <, never <=. Re-placing at the same level is exactly how _rebracket_or_escalate and reconcile_unbracketed_positions recover a dead or rejected bracket — an off-by-one to <= would veto every recovery and strand the position naked, reintroducing the failure #195 just closed. test_rail9_allows_re_placing_a_bracket_at_the_SAME_stop guards it.
Tests
5 new (4 written failing first; the fifth passes already as a regression guard that protective_stop does not shadow the entry path). Covers: a widening bracket now vetoed, a ratcheting one allowed, re-placement at the same stop allowed, min-move not triggered on a bracket, and entries still using their own stop.
ruff check keel tests packages clean, mypy clean on both changed modules, 2293 passed / 1 skipped.
Scope note
No behaviour change on any path exercised today: the first bracket after an entry sees open_stop unset (it is written only after a successful placement), and both recovery paths re-place at the recorded level, which passes. This closes the gap ahead of stop rolls ever being wired up — at which point rail 9 becomes the un-overridable backstop behind _roll_stop's own local check.
fix(brokers): read robinhood's real estimated_price shape, correct every fixture (#218)
Findings from the first live run against a real Robinhood credential (#216), so these are
statements about the venue rather than readings of the documentation. Ten requests, zero 401s:
signing, headers, the timestamp window, the base URL, pagination and every endpoint path are
confirmed correct and none of them are touched here.
Closes #217. Follows #216. Removes an assumed basis for part of #198.
F1 (blocker) -- _estimated_price read a field the venue does not send
adapter.py read _field(rows[0], "price", "0"). The observed row is:
{'symbol', 'side', 'quantity', 'timestamp', 'fee_ratio', 'est_fee', 'ask', 'est_total_cost'}
There is no price. Every market preview against the live venue came back
est_quote_size = 0.000 with errors populated -- confirm mode was unusable on this venue.
- The unit price is now read from the column named after the side that was asked for
(to_price_side: buy ->ask, sell ->bid). There is deliberately no fallback to the other
side's column: pricing a sell off anaskoverstates the proceeds of an exit, the exact
optimistic directionto_price_sideexists to prevent. A row not carrying the requested side is
treated as unpriced. preview_ordernow reads the venue's ownest_feerather than multiplying the notional by
the account'sfee_tier_status.fee_ratio.detail["fee_basis"]says which was used, and
GET /accounts/is no longer fetched at all on a healthy market preview -- one request instead
of two, on a path the executor uses while unwinding.est_total_costis reconciled, not assumed. See below.- The #194 S1 behaviour is preserved and now has more paths feeding it: a missing or unusable
value still yieldsNone-> populatedPreview.errors, never a silent zero. synthetic=True/supports_native_preview=Falseare unchanged, and the docstrings now argue
for it rather than stating it:/estimated_price/prices a quantity. It does not validate
the order, check buying power, check the account's size bounds, or reserve anything -- an order
it prices happily can be rejected the instant it is placed. That gap is whatPreview.synthetic
exists to carry, and reading more of the venue's numbers does not close it.
Confirmed live on the branch. SELL 0.001 BTC -> quote=64.95451 fee=0.617067845,
BUY 0.001 BTC -> quote=64.947570 fee=0.617001915, both synthetic=True, errors=(), two GETs
and no /accounts/ call. Side mapping verified: side=ask answers with an ask column,
side=bid with a bid column.
On est_total_cost: what interpretation was chosen, and why it is not a guess
I could not run live, so rather than pick a reading and hope, the relation is determined from
the venue's own numbers on every response. The row states price, quantity, est_fee and
est_total_cost -- one equation, one unknown -- and exactly one of three readings fits any
self-consistent response:
| reading | relation | fee-exclusive notional |
|---|---|---|
est_total_cost |
total == notional |
total |
est_total_cost_less_est_fee |
total == notional + fee |
total - fee |
est_total_cost_plus_est_fee |
total == notional - fee |
total + fee |
The third is not padding: a buy's "total cost" plausibly adds the fee while a sell's plausibly
nets it out of the proceeds, and only the ask side was observed live.
Pushing back on one instruction in the issue: "use est_total_cost for est_quote_size"
cannot be right unconditionally. Preview carries est_quote_size and est_fee as separate
fields, and the limit path fills est_quote_size with base_size * limit_price -- a
fee-exclusive notional. Assigning a fee-inclusive total straight into it would double-count
the fee at the confirm gate, once inside the quote size and once in est_fee. So the venue's
number is what is used, adjusted by the venue's own fee according to whichever relation its
numbers satisfy. detail["cost_basis"] reports which one.
A total fitting none of the three is priced from est_total_cost exactly as sent and
reported through Preview.errors. That middle course is deliberate: refusing to price would
degrade an exit preview over a number that is probably right, and pricing it silently would put a
cost in front of a human with an unverified relationship to the order.
…truncated — full description in #218.
fix(cli): render Preview.synthetic at the confirm gate; an estimate must not look like a quote (#221)
keel/cli.py:_interactive_confirm took a raw dict and knew nothing about the port's Preview, so it had nowhere to render synthetic. Preview's docstring makes that a requirement of anything rendering one:
synthetic=Truemeans these numbers are an estimate the adapter computed, not a quote the broker returned. Anything rendering a Preview must surface that distinction: approving an estimate must never look identical to approving a broker's own quote.
Latent today (Coinbase has a native preview endpoint, so nothing synthetic reaches a human), real for the first synthesizing venue. Worse there: an unpriceable synthesized preview comes back as zeroes, which on an undecorated key/value screen reads as a harmless "$0.00 order" rather than "keel has no idea what this costs".
What a human sees now
Native / broker-quoted — unchanged question, new provenance rule:
Rails PASSED. Order preview:
========================================================================
BROKER QUOTE -- the venue priced this order itself.
========================================================================
product_id: BTC-USD
side: BUY
est_base_size: 0.00004821
est_quote_size: 5.00
est_fee: 0.03
Place this order? [y/N]:
Synthetic, priced — same numbers, unmistakably not the same screen. Still an ordinary y/N: the warning is the banner, not ceremony on every exit.
Rails PASSED. Order preview:
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! SYNTHETIC ESTIMATE -- NOT A BROKER QUOTE.
!! keel's adapter computed these figures from a price lookup. The
!! venue has NOT priced, validated or reserved anything, and is bound
!! by none of the numbers below. The fill can differ.
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
product_id: BTC-USD
side: BUY
est_base_size: 0.00004821
est_quote_size: 5.00
est_fee: 0.03
estimated_price: 103700.00
Place this order? [y/N]:
Synthetic, unpriced, carrying errors — the case the issue is really about:
Rails PASSED. Order preview:
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! SYNTHETIC ESTIMATE -- NOT A BROKER QUOTE.
!! keel's adapter computed these figures from a price lookup. The
!! venue has NOT priced, validated or reserved anything, and is bound
!! by none of the numbers below. The fill can differ.
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
product_id: DOGE-USD
side: SELL
est_base_size: 0
est_quote_size: 0
est_fee: 0
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! UNPRICED -- this preview carries no usable size.
!! This is NOT a zero-cost order -- it is an order whose cost could
!! not be determined. Approving it sends an order to the venue with
!! no idea what it will spend.
!! PREVIEW ERRORS (1) -- reported against this order:
!! - no estimated_price for DOGE-USD
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
This preview is NOT a reliable quote. To place it anyway you must type the phrase "place anyway" -- anything else declines.
Type "place anyway" to place, or press Enter to decline:
The friction, and why it is not a block
An unpriced / error-carrying / unparseable preview upgrades the question from [y/N] to a typed place anyway. A y at a [y/N] prompt is muscle memory after the tenth order of the day, and the point of the banners is that this screen is not like the last ten.
It is deliberately not a refusal. An unpriced preview is exactly what a human sees when a venue's pricing endpoint is down and they are trying to close a position; walling off the exit path would trap a position behind a broken preview endpoint — a worse money outcome than a warned-and-approved order. Harder to do, never impossible. Ctrl-C / EOF at the prompt declines rather than raising out of the gate.
The live Coinbase path is untouched in substance
…truncated — full description in #221.
fix(brokers): sum robinhood's per-order fee_charged into a real fees_usd (#222)
RobinhoodAdapter.get_fee_summary() returned fees_usd=Decimal("0") unconditionally. FeeSummary's docstring names subscription-lapse detection as its consumer, and the contradiction it looks for is a fee charged while the user claims a fee-free allowance. Pinned at zero, that check did not error against this venue — it silently passed, for every account, every time. A rail that always passes is worse than an absent one, because it reads as coverage.
fees_usd is now summed from GET /api/v2/crypto/trading/orders/, filtered to the same trailing 30 days thirty_day_volume covers.
Window: the filter exists, and which one matters
I fetched https://docs.robinhood.com/crypto/trading/ rather than guessing. The page is a Next.js SPA with an empty __NEXT_DATA__; the real source is a complete OpenAPI 3.0.1 spec embedded in a JS chunk, which I extracted and read directly. The list endpoint documents account_number (required), cursor, created_at_start, created_at_end, updated_at_start, updated_at_end, symbol, side, type, state. Server-side time filters exist, so no client-side sweep-and-discard is needed.
updated_at_start, not created_at_start. A fee is charged when an execution happens, and an execution necessarily bumps updated_at — so that result set is a superset of the orders carrying an in-window fee and can never omit one. created_at_start has no such property: a StopLimitGTC resting forty days and filling this morning was created outside the window and charged its fee inside it. keel rests GTC brackets by design, so that is the normal case, not a corner. Under-reporting is the false negative this issue is about, so between two imperfect filters the correct one is the one that cannot under-report.
Decisions that could each have reintroduced the bug
- No
statefilter; every state counted. A partially-filled-then-cancelled order endscanceledhaving been charged a real fee, so filtering tofilledunder-reports. No filter is needed anyway —fee_chargedis documented as the fee charged based on executed fills, so the field is already its own state filter, reading zero on an order that never traded. estimated_fee_remainingis never read. It is an estimate of a fee not yet charged;fees_usdis consumed as an observation.- An incomplete sweep raises rather than returning a partial sum.
FeeSummaryhas no field to mark a total partial (fees_usdis a bareDecimal), so a truncated sum is indistinguishable from a complete one — the same always-passing false negative in a new costume. The transport's existing_MAX_PAGESalready raises, andget_fee_summarydoes not catch it. This inverts the_account/cancel_orderrule ("a raise on the way out of a position can trap it") and safely:get_fee_summaryis a reconciliation read, never a step in an unwind.
Cost
1 + N requests, N being history pages in the window: one GET /accounts/ plus the page walk, capped at 20. Worst case 21 against a 100 req/min limit with no backoff; realistically 2. The server-side window filter is what stops it growing with the account's total age forever. No bound below _MAX_PAGES was added — a tighter cap would truncate silently, and truncation is the failure this issue is about.
Two residual inaccuracies, documented not papered over
- An order straddling the window edge contributes its whole fee.
fee_chargedis order-level and v2'sexecutions[]rows carry onlyeffective_price/quantity/timestamp— no per-execution fee — so a fee cannot be split at the boundary even in principle. This over-counts, never under-counts: an over-count points lapse detection at a fee genuinely charged, just slightly earlier than claimed; an under-count hides one. - The window is not provably identical to the venue's.
thirty_day_volume's boundary is undocumented; ours is cut from the local clock. They match in length and intent, not to the second. Comparable as magnitudes; do not divide one by the other for an exact effective rate.
Unverifiable without placing a real order
…truncated — full description in #222.
fix(analysis): scale is_round_number to the price's own magnitude (#227)
is_round_number had no sense of scale. Fixing it, with the before/after #225 requires.
The defect
def is_round_number(price: Decimal, step: Decimal = Decimal("0.005")) -> bool:
remainder = price % step
distance = min(remainder, step - remainder)
return distance <= step * Decimal("0.1")step is an absolute half-cent. Coinbase quotes BTC/ETH/PAXG to two decimals and 0.01 = 2 × 0.005, so every quotable price is an exact multiple of step, remainder is exactly zero, and the function returns True unconditionally. The distance <= step * 0.1 test never got a chance to be false.
As weight 1 of DEFAULT_WEIGHTS' 14, three of the five live allowlist assets carried an unconditional +1 on every CTS score. A constant is worse than a redundant factor — a redundant factor at least varies — and because it applied to three assets and not the other two, the CTS total was not comparable across the allowlist.
What "round" now means, and why
A round handle is a price with few significant figures — 65,000, 3,400, 0.38. That is a property of the price relative to its own magnitude, which is exactly what an absolute constant cannot express.
spacing = 10 ** (floor(log10(price)) - 1) # the two-significant-figure grid
present ⟺ dist_to_nearest_multiple <= spacing * tolerance # tolerance default 0.02
Computed from Decimal.adjusted() — exact integer exponent arithmetic, no float log10, which would misplace the grid a few ulps under a power of ten.
Three decisions, each argued in the write-up:
- Two significant figures, not three. Two is what the words mean (65,100 and 0.381 are not handles). It is also what survives measurement: a three-figure grid pushes the spacing onto ADA/XLM's quote increment, and tick quantization alone then drives the base rate — ADA reaches 0.2996 against BTC's 0.1861 at the same tolerance, a 1.61× spread manufactured by quote precision. Same class of artifact this fix removes.
toleranceis a fraction of the handle SPACING, not of price. Both scale with the instrument, so both fix the reported bug; they differ in what they hold constant. A fraction of price makes the presence rate depend on where in the decade the price sits — the spacing is 10% of price just above a power of ten and 1% just below, so the same rule would fire ten times as often on BTC at 99,000 as at 10,500, and the factor would silently change meaning as an asset trended through a decade. BTC's daily history spans 15,760–124,720, two decade crossings. A fraction of spacing makes P(present) identically2 × toleranceregardless of price, decade position and quote precision — #225's acceptance criterion restated as an invariant.- No
quote_increment, deliberately.assemble_cts_contextis a pure function of(setup, candles)and #224's offline replay — the instrument measuring this very change — depends on that. Threading venue state into ananalysis.*primitive to fix a factor that doesn't need it would make the scoring path unreplayable. Tick size is a venue's quoting rule; a psychological handle is a property of the number.
Before / after — P(present), same 6,827 daily bars
Unconditional sample, ONE_DAY, expanding window (which reproduces the live path exactly). #224's harness, keel/research/cts_factors.py, reused unchanged — no second instrument was written.
| asset | N | before | after | Δ |
|---|---|---|---|---|
| BTC-USD | 1,648 | 1.0000 | 0.0358 | −0.9642 |
| ETH-USD | 1,648 | 1.0000 | 0.0334 | −0.9666 |
| PAXG-USD | 259 | 1.0000 | 0.0463 | −0.9537 |
| ADA-USD | 1,636 | 0.2170 | 0.0465 | −0.1705 |
| XLM-USD | 1,636 | 0.1901 | 0.0440 | −0.1461 |
| pooled | 6,827 | 0.6183 | 0.0401 | −0.5781 |
Cross-asset spread (max/min): 5.26× → 1.39×. That number is the acceptance test. The residual 1.39× is the sampling spread of a ~4% Bernoulli rate over 259–1,648 bars; PAXG (N=259) is the widest cell.
The repaired factor now sits inside the panel rather than dominating it — rarer than candlestick_pattern (0.2026), commoner than rsi_extreme (0.0230).
The before-arm is verified, not asserted
…truncated — full description in #227.
fix(brokers): map Robinhood's partially_filled state to OPEN (#229)
What
STATE_TO_PORT_STATUS in packages/keel-broker-robinhood/keel_broker_robinhood/translate.py only covered Robinhood's query-filter state enum (open, canceled, filled, failed, pending). The order-response enum instead carries partially_filled and omits pending. A partially_filled order therefore fell through to_port_status's default and read as PENDING.
partially_filled now maps to OPEN: a partially-filled GTC limit is still resting and working at the venue, so OPEN is the honest status; PENDING reads as not-yet-working. OPEN is already in the port's accepted status vocabulary (see the conformance suite's status assertion).
The map now covers the union of both enums (pending was kept, not removed) since either shape could plausibly arrive in a response.
Unverified spelling
Per #198, no Robinhood order object has ever been observed live, and Robinhood's own docs disagree about which state enum applies to the order-response object. partially_filled's exact spelling is therefore doc-sourced, not confirmed. The added comment says so explicitly and names which of the two documented enums it came from, rather than presenting it as verified.
to_port_status's fallthrough-to-PENDING default is unchanged -- its docstring's reasoning (an unrecognised state means the adapter doesn't know the outcome; FAILED would falsely declare a terminal result) still holds and applies to any future unmapped state.
Latent, not a behaviour fix
Confirmed by reading keel/execution/reconcile.py: it branches only on _FILLED = "FILLED" (line 36) and _DEAD = frozenset({"CANCELLED", "CANCELED", "EXPIRED", "FAILED"}) (line 37). The status != _FILLED check at line 102 treats every non-terminal status identically -- OPEN and PENDING take the exact same "still resting, keep polling" path today. This PR corrects a factual claim about the venue's vocabulary; it does not change behaviour until some future consumer distinguishes OPEN from PENDING.
Test plan
TDD: added test_to_port_status_maps_partially_filled_to_open_not_pending, confirmed red before the fix (to_port_status("partially_filled") returned "PENDING"), green after. Existing test_to_port_status_defaults_an_unknown_state_to_pending_not_failed and test_to_port_status_defaults_none_to_pending pin that the fallthrough default is unchanged and unnarrowed.
-
uv run ruff check keel tests packages scripts-- all checks passed -
uv run pytest -q-- 2445 passed, 1 skipped (baseline 2444 passed / 1 skipped + 1 new test; the one skip is the conformance candles probe,suite.py:254, as expected) -
uv run mypy-- Success: no issues found in 219 source files
fix(tooling): summarise the whole list in the probe, and restore the field it hid (#232)
Closes #230.
D1 — shape_of only ever inspected results[0]
It reduced a list to [shape_of(value[0]), "... N items"], so every probe validated one element and reported a match for the whole collection. Live, trading_pairs returns 89 pairs in two distinct key-sets: 63 carry min_order_amount (BTC-USD 0.1 and ETH-USD among them), 26 do not — and results[0] is BILL-USD, one of the 26. The probe reported 5/5 and then 6/6 matched while blind to a field on 71% of pairs, including every asset keel trades.
shape_of now merges every element: the union of the keys, a key carried by only some of them marked min_order_amount (63/89), and a key the venue types inconsistently rendered Decimal|str (77 str, 12 Decimal) instead of silently taking the first. One summary row and a count come back however long the list is — an 89-pair response never prints 89 shapes.
How a partially present key compares against a fixture
A fixture is a single representative object and cannot express "63/89", so the convention is that the fixture carries the union of what a row can hold, and the count is reported as information rather than as a mismatch. rh_trading_pairs.json's one row is BTC-USD, and BTC-USD is sent the minimum, so:
- fixture omits
min_order_amount→NEW AT VENUE results[].min_order_amount— the #218 regression, now catchable; - fixture carries it → clean match, plus a printed note.
Mechanically: every note lives in a trailing (...) suffix on the shape token, and compare_shapes compares tokens with that suffix stripped. It rides on the key, so a partially present key whose value is an object is annotated the same way as one whose value is a leaf, and it survives --json verbatim. Treating partial presence as a difference instead would fail the trading_pairs probe on every run against a venue behaving exactly as measured — the same cry-wolf failure #217 F5 taught this script to avoid.
A mixed type is deliberately not bare-equal to either of its halves, so it still reports TYPE DIFFERS with both tallies in the message. At a venue already caught quoting the same kind of value two ways in one object (#217 F6), that is a finding, not a formatting detail.
The report, on the measured 63/26 split
=== trading_pairs ===
shape matches rh_trading_pairs.json
note: results[].min_order_amount present on 63/89 elements
and in --json:
"results": [
{
"asset_code": "str",
"asset_increment": "str",
"is_api_tradable": "bool",
"max_order_size": "str",
"min_order_amount (63/89)": "str",
"quote_code": "str",
"quote_increment": "str",
"status": "str",
"symbol": "str"
},
"... 89 items"
]That exact run — 89 rows, BILL-USD first, 63 carrying the key — is asserted end to end in test_the_real_63_of_89_split_matches_the_fixture_and_is_reported, including exit code 0 and the note line.
D2 — the regression
min_order_amount is restored to tests/fixtures/rh_trading_pairs.json with BTC-USD's real 0.1. The row's max_order_size is corrected to the measured 20.0000000000000000 at the same time (it read 10.00000000); the increments were already correct.
The false claim is corrected in packages/keel-broker-robinhood/README.md (§ "No published minimum order size" → "The minimum order size is published, but only on some pairs"), in RobinhoodTransport.get_trading_pairs' docstring, and in the two suite tests that asserted the field's absence. Dated documents under docs/experiments/ are left alone. #198's prerequisite list still needs its comment corrected — min_order_amount is a lower-bound source for every asset keel trades, read as optional per pair.
D3 — regression test
test_a_key_only_a_LATER_element_carries_is_still_reported. Against the old shape_of:
> assert len(diffs) == 1, f"a key only the second element carries went unreported: {diffs}"
E AssertionError: a key only the second element carries went unreported: []
E assert 0 == 1
E + where 0 = len([])
tests/scripts/test_robinhood_smoke.py:178: AssertionError
1 failed, 31 deselected in 0.05s
Also
…truncated — full description in #232.
Compliance & rails
docs(experiments): assess cTrader Open API and refuse it on charter grounds (#203)
cTrader came up as a candidate execution venue. This records the assessment and the answer — no — so the question does not get re-litigated from scratch. No adapter, no dependency, no entry point: the deliverable is the finding.
Closes #201.
The finding
cTrader Open API has no unleveraged spot-ownership primitive. It is not a spot venue with leverage bolted on — leverage, margin, short-symmetry and cash-only P&L are load-bearing in every core message type, and no configuration of the account or the API removes all four at once:
ProtoOATradercarriesleverageInCents/maxLeverageas always-present account fields;ProtoOAPositioncarriesusedMargin/marginRate/swapas baseline fields on every position.ProtoOATradeSideSELL opens a short — it is symmetric with BUY, not a disposal of something held. There is no "sell what you own" path.- There is no cash-balance-in-asset concept anywhere. Only a deposit-currency balance plus position P&L, so nothing ever represents "you hold 0.1 BTC".
- The
swapFree"Shariah account" does not rescue it: it substitutes a dailyrolloverCommissionfor swap, and touches neither the leverage, the short symmetry, nor the absent custody. It is the answer to one question out of four, and not the one that matters.
The KB already rules on this product class directly, which is what turns the argument from inference into citation: §56.1 as corrected by §66.3 — retail "spot" FX is "perpetually rolled to avoid the actual delivery of the currency," with the interest differential charged for the deferral, so a "spot" label is not evidence of spot settlement. §66.3's litmus test — "transfer of property, substantively or constructively" — is exactly what cTrader fails. §28.1–28.2 name CFDs outright in the gharar exclusion set.
What this changed about our own defences
The assessment was independently reviewed, and the review's most useful finding was that the first draft described keel's gates as stronger than they are. Those corrections are in this PR and matter more than the verdict did — a refusal argued from defences that do not hold is worse than no document:
- Rail 17 is entries-only and one of two
LIVE_STATE_RAILS, so it is skipped in paper. Its input is the operator's attestation, not anything an adapter reports — it fails closed by default, but it is operator-honesty, not adapter-honesty. - Rail 19's check does not fail cTrader.
EUR-USDparses cleanly as a spot id; this is the two-segment residualguards.pyalready documents. The rail contributes its charter comment, not its grammar. - The curation screen is not a reliable gate against a CFD wrapper at all —
AssetAttestationis keyed on the base asset with no field for an instrument wrapper, so a BTC CFD reuses BTC's admitted spot attestation. Filed as #202 rather than papered over.
So the honest count is two gates plus the port's own vocabulary, not three independent gates.
Market data, answered rather than dodged
cTrader's market data is genuinely good, and every gate above is execution-side — so a read-only feed trips none of them and would not touch the broker port at all. The document says so and answers on cost/benefit instead: dealer-quoted marks rather than exchange prints, cross-venue basis against the Coinbase spot universe keel actually trades, an SDK with no release in ~2 years, and disproportionate onboarding. Not worth it today, but explicitly not charter-barred the way execution is — claiming otherwise would be the same overreach the review caught three times.
Evidence, stated honestly
A documentation review, not a live probe. No account was opened, no application registered, no connection made. Protocol claims cite help.ctrader.com/open-api/*; SDK-maintenance claims cite PyPI and GitHub. There is no re-runnable probe script here, unlike the Coinbase asset-class study, and the document says so rather than implying empirical evidence it does not have.
Why nothing was built
…truncated — full description in #203.
docs(experiments): assess Trade Nation; refuse it on the instrument, not the transport (#210)
Closes #204.
Trade Nation came up as a candidate execution venue. This records the answer so the question is not re-litigated from scratch, following the house form set by docs/experiments/2026-08-05-coinbase-asset-class-feasibility.md and the cTrader assessment in #203.
Verdict
No — and the refusal is structural, resting on the instrument rather than the transport.
What distinguishes this from cTrader is that a technically usable Python order path does exist here. MT4 accounts are self-serve, Expert Advisors are an advertised feature (Automate using Expert Advisors (EAs) on their own platforms page), the live server name is public, and open-source MQL4-to-Python bridges are a solved problem. A determined engineer could have Python placing live orders at Trade Nation inside a weekend. So the refusal cannot rest on "there is no API" — and the document deliberately builds that counter-case at full strength before answering it.
It is answered on the only ground that matters: every keel gate is a gate on what is traded, not on how the order arrived. A bridge does not launder gharar.
Trade Nation is CFD and spread-betting only — leveraged, financed, non-deliverable, short-symmetric, with no rights of ownership (UK Client Agreement cl. 3.5). There is no unleveraged spot ownership anywhere in its product range, so rails 17, 18 and 19 and the curation screen each reject it independently, and rail 19's own comment records that spot-only is the charter with no config field to widen it.
On the missing API
The negative is established by enumeration, not by a failed search, so it is falsifiable rather than an absence of evidence: 1,423 sitemap URLs plus 160 support URLs with zero connectivity pages, seven direct path probes, and "API" appearing zero times on six key pages. There is also no vendor to appeal to — FINSA EUROPE LTD is TRADE NATION FINANCIAL UK LTD (Companies House 07073413, renamed 2022), so com.finsa.* is a legacy in-group namespace and TN Trader is proprietary. No white-label API doc exists to read.
Scope
Documentation review only. No account was opened; api.tradenation.com is a private backend for their own apps and was deliberately not probed. Builds nothing — no adapter package, no dependency, no entry point, no conformance fake.
Review
Independently reviewed by a fresh reviewer before this PR: verdict APPROVE WITH CHANGES, all 6 required changes applied. The reviewer verified all 55 file:line citations (zero errors after an earlier fix pass corrected 7) and 9 of the KB quotations verbatim, and independently falsified two details of the enumeration — which the method is designed to permit. Corrections applied cover the stale §66.7 crypto claim (since partially closed by §71/§72/§85), real platform-specific instrument names, the /api/ 302, dividend adjustments, and the primary-source ownership clause. The reviewer's strongest attack — the swap-free/"Islamic" account — is now pre-empted in the document: it removes the riba on the rollover and leaves gharar and cash-settlement-no-delivery untouched.
Note on the rails' limits
The document volunteers where the rails stop rather than overstating them. A cosmetic rename to EUR-USD is a faithful transliteration that clears rails 18 and 19 outright, leaving only rail 1 and the curation screen — and #202 is why the screen is not claimed as an unconditional defence. That residual is stated plainly rather than papered over.
feat(compliance): attest the instrument wrapper, not just the underlying (#223)
Closes #202.
The gap
AssetAttestation is keyed on asset — a base-leg symbol — so it can only ever describe the underlying. The honest attestation for the underlying of a BTC CFD is character-for-character BTC's existing, already-admitted spot one (sector=payments, backing=native, pays_yield=False), so the screen admitted it. Swap financing, leverage and counterparty exposure are properties of the contract, and no amount of care taken over the asset claim could ever surface them.
The decision
Keep AssetAttestation exactly as-is — a per-asset claim about the underlying — and add a separate InstrumentAttestation keyed per (venue, product_id) carrying a wrapper. Admission requires both. Sector/backing/yield are facts about the underlying; "what contract is this listing" is a fact about a venue's product.
Keyed on product_id, not (venue, asset), because Coinbase — the one venue keel already uses — lists both BTC-USD (spot) and BTC-PERP-USD against the same base leg. A per-asset wrapper claim would be factually wrong today, not merely imprecise once a second venue lands.
The wrapper is attested, never computed. The id's shape cannot answer it: a cTrader CFD spells itself BTC-USD and parse_spot_product_id reads that as well-formed spot — that is the gap. The venue's own product_type is its self-report about its own product, which makes it excellent input to the human's source and unacceptable as the claim itself.
spot_instrument (the id's grammar) and instrument_wrapper (the human's claim) are complementary and deliberately not merged — both fire for a derivative-shaped id attested as spot, so neither failure can hide behind the other.
Rejected alternatives
- Add a wrapper axis to
AssetAttestation— it is keyed per asset, so it cannot express thatBTC-USDandBTC-PERP-USDare different contracts on the same venue. - Auto-attest from Coinbase
product_type— that is the venue self-reporting on its own product. It is evidence to cite, not the claim. - Derive the wrapper from the id shape — a CFD spells itself exactly like spot. This is precisely why the gap exists.
- A per-trade rail in
guards.py— the screen is a curation gate by charter, andguards.checkhas no broker/venue handle. That is the dead-gate patterncapabilities.pyexplicitly warns about. - Backfill
spotfor the current allowlist — see below.
⚠️ Accepted consequence: REJECT until attested
keel assets screen now reports REJECT (instrument_wrapper: UNATTESTED) for every product — including BTC-USD, ETH-USD and PAXG-USD — until the operator runs keel assets attest-instrument once per product.
This is the fail-closed default and it is intended. It does not affect live trading: screen_asset has exactly one production caller, and rail 1 gates live buys on config.allowlist directly, not on the screen.
There is deliberately no backfill, no default-to-spot, and no auto-attestation. Seeding spot rows for currently-allowlisted products would fabricate exactly the claim this gap is about, for the three products the project is most likely to stop questioning. The failure message names the exact command that fixes it — that is how the operator is told.
$ keel assets attest-instrument --product BTC-USD --wrapper spot \
--source "coinbase product spec" --attested-by "<name>"
Not waivable
WAIVABLE_CRITERIA stays exactly frozenset({"history"}), as #202 requires — a waiver here would waive the charter, not a threshold. A stray screen_exceptions row for instrument_wrapper is dropped by the up-front filter and pinned by a test.
instrument_wrapper is deliberately not in DATA_DERIVED_FAILURES: like settlement, it reads an attestation and never touches candles, so it stays a real, assessable verdict at zero bars.
Changes
…truncated — full description in #223.
Research & validation
research: are the 11 CTS factors collinear? — momentum cluster refuted, trend cluster real and small (#224)
Closes #208.
Research only. DEFAULT_WEIGHTS, every factor, every gate and every threshold are untouched — no scoring behaviour changes in this PR. The issue is explicit that this is "not a drive-by tweak to the scoring weights", and it isn't one.
The answer
| the question #208 asked | answer | number |
|---|---|---|
momentum cluster {rsi_extreme, rsi_divergence, deceleration} is collinear? |
no — refuted | mean within φ −0.018 vs +0.025 background |
trend cluster {condition_aligned, ema_fan_aligned} is collinear? |
yes, mildly | mean within φ +0.190 vs +0.018 background |
| does collinearity inflate the score? | barely | Var ratio 1.161 → ≈8.6 effective independent factors of 10 |
| largest collinearity anywhere | not a pre-declared cluster | deceleration × candlestick_pattern φ +0.254 |
rsi_extreme and rsi_divergence co-occur ZERO times in 6,822 bars (Jaccard 0.000, lift 0.00). They read the same indicators.rsi(closes) array and are wired to fire in disjoint states — a bullish divergence needs RSI making a higher low, which is by construction off the oversold extreme. Sharing an input is not sharing an answer. Collapsing that cluster, the remedy #208 proposes, would destroy information rather than remove duplication.
The trend pair is real (10–22× background, stable on all five assets, reproduced at +0.201 on a 27× larger sample) and small: 3.6% shared variance over 4 of 14 raw points.
This is a negative result on the headline hypothesis and it is reported as one. #208's structural argument is a correct description of the wiring and a wrong prediction about the output.
N, and how it was obtained
The issue's proposed data source — the signals table — holds one row in keel.db and zero in keel-live.db. The sample had to be reconstructed, which is possible because engine.assemble_cts_context is a pure function of (setup, candles) and keel.db.candles holds 611,176 bars back to 2021.
| arm | population | granularity | window | N |
|---|---|---|---|---|
| 1 (headline) | every bar | ONE_DAY | expanding | 6,822 |
| 2 (replication) | every bar | ONE_HOUR | rolling 500 | 186,725 |
| 3 (window sensitivity) | every 20th bar, BTC | ONE_HOUR | 250/500/1000 | 2,207 each |
| 4 (contrast only) | fired, gate-cleared signals | ONE_DAY | expanding | 77 |
N = 6,822 = every cached daily bar after a 200-bar warm-up on the five live-allowlist assets (1,647 BTC + 1,647 ETH + 258 PAXG + 1,635 ADA + 1,635 XLM). Expanding window because that is literally what the live path passes (agent.run_once → repo.get_candles with no bounds → engine.evaluate), and find_levels/detect_phase read the whole list they are handed.
Both samples are reported and the unconditional one carries the conclusion. Restricting to fired signals conditions on Rule.detect() and the choppy/higher-TF/kill-zone gates, which are functions of the same regime state several factors read — a collider. Arm 4 demonstrates it rather than assuming it: P(condition_aligned) goes 0.275 → 0.818, P(ema_fan_aligned) 0.301 → 0.909, and the variance ratio falls below 1 (0.663) because conditioning truncates rather than decorrelates.
Multiple testing
45 pairwise tests in arm 1 (10 of 11 factors vary; seasonality is weighted 0 and hardcoded False), 36 in arm 2 (sr_touches is constant under a 500-bar hourly window). Holm–Bonferroni at α = 0.05 over exactly the pairs tested, not over 11·10/2. Each ledger row carries its own hypotheses_tested.
Then the write-up says plainly, rather than dressing it up: at N = 6,822, φ = 0.041 clears Holm; at N = 186,725, φ = 0.054 gives p = 1.9×10⁻¹¹⁷. 23 of 45 pairs are "significant". Significance answers "is this exactly zero", which nobody asked. No conclusion in the document rests on a p-value, and the caveats note that serial dependence between bars makes every p-value optimistic anyway.
The finding worth acting on is not collinearity
levels.is_round_number(price, step=Decimal("0.005")) treats step as an absolute half-cent. Coinbase quotes BTC/ETH/PAXG to two decimals, so price % 0.005 == 0 always and the check can never fail. Verified directly:
…truncated — full description in #224.
Docs, CI & tooling
feat(tooling): read-only live probe for the robinhood adapter's fixture shapes (#216)
Adds scripts/robinhood_smoke.py — an operator tool that validates the Robinhood adapter's assumptions against the live venue without placing an order.
Why
tests/broker_robinhood/ runs entirely against a canned transport, so it proves the adapter is internally consistent with fixtures we wrote ourselves. It cannot prove:
- that Robinhood accepts our Ed25519 signature (the signing tests verify against our own verify-key — circular w.r.t. the venue);
- that the endpoint paths are real (pinned against our reading of the docs, and #194 showed that reading can be wrong in both directions);
- that the response shapes are real —
tests/fixtures/rh_accounts.json's nestedfee_tier_statusdrives every number inget_fee_summaryand everyPreview.est_fee, and nothing outside this repo corroborates it.
What it does
Five read-only GETs — accounts, trading_pairs, best_bid_ask, estimated_price, holdings — and a structural diff of each against its committed fixture, reported in both directions. A key the fixture invented is the dangerous one; a key the venue sends and the fixture omits is how a capability gets missed (fees_usd, #197, is exactly that shape).
Two safety properties, both tested
Read-only by construction. The guard is installed onto the transport's _request, not wrapped around it. A __getattr__ wrapper looks correct and is not — the transport's own methods call self._request and sail straight past it, so the guarantee would hold for the tests and not for the probes. That bug was written, caught by test_running_every_probe_issues_only_gets, and fixed; the test now pins it.
Shapes, not values. Every leaf is replaced by its type, so balances and holdings never reach a terminal, a CI log, or a pasted bug report.
It also pre-checks the private key's length, because pasting the base64 public key Robinhood's credential page asked for produces a 401 indistinguishable from a signing bug.
Not covered
The order lifecycle — state values, fee_charged, whether a cancel 200 confirms. No sandbox exists, so that needs a real order; tracked under #198.
Gates
ruff: All checks passed!
mypy: Success: no issues found in 216 source files
pytest: 2315 passed, 1 skipped (+47 tests; skip is still the conformance candles probe)
Refs #215. Not shipped in the wheel — operator tooling, like migration_smoke.py.
ci: run the suite once per PR, not twice per merge (#220)
What
ci.yml triggered on both pull_request and push: branches: [main], so every squash-merge re-ran the full suite (lint, test, build identity) on content that had already passed minutes earlier as the PR run. That's roughly double the Actions minutes for zero new information, since the merge commit's tree is identical to what the PR run already tested.
- Removed the
push: branches: [main]trigger. - Added
workflow_dispatchso the suite can still be run on demand againstmain(e.g. before cutting a release, or after changing branch protection settings). - Kept
pull_request— that's the gate that actually protects merges, and for a tool that moves money it's the one worth paying for. - Rewrote the header comment: the old one justified running on push ("otherwise
maincan drift red between releases and the gate discovers it at the worst moment"), which is exactly the reasoning being traded away here, so it no longer belonged.
Why this is safe
release.yml already re-runs ruff + pytest as its own gate before it will build an artifact — "a red suite must never produce an artifact that could touch funds." So main still cannot ship red even with nothing running automatically on push.
Residual risk (stated plainly)
A PR that was green against an older main can merge and turn out to be semantically red against the main it actually landed on — two independently-green changes that conflict once combined. Previously, the push-triggered CI run would have caught that immediately on merge. Now it surfaces at release time instead.
Branch protection's "require branches to be up to date before merging" would close this gap by forcing every PR to re-test against the latest main before merge — but that's deliberately not being relied on here, since it would reintroduce the double-run cost this PR removes (just moved to before-merge instead of after).
Validation
uv run python -c "import yaml; print(list(yaml.safe_load(open('.github/workflows/ci.yml')).keys()))"parses cleanly (PyYAML maps the barewordonkey toTrueper YAML 1.1 — a pre-existing quirk unrelated to this change, confirmed identical on the pre-change file and onrelease.yml).- Confirmed no docs reference CI running on push/merge (
docs/RELEASING.mdchecked); no doc changes needed. - Only
.github/workflows/ci.ymltouched — triggers and header comment only, job steps unchanged.
Closes #200
docs(strategy): entry_technique is a recorded label, not a sizing/stop control (#231)
Summary
keel/strategy/indicators_cts.py documented a risk-graded entry ladder — confirm_3bar = "smaller size, wider stop", aggressive = "larger size, tighter stop". Nothing in the codebase implements any of it.
entry_technique is computed in engine.py:144 and reaches exactly two destinations: the signals audit payload (engine.py:360) and a technique= field on an agent.enter_evaluated log line (agent.py:1166). git grep -nE "technique (==|!=|in )" -- '*.py' returns nothing outside tests. Position size and stop distance are identical whether CTS scores 2 or 12.
This PR removes the documented behaviour the code never had and rewrites the docstrings to describe what entry_technique actually is: a recorded label naming the confluence tier at signal time, kept for audit and later analysis.
Nothing changes at runtime. The diff is docstrings only:
low=5/high=8defaults: untouched- the three returned literals
confirm_3bar/signal_candle/aggressive(persisted in thesignalstable): untouched, same spellings DEFAULT_WEIGHTSand every call site: untouched
Wiring the ladder into sizing/stops remains possible later, but it is a live position-sizing change and needs its own evidence — backtest data showing graded sizing beats flat sizing, plus a rails review (sizing interacts with the per-order and per-day caps) — before it earns a docstring claim like the one this removes.
Closes #228
Test plan
-
uv run ruff check keel tests packages scripts— all checks passed -
uv run pytest -q— 2444 passed, 1 skipped (matches main baseline exactly; no test count change since no behaviour changed) -
uv run mypy— no issues found in 219 source files - Diff reviewed: docstrings only, zero logic change
ci: make CI manual-only, and add a SonarQube + Snyk workflow (#234)
Two related changes to the Actions setup.
⚠️ Before this can run: two secrets must exist
gh secret list is empty at both repo and org level. The new workflow will fail its first job until these are added at Settings → Secrets → Actions:
| Secret | Where to get it |
|---|---|
SONAR_TOKEN |
SonarQube → My Account → Security → Generate Token |
SNYK_TOKEN |
Snyk → Account Settings → Auth Token (or snyk config get api) |
The failure is deliberately made legible: a preflight job runs before either scanner and fails with an explicit message naming both secrets and linking the settings page, rather than letting Sonar emit "You're not authorized" and Snyk emit a generic container auth error.
Two more first-run items, both documented in sonar-project.properties: sonar.projectKey is set to CodeGateSoftware_keel and must match the project as it exists on the Sonar server; sonar.organization is left commented out because SonarQube Cloud requires it and a wrong value fails more confusingly than a missing one.
1. ci.yml becomes manual-only
on:
workflow_dispatch:pull_request is removed; workflow_dispatch is untouched.
The accepted consequence, stated plainly: nothing automatically verifies a PR before merge any more — no lint, no suite, no build-identity check stands between a branch and main. A breakage introduced by a merge is discovered at release time, or by whoever next dispatches CI — not at merge time.
What survives is the gate inside release.yml: it re-runs ruff + pytest on every release before it will build an artifact, so a red main still cannot ship a wheel, a tag, or a release asset.
The header comment previously argued at length for keeping pull_request ("the gate that actually protects merges… worth paying for"). That argument no longer describes the file, so it has been rewritten. The concurrency block's comment explaining github.ref for pull_request runs was also stale and is corrected.
🔎 A finding worth your attention: the main ruleset is inert
The task premise was that main has no branch protection. That is true in effect but not quite in fact, and the difference is worth flagging:
- Classic branch protection:
GET /branches/main/protection→ 404 Branch not protected. - But there is an active repository ruleset named
main(id20701832), created 2026-08-11, which does configure 1 required approving review and two required status checks. - It matches no refs. Its
conditions.ref_name.includeis[], and authoritativelyGET /repos/CodeGateSoftware/keel/rules/branches/mainreturns[]— zero rules in force on the branch. - Separately, its two required contexts —
"Lint, Unit tests, Build"and"Integration Tests"— match no job this repo defines (keel's only CI job istest). Even if the ruleset were pointed atmain, those checks would never report and PRs would block indefinitely.
So no required status check compensates for the trigger change, and the comment in ci.yml says so accurately rather than claiming "no protection exists". If the ruleset was meant to be live, it needs both its ref targeting and its check contexts fixed — happy to do that separately.
2. New code-quality.yml — SonarQube + Snyk
Modelled on TempTrak-Ingestion-Service/.github/workflows/code-quality.yml, adapted rather than transliterated. keel is Python 3.14 + uv with a 5-package uv workspace; the reference is a Node/TS monorepo.
on:
workflow_dispatch:
schedule:
- cron: "0 6 * * 1"Three jobs: preflight (secret check) → sonarqube and snyk in parallel.
Deliberate divergences from the reference
…truncated — full description in #234.
ci: gate merges on the test check, and verify the merge result (#238)
Adds push: branches: [main] back to ci.yml, keeping workflow_dispatch.
on:
push:
branches: [main]
workflow_dispatch:Why this is not the redundant run it was called in #200
#200 removed this trigger on the grounds that it re-ran content the pull_request run had already passed. That reasoning held while pull_request still existed. It does not now — #234 removed it, so nothing automatic runs at all.
It was also incomplete even then. A pull_request run tests the PR head; a push run tests main after the merge landed. Those diverge whenever two independently-green branches conflict semantically — each passes alone, the merge of them does not. Only the post-merge run observes the tree that actually resulted.
What this does and does not buy
Does: every merge is verified within minutes, against the real post-merge tree. Given three PRs in this repo have merged mid-flight while an agent was still pushing to them (#192, #218, #235), time-to-discovery is the practical problem, and this addresses it.
Does not: block a merge. It runs after. Blocking needs two things this repo lacks, and they must move together:
- a
pull_requesttrigger, and - a ruleset that actually matches
mainand requires thetestcontext.
The ruleset named main exists but matches no refs (ref_name.include is empty; GET /rules/branches/main returns []), and its required contexts ("Lint, Unit tests, Build", "Integration Tests") match no job here. Pointing it at main without fixing the contexts would block every PR forever on checks that can never report. Tracked in #236.
release.yml is unchanged and remains the gate that matters for money — it re-runs ruff + pytest before building an artifact, so a red main still cannot ship.
Concurrency
Unchanged (ci-${{ github.ref }}). The comment is updated: back-to-back merges to main now cancel the older run, which is correct — the newest main is the only one whose result still matters.
Validation
- YAML parses; triggers read
{'push': {'branches': ['main']}, 'workflow_dispatch': None}(checked viad[True], since PyYAML follows YAML 1.1 where barewordonis boolean). actionlintclean, exit 0.- Job steps untouched — the diff is triggers and comments only.
docs: explain the dashboard's field names and the four money settings (#240)
Two documentation gaps, split along what the operator sees vs. what the operator edits.
TUI help (h overlay) — the vocabulary on screen
_equity_lines prints keel's internal field names verbatim, and nothing on the dashboard explains them:
equity_state_mode: paper
high_water_mark: 11000
drawdown: total=0 (ceiling 0.2) weekly=0 (ceiling 0.1)
rail11 (drawdown breaker): ok
paper_cash_usdc: 11000
The v overlay adds sig blk ent exi err. A screen of zeroes reads identically whether keel found no setup or found one and a rail vetoed it — the difference between an idle deployment and a correctly-declining one.
Adds a Glossary section to the existing h overlay (after "Live balance", before "Help mode"), in the existing _row/_note idiom: cycle, signal, sig / blk / ent / exi / err, paper_cash_usdc, equity_state_mode, high_water_mark / drawdown / rail11.
- No new key binding.
- Widest new line is 85 columns against the help's existing 88, so nothing newly clips under
_paint; scrolling is unchanged (161 lines total). - Four new tests in the existing style, section-scoped so an assertion can't be satisfied by a word three sections away.
README — the settings in a file
Adds "How much money moves" after "Confirm vs. autonomy", covering the four settings and, crucially, how they interact.
| setting | the surprise |
|---|---|
paper.starting_equity_usd |
ONE-TIME seed, first paper run only (agent.py, the paper_trader.get_cash() is None branch). Editing it later resizes nothing, and no command clears the persisted paper_cash_usdc. |
paper.monthly_contribution_usd |
compounds against that small base |
caps.max_exposure_usd |
dual role — held-at-once ceiling and the live sizing equity proxy, so risk_pct is a fraction of it, not of real equity |
| rail 14's monthly allowance | lives in broker_subscriptions (DB, not config) — which is why it drifts out of step with the caps |
The interaction is the point: sizing scales with equity (or the proxy), the rail-14 allowance is a fixed dollar figure. When they drift apart every setup is vetoed and the system looks broken while working exactly as designed. Recorded with the verified case — $11,000 equity, risk_pct: 0.01, a 3.35%-wide stop sizing to $3,284.67 against a $500/month allowance; reseeding at $500 sizes the same setup at $149.30. Plus the mechanic that makes it counter-intuitive: a tighter stop yields a LARGER position (size = risk ÷ stop-distance), which is how a 1% risk became a 30% position.
Verification
Every claim was read out of the source, not assumed. Notable precision fixes made during review: starting_equity_usd: 0 seeds from real mark-to-market equity (a value > 0 is an override, not a fallback); the sizing proxy applies to the live non-DCA ENTER path only — paper mode passes equity_override; the contribution is per UTC calendar month; and config.yaml does still supply rail 14's tier catalogue and unattested fallback, so the DB claim is scoped to the in-force allowance.
Documentation only — no behaviour, default, or config value changed. pyproject.toml untouched; nothing under docs/experiments/ touched.
$ uv run ruff check keel tests packages scripts
All checks passed!
$ uv run pytest -q
2668 passed, 1 skipped in 31.65s
SKIPPED [1] packages/keel-broker-api/keel_broker_api/conformance/suite.py:254:
adapter serves no granularity the suite could exercise
$ uv run mypy
Success: no issues found in 221 source files
2668 = the 2664 baseline + 4 new tests; exactly the one expected skip.
Other changes
docs(experiments): teardown quantcrawler.com for near-term and SaaS lessons (#193)
Competitive teardown of quantcrawler.com — a trading-automation SaaS (webhook→broker execution, a prop-firm risk layer, a trade copier, ~299 pages of programmatic SEO) — mapped onto keel, for both near-term engineering and a possible SaaS future.
Doc: docs/experiments/2026-08-09-quantcrawler-teardown.md
The finding that matters
The comparison surfaced a P0 in keel's live path — though not the one it first looked like, and the doc records all three passes because the wrong answers are instructive.
A rails veto cannot strand a filled entry: rails 3/4/5/6/8/11/13/14/16/17 are is_buy-gated and cannot fire on a protective sell (rail 13 says so outright — "SELL is exempt"). At initial placement the bracket's notional is strictly smaller than the entry that just cleared the same rails.
But if not result.placed (executor.py:772) also catches a broker rejection (min-size, precision, venue error), and that is reachable:
- the entry fills, the bracket is never placed, and no order row is written (the insert is after the guard gate)
reconcile_open_ordersiterates onlystatus="pending"(reconcile.py:52), so_rebracket_or_escalatenever sees it — it only heals brackets that were accepted and later diedkeel-live-run.sh:74gates on a UTC day-stamp — "the trigger count is catch-up BREADTH, not cadence" — so the window is ~24h, not 1hkeel killhalts new orders and closes nothing; there is no flatten- and
_roll_stop'sCRITICAL position_unprotectedis dead code live (rolls are unwired;tests/execution/test_executor.py:1415is the tripwire). The reachable path emits only a WARNING.
Recommended fix: re-point _rebracket_or_escalate at the positions ledger (tranches whose bracket is absent or rejected) instead of status="pending". Reuses existing machinery, bounds the window to one cycle, needs no rail exemption. Plus an alert webhook (§1.2) — with a once-daily cycle and no flatten, a WARNING in a logfile on a headless Mac is the difference between a bad trade and a day-long unhedged position.
The doc explicitly rejects the two tempting fixes: exempting sells from rails that already exempt them (rail 2 is the only per-order magnitude check on the sell side — removing it leaves a qty bug nothing to hit), and auto-flattening into the same book that just rejected the order.
Also covered
- Robinhood adapter: adopt their "refuse rather than silently substitute" stance — declare the quote-sized-entry gap in
BrokerCapabilitiesrather than approximating with base quantity. - A latent gap:
place_bracketpasses the stop asentrywithstop=None, so rail 9 never evaluates the bracket's own stop level. - A testable critique of existing keel code: their "one indicator per category" rule challenges the 11-factor CTS, which carries RSI extreme + RSI divergence + deceleration — arguably three reads of one momentum axis.
- SaaS: multi-tenancy blockers, the halal + research-discipline positioning, the free-calculator/data-asset GTM playbook, and the licensing question (keel decides what to trade, unlike their user-directed webhooks) that gates any paid multi-tenant launch.
- Their trading content is SEO filler, and its one rigor claim — 50,000 trials/ticker with no multiple-testing correction — is precisely the failure mode
keel/research/cscv.pyanddeflate.pyexist to prevent. Their "TICK VALIDATED" results page shows byte-identical numbers across five different instruments.
No code changes; documentation only. All claims about keel's behaviour are cited by file:line and were verified against source, including by an independent reviewer that refuted two earlier drafts.
docs(experiments): assess US equities and refuse on screening capability (#211)
Asks whether keel can add US stocks — from Coinbase, and if not, from another broker. The answer is no, and the binding gate is keel's own screening capability rather than any venue. That makes stocks "not yet" where cTrader's CFDs (#203) were "never".
Feasibility only. No code changed, no adapter added, no dependency introduced.
Two refusals, each sufficient on its own
1. §71.6 rejects equity-representing instruments BY CAPABILITY. They require share-style business and financial screening keel performs none of. Broker-independent — no choice of venue fixes it and no probe can move it. asset_attestations cannot even express the answer: (asset, sector, backing ∈ {ayn,dayn,native}, pays_yield, source) has no vocabulary for debt ÷ market cap.
2. Coinbase's equity surface has no market data at any price. A live re-run of the committed probe on 2026-08-09 (POST-guarded, create_order never called) found ONE_DAY candles n=0 across 21 trading days, empty market_open_price/market_close_price on regular sessions, and empty price/best_bid_price/mid_market_price on all 1000 products sampled. Order book and market trades 500. Preview 403s.
The venue premise collapsed and the verdict didn't move
Coinbase does expose an equities order path — equity_order_metadata on create-order, quoted from their reference. The brief for this document said otherwise and was wrong. Had the charter gate been a formality, that correction would have flipped the answer. It changes nothing, which is what makes §2 rather than §1 the finding worth keeping.
Corrections against prior work
- The 2026-08-05 study, on two counts: "order path refused by design" rested solely on a preview 403 and was never established; and the non-deterministic-universe finding no longer reproduces (stable cursor walk to 19,188 ids).
- KB §65.10, whose DJIM description is faithful to Ayub but no longer describes DJIM after its 2023 screen retirements — a screen built against it would implement a retired methodology.
- Robinhood: shipped official agentic equities trading 2026-05-27, so "crypto-only" is now false. But it is an MCP server requiring interactive OAuth PKCE consent with no
client_credentialsgrant — unusable from keel's headless launchd runner, and its singleinternalscope forecloses a data-only integration. - Schwab: the widely-repeated "AI-driven functionality" scrutiny is not Schwab's language; it traces to a non-affiliated site created 2026-04-20.
AAOIFI SS 21 recorded as decided
Criteria quoted from the standard: debt ≤30% of market cap, interest-taking deposits ≤30% (not cash+securities as DJIM uses), prohibited income ≤5% of total income, tangible assets ≥30%. No receivables test, no averaging window. Two tensions left deliberately open: SS 21 reads as exemption from prohibition rather than permissive tolerance, and clause 3/4/6/1 would exempt keel's ~24-day holds from purification by timing — satisfying the obligation by not being there on the measurement date.
Reviewer note
This document went through three research passes; the first two produced confident, well-formed claims that direct verification reduced, including one fabricated enum that survived inside the very section announcing its withdrawal. The audit trail and a short research-integrity section are in the document deliberately. Unverified claims are labelled as such — most importantly, create_order on an equity has never been called, and §71.6 applied to real US shares remains an inference.
docs(experiments): correct the equities assessment after independent review (#213)
Corrections to the equities decision record merged in #211, from an independent review that landed after the merge. The verdict is unchanged — keel still cannot add US equities, and the binding gate is still §71.6's screening capability. Every change here is about accuracy of evidence and internal consistency.
Feasibility only. No code changed, no adapter added, no dependency introduced.
The two that mattered
Rails 18 and 19 were inverted in the verdict table. Verified against keel/execution/guards.py: rail 18 is what actually rejects Coinbase equities — quote_currency_of returns None on a 64-char hash — and its own comment calls it "an INSTRUMENT-CLASS gate wearing a currency's clothes." Rail 19 is a grammar check that a sane adapter's AAPL-USD would pass. §5/E2 had this right; the verdict table was the sole outlier.
"Two independent refusals, each sufficient on its own" was overstated. It contradicted §7's own "the whole verdict rests on this single step," and refusal 2 is Coinbase-specific — the document concedes it "would be answered by choosing Alpaca." Only refusal 1 answers the question as posed. The verdict stands on one leg, and now says so.
Evidence-integrity corrections
- F1 — attribution, not fabrication. The decisive 2026-08-09 market-data measurements were attributed to the committed probe script, which contains no
recent_trading_days,equity_trading_flags, or per-product price fields. They came from ad-hoc queries written for the fact-check. The numbers are not in doubt; their reproducibility was overclaimed. Now marked⚠️ ad-hoc, not re-runnable — the one class of evidence a future reader cannot reproduce from the tree. - Two withdrawals were wrong and are restored:
equitoccurs 150 times in the spec, and named equity error codes exist (INVALID_EQUITY_TRADING_SESSION,PREVIEW_INVALID_EQUITY_TRADING_SESSION). Both were greppable the whole time. - A retraction retracted something true.
EquityTradingSessionis the real schema name; only its five value spellings were invented. Added as a fourth research-integrity rule. - 19,188 ids hit the script's 20-page cap (
probe.py:231) — termination was never established, only that the same walk now returns 47% more ids with zero drift. - "All 1000 products" is a first-page ~5% sample of a 19,188-id universe. Qualified at all nine sites.
AAOIFI §3 corrections — these change the implementation spec
- Clause 3/19 is a regime selector, not a fourth screening ratio. Falling below 30% tangible assets does not forbid the share; it means the sarf and debt-trading rules must be observed. The Appendix says so explicitly. It was listed as a peer ratio and specced as an attestation field.
- Clauses 3/17, 3/18 and 3/20 were omitted entirely — a screen built to §3's spec would have shipped without them, including 3/18's bar on shares of a corporation whose assets are entirely debts.
- The "SS 21 contradicts itself on the 3/19 floor" claim was overstated: the footnote's numerator is "tangible and cash assets" against 3/19's "assets, benefits and rights" with cash in the denominator. Different measures. The quotation had stopped just before the words that show it.
External-source corrections
…truncated — full description in #213.
docs(config): document the robinhood probe's key names in .env.example (#219)
scripts/robinhood_smoke.py (#216) has required ROBINHOOD_API_KEY and ROBINHOOD_PRIVATE_KEY since it landed, and #218 exercised it against the live venue twice, but neither PR added the names to .env.example. The only record of what to put in .env was the script's own failure message — which you only see after you already went looking.
What changed
Two names and a four-line comment in .env.example. Nothing executable.
Why the comment carries its weight
- Probe-only is the load-bearing fact.
load_secretsdoes not read these. A reader who fills them in and expects the Robinhood adapter to come alive in a run has misread the state of the package. The adapter README says so at length; this is the one-line version at the point of use. - The seed encoding is called out because a PEM is the likelier guess, and getting it wrong fails with a character-length error that never names the cause.
Verification
uv run pytest tests/broker_robinhood tests/scripts/test_robinhood_smoke.py -q→ 167 passed (unchanged; this PR touches no code).- Claims checked against source, not memory: the probe-only status against
packages/keel-broker-robinhood/README.md:121-122, the seed encoding againstscripts/robinhood_smoke.py:191.