Skip to main content

rustrade_execution/client/
mod.rs

1//! Execution client implementations for various exchanges.
2//!
3//! # Connector Comparison
4//!
5//! | Connector | Reconnect | Dedup | Fill Recovery | Ended-order Recovery | Heartbeat | Cancel answers once |
6//! |-----------|-----------|-------|---------------|----------------------|-----------|---------------------|
7//! | [`binance`] | Auto (1s→30s backoff) | 10k LRU | REST after reconnect | REST after reconnect | 30s | cancelled |
8//! | [`alpaca`] | Auto (1s→30s backoff) | 2k LRU | REST after reconnect | REST after reconnect | 35s | accepted |
9//! | [`ibkr`] | ibapi-managed | 10k LRU, fills only | Executions request after reconnect | None | N/A | submitted |
10//! | [`hyperliquid`] | SDK-managed | 10k LRU, fills only | Venue snapshot on resubscribe | REST after reconnect | SDK-managed | cancelled |
11//!
12//! "Ended-order Recovery" is whether a reconnect reports how an order the client held as live
13//! ended while the stream was down, and only those clients implement [`OrderStatusClient`]. The
14//! last column is when [`ExecutionClient::cancel_order`] answers `Ok`. Only for "cancelled" has the
15//! order ended by then; see that method.
16//!
17//! # Resilience Philosophy
18//!
19//! **WebSocket-based connectors** (Binance, Alpaca) implement auto-reconnection with
20//! fill recovery and deduplication. After reconnect, they query REST APIs for missed
21//! fills and deduplicate against the LRU cache to prevent duplicate processing.
22//!
23//! **IBKR** uses TCP to local TWS/Gateway. `ibapi` reconnects that socket itself; the
24//! account stream stays open across it and recovers the missed fills from TWS's executions,
25//! deduplicating against the LRU cache. Replacing a client that is gone for good requires IB
26//! Gateway availability and client ID coordination — decisions that belong in the caller's
27//! wrapper. See [`ibkr`] module docs for caller responsibilities.
28//!
29//! **Hyperliquid** delegates reconnection to the official SDK's `with_reconnect()` mechanism, but
30//! deduplicates fills itself: the SDK resubscribes on reconnect and the venue opens a `userFills`
31//! subscription with a snapshot, so every reconnect redelivers fills already seen. That snapshot
32//! is the only fill recovery: it holds recent fills, how many Hyperliquid does not say, so callers
33//! needing older ones call [`ExecutionClient::fetch_trades`], whose results are not deduplicated
34//! against the stream. Once the snapshot has been sent on, a reconnect checks how the orders the
35//! client holds as live ended, like Binance's and Alpaca's.
36//!
37//! # Known Limitations
38//!
39//! Only Binance (Spot and Margin), Alpaca and Hyperliquid (perpetuals and spot) recover the **order
40//! lifecycle events** (cancelled, expired, rejected, a fill that completes an order) they missed
41//! while disconnected, and only for the orders the client holds as live; IBKR recovers fills only
42//! (#370). [`OrderStatusClient`] is the lookup that recovery is built on: given the orders a caller
43//! still holds as live, it says how each that has ended did end. Binance Spot, Binance Margin,
44//! Alpaca, both Hyperliquid clients and the mock client implement it.
45//!
46//! The engine closes part of that gap from the account snapshot each reconnect produces: an order a
47//! complete list no longer shows is retired (see [`ExecutionClient::account_snapshot`]). That covers
48//! the connectors whose snapshot declares its orders complete — Binance Spot and Margin,
49//! Hyperliquid, Alpaca, and the mock venue — but not IBKR (#371), and it cannot tell how an order
50//! ended. A caller that needs more reconciles against
51//! [`ExecutionClient::fetch_open_orders`] after a reconnect.
52
53use crate::{
54    UnindexedAccountEvent, UnindexedAccountSnapshot,
55    balance::AssetBalance,
56    borrow_capacity::BorrowCapacity,
57    error::UnindexedClientError,
58    order::{
59        Order, UnindexedInactiveOrder, UnindexedOrderKey,
60        bracket::{BracketOrderRequest, BracketOrderResult},
61        request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
62        state::{Open, UnindexedOrderState},
63    },
64    shortability::Shortability,
65    trade::TradesRead,
66};
67use chrono::{DateTime, Utc};
68use futures::Stream;
69use rustrade_instrument::{
70    Side,
71    asset::name::AssetNameExchange,
72    exchange::ExchangeId,
73    instrument::{kind::InstrumentKindDiscriminant, name::InstrumentNameExchange},
74};
75use std::future::Future;
76
77// Account-event deduplication over rustrade's own event type. Gated on the clients that use it
78// so a build selecting none of them does not compile it unused. Alpaca deduplicates too, but
79// against a raw `SmolStr` fill key rather than this cache, so it is deliberately not in this list.
80#[cfg(any(feature = "binance", feature = "hyperliquid", feature = "ibkr"))]
81pub(crate) mod dedup;
82
83// What a client knows of its live orders, and the reconnect check of how those orders ended while
84// its account stream was down. Gated on the clients that use it, like `dedup`.
85#[cfg(any(
86    feature = "alpaca",
87    feature = "binance",
88    feature = "hyperliquid",
89    feature = "ibkr"
90))]
91pub(crate) mod order_recovery;
92
93// Alpaca ExecutionClient implementation (options, equities, crypto — single unified API)
94#[cfg(feature = "alpaca")]
95pub mod alpaca;
96
97// Binance ExecutionClient implementations: Spot (live) and Cross Margin (identity/config scaffolding)
98#[cfg(feature = "binance")]
99pub mod binance;
100
101// Hyperliquid perpetual futures and spot ExecutionClient implementations
102#[cfg(feature = "hyperliquid")]
103pub mod hyperliquid;
104
105// Interactive Brokers ExecutionClient implementation (equities, futures, options, forex)
106#[cfg(feature = "ibkr")]
107pub mod ibkr;
108
109pub mod mock;
110
111/// An instrument an [`ExecutionClient`] is about to be built to trade, as
112/// [`ExecutionClient::validate_config`] sees it.
113///
114/// `#[non_exhaustive]`, so further detail can be added without breaking an implementation that
115/// reads these fields; construct one with [`Self::new`].
116#[derive(Debug, Clone, Copy, Eq, PartialEq)]
117#[non_exhaustive]
118pub struct ClientInstrument<'a> {
119    /// The name the venue knows the instrument by, which is what a client's requests carry.
120    pub name_exchange: &'a InstrumentNameExchange,
121    /// The instrument's kind.
122    pub kind: InstrumentKindDiscriminant,
123}
124
125impl<'a> ClientInstrument<'a> {
126    /// The view of an instrument named `name_exchange`, of kind `kind`.
127    pub fn new(
128        name_exchange: &'a InstrumentNameExchange,
129        kind: InstrumentKindDiscriminant,
130    ) -> Self {
131        Self {
132            name_exchange,
133            kind,
134        }
135    }
136}
137
138// `+ Send` bounds on async method return types required for multi-threaded
139// Tokio runtime. This is a breaking change vs upstream — any `!Send` executor
140// implementation would fail to compile.
141pub trait ExecutionClient
142where
143    Self: Clone,
144{
145    const EXCHANGE: ExchangeId;
146
147    /// The [`InstrumentKindDiscriminant`]s this client can trade.
148    ///
149    /// A client works off [`InstrumentNameExchange`] strings and never inspects an instrument's
150    /// kind, so nothing downstream can infer this from the implementation — routing a `Future`
151    /// through a spot-only venue produces a rejected or, worse, misinterpreted order rather than a
152    /// type error. Declaring the set here lets [`ExecutionBuilder`] reject an unsupported
153    /// instrument set at build time, before any order is sent.
154    ///
155    /// Declare what the *venue and this implementation together* can actually route, not what the
156    /// exchange's product catalogue advertises. A kind the exchange offers but this client builds
157    /// no request for does not belong in the set.
158    ///
159    /// There is deliberately no default. A new client must state its capabilities rather than
160    /// inherit a permissive set that would silently admit every kind.
161    ///
162    /// It is a `const` and not a `fn supported_kinds(&self)` because [`ExecutionBuilder`] validates
163    /// the instrument set *before* [`Self::new`] is ever called — there is no instance to ask at the
164    /// point the answer is needed, and deferring validation until after construction would mean
165    /// standing up a client for a venue the engine is about to reject. A capability set that varies
166    /// with `Self::Config` therefore cannot be expressed here; it would need a separate
167    /// pre-construction hook on the config.
168    ///
169    /// [`ExecutionBuilder`]: https://docs.rs/rustrade/latest/rustrade/execution/builder/struct.ExecutionBuilder.html
170    const SUPPORTED_KINDS: &'static [InstrumentKindDiscriminant];
171
172    type Config: Clone;
173
174    /// Checks `config` against the instruments this client is about to be built to trade, before
175    /// [`Self::new`] is called. `Err` names what is wrong.
176    ///
177    /// [`SUPPORTED_KINDS`](Self::SUPPORTED_KINDS) rejects a kind the client cannot route at all.
178    /// This catches what only the config knows: a per-instrument entry that is invalid, or that
179    /// disagrees with the instrument it is keyed to, such as a contract registered as a stock for
180    /// an instrument modelled as an option. Each such entry would otherwise be skipped or acted on
181    /// silently once the client runs.
182    ///
183    /// The default accepts every config. Override it when `Self::Config` carries per-instrument
184    /// detail.
185    ///
186    /// [`ExecutionBuilder`] calls it, after checking `SUPPORTED_KINDS`, with the instruments
187    /// registered on this client's exchange. A client constructed by calling `new` directly is not
188    /// checked; call this first to get the same check.
189    ///
190    /// [`ExecutionBuilder`]: https://docs.rs/rustrade/latest/rustrade/execution/builder/struct.ExecutionBuilder.html
191    fn validate_config(
192        config: &Self::Config,
193        instruments: &[ClientInstrument<'_>],
194    ) -> Result<(), String> {
195        let _ = (config, instruments);
196        Ok(())
197    }
198
199    // `+ Send` required so generic code (e.g. ExecutionManager) can pass
200    // the stream to tokio::spawn, which requires Send.
201    type AccountStream: Stream<Item = UnindexedAccountEvent> + Send;
202
203    fn new(config: Self::Config) -> Self;
204
205    /// Reads the account's balances, and its open orders and positions for `instruments`, from the
206    /// venue.
207    ///
208    /// Besides startup, the engine's `ExecutionManager` calls this again on every account-stream
209    /// reconnect, and emits the result ahead of the new stream's updates. It is how the engine
210    /// catches up on what changed while the stream was down.
211    ///
212    /// # Order completeness
213    ///
214    /// Each [`InstrumentAccountSnapshot`](crate::InstrumentAccountSnapshot) states through
215    /// `orders_complete` whether its `orders` list is every order open at the venue for that
216    /// instrument. Set it only when it is true: the engine retires a tracked order that a complete
217    /// list omits, so a list that can miss an open order (a capped page, an order dropped on
218    /// conversion, an order reported under an id other than the one it was placed with) must say
219    /// `false`. `false` is always safe; it only forgoes that reconciliation.
220    fn account_snapshot(
221        &self,
222        assets: &[AssetNameExchange],
223        instruments: &[InstrumentNameExchange],
224    ) -> impl Future<Output = Result<UnindexedAccountSnapshot, UnindexedClientError>> + Send;
225
226    /// Returns a live stream of account events (fills, order updates, balance changes).
227    ///
228    /// # Startup race window
229    ///
230    /// There is an unavoidable gap between the WebSocket subscribe response and the
231    /// first event being delivered: fills arriving in this window (typically milliseconds,
232    /// no sub-millisecond guarantee) are silently dropped. `account_snapshot` reconciles
233    /// open-order state, but TRADE fills in this window are not recoverable from the stream
234    /// alone. Callers that require fill completeness at startup **must** call
235    /// [`ExecutionClient::fetch_trades`] with at least a 1-second lookback after this method returns.
236    ///
237    /// # Backpressure
238    ///
239    /// Implementations use unbounded internal channels. If the consumer cannot keep up,
240    /// events queue in memory rather than being dropped — per library philosophy, OOM
241    /// crashes are preferable to silent data loss. Consumers requiring backpressure
242    /// should implement it at their boundary (e.g., bounded channel with overflow policy).
243    fn account_stream(
244        &self,
245        assets: &[AssetNameExchange],
246        instruments: &[InstrumentNameExchange],
247    ) -> impl Future<Output = Result<Self::AccountStream, UnindexedClientError>> + Send;
248
249    /// Cancel an order at the venue.
250    ///
251    /// # Return value
252    ///
253    /// Every request gets exactly one answer, which the engine's `ExecutionManager` forwards as the
254    /// order's response. An implementation must not swallow a request: a failure to send it, or to
255    /// read the venue's answer, is an `Err`.
256    ///
257    /// - `Ok(Cancelled)`: the venue took the cancel. Whether the order has *ended* by then depends
258    ///   on the venue; see below.
259    /// - `Err`: the venue refused the cancel, or it could not be sent or answered. The order may
260    ///   still be open, or may have ended some other way, such as by filling. The account stream
261    ///   reports which.
262    ///
263    /// # A taken cancel is not always an ended order
264    ///
265    /// Some venues answer a cancel before carrying it out. Until they do, the order is live and can
266    /// still fill. From those venues `Ok(Cancelled)` means only that the cancel was accepted, and
267    /// the account stream reports how the order actually ended, including any fill in between. The
268    /// engine stops tracking an order on `Ok(Cancelled)` from any venue.
269    ///
270    /// Binance, Hyperliquid and the mock venue answer once the order is cancelled. Alpaca answers
271    /// once it has accepted the cancel, and IBKR once the cancel is submitted. The
272    /// [connector comparison](crate::client#connector-comparison) lists the same for each
273    /// connector; the mock venue is not one, so it has no row there.
274    ///
275    /// # `filled_quantity` and `time_exchange`
276    ///
277    /// `Cancelled::filled_quantity` is the quantity the venue reported filled where the answer
278    /// carries one, which Binance's and the mock venue's do. Alpaca, IBKR and Hyperliquid answer
279    /// without it and report zero, which does not mean nothing filled. The fill total comes from
280    /// the account stream, or from [`Self::fetch_trades`]. Likewise `time_exchange` is the venue's
281    /// time where the answer carries one, and the local time the answer arrived otherwise.
282    fn cancel_order(
283        &self,
284        request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
285    ) -> impl Future<Output = UnindexedOrderResponseCancel> + Send;
286
287    // `+ Send` on default method return types for multi-threaded Tokio runtime
288    fn cancel_orders<'a>(
289        &self,
290        requests: impl IntoIterator<Item = OrderRequestCancel<ExchangeId, &'a InstrumentNameExchange>>,
291    ) -> impl Stream<Item = UnindexedOrderResponseCancel> + Send {
292        futures::stream::FuturesUnordered::from_iter(
293            requests
294                .into_iter()
295                .map(|request| self.cancel_order(request)),
296        )
297    }
298
299    /// Place an order on the exchange.
300    ///
301    /// # Return value
302    ///
303    /// Every request gets exactly one answer, which the engine's `ExecutionManager` forwards as the
304    /// order's response. An implementation must not swallow a request: a failure to send it, or to
305    /// read the venue's answer, is `OpenFailed`.
306    ///
307    /// Returns `OrderState` directly rather than `Result<Open, OrderError>`:
308    /// - `OrderState::Active(Open)` - order is resting on the order book
309    /// - `OrderState::Inactive(FullyFilled)` - order was immediately filled (includes `avg_price` when available)
310    /// - `OrderState::Inactive(Expired)` - order ended in the response itself without filling
311    ///   completely, carrying what filled: an IOC or FOK order that found too little liquidity,
312    ///   or one expired by self-trade prevention
313    /// - `OrderState::Inactive(Cancelled)` - order the venue reports cancelled in the response
314    ///   itself, carrying what filled
315    /// - `OrderState::Inactive(OpenFailed)` - order placement failed (API error, connectivity, a
316    ///   rejection the venue reports in the response, etc.)
317    ///
318    /// An implementation returns `Expired` or `Cancelled` only when its venue reports the order's
319    /// status in the placement response (currently Binance Spot and Margin). The others return
320    /// `Open` for such an order, and their account stream reports how it ended.
321    ///
322    /// This design allows immediate fills to carry metadata (e.g., `avg_price`) that
323    /// would be lost if we had to infer terminal state from `Open::filled_quantity`.
324    fn open_order(
325        &self,
326        request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
327    ) -> impl Future<Output = Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>> + Send;
328
329    // `+ Send` on default method return types for multi-threaded Tokio runtime
330    fn open_orders<'a>(
331        &self,
332        requests: impl IntoIterator<Item = OrderRequestOpen<ExchangeId, &'a InstrumentNameExchange>>,
333    ) -> impl Stream<Item = Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>> + Send
334    {
335        futures::stream::FuturesUnordered::from_iter(
336            requests.into_iter().map(|request| self.open_order(request)),
337        )
338    }
339
340    /// Fetch current balances for the specified assets.
341    ///
342    /// An empty `assets` slice is the "return all" sentinel: implementations must return
343    /// balances for every asset held. When non-empty, only the listed assets are returned.
344    fn fetch_balances(
345        &self,
346        assets: &[AssetNameExchange],
347    ) -> impl Future<Output = Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError>> + Send;
348
349    /// Fetch currently open orders, optionally filtered by instrument.
350    ///
351    /// An empty `instruments` slice is the "return all" sentinel: implementations must
352    /// return open orders across all instruments. When non-empty, only orders for the
353    /// listed instruments are returned.
354    fn fetch_open_orders(
355        &self,
356        instruments: &[InstrumentNameExchange],
357    ) -> impl Future<
358        Output = Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError>,
359    > + Send;
360
361    /// Read the trades (fills) from `start` to `end`, both inclusive, optionally filtered by
362    /// instrument.
363    ///
364    /// An empty `instruments` slice is the "return all" sentinel: implementations must
365    /// return trades across all instruments. When non-empty, only trades for the listed
366    /// instruments are returned.
367    ///
368    /// # Bounded, resumable reads
369    ///
370    /// One call is bounded by the venue's limits, so it may stop before `end`.
371    /// [`TradesRead::resume`] says whether it did:
372    /// - `None`: every fill in the span the venue still holds was read.
373    /// - `Some(t)`: call again with `start = t` and the same `end` to read on. That read can
374    ///   return again fills the previous one returned, so match them by instrument and
375    ///   [`TradeId`](crate::trade::TradeId).
376    ///
377    /// The caller owns the loop, and with it how many calls, and how long, it spends on a span.
378    /// A `start` after `end` is an empty span, read as no trades and `resume: None`.
379    ///
380    /// # Venue limits
381    ///
382    /// `resume: None` covers what the venue still holds, which can be less than the span:
383    /// - IBKR returns the current trading day's executions only.
384    /// - Hyperliquid keeps only each wallet's 10,000 most recent fills.
385    ///
386    /// Binance needs instruments: an empty slice reads nothing. Each client documents its limits.
387    ///
388    /// # Errors
389    ///
390    /// [`Truncated`](crate::error::ClientError::Truncated) when a read cannot advance, because
391    /// more fills share the span's first moment than one call can read past.
392    ///
393    /// The fee asset (`AssetNameExchange`) may be quote, base, or third-party (e.g., BNB).
394    /// Use `fees.fees_quote` for quote-equivalent value when available.
395    fn fetch_trades(
396        &self,
397        start: DateTime<Utc>,
398        end: DateTime<Utc>,
399        instruments: &[InstrumentNameExchange],
400    ) -> impl Future<
401        Output = Result<
402            TradesRead<AssetNameExchange, InstrumentNameExchange>,
403            UnindexedClientError,
404        >,
405    > + Send;
406}
407
408/// Extension trait for exchanges that support native bracket orders.
409///
410/// A bracket order consists of three linked orders:
411/// 1. **Entry**: Limit order to enter the position
412/// 2. **Take Profit**: Limit order to exit at profit target
413/// 3. **Stop Loss**: Stop (or stop-limit) order to exit at loss limit
414///
415/// When either exit leg fills, the exchange automatically cancels the other.
416///
417/// # Type-Level Capability
418///
419/// This is a supertrait of [`ExecutionClient`], enabling compile-time capability checks:
420/// - `impl ExecutionClient` — basic order operations
421/// - `impl ExecutionClient + BracketOrderClient` — includes bracket orders
422///
423/// This follows Rust idioms like `Read + Seek` or `Iterator + ExactSizeIterator`.
424///
425/// # Why Supertrait Over Alternatives
426///
427/// **vs. associated types on `ExecutionClient`**: Callers can't construct
428/// `Self::BracketRequest` without knowing the concrete type — adds trait surface
429/// without enabling generic use.
430///
431/// **vs. default impl returning `Unsupported`**: Puts a "dead method" on every
432/// client (MockExecution, BinanceSpot, HyperliquidClient). Compile-time capability
433/// via trait bounds is better than runtime errors.
434///
435/// # Result Types
436///
437/// [`BracketOrderResult`] uses `Option<Order>` for child legs to document API divergence:
438///
439/// | Exchange | `take_profit` | `stop_loss` | Reason |
440/// |----------|---------------|-------------|--------|
441/// | IBKR     | `Some(...)` | `Some(...)` | Returns all three orders immediately |
442/// | Alpaca   | `None` | `None` | Child legs created server-side |
443///
444/// # Example
445///
446/// ```ignore
447/// use rustrade_execution::client::{ExecutionClient, BracketOrderClient};
448/// use rustrade_execution::order::bracket::{BracketOrderRequest, RequestOpenBracket};
449///
450/// async fn place_bracket<C: ExecutionClient + BracketOrderClient>(
451///     client: &C,
452///     request: BracketOrderRequest<ExchangeId, &InstrumentNameExchange>,
453/// ) -> BracketOrderResult {
454///     client.open_bracket_order(request).await
455/// }
456/// ```
457pub trait BracketOrderClient: ExecutionClient {
458    /// Place a bracket order (entry + take-profit + stop-loss).
459    ///
460    /// # Request
461    ///
462    /// The [`BracketOrderRequest`] contains:
463    /// - `key`: Order key (exchange, instrument, strategy, client order ID)
464    /// - `state`: [`RequestOpenBracket`](crate::order::bracket::RequestOpenBracket) with
465    ///   side, quantity, prices, and optional stop-loss limit price
466    ///
467    /// # Constraints
468    ///
469    /// - `time_in_force` must be `Day` or `GoodUntilCancelled` on most exchanges
470    /// - Entry order type is always `Limit`
471    /// - Price ordering must be valid for the side (see [`RequestOpenBracket`](crate::order::bracket::RequestOpenBracket))
472    ///
473    /// # Exchange-Specific Field Handling
474    ///
475    /// `RequestOpenBracket::stop_loss_limit_price` is **not honored uniformly**:
476    /// - **Alpaca**: When `Some`, the stop-loss leg becomes a stop-limit order at that price.
477    /// - **IBKR**: Silently ignored — the stop-loss leg is always a stop (market) order.
478    ///
479    /// Generic callers `T: BracketOrderClient` must treat this field as advisory.
480    ///
481    /// # Return Value
482    ///
483    /// Returns [`BracketOrderResult`] with:
484    /// - `parent`: Always present (entry order)
485    /// - `take_profit`: `Some` if exchange returns legs immediately (IBKR), `None` otherwise (Alpaca)
486    /// - `stop_loss`: `Some` if exchange returns legs immediately (IBKR), `None` otherwise (Alpaca)
487    ///
488    /// Either all orders are `Active(Open)` or all are `Inactive` (placement failed), with one
489    /// exception: a leg of a failed placement whose fate at the venue is unknown (no status
490    /// arrived, or it was accepted and its rollback cancel could not be sent) is returned
491    /// `Active(Open)`, so the account stream can resolve it. Only IBKR returns such legs today;
492    /// see its `open_bracket_order`.
493    fn open_bracket_order(
494        &self,
495        request: BracketOrderRequest<ExchangeId, &InstrumentNameExchange>,
496    ) -> impl Future<Output = BracketOrderResult> + Send;
497}
498
499/// Extension trait for clients that can say how an order ended at the venue.
500///
501/// A client that loses its account stream recovers the fills it missed when it reconnects, but not
502/// an order that was cancelled, expired or rejected in the meantime: the event that said so went
503/// out while nothing was listening. The engine then keeps such an order as live until a complete
504/// account snapshot no longer lists it, and even then cannot tell how it ended. This trait is the
505/// lookup that answers that: given the orders a caller still holds as live, it returns how each one
506/// that has ended did end.
507///
508/// # By order, not by time
509///
510/// The obvious alternative, asking the venue for every order closed since the disconnect, does not
511/// work. Binance's `allOrders` and Alpaca's `GET /v2/orders?status=closed` both filter their time
512/// window on when an order was **created**, so a window opening at the disconnect cannot see an
513/// order placed before it and cancelled during it — which is the order that matters, because the
514/// caller is tracking it. Looking each order up by its client order id finds it whenever it was
515/// placed.
516///
517/// # Type-Level Capability
518///
519/// A supertrait of [`ExecutionClient`], for the reason [`BracketOrderClient`] is one: a client that
520/// cannot look orders up does not implement it, rather than carrying a method that fails at run
521/// time. A caller that needs the lookup bounds on `ExecutionClient + OrderStatusClient`.
522pub trait OrderStatusClient: ExecutionClient {
523    /// How each of `orders` ended, for those that have.
524    ///
525    /// # Contract
526    ///
527    /// - One entry per order that has ended, in no particular order:
528    ///   [`FullyFilled`](crate::order::state::InactiveOrderState::FullyFilled),
529    ///   [`Cancelled`](crate::order::state::InactiveOrderState::Cancelled) carrying what filled
530    ///   before it, [`Expired`](crate::order::state::InactiveOrderState::Expired), or
531    ///   [`OpenFailed`](crate::order::state::InactiveOrderState::OpenFailed) for an order the venue
532    ///   accepted for processing and later rejected (Binance `REJECTED`, Alpaca `rejected`). An
533    ///   order rejected in the response to its open request was answered there and is not expected
534    ///   here.
535    /// - An order still live is **omitted**, and so is one the venue does not know. Neither is an
536    ///   error. A caller compares the result with what it asked for.
537    /// - An empty `orders` returns an empty list. Unlike
538    ///   [`ExecutionClient::fetch_open_orders`]'s empty slice, it does not mean "all".
539    /// - An order is found by its [`cid`](crate::order::OrderKey::cid), and each returned order
540    ///   carries the key it was asked for, so a `strategy` the venue does not record survives.
541    /// - A key whose `instrument` is not the one the order traded is treated as unknown. The
542    ///   `exchange` is not checked: a client answers for its own venue.
543    /// - A `cid` asked about more than once is reported at most once, under the first key that
544    ///   finds it.
545    ///
546    /// # Errors
547    ///
548    /// `Err` if the venue could not answer for every order. A partial list is never returned,
549    /// because an order missing from it would read as still live.
550    fn fetch_ended_orders(
551        &self,
552        orders: &[UnindexedOrderKey],
553    ) -> impl Future<Output = Result<Vec<UnindexedInactiveOrder>, UnindexedClientError>> + Send;
554}
555
556/// Extension trait for clients that can say what the venue would lend for a short sale.
557///
558/// Answers, before an order is sent, the question a short sale otherwise learns only from its
559/// rejection: whether the instrument may be sold short, whether it is easy to borrow, how much the
560/// venue has to lend and at what fee. Each venue reports a different subset, so each field of the
561/// [`Shortability`] it returns is optional; the implementations say which fields they fill.
562///
563/// # Type-Level Capability
564///
565/// A supertrait of [`ExecutionClient`], for the reason [`BracketOrderClient`] is one: a client
566/// whose venue has nothing to lend, or that does not report it, does not implement it. A simulated
567/// venue reads the same [`Shortability`] from a
568/// [`ShortabilityProvider`](crate::shortability::ShortabilityProvider) instead.
569pub trait ShortabilityClient: ExecutionClient {
570    /// What the venue says about lending `instrument` for a short sale.
571    ///
572    /// # Contract
573    ///
574    /// - **Advisory.** `Ok` describes the venue's lending as it reported it, not a promise to lend:
575    ///   a short sale must still handle
576    ///   [`ApiError::BorrowRejected`](crate::error::ApiError::BorrowRejected).
577    /// - **Not this account's capacity.** [`Shortability::available`] is what the venue has to
578    ///   lend, not what is left of it for this account; that is
579    ///   [`BorrowCapacityClient::fetch_borrow_capacity`], where a venue reports it.
580    /// - **May be expensive.** An implementation may make several requests, some of them heavy
581    ///   against the venue's rate limit, or hold a scarce resource such as a market data line;
582    ///   each says what it costs. Fetch it when deciding on a short, not on every tick.
583    /// - **May be slow.** Where the venue streams its answer rather than replying to a request,
584    ///   the call waits for it, up to a timeout the implementation documents: seconds, not
585    ///   milliseconds.
586    /// - **May outlive its future.** An implementation that reads on a blocking thread finishes
587    ///   that read, and releases what it holds, even when the returned future is dropped first;
588    ///   each says so.
589    ///
590    /// # Errors
591    ///
592    /// - [`ApiError::InstrumentInvalid`](crate::error::ApiError::InstrumentInvalid) for an
593    ///   instrument the venue does not know, or one of a kind that is not sold short by borrowing
594    ///   it, such as an option.
595    /// - An error, rather than a [`Shortability`] with nothing known, when the venue does not
596    ///   answer, including when the account is not entitled to the answer, such as one without the
597    ///   market data subscription a venue sends it through.
598    fn fetch_shortability(
599        &self,
600        instrument: &InstrumentNameExchange,
601    ) -> impl Future<Output = Result<Shortability, UnindexedClientError>> + Send;
602}
603
604/// Extension trait for clients that can say how much more this account can borrow.
605///
606/// Where [`ShortabilityClient`] says what the venue has to lend, this says how much of it this
607/// account could take now, given its collateral, its limit and what it has already borrowed. The
608/// two are separate types because they are different facts; see [`BorrowCapacity`].
609///
610/// # Type-Level Capability
611///
612/// A supertrait of [`ExecutionClient`], for the reason [`BracketOrderClient`] is one. Only a venue
613/// that lends against an account's collateral and reports what is left of it implements it.
614pub trait BorrowCapacityClient: ExecutionClient {
615    /// How much this account can borrow now for an order on `instrument` on `side`.
616    ///
617    /// A sell borrows the instrument's base asset, which is a short; a buy borrows its quote asset,
618    /// which is a leveraged long. The result names that asset, and its amounts are in its units.
619    ///
620    /// # Contract
621    ///
622    /// **Advisory**, as [`BorrowCapacity`] says: the amounts are not reserved, so an order sized to
623    /// them must still handle [`ApiError::BorrowRejected`](crate::error::ApiError::BorrowRejected).
624    ///
625    /// # Errors
626    ///
627    /// [`ApiError::InstrumentInvalid`](crate::error::ApiError::InstrumentInvalid) for an instrument
628    /// the client cannot borrow against, such as a pair the venue does not trade on margin.
629    fn fetch_borrow_capacity(
630        &self,
631        instrument: &InstrumentNameExchange,
632        side: Side,
633    ) -> impl Future<Output = Result<BorrowCapacity, UnindexedClientError>> + Send;
634}
635
636/// The capability table, pinned.
637///
638/// [`ExecutionClient::SUPPORTED_KINDS`] is a `const`: widening one, or adding a kind a client builds
639/// no request for, compiles and passes clippy. The only thing it changes is which instruments
640/// [`ExecutionBuilder`](https://docs.rs/rustrade/latest/rustrade/execution/builder/struct.ExecutionBuilder.html)
641/// admits — so the failure mode is not a broken build but an instrument reaching a venue that cannot
642/// encode it, as a silently wrong order. These assertions exist so that changing a client's declared
643/// capabilities is a deliberate edit to a test that says what the venue can route, rather than a
644/// one-word diff nothing observes.
645///
646/// The real-client cases are each gated on their own feature, matching the module gates above; the
647/// mock case is ungated, like its module. So with `default = []`, a bare
648/// `cargo test -p rustrade-execution` compiles only the mock assertion — pinning a real client's
649/// capabilities requires `--features <name>` or `--all-features`.
650#[cfg(test)]
651mod tests {
652    use super::*;
653    use crate::client::mock::MockExecution;
654
655    #[test]
656    fn mock_supports_only_the_kinds_it_can_model() {
657        // No expiry, funding schedule or contract chain in `MockExchange`, so `Perpetual`, `Future`
658        // and `Option` have no faithful projection onto it. Kept in step with the projection in
659        // `generate_mock_exchange_instruments`, which panics on a kind it cannot project.
660        assert_eq!(
661            <MockExecution<fn() -> DateTime<Utc>> as ExecutionClient>::SUPPORTED_KINDS,
662            &[
663                InstrumentKindDiscriminant::Spot,
664                InstrumentKindDiscriminant::Cfd
665            ]
666        );
667    }
668
669    #[cfg(feature = "binance")]
670    #[test]
671    fn binance_spot_and_margin_are_spot_only() {
672        use crate::client::binance::{BinanceMargin, BinanceSpot};
673
674        assert_eq!(
675            BinanceSpot::SUPPORTED_KINDS,
676            &[InstrumentKindDiscriminant::Spot]
677        );
678        // Cross margin trades the same spot instruments on borrowed funds -- the leverage is in the
679        // account, not the instrument kind.
680        assert_eq!(
681            BinanceMargin::SUPPORTED_KINDS,
682            &[InstrumentKindDiscriminant::Spot]
683        );
684    }
685
686    #[cfg(feature = "hyperliquid")]
687    #[test]
688    fn hyperliquid_splits_perpetual_and_spot_across_two_clients() {
689        use crate::client::hyperliquid::{HyperliquidClient, spot::HyperliquidSpotClient};
690
691        assert_eq!(
692            HyperliquidClient::SUPPORTED_KINDS,
693            &[InstrumentKindDiscriminant::Perpetual]
694        );
695        assert_eq!(
696            HyperliquidSpotClient::SUPPORTED_KINDS,
697            &[InstrumentKindDiscriminant::Spot]
698        );
699    }
700
701    #[cfg(feature = "alpaca")]
702    #[test]
703    fn alpaca_trades_equities_and_options() {
704        use crate::client::alpaca::AlpacaClient;
705
706        assert_eq!(
707            AlpacaClient::SUPPORTED_KINDS,
708            &[
709                InstrumentKindDiscriminant::Spot,
710                InstrumentKindDiscriminant::Option
711            ]
712        );
713    }
714
715    #[cfg(feature = "ibkr")]
716    #[test]
717    fn ibkr_omits_cfd_because_it_builds_no_contract_for_one() {
718        use crate::client::ibkr::IbkrClient;
719
720        // `ContractConfig::to_contract` builds `STK`, `FUT`, `OPT` and `CASH` and nothing else, so a
721        // CFD routed here would be encoded as some other security type -- exactly the silent
722        // wrong-symbol order this const exists to reject. Adding `Cfd` to the list requires adding
723        // the arm first.
724        assert_eq!(
725            IbkrClient::SUPPORTED_KINDS,
726            &[
727                InstrumentKindDiscriminant::Spot,
728                InstrumentKindDiscriminant::Future,
729                InstrumentKindDiscriminant::Option
730            ]
731        );
732    }
733}