fix(executor): refuse a stop roll that reaches the target, and record why #502 stage 2 is blocked - #560
Merged
Merged
Conversation
… why #502 stage 2 is blocked Started on #502 stage 2 -- "the executor still builds its raw trigger_bracket_gtc dict against the pre-port CoinbaseClient, and moving it is stage 2's job" -- and found it cannot be done as scoped. What CAN be delivered now is the guarantee the migration would have bought, so this ships that. ── WHY STAGE 2 IS BLOCKED ───────────────────────────────────────────────────── The port's signature is `place_order(spec: OrderSpec, *, idempotency_key=None)`. The executor calls `broker.place_order(product_id, side, order_configuration)` -- three positional arguments and a raw venue dict, which is the PRE-PORT CoinbaseClient's shape. The executor is not on the port at all; it imports only `keel_broker_api.results`, and every order kind it places (`_order_configuration` for market and limit, `_bracket_order_configuration` for the bracket) is a hand-built Coinbase dict. So moving the bracket alone has two options and both are wrong: * translate in the executor -- requires importing `keel_broker_coinbase` into `keel/execution/`, which is the layering regression the port exists to prevent; or * migrate the executor/broker boundary to specs -- which is #524, and touches every live order path, not the bracket. #502 stage 2 is therefore gated on #524. Recorded in the issue rather than worked around. ── THE GAP THAT DID NOT NEED THE MIGRATION ──────────────────────────────────── `BracketGTC.__post_init__` refuses a stop at or above the take-profit -- "a coin flip wearing a protective order's name", since the two exits then race at the same level and whichever the venue evaluates first decides profit or loss. `_roll_stop` has never checked it. It guards against WIDENING (`new_stop < prior_stop`) and against a missing target, and then places whatever it was given. Reachable rather than theoretical: `trail_stop_atr` computes `price - atr * multiplier`, and the live agent cycles ONCE A DAY, so a gap through the target that reconciliation has not caught up with leaves a recorded target below the newly computed stop. Refusing is the conservative half: the roll is abandoned and the EXISTING bracket stays in force, so the position keeps the protection it already has. The alternative is cancelling a working bracket to install an inverted one -- and if the venue refuses that, the position is naked until the next sweep. `>=`, not `>`. Equal is the subtler half: two equal prices read as an ordinary pair of numbers and describe a stop and a target racing at the same price. `BracketGTC` refuses equal legs as firmly as inverted ones; so does this. Both tests mutation-checked -- removing the guard fails two, weakening `>=` to `>` fails the equal-legs one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6yA5khYnJ2qzheArRToQ2
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.
Started on #502 stage 2 — "the executor still builds its raw
trigger_bracket_gtcdictagainst the pre-port
CoinbaseClient, and moving it is stage 2's job" — and found it cannot bedone as scoped. This ships the guarantee that migration would have bought, and records the blocker.
Why stage 2 is blocked on #524
The port's signature is
place_order(spec: OrderSpec, *, idempotency_key=None).The executor calls
broker.place_order(product_id, side, order_configuration)— three positionalarguments and a raw venue dict, which is the pre-port
CoinbaseClient's shape. The executor isnot on the port at all: it imports only
keel_broker_api.results, and every order kind it places(
_order_configurationfor market and limit,_bracket_order_configurationfor the bracket) is ahand-built Coinbase dict.
So moving the bracket alone has exactly two options, and both are wrong:
keel_broker_coinbaseimported intokeel/execution/,the layering regression the port exists to prevent; or
path, not the bracket.
I've left this in #502 rather than working around it.
The gap that didn't need the migration
BracketGTC.__post_init__refuses a stop at or above the take-profit — "a coin flip wearing aprotective order's name", since the two exits then race at the same level and whichever the venue
evaluates first decides whether the position took a profit or a loss.
_roll_stophas never checked it. It guards against widening (new_stop < prior_stop) andagainst a missing target, then places whatever it was handed.
Reachable rather than theoretical:
trail_stop_atrcomputesprice - atr * multiplier, and thelive agent cycles once a day, so a gap through the target that reconciliation hasn't caught up
with leaves a recorded target sitting below the newly computed stop.
Refusing is the conservative half — the roll is abandoned and the existing bracket stays in
force, so the position keeps the protection it already had. The alternative is cancelling a
working bracket to install an inverted one, and if the venue then refuses it, the position is naked
until the next sweep.
>=, not>. Equal is the subtler half: two equal prices read as an ordinary pair of numbers, andwhat they describe is a stop and a target racing at the same price.
BracketGTCrefuses equal legsas firmly as inverted ones; so does this.
Verification
Full suite green (4276 passed, 3 skipped), ruff and mypy clean.
Both tests mutation-checked:
>=to>The tests assert the conservative outcome specifically — the working bracket is still
pendingrather than
canceled, and bothopen_stop:andopen_target:are unchanged — because "refused"would otherwise be satisfied by a path that had already cancelled the protection.
🤖 Generated with Claude Code
https://claude.ai/code/session_01T6yA5khYnJ2qzheArRToQ2