Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/keel-broker-alpaca/keel_broker_alpaca/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
),
Expand Down
15 changes: 15 additions & 0 deletions packages/keel-broker-alpaca/keel_broker_alpaca/translate.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from typing import Any, assert_never

from keel_broker_api.orders import (
BracketGTC,
LimitGTC,
MarketIOCByBase,
MarketIOCByQuote,
Expand Down Expand Up @@ -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)

Expand Down
39 changes: 39 additions & 0 deletions packages/keel-broker-api/keel_broker_api/conformance/suite.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"),
),
}


Expand Down Expand Up @@ -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:
Expand Down
73 changes: 71 additions & 2 deletions packages/keel-broker-api/keel_broker_api/orders.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
16 changes: 16 additions & 0 deletions packages/keel-broker-coinbase/keel_broker_coinbase/translate.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from typing import assert_never

from keel_broker_api.orders import (
BracketGTC,
LimitGTC,
MarketIOCByBase,
MarketIOCByQuote,
Expand Down Expand Up @@ -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)

Expand Down
5 changes: 5 additions & 0 deletions packages/keel-broker-fake/keel_broker_fake/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions packages/keel-broker-kraken/keel_broker_kraken/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
14 changes: 14 additions & 0 deletions packages/keel-broker-robinhood/keel_broker_robinhood/translate.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from typing import Any, assert_never

from keel_broker_api.orders import (
BracketGTC,
LimitGTC,
MarketIOCByBase,
MarketIOCByQuote,
Expand Down Expand Up @@ -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)

Expand Down
Loading