Skip to content

fix(executor): refuse a stop roll that reaches the target, and record why #502 stage 2 is blocked - #560

Merged
eaitbrahim merged 1 commit into
mainfrom
feat-502-stage2
Aug 26, 2026
Merged

fix(executor): refuse a stop roll that reaches the target, and record why #502 stage 2 is blocked#560
eaitbrahim merged 1 commit into
mainfrom
feat-502-stage2

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

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. 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 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 exactly two options, and both are wrong:

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 a
protective 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_stop has never checked it. It guards against widening (new_stop < prior_stop) and
against a missing target, then places whatever it was handed.

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 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, and
what they describe is a stop and a target racing at the same price. BracketGTC refuses equal legs
as firmly as inverted ones; so does this.

Verification

Full suite green (4276 passed, 3 skipped), ruff and mypy clean.

Both tests mutation-checked:

mutation result
remove the guard 2 tests fail
weaken >= to > the equal-legs test fails

The tests assert the conservative outcome specifically — the working bracket is still pending
rather than canceled, and both open_stop: and open_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

… 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
@eaitbrahim
eaitbrahim merged commit 556d15b into main Aug 26, 2026
4 checks passed
@eaitbrahim
eaitbrahim deleted the feat-502-stage2 branch August 26, 2026 23:16
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.

1 participant