Skip to content

feat(executor): routing-time max-spread gate for live BUY entries - #360

Merged
eaitbrahim merged 2 commits into
mainfrom
feat/350-spread-gate-rail
Aug 18, 2026
Merged

feat(executor): routing-time max-spread gate for live BUY entries#360
eaitbrahim merged 2 commits into
mainfrom
feat/350-spread-gate-rail

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

Fixes #350.

What

A live BUY whose previewed book shows (best_ask − best_bid) / mid at or beyond execution.max_entry_spread_pct (new execution: 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 (tokens max_entry_spread / book_unreadable, the same one-legible-token shape rail violations use) and logged at WARNING as a structured event (executor.entry_spread_refused with the measured spread, threshold and product; executor.entry_book_unreadable for 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

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 pins execution.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.
  • Shipped config templates (keel/templates/config.yaml + repo config.yaml, config.live.yaml) document the knob.

Gates

  • uv run pytest -q — 2965 passed, 1 skipped
  • uv run ruff check keel tests packages — clean
  • uv run mypy — clean

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.
@sonarqubecloud

Copy link
Copy Markdown

@eaitbrahim
eaitbrahim merged commit e2e2189 into main Aug 18, 2026
6 checks passed
@eaitbrahim
eaitbrahim deleted the feat/350-spread-gate-rail branch August 18, 2026 06:13
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A routing-time maximum-spread gate — refuse entries when the live book is too wide

1 participant