Skip to content

fix(rails): let rail 9 see a protective bracket's own stop - #212

Merged
eaitbrahim merged 1 commit into
mainfrom
fix/rail9-sees-bracket-stop
Aug 9, 2026
Merged

fix(rails): let rail 9 see a protective bracket's own stop#212
eaitbrahim merged 1 commit into
mainfrom
fix/rail9-sees-bracket-stop

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

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_stop

Why 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.

🤖 Generated with Claude Code

`place_bracket` carries the bracket's trigger price in `entry` and leaves
`stop=None` -- correct, because the order IS the stop rather than an
entry protected by one elsewhere. But rail 9 (no stop-loss widening) is
guarded by `intent.stop is not None`, so it skipped every bracket keel
has ever placed, and the ratchet-only invariant was enforced only inside
`_roll_stop` -- which has no production caller
(tests/execution/test_executor.py:1415 is the tripwire). On the live path
nothing checked that a bracket's stop had not moved against the position.

Adds `OrderIntent.protective_stop`, set by `place_bracket` and
`_roll_stop`, and read by rail 9 when `stop` is absent.

Deliberately a SEPARATE field rather than populating `stop`: rail 7
(min-move/anti-scalping) measures `abs(entry - stop) / entry`, and a
bracket's entry and stop are the same price by construction, so reusing
`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. Rail 7 stays inert on brackets by design;
that is now stated rather than implied.

The comparison stays strictly `<`, never `<=`: re-placing at the SAME
level is how `_rebracket_or_escalate` and
`reconcile_unbracketed_positions` recover a dead or rejected bracket, and
vetoing that would strand the position naked -- the failure #195 closed.
Covered by a test so a future tightening cannot reintroduce it.

Closes #206

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@eaitbrahim eaitbrahim added fix Bug fix (groups under Fixes) rails Un-overridable safety rail / guard (Compliance & rails) labels Aug 9, 2026
@eaitbrahim
eaitbrahim merged commit 0298a27 into main Aug 9, 2026
1 check passed
eaitbrahim added a commit that referenced this pull request Aug 11, 2026
…the CTS scoring fix (#241)

A minor bump, not a patch, for three reasons that each require operator action or
change behaviour the deployment is currently relying on.

SCHEMA. `SCHEMA_VERSION` goes 9 -> 10 (#223). Both deployed databases are at 9 and
must be migrated before this build can use them.

BEHAVIOUR REQUIRING OPERATOR ACTION. #223 adds a second attested claim -- what
CONTRACT a venue listing is, not only what the underlying asset is. It fails closed
with no backfill, deliberately, so after this lands `keel assets screen` REJECTS
every product with `instrument_wrapper: UNATTESTED` until `keel assets
attest-instrument` is run once per product. Live trading is unaffected: rail 1 gates
buys on `config.allowlist`, not on the screen.

LIVE SCORING CHANGED. #227 fixed `is_round_number`, which returned True for every
2dp-quoted price and so handed BTC/ETH/PAXG a free CTS point on every bar. Scores on
those three assets are genuinely lower under this build than under 0.5.7.

Also ships: the Robinhood crypto adapter behind the broker port (#216/#218/#222/#229,
not wired to the live path), the TUI activity feed (#235/#237), the CTS factor
collinearity study (#224), `Preview.synthetic` at the confirm gate (#221), rail 9
seeing a bracket's own stop (#212), and CI gating merges on the `test` check
(#234/#238).

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@eaitbrahim
eaitbrahim deleted the fix/rail9-sees-bracket-stop branch August 13, 2026 16:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fix Bug fix (groups under Fixes) rails Un-overridable safety rail / guard (Compliance & rails)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Rail 9 never evaluates a protective bracket's own stop level

1 participant