Skip to content

feat(broker-api): a bracket is one order kind, because two legs race (#502 stage 1) - #521

Merged
eaitbrahim merged 1 commit into
mainfrom
feat-502-stage1
Aug 23, 2026
Merged

feat(broker-api): a bracket is one order kind, because two legs race (#502 stage 1)#521
eaitbrahim merged 1 commit into
mainfrom
feat-502-stage1

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

Stage 1 of the #502 design. Port vocabulary only — zero live-path change, and deliberately no live caller.

The executor still builds its raw trigger_bracket_gtc dict against the pre-port CoinbaseClient; moving it is stage 2's job. Grep confirms zero references to BracketGTC under keel/.

What it adds

BracketGTC joins the OrderSpec sum type. An exit bracket that closes a held position — not an entry-plus-exits parent order, because keel enters with market IOC and protects afterwards, so encoding an entry would represent a shape no keel path produces.

__post_init__ refuses non-positive numerics and a stop that doesn't sit on the losing side of the target. Equal legs are refused as firmly as inverted ones — an equal-leg "bracket" is a stop and a target racing at the same price, which is not a shape a rule can mean.

No stop_direction field. Derivable from side exactly as StopLimitGTC derives it; a field would let a caller build a SELL bracket that triggers upward — representable nonsense, which is what the sum type exists to prevent.

Names are keel's, not Coinbase's. take_profit_price, not limit_price — so a second venue's translation doesn't start from Coinbase's vocabulary, and so it can't be confused with LimitGTC.limit_price.

The Coinbase translation is byte-identical to what executor._bracket_order_configuration emits today, pinned by a parity test so the two can't drift while both exist. The test imports both; production code doesn't.

Deviation from the plan, worth knowing because it recurs

The design assumed "other adapters refuse structurally, add no code." Half true. Alpaca and Robinhood match OrderSpec exhaustively with assert_never, so widening the union broke both under mypy — each needed an explicit refusal case.

That's the better outcome (it mirrors the defence-in-depth Robinhood's translator already applies), but every future OrderSpec kind will hit the same wall, including stage 2's.

Two venue facts established while here

  • Alpaca does support order_class=bracket — equities-only, and this adapter declares asset_classes={"equity"}. So its absence means "not written yet", not "impossible".
  • Robinhood and Kraken are genuine venue limitations — Kraken's close[...] is an OTO, not an OCO.

Latent gap found, not fixed here

Kraken has no _reject_unsupported — every method raises NotImplementedError rather than UnsupportedOrder, so it would fail test_every_undeclared_order_kind_is_refused if it had a conformance subclass. It doesn't have one, so nothing fails today. Flagging rather than widening this PR's scope.

Verification

  • pytest -q4,550 passed, 3 skipped
  • ruff check keel tests packages — clean
  • mypy — clean, 353 source files

All three re-run independently after the implementing agent reported, not taken on trust.

Not in this PR

Stages 2–4 of the design: the hardened roll protocol, the run_once stop-management step, and the adapter flip. Two prerequisites surfaced by the same design pass are filed separately as #519 (crash between cancel and replace leaves a position naked and silent) and #520 (positions.initial_stop not persisted, so the live break-even roll is uncomputable).

…502 stage 1)

Stage 1 of the #502 design: the port vocabulary, and nothing else. This ships
with NO live caller, deliberately -- the executor still builds its raw
trigger_bracket_gtc dict against the pre-port CoinbaseClient, and moving it is
stage 2's job. Grep confirms zero references to BracketGTC under keel/.

BracketGTC joins the OrderSpec sum type. It is an EXIT bracket that closes a
held position, not an entry-plus-exits parent order: keel enters with market IOC
and protects afterwards, so encoding an entry here would represent a shape no
keel path produces. __post_init__ refuses non-positive numerics and a stop that
does not sit on the losing side of the target -- equal legs are refused as firmly
as inverted ones, because an equal-leg 'bracket' is a stop and a target racing at
the same price, which is not a shape a rule can mean.

No stop_direction field. It is derivable from side exactly as StopLimitGTC
derives it, and a field would let a caller build a SELL bracket that triggers
upward -- representable nonsense, which is what the sum type exists to prevent.
The port's names are keel's (take_profit_price), not Coinbase's (limit_price), so
a second venue's translation does not start from Coinbase's vocabulary and so it
cannot be confused with LimitGTC.limit_price.

The Coinbase translation is byte-identical to what executor._bracket_order_configuration
emits today -- three keys, no stop_direction -- and a test pins that parity so the
two cannot drift while both exist. The test imports both; production code does not.

DEVIATION FROM THE PLAN, recorded because it recurs: 'other adapters refuse
structurally, add no code' was only half true. Alpaca and Robinhood match
OrderSpec exhaustively with assert_never, so widening the union broke both under
mypy and each needed an explicit refusal case. That is the better outcome -- it
mirrors the defence-in-depth Robinhood's translator already applies -- but every
future OrderSpec kind will hit the same wall.

Two venue facts worth recording while they were established: Alpaca DOES support
order_class=bracket, but equities-only, and this adapter declares
asset_classes={'equity'} -- so its absence means 'not written yet', not
'impossible'. Robinhood and Kraken are genuine venue limitations (Kraken's
close[...] is an OTO, not an OCO).

Gates: pytest 4550 passed / 3 skipped; ruff check keel tests packages clean; mypy
clean across 353 source files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyeggYtojNXCTHeD3JHxb6
@eaitbrahim
eaitbrahim merged commit 6018664 into main Aug 23, 2026
5 checks passed
@eaitbrahim
eaitbrahim deleted the feat-502-stage1 branch August 23, 2026 11:29
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