Shariah Compliance Engine Features — keel
This page describes only what the engine repository can show. Each section links to the source that proves it — if a claim ever drifts from the code, the link is how you catch us.
Attested asset screening — fails closed
Admission to the allowlist is split by what is knowable. Market facts are computed. The Shariah questions are not: whether a token's core purpose is a haram sector, whether it is asset-backed ('ayn) or a claim on a debtor (dayn), and whether it pays a riba-like yield. Those are attested through keel assets attest, never inferred. An absent attestation is a rejection, not a default pass.
Verify in the repository: compliance/screen.py
The rails — twenty-one checks no order can skip
Deterministic guards that nothing can override, not even autonomy mode. Twenty-one of them — numbered 1–14 and 16–22, because rail 15 was retired and its number was never reused:
- The halal allowlist, per-order and per-day spend caps, exposure and concentration caps
- Correlation-aware sizing, a minimum-move floor, no-martingale and no-stop-widening
- Total and weekly drawdown breakers, plus a breaker for consecutive losses and edge decay
- Feed-staleness and quote-balance checks
- Rail 14 — venue subscription/withdrawal attestations: live BUYs are refused until the operator attests
- Rail 17 — §65.4 qabd: withdrawal capability is attested and enforced, because an asset that cannot be withdrawn may not have been validly possessed
- A maximum-spread entry gate that refuses live BUYs at a spread of 50 basis points or wider, and refuses outright if the order book cannot be read
- Rail 20 — trade scope: a credential that reads fine is not evidence it can trade, so every live entry is vetoed until the venue itself has confirmed that credential may place one
- Rail 21 — base balance: a SELL is refused when the venue affirmatively reports no holding, and deliberately let through when the balance is merely unknown, because a blocked exit strands a position that wanted out
- Rail 22 — cash posture: a venue must be attested as cash-only, and that attestation is bound to the credential that earned it and expires, because a margin-enabled account can borrow without ever being asked
- A rail veto names the rail that fired and the command that clears it
Verify in the repository: execution/guards.py
Trade scope — the venue gets to falsify what the operator attested
A credential that reads cleanly is not evidence it can trade. A well-formed key whose every read succeeded still had its first live order refused: You do not have permission to perform this action. So the operator's claim that a venue's key may place live entries is now a record the venue itself can overturn — keel scope attest writes the operator's half, a refusal from the venue overwrites it, and the record is bound to the fingerprint of the credential that earned it, so swapping the key does not inherit the old key's permission. Rail 20 reads that record and vetoes live entries for as long as it says no. Exits are deliberately untouched, because an exit must always be able to leave.
Verify in the repository: commands/scope.py
Strategy gates — candidate → paper → live
A rule must walk three stages before it can touch live money. Promotion clears a two-part gate: performance floors, and an overfitting check (PBO/CSCV). The 100-trade sample floor can be met by the rule's own backtest, or pooled across products in paper. Pooling requires at least five products contributing ten trades each, because a pool of correlated samples overstates its own power.
Verify in the repository: agent.py — RULE_REGISTRY
Honest measurement, against DCA
keel simulate replays the real rules over fetched history, compares against a simple DCA benchmark, and writes a GO-LIVE / TRAIN-MORE report naming every gate and its numbers. The backtester prices slippage per product, scaled from each asset's own liquidity — from 5 basis points at the most liquid end up to 183.8, the rate the thinnest name in the measured universe pays — so a result cannot be flattered by a thin order book. Measured across that universe, not one of the 24 assets reaches the 5bp floor. The report states which pass used which rate, because the edge table prices per product while the account pass and the DCA benchmark are still flat. On the default rules it will very likely tell you TRAIN-MORE. That is the engine working, not broken.
Verify in the repository: the experiment record
The order ledger records the book keel was actually filled against
The orders table had been written since the beginning with no surface reading it, so "what did keel buy and sell, and at what price" was answerable only by opening SQLite by hand. It is a service now: keel orders renders the report, and the local web console projects that same report rather than recomputing it, so the two front ends cannot drift apart. Each order carries the venue's own top of book at the moment of submission, stored as the bid and the ask rather than as a single spread number — a spread computed and then stored loses the two numbers it came from. At this deployment's order sizes the spread is very nearly the whole cost, so it is now recorded as evidence instead of reconstructed afterwards.
Verify in the repository: commands/orders.py
keel research — one front door over fourteen evidence modules
The evidence toolkit had thirteen modules and nowhere that said so; six of them were reachable only by reading the source. keel research index now names all fourteen — what each one answers, what it cannot answer, and the command that runs it. It adds no statistics of its own: the five that already had a home under keel trials are registered a second time as the same command objects, never copies, because a front door that reimplements drifts the moment one copy takes a bugfix the other does not. The "cannot answer" column is meant to be read as carefully as the other one.
Verify in the repository: commands/research.py
Three deployment profiles that share nothing
Daily paper, live, and an hourly evidence profile (paper-hourly) — each with its own database and config. The hourly profile exists because the daily clock measures only 2.15 signals per asset-year, which puts a 100-trade review 31 to 84 years away. The same rules on ONE_HOUR bars fire 49.4 signals per asset-year, about 940 entry signals a year once pooled. That moves a forward-evidence review to weeks instead of decades. The hourly profile is measured net negative too: it exists to collect admissible forward evidence, not profit.
Verify in the repository: operator runbook
A broker port, not a broker lock-in
Adapters implement one contract — the keel-broker-api port — and register under the keel.brokers entry point. Coinbase Advanced Trade is the reference adapter; Robinhood ships as an optional, deliberately unwired venue; an Alpaca adapter joined in v0.10.0; and a Kraken adapter is present as a port-complete stub whose every data and market method raises — it proves the port's shape, not that keel can trade there. A deliberately divergent fake venue keeps the port honest: the conformance suite runs against both, inside a suite of about 6,650 tests.
Verify in the repository: packages/
A bracket is one order kind, because two legs race
The port that adapters implement gained a bracket as a single order kind — BracketGTC, an exit bracket that closes a held position — not two orders to place and pair client-side. Two separately placed legs race: if one fills and the survivor is never cancelled, a live order remains able to sell a position that is already closed, and two legs each carrying the full quantity commit that position twice. One native order removes both failure modes by construction. This is stage one, port vocabulary: every adapter maps it, the conformance suite pins it, and keel's live path still places the venue's own native bracket directly — moving that call onto the port is the next stage. It is correctness work: nothing here claims the engine trades better, only that there are fewer ways to be wrong.
Verify in the repository: orders.py — BracketGTC
Order size quantized to the venue's precision — an exit is never blocked
A risk-sized order almost never lands on a round number, and a venue rejects a size that carries more decimals than the product's increment — the engine's first risk-sized live order was refused on exactly that: a formatting error, not a rail. Sizes are now quantized down to the increment the venue declares for the product, on both sides of the trade. When the increment is unknown, the two sides fail in opposite directions, deliberately: a BUY is refused — a refused entry costs nothing — while a SELL is sent as-is and logged, because an exit that cannot leave is a worse failure than an entry that cannot start. A size that quantizes down to zero is refused outright.
Verify in the repository: execution/executor.py
Partial fills are a state of their own; an exit releases only what sold
A market order that only partly fills is no longer forced to look either pending or filled: 0 < filled < ordered is its own non-terminal state, with the venue-observed quantity recorded beside the ordered one and the venue's running average as the fill price. Everything downstream uses what actually executed — the averaging basis counts a partial BUY at its filled quantity, never at a size that bought nothing. An entry that only partly fills warns loudly, because its exit bracket was sized for the ordered quantity and may exceed what is actually held; resizing it is deliberately an operator action, not an automatic one. An exit that only partly fills books the outcome for the quantity that actually sold and withholds release of the still-held remainder — a tranche closes only when its fill covers it. Which side the warning belongs to is pinned by test.
Verify in the repository: execution/reconcile.py
The crash ledger is written before the cancel, so a dead roll is loud
Rolling a stop means cancelling the resting bracket and placing its replacement — and a process dying between the two used to leave the position both naked and silent, because reconciliation deliberately stays quiet about rows with no recorded intent, so as not to train the alert to be ignored. The order of operations is now reversed: the unbracketed-intent record — the crash ledger naming the position that lost its protection — is written before the cancel is issued. A roll that dies mid-flight is loud on the very next reconciliation cycle, carrying the numbers needed to re-place the bracket.
Verify in the repository: execution/executor.py — _roll_stop
Robinhood: the venue's own rules checked before an order is sent
The Robinhood adapter — still optional, still deliberately unwired — reads the per-pair rules the venue publishes (a minimum order size denominated in quote currency, the size increment, a maximum) and checks every order against them before submission, so a sub-minimum or off-increment order is refused locally instead of discovered at the venue. And its best_bid_ask endpoint is not a book snapshot — the two legs are sampled independently and cross on the most liquid pairs — so the adapter returns a quote only when the venue's own numbers order coherently, and refuses to synthesize one otherwise.
Verify in the repository: keel-broker-robinhood/adapter.py
Confirm by default; autonomy changes who is asked
keel previews each order and asks you at the terminal. Running headless, with no one to ask, it declines. keel autonomy on changes who is asked, never what is allowed. To stop trading, keel kill — the kill-switch fails closed.
Verify in the repository: the README, 'How keel works'
keel ships inert
Nothing trades until you promote a rule, attest the venue subscription, fund the account, and — in confirm mode — type y. Long-only spot only: no leverage, no shorting, no derivatives, and sizing uses actual cash, so no riba.
Account-level obligations no rail can see (disabling USDC rewards on idle balances, chiefly) are the operator's to verify — the operator runbook lists them.
And the paper profile is where every rule starts: free, with simulated fills and nothing at risk. It needs no funded venue account and no trading credentials — a free, read-only market-data key is all it asks for. It is not a demo. Paper trades from the same parameter set count toward the promotion gate's minimum, which makes paper evidence the first stage of the gauntlet.