feat(executor): routing-time max-spread gate for live BUY entries - #360
Merged
Conversation
Fixes #350. WHAT. A live BUY whose previewed book shows `(best_ask - best_bid) / mid` at or beyond `execution.max_entry_spread_pct` (new config section, default 0.005 = 50bp) is REFUSED after the preview and before the confirm gate and placement -- so a thin book cannot be entered at a moment its spread alone makes the fill economics materially worse than the cost model assumes. The refusal is recorded in `ExecutionResult.vetoed_by` (the tokens `max_entry_spread` / `book_unreadable`, the same one-legible-token shape rail violations use) and logged at WARNING as a structured event carrying the measured spread, the threshold and the product. WHY. The issue is the rail-work agreement CONTRIBUTING requires; the operator proposed it in the Phase 10 expansion review. It is the live-path half of the spread guardrail whose sizing half (#358) caps every Tier-2 addition at a 2% target weight. Design decisions: - Post-preview placement, deliberately: `guards.check` is broker-less by design, and the book exists only in the `broker.preview_order` result -- so this is a routing-time gate BESIDE the eighteen rails, not a numbered `guards.check` rail. It consumes the SAME preview #332's `_warn_if_market_routing_overrides_entry` reads: one helper (`_preview_book`, bid/ask/mid-safe: missing keys, NaN, non-finite, non-positive all read as unreadable), two consumers. The warning keeps its exact behavior and position (its tests pass unchanged); the gate runs after it, before confirm/place. - BUY-only: exits, brackets, stop rolls and scale-outs are never gated -- the same principle that makes rail 17 halt entries, not exits. - Paper mode never runs the gate: `_paper_enter` fills synthetically without a preview, so the paper-hourly profile accrues NO evidence about it -- a reason this ships before any live resumption, not after paper expansion. - Fail-closed on an unreadable book: a live BUY whose preview carries no readable bid AND ask is refused with the distinct `book_unreadable` reason and logged loudly. "Cannot know" is a different fact from "too wide"; the real venue's preview carries both sides (`cb_client.preview_order` maps best_bid/best_ask to Decimal), so an unreadable book means a degraded response -- the moment not to spend. The spread arithmetic also refuses (rather than swallows, as #336 taught the warning to do) on extreme-exponent Overflow. - 50bp default anchored to #334's `SLIPPAGE_CAP_PCT`: the backtest never assumes more than 50bp per-leg slippage on even the thinnest book, so a spread AT the cap has consumed the model's entire worst-case cost and the taker fee rides outside it -- hence the boundary is >= (fail-closed), unlike #332's strictly-greater visibility threshold. Validated on load to (0, 0.10], ConfigError naming `execution.max_entry_spread_pct`. Test-side consequence, honestly reported: the shared test fakes' bookless default previews modelled a shape the real venue does not return, and under a fail-closed gate every such "normal successful BUY" test would refuse. The fakes (test_executor, test_agent, test_cli, test_reconcile) now carry both book sides, and the one #332 test that borrowed the default preview as its degraded/bookless shape constructs that shape explicitly -- meaning unchanged, sourcing changed. Golden config fixtures regenerated via the documented script; the defaults golden now pins the 0.005.
…-confirm ordering Two review findings on #350's money-path arms, both test-only, plus one docstring enumeration: - The extreme-exponent fail-closed arm of `_entry_spread_gate` (the `except ArithmeticError` whose spread is uncomputable) had no test. Pinned both ways a readable-sides book can still break the arithmetic: an `Overflow` on `bid + ask` at `1E+999999999` magnitudes, and a `DivisionByZero` from a subnormal pair whose mid half-even rounds to zero while the difference survives nonzero. #332's warning swallows these (telemetry); the gate must refuse with `book_unreadable` -- each case verified to fail under a swallow-like-telemetry mutation before landing. - Every gate test ran autonomous mode, so nothing pinned that the gate sits BEFORE the confirm gate. A wide-book BUY in `mode="confirm"` with an approving confirm_fn is still refused and the approver is never consulted -- a human cannot approve around the gate. Verified to fail under a confirm-first reordering mutation. - `summarise_cycle`'s docstring enumerated two non-placement causes (rails, confirm gate); added the third honestly -- the routing-time entry-spread gate refusing a live BUY. Docstring only, no behavior change.
|
This was referenced Aug 18, 2026
Merged
eaitbrahim
added a commit
that referenced
this pull request
Aug 18, 2026
Version bump (six pyprojects, ==-pinned siblings included) for the release workflow. Shipped since 0.9.1: - #350 / #360: live BUY entries are refused when the venue spread at routing exceeds execution.max_entry_spread_pct (default 50bp, anchored to the backtest slippage cap). BUY-only, live-only, fail-closed on an unreadable book; SELLs and paper never gated.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Fixes #350.
What
A live BUY whose previewed book shows
(best_ask − best_bid) / midat or beyondexecution.max_entry_spread_pct(newexecution:config section, default 0.005 = 50bp) is refused after the preview and before the confirm gate and placement — so a thin book cannot be entered at a moment its spread alone makes the fill economics materially worse than the cost model assumes. The refusal is recorded inExecutionResult.vetoed_by(tokensmax_entry_spread/book_unreadable, the same one-legible-token shape rail violations use) and logged at WARNING as a structured event (executor.entry_spread_refusedwith the measured spread, threshold and product;executor.entry_book_unreadablefor the fail-closed arm).This is the live-path half of the spread guardrail whose sizing half (#358) caps every Tier-2 addition at a 2% target weight.
Design decisions
guards.checkis broker-less by design, and the book exists only in thebroker.preview_orderresult — so this is a routing-time gate beside the eighteen rails, not a numberedguards.checkrail (no renumbering anywhere). It consumes the same preview fix(executor): warn loudly when a conditional entry price is overridden by market routing (#260) #332's_warn_if_market_routing_overrides_entryreads: one helper (_preview_book— bid/ask read independently, missing-keys/NaN/non-finite/non-positive-safe), two consumers. The fix(executor): warn loudly when a conditional entry price is overridden by market routing (#260) #332 warning keeps its exact behavior and position; the gate runs after it, before confirm/place._paper_enterfills synthetically without a preview, so the paper-hourly profile accrues no evidence about this gate — a reason it ships before any live resumption, not before paper expansion.book_unreadablereason. "Cannot know" is a different fact from "too wide". Verified the real venue's preview carries both sides:cb_client.preview_ordermapsbest_bid/best_asktoDecimal(tests/fixtures/cb_preview_order.json), and the port'sPreview.detailcarries them as strings.SLIPPAGE_CAP_PCT: the backtest never assumes more than 50bp per-leg slippage on even the thinnest book, so a spread AT the cap has consumed the model's entire worst-case cost estimate and the taker fee rides outside it — hence the boundary is >= (fail-closed), unlike fix(executor): warn loudly when a conditional entry price is overridden by market routing (#260) #332's strictly-greater visibility threshold. Validated on load to (0, 0.10]; violations raiseConfigErrornamingexecution.max_entry_spread_pct.Test-side consequence (honestly reported)
The shared test fakes' bookless default previews modelled a shape the real venue does not return, and under a fail-closed gate every "normal successful BUY" test through them would refuse. The fakes (
tests/execution/test_executor.py,tests/test_agent.py,tests/test_cli.py,tests/execution/test_reconcile.py) now carry both book sides, and the one #332 test that borrowed the default preview as its degraded/bookless shape (test_a_preview_without_a_book_quote_is_silent_not_fatal) constructs that shape explicitly — meaning unchanged, sourcing changed. The golden config fixtures were regenerated via the documented script; the defaults golden now pinsexecution.max_entry_spread_pct = 0.005.Docs
docs/operator-runbook.md: the Tier-2 guardrail story — sizing half (feat(paper-hourly): expand the universe 8->19, Tier-2 capped at 2% target weight #358's 2% cap) + live-path half (this gate), and why paper accrues no evidence about it.README.md+docs/fiqh-basis.md: the gate described beside the rails enumeration as exactly what it is (BUY-only, live-only, fail-closed); the "eighteen rails" count is unchanged because this is not a numbered rail.keel/templates/config.yaml+ repoconfig.yaml,config.live.yaml) document the knob.Gates
uv run pytest -q— 2965 passed, 1 skippeduv run ruff check keel tests packages— cleanuv run mypy— clean