From eb2678a9b7281b2018ef98d35aaedd00c4170f79 Mon Sep 17 00:00:00 2001 From: Elmehdi Aitbrahim Date: Sun, 23 Aug 2026 06:10:27 -0400 Subject: [PATCH] feat(broker-api): a bracket is one order kind, because two legs race (#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) Claude-Session: https://claude.ai/code/session_01NyeggYtojNXCTHeD3JHxb6 --- .../keel_broker_alpaca/adapter.py | 6 + .../keel_broker_alpaca/translate.py | 15 ++ .../keel_broker_api/conformance/suite.py | 39 +++++ .../keel-broker-api/keel_broker_api/orders.py | 73 ++++++++- .../keel_broker_coinbase/adapter.py | 5 +- .../keel_broker_coinbase/translate.py | 16 ++ .../keel_broker_fake/adapter.py | 5 + .../keel_broker_kraken/adapter.py | 5 + .../keel_broker_robinhood/adapter.py | 6 + .../keel_broker_robinhood/translate.py | 14 ++ tests/broker_api/test_orders.py | 139 +++++++++++++++++- tests/broker_coinbase/test_translate.py | 67 ++++++++- 12 files changed, 384 insertions(+), 6 deletions(-) diff --git a/packages/keel-broker-alpaca/keel_broker_alpaca/adapter.py b/packages/keel-broker-alpaca/keel_broker_alpaca/adapter.py index a7557e85..e7f9abbd 100644 --- a/packages/keel-broker-alpaca/keel_broker_alpaca/adapter.py +++ b/packages/keel-broker-alpaca/keel_broker_alpaca/adapter.py @@ -87,6 +87,12 @@ # market orders (`market_ioc_quote`), fractional-qty market orders (`market_ioc_base`), # GTC limits, and GTC stop-limits. The port has no bracket/OCO or stop-market kind to # declare or refuse -- see the module docstring's "Bracket" note. + # `bracket_gtc` is NOT declared, and the reason is "not built here yet", not "the venue + # cannot": Alpaca's `order_class=bracket` (`take_profit.limit_price` + `stop_loss.stop_price` + # on one POST /v2/orders) is real, and this adapter serves EQUITIES, which is exactly the + # asset class it is available for -- Alpaca's order-class reference restricts crypto to + # `simple` but allows equities `bracket`/`oco`/`oto`. Undeclared means `_reject_unsupported` + # refuses it, which is the honest answer until someone writes and tests the translation. supported_orders=frozenset( {"market_ioc_quote", "market_ioc_base", "limit_gtc", "stop_limit_gtc"} ), diff --git a/packages/keel-broker-alpaca/keel_broker_alpaca/translate.py b/packages/keel-broker-alpaca/keel_broker_alpaca/translate.py index 37de2c89..ae5b92bf 100644 --- a/packages/keel-broker-alpaca/keel_broker_alpaca/translate.py +++ b/packages/keel-broker-alpaca/keel_broker_alpaca/translate.py @@ -26,6 +26,7 @@ from typing import Any, assert_never from keel_broker_api.orders import ( + BracketGTC, LimitGTC, MarketIOCByBase, MarketIOCByQuote, @@ -198,6 +199,20 @@ def to_order_body(spec: OrderSpec, *, client_order_id: str) -> dict[str, Any]: "limit_price": _render(spec.limit_price), "extended_hours": False, } + case BracketGTC(): + # Alpaca's `order_class=bracket` is real, but only for EQUITIES -- and this adapter + # serves equities, so the honest reason this raises is that nobody has written and + # tested the translation yet, NOT that the venue cannot. `_reject_unsupported` + # already refused this kind from the capability declaration; this is the second gate, + # placed exactly where the `MarketIOCByQuote` precedent in the Robinhood sibling sits, + # so a bug that routes a bracket past the first one still cannot put a body on the + # wire. Whoever implements it: `take_profit.limit_price` + `stop_loss.stop_price` on + # one `POST /v2/orders`, and delete this case rather than widening it. + raise UnsupportedOrder( + "alpaca's bracket order class is not translated by this adapter yet; " + "the venue supports it for equities, but an untested translation on the " + "live-money path would place a protective order nobody has seen accepted" + ) case _: assert_never(spec) diff --git a/packages/keel-broker-api/keel_broker_api/conformance/suite.py b/packages/keel-broker-api/keel_broker_api/conformance/suite.py index ae422523..ae0e59f9 100644 --- a/packages/keel-broker-api/keel_broker_api/conformance/suite.py +++ b/packages/keel-broker-api/keel_broker_api/conformance/suite.py @@ -31,6 +31,7 @@ def broker(self) -> MyVenueAdapter: from keel_broker_api.capabilities import ASSET_CLASSES, BrokerCapabilities from keel_broker_api.orders import ( ORDER_KINDS, + BracketGTC, LimitGTC, MarketIOCByBase, MarketIOCByQuote, @@ -74,6 +75,16 @@ def broker(self) -> MyVenueAdapter: stop_price=Decimal("60000"), limit_price=Decimal("59900"), ), + # A SELL exit bracket on a long: stop below, target above. `BracketGTC.__post_init__` + # refuses any other arrangement, so this entry cannot silently rot into a shape no adapter + # would be right to accept. + "bracket_gtc": BracketGTC( + product_id=_PRODUCT, + side=Side.SELL, + base_size=Decimal("0.1"), + take_profit_price=Decimal("70000"), + stop_trigger_price=Decimal("60000"), + ), } @@ -249,6 +260,34 @@ def test_every_undeclared_order_kind_is_refused(self) -> None: with pytest.raises(UnsupportedOrder): broker.place_order(_SPEC_BY_KIND[kind]) + def test_the_bracket_declaration_cannot_lie_in_either_direction(self) -> None: + """`bracket_gtc` gets its own case because it is the kind whose two answers diverge most. + + The generic pair above (`..._is_actually_accepted` / `..._is_refused`) already sweeps + every kind an adapter declares and every kind it does not. This restates the contract for + the bracket specifically, at the one place an adapter author adding a venue will read it, + because a bracket is the only kind where a venue's *inability* is the common case rather + than the exception: exactly one of the venues keel targets today has a native single-order + bracket, and the other three would have to synthesise one out of two legs to say yes. + + Synthesis is what this test forbids. An adapter that declared `bracket_gtc` and quietly + placed a stop and a target as two independent orders would be committing the position + twice and re-opening the client-side pairing race the native bracket exists to close -- + and it would look, from the port, exactly like an adapter that did the right thing. So + the declaration is the whole promise: say yes and the suite makes you place it; say + nothing and the suite makes you refuse it out loud, with `UnsupportedOrder` rather than a + substituted order type. + """ + broker = self.broker() + spec = _SPEC_BY_KIND["bracket_gtc"] + + if "bracket_gtc" in broker.capabilities().supported_orders: + assert isinstance(broker.place_order(spec), PlaceResult) + return + + with pytest.raises(UnsupportedOrder): + broker.place_order(spec) + # --- capabilities cannot lie about preview -------------------------------------------- def test_preview_matches_its_declaration(self) -> None: diff --git a/packages/keel-broker-api/keel_broker_api/orders.py b/packages/keel-broker-api/keel_broker_api/orders.py index 3a502dc0..cfde725a 100644 --- a/packages/keel-broker-api/keel_broker_api/orders.py +++ b/packages/keel-broker-api/keel_broker_api/orders.py @@ -89,14 +89,83 @@ def __post_init__(self) -> None: _require_positive("limit_price", self.limit_price) -OrderSpec = MarketIOCByQuote | MarketIOCByBase | LimitGTC | StopLimitGTC +@dataclass(frozen=True) +class BracketGTC: + """Native exit bracket: ONE order carrying both protective prices, good until cancelled. + + The VENUE owns the race between the stop and the target, and that is the whole reason the + kind exists. The alternative keel shipped first was two independent SELL legs paired + client-side: a fill we failed to observe left the sibling live and able to sell an + already-closed position, and because both legs were sized at the full quantity a 1x position + was committed 2x. Neither failure mode exists when there is only one order and no sibling to + cancel. + + ⚠️ This is an **exit** that closes an EXISTING position -- not an entry-plus-exits parent + order, which several venues also call a bracket. keel enters with a market IOC and protects + the position afterwards, so a kind that could carry an entry price would describe a shape no + keel path produces. Making it expressible would only give a future caller a way to ask for + something the engine has no code to mean. + + There is deliberately **no `stop_direction` field**. The direction is a function of `side` + and is derived at translation time, exactly as `StopLimitGTC`'s is: a SELL bracket protects a + long, so its stop triggers on the way down. A field would make a SELL bracket that triggers + UPWARD representable -- nonsense the venue would refuse, or worse, honour -- and refusing to + represent nonsense is what this sum type is for. + + The price names are **keel's, not Coinbase's**. Coinbase spells the take-profit `limit_price`; + adopting that here would make a second venue's translation start from Coinbase's vocabulary + rather than the port's, and would put a field named `limit_price` on two different order kinds + where it means two different things (`LimitGTC.limit_price` is the price of the whole order; + this one is the profitable half of a pair). `take_profit_price` says which exit it is. + """ + + kind: ClassVar[str] = "bracket_gtc" + initial_status: ClassVar[str] = "open" + + product_id: str + side: Side + base_size: Decimal + take_profit_price: Decimal + stop_trigger_price: Decimal + + def __post_init__(self) -> None: + _require_positive("base_size", self.base_size) + _require_positive("take_profit_price", self.take_profit_price) + _require_positive("stop_trigger_price", self.stop_trigger_price) + # An inverted OR EQUAL pair is not a bracket. Equal is the subtler half and the reason + # this is `>=` rather than `>`: two equal prices read as a perfectly ordinary pair of + # numbers, and what they describe is a stop and a target racing at the same price, where + # whichever side the venue happens to evaluate first decides whether the position took a + # profit or a loss. That is a coin flip wearing a protective order's name. Both halves + # are refused here, at construction, where the caller's own numbers are still in scope -- + # not at the venue, where the position is already open and unprotected. + if self.side is Side.SELL and self.stop_trigger_price >= self.take_profit_price: + raise ValueError( + f"a SELL bracket exits a long: stop_trigger_price ({self.stop_trigger_price}) " + f"must be below take_profit_price ({self.take_profit_price})" + ) + if self.side is Side.BUY and self.stop_trigger_price <= self.take_profit_price: + raise ValueError( + f"a BUY bracket exits a short: stop_trigger_price ({self.stop_trigger_price}) " + f"must be above take_profit_price ({self.take_profit_price})" + ) + + +OrderSpec = MarketIOCByQuote | MarketIOCByBase | LimitGTC | StopLimitGTC | BracketGTC ORDER_KINDS: frozenset[str] = frozenset( - {MarketIOCByQuote.kind, MarketIOCByBase.kind, LimitGTC.kind, StopLimitGTC.kind} + { + MarketIOCByQuote.kind, + MarketIOCByBase.kind, + LimitGTC.kind, + StopLimitGTC.kind, + BracketGTC.kind, + } ) __all__ = [ "ORDER_KINDS", + "BracketGTC", "LimitGTC", "MarketIOCByBase", "MarketIOCByQuote", diff --git a/packages/keel-broker-coinbase/keel_broker_coinbase/adapter.py b/packages/keel-broker-coinbase/keel_broker_coinbase/adapter.py index e2774067..48fc15a4 100644 --- a/packages/keel-broker-coinbase/keel_broker_coinbase/adapter.py +++ b/packages/keel-broker-coinbase/keel_broker_coinbase/adapter.py @@ -39,8 +39,11 @@ _CAPABILITIES = BrokerCapabilities( venue="coinbase", + # `bracket_gtc` is declared because Coinbase Advanced Trade natively serves it: + # `order_configuration.trigger_bracket_gtc` is one order carrying both exits, and the venue + # disables the losing side when the other fills. It is the only venue keel targets that does. supported_orders=frozenset( - {"market_ioc_quote", "market_ioc_base", "limit_gtc", "stop_limit_gtc"} + {"market_ioc_quote", "market_ioc_base", "limit_gtc", "stop_limit_gtc", "bracket_gtc"} ), supports_native_preview=True, synthesizes_preview=False, diff --git a/packages/keel-broker-coinbase/keel_broker_coinbase/translate.py b/packages/keel-broker-coinbase/keel_broker_coinbase/translate.py index 24b5ca66..6508a7f3 100644 --- a/packages/keel-broker-coinbase/keel_broker_coinbase/translate.py +++ b/packages/keel-broker-coinbase/keel_broker_coinbase/translate.py @@ -9,6 +9,7 @@ from typing import assert_never from keel_broker_api.orders import ( + BracketGTC, LimitGTC, MarketIOCByBase, MarketIOCByQuote, @@ -41,6 +42,21 @@ def to_order_configuration(spec: OrderSpec) -> dict[str, dict[str, str]]: "stop_direction": _stop_direction(spec), } } + case BracketGTC(): + # Byte-for-byte what `keel.execution.executor._bracket_order_configuration` has been + # sending on the live path -- same three keys, same order, and notably NO + # `stop_direction`, which the stop-limit config above does require. That asymmetry is + # Coinbase's, not an omission: a bracket's two prices already say which way each side + # triggers. Parity with the shipped, venue-ACCEPTED dict is the contract here, so this + # case must not be "improved"; the port's job is to reach the same wire shape through + # a typed spec. `tests/broker_coinbase/test_translate.py` pins the two together. + return { + "trigger_bracket_gtc": { + "base_size": str(spec.base_size), + "limit_price": str(spec.take_profit_price), + "stop_trigger_price": str(spec.stop_trigger_price), + } + } case _: assert_never(spec) diff --git a/packages/keel-broker-fake/keel_broker_fake/adapter.py b/packages/keel-broker-fake/keel_broker_fake/adapter.py index d19cd39c..4106a95e 100644 --- a/packages/keel-broker-fake/keel_broker_fake/adapter.py +++ b/packages/keel-broker-fake/keel_broker_fake/adapter.py @@ -51,6 +51,11 @@ _CAPABILITIES = BrokerCapabilities( venue="fake", + # `bracket_gtc` is undeclared deliberately, and here the answer is neither "the venue can" + # nor "the venue cannot" -- there is no venue. This fake stands in for the venues that REFUSE + # a bracket, which is three of the four real adapters; something has to exercise the refusing + # half of the conformance contract, and a fake that supported every kind would exercise + # nothing. Its `place_order` raises `UnsupportedOrder` for anything not listed here. supported_orders=frozenset({"market_ioc_base", "limit_gtc", "stop_limit_gtc"}), supports_native_preview=False, synthesizes_preview=False, diff --git a/packages/keel-broker-kraken/keel_broker_kraken/adapter.py b/packages/keel-broker-kraken/keel_broker_kraken/adapter.py index ba06b1ea..42cdc3a7 100644 --- a/packages/keel-broker-kraken/keel_broker_kraken/adapter.py +++ b/packages/keel-broker-kraken/keel_broker_kraken/adapter.py @@ -47,6 +47,11 @@ _CAPABILITIES = BrokerCapabilities( venue="kraken", + # Empty for the stub reason above, but the venue fact is worth recording before anyone + # fills this in: Kraken's `AddOrder` has no two-sided bracket. `close[ordertype]` attaches + # ONE conditional close (a stop-loss or a take-profit, not both) that is triggered by the + # primary order's execution and is an independent order thereafter -- an OTO, not an OCO. + # So `bracket_gtc` will still not be declarable here once the rest of the stub is written. supported_orders=frozenset(), supports_native_preview=False, synthesizes_preview=False, diff --git a/packages/keel-broker-robinhood/keel_broker_robinhood/adapter.py b/packages/keel-broker-robinhood/keel_broker_robinhood/adapter.py index 9796c9d1..69fecaf2 100644 --- a/packages/keel-broker-robinhood/keel_broker_robinhood/adapter.py +++ b/packages/keel-broker-robinhood/keel_broker_robinhood/adapter.py @@ -98,6 +98,12 @@ # rejected -- so "immediate or cancel" describes what Robinhood's market order already does. # There is no resting-market-order variant to be confused with. The port kind that WOULD be a # lie is the quote-sized one, and it is not declared. + # `bracket_gtc` is absent because the VENUE has no such order. The crypto trading API's + # order types are market, limit, stop_loss and stop_limit -- each a single trigger, with no + # bracket/OCO type carrying a take-profit and a stop in one order. Declaring it would mean + # synthesising one from two legs, which is precisely the client-side pairing race (and the + # 2x inventory commitment) the native bracket exists to remove. This absence is a venue fact + # and will not change until Robinhood ships the order type. supported_orders=frozenset({"market_ioc_base", "limit_gtc", "stop_limit_gtc"}), # No preview endpoint exists on this API, so every Preview this adapter returns must label # itself `synthetic=True`. diff --git a/packages/keel-broker-robinhood/keel_broker_robinhood/translate.py b/packages/keel-broker-robinhood/keel_broker_robinhood/translate.py index e918b09f..6996b126 100644 --- a/packages/keel-broker-robinhood/keel_broker_robinhood/translate.py +++ b/packages/keel-broker-robinhood/keel_broker_robinhood/translate.py @@ -23,6 +23,7 @@ from typing import Any, assert_never from keel_broker_api.orders import ( + BracketGTC, LimitGTC, MarketIOCByBase, MarketIOCByQuote, @@ -198,6 +199,19 @@ def to_order_body(spec: OrderSpec, *, client_order_id: str) -> dict[str, Any]: "time_in_force": TIME_IN_FORCE, }, } + case BracketGTC(): + # The VENUE has no bracket. Robinhood's crypto order types are market, limit, + # stop_loss and stop_limit -- every one a single trigger; none carries a take-profit + # and a stop together. The only way to answer this spec would be to place two + # independent legs and pair them client-side, which re-creates the unobserved-fill + # race (and the 2x inventory commitment) that `BracketGTC` exists to eliminate. + # Refusing is the correct translation, not a gap: a caller asking for one order that + # the venue arbitrates must not silently receive two that keel arbitrates. + raise UnsupportedOrder( + "robinhood's crypto API has no bracket/OCO order type; synthesizing one from a " + "separate stop and target would commit the position twice and put the " + "stop-vs-target race back on the client, which is what a bracket removes" + ) case _: assert_never(spec) diff --git a/tests/broker_api/test_orders.py b/tests/broker_api/test_orders.py index a772b8f0..a9b3efa6 100644 --- a/tests/broker_api/test_orders.py +++ b/tests/broker_api/test_orders.py @@ -5,6 +5,7 @@ import pytest from keel_broker_api.orders import ( ORDER_KINDS, + BracketGTC, LimitGTC, MarketIOCByBase, MarketIOCByQuote, @@ -19,14 +20,21 @@ def test_each_variant_has_a_distinct_kind() -> None: MarketIOCByBase.kind, LimitGTC.kind, StopLimitGTC.kind, + BracketGTC.kind, + } + assert kinds == { + "market_ioc_quote", + "market_ioc_base", + "limit_gtc", + "stop_limit_gtc", + "bracket_gtc", } - assert kinds == {"market_ioc_quote", "market_ioc_base", "limit_gtc", "stop_limit_gtc"} def test_order_kinds_lists_every_variant() -> None: """ORDER_KINDS is what capabilities are declared against -- it must not drift.""" assert ORDER_KINDS == frozenset( - {"market_ioc_quote", "market_ioc_base", "limit_gtc", "stop_limit_gtc"} + {"market_ioc_quote", "market_ioc_base", "limit_gtc", "stop_limit_gtc", "bracket_gtc"} ) @@ -79,3 +87,130 @@ def test_rejects_non_positive_size() -> None: MarketIOCByQuote(product_id="BTC-USD", side=Side.BUY, quote_size=Decimal("0")) with pytest.raises(ValueError, match="base_size must be positive"): MarketIOCByBase(product_id="BTC-USD", side=Side.BUY, base_size=Decimal("-1")) + + +def test_bracket_gtc_joins_the_sum_type() -> None: + """A bracket is a kind of its own, not a `StopLimitGTC` with an extra price bolted on.""" + assert BracketGTC.kind == "bracket_gtc" + assert BracketGTC.initial_status == "open" + assert "bracket_gtc" in ORDER_KINDS + + +def test_bracket_gtc_carries_both_exit_prices_under_keels_own_names() -> None: + """`take_profit_price`, not Coinbase's `limit_price`. + + The port's vocabulary is keel's, so a second venue's translation does not start from + Coinbase's spelling -- and so the take-profit cannot be read as `LimitGTC.limit_price`, + which is a different price on a different order. + """ + spec = BracketGTC( + product_id="BTC-USD", + side=Side.SELL, + base_size=Decimal("0.5"), + take_profit_price=Decimal("70000"), + stop_trigger_price=Decimal("60000"), + ) + assert spec.take_profit_price == Decimal("70000") + assert spec.stop_trigger_price == Decimal("60000") + assert not hasattr(spec, "limit_price") + + +def test_bracket_gtc_has_no_stop_direction_field() -> None: + """Direction is derivable from `side`, exactly as `StopLimitGTC`'s is. + + A field would make a SELL bracket that triggers UPWARD representable, which is the class of + nonsense the sum type exists to prevent. + """ + spec = BracketGTC( + product_id="BTC-USD", + side=Side.SELL, + base_size=Decimal("0.5"), + take_profit_price=Decimal("70000"), + stop_trigger_price=Decimal("60000"), + ) + assert not hasattr(spec, "stop_direction") + + +def test_bracket_gtc_rejects_non_positive_numerics() -> None: + with pytest.raises(ValueError, match="base_size must be positive"): + BracketGTC( + product_id="BTC-USD", + side=Side.SELL, + base_size=Decimal("0"), + take_profit_price=Decimal("70000"), + stop_trigger_price=Decimal("60000"), + ) + with pytest.raises(ValueError, match="take_profit_price must be positive"): + BracketGTC( + product_id="BTC-USD", + side=Side.SELL, + base_size=Decimal("1"), + take_profit_price=Decimal("0"), + stop_trigger_price=Decimal("60000"), + ) + with pytest.raises(ValueError, match="stop_trigger_price must be positive"): + BracketGTC( + product_id="BTC-USD", + side=Side.SELL, + base_size=Decimal("1"), + take_profit_price=Decimal("70000"), + stop_trigger_price=Decimal("-1"), + ) + + +def test_sell_bracket_refuses_an_inverted_pair() -> None: + """A SELL bracket exits a long: the stop is below, the target above.""" + with pytest.raises(ValueError, match="must be below take_profit_price"): + BracketGTC( + product_id="BTC-USD", + side=Side.SELL, + base_size=Decimal("1"), + take_profit_price=Decimal("60000"), + stop_trigger_price=Decimal("70000"), + ) + + +def test_sell_bracket_refuses_an_equal_pair() -> None: + """Equal is not a degenerate bracket, it is a stop and a target racing at one price. + + Whichever the venue evaluates first decides whether the position took a profit or a loss -- + a coin flip dressed as a protective order. The inverted case at least looks wrong; this one + reads as a valid pair of numbers, which is why it gets its own test. + """ + with pytest.raises(ValueError, match="must be below take_profit_price"): + BracketGTC( + product_id="BTC-USD", + side=Side.SELL, + base_size=Decimal("1"), + take_profit_price=Decimal("65000"), + stop_trigger_price=Decimal("65000"), + ) + + +def test_buy_bracket_mirrors_the_sell_check() -> None: + """A BUY bracket exits a short: the stop is above, the target below.""" + ok = BracketGTC( + product_id="BTC-USD", + side=Side.BUY, + base_size=Decimal("1"), + take_profit_price=Decimal("60000"), + stop_trigger_price=Decimal("70000"), + ) + assert ok.stop_trigger_price > ok.take_profit_price + + with pytest.raises(ValueError, match="must be above take_profit_price"): + BracketGTC( + product_id="BTC-USD", + side=Side.BUY, + base_size=Decimal("1"), + take_profit_price=Decimal("70000"), + stop_trigger_price=Decimal("60000"), + ) + with pytest.raises(ValueError, match="must be above take_profit_price"): + BracketGTC( + product_id="BTC-USD", + side=Side.BUY, + base_size=Decimal("1"), + take_profit_price=Decimal("65000"), + stop_trigger_price=Decimal("65000"), + ) diff --git a/tests/broker_coinbase/test_translate.py b/tests/broker_coinbase/test_translate.py index 70604824..0cb3be21 100644 --- a/tests/broker_coinbase/test_translate.py +++ b/tests/broker_coinbase/test_translate.py @@ -3,7 +3,13 @@ from decimal import Decimal import pytest -from keel_broker_api.orders import LimitGTC, MarketIOCByBase, MarketIOCByQuote, StopLimitGTC +from keel_broker_api.orders import ( + BracketGTC, + LimitGTC, + MarketIOCByBase, + MarketIOCByQuote, + StopLimitGTC, +) from keel_broker_coinbase.translate import to_order_configuration from keel_core.types import Side @@ -54,3 +60,62 @@ def test_decimals_are_rendered_as_exact_strings_not_floats() -> None: def test_unknown_spec_type_raises() -> None: with pytest.raises(Exception): to_order_configuration(object()) # type: ignore[arg-type] + + +def test_bracket_gtc() -> None: + spec = BracketGTC( + product_id="BTC-USD", + side=Side.SELL, + base_size=Decimal("0.5"), + take_profit_price=Decimal("70000"), + stop_trigger_price=Decimal("60000"), + ) + assert to_order_configuration(spec) == { + "trigger_bracket_gtc": { + "base_size": "0.5", + "limit_price": "70000", + "stop_trigger_price": "60000", + } + } + + +def test_bracket_gtc_carries_no_stop_direction() -> None: + """Unlike `stop_limit_stop_limit_gtc`, Coinbase's trigger bracket takes no direction. + + The bracket's two prices already say which way each side triggers, and the shipped + `executor._bracket_order_configuration` has never sent one. Adding a key the venue-accepted + dict does not carry would be a change to the wire shape dressed as a port migration. + """ + spec = BracketGTC( + product_id="BTC-USD", + side=Side.SELL, + base_size=Decimal("0.5"), + take_profit_price=Decimal("70000"), + stop_trigger_price=Decimal("60000"), + ) + assert "stop_direction" not in to_order_configuration(spec)["trigger_bracket_gtc"] + + +def test_bracket_gtc_is_byte_identical_to_what_the_executor_ships_today() -> None: + """Parity with the shipped, venue-accepted dict IS the contract for this kind. + + `executor._bracket_order_configuration` is what Coinbase has actually been accepting on the + live path. The port's job here is to reach the same wire shape through a typed spec, not to + improve on it -- so this test pins the two together and will fail the moment either side + drifts. + + The TEST imports both; production code must not. `keel_broker_coinbase` is a standalone + package that knows nothing about `keel.execution`, and the day Stage 2 switches the live + caller over, this assertion is what says the switch changed no bytes on the wire. + """ + from keel.execution.executor import _bracket_order_configuration + + qty, target, stop = Decimal("0.12345678"), Decimal("70123.45"), Decimal("60987.65") + spec = BracketGTC( + product_id="BTC-USD", + side=Side.SELL, + base_size=qty, + take_profit_price=target, + stop_trigger_price=stop, + ) + assert to_order_configuration(spec) == _bracket_order_configuration(qty, target, stop)