fix(rails): let rail 9 see a protective bracket's own stop - #212
Merged
Conversation
`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
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>
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.
Closes #206. Surfaced while fixing #195.
The gap
place_bracketcarries the bracket's trigger price inentryand leavesstop=None: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:1415is 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 byplace_bracketand_roll_stop, read by rail 9 whenstopis absent:Why a separate field and not just
stop— this is the part worth reviewing. Rail 7 (min-move/anti-scalping) measuresabs(entry - stop) / entry, and a bracket's entry and stop are the same price by construction. Populatingstopwould 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_floorpins 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_escalateandreconcile_unbracketed_positionsrecover 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_stopguards it.Tests
5 new (4 written failing first; the fifth passes already as a regression guard that
protective_stopdoes 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 ownstop.ruff check keel tests packagesclean,mypyclean 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_stopunset (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