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 | Heartbeat | Cancel answers once |
6//! |-----------|-----------|-------|---------------|-----------|---------------------|
7//! | [`binance`] | Auto (1s→30s backoff) | 10k LRU | REST after reconnect | 30s | cancelled |
8//! | [`alpaca`] | Auto (1s→30s backoff) | 2k LRU | REST after reconnect | 35s | accepted |
9//! | [`ibkr`] | ibapi-managed | 10k LRU, fills only | Executions request after reconnect | N/A | submitted |
10//! | [`hyperliquid`] | SDK-managed | 10k LRU, fills only | Caller responsibility | SDK-managed | cancelled |
11//!
12//! The last column is when [`ExecutionClient::cancel_order`] answers `Ok`. Only for "cancelled" has
13//! the order ended by then; see that method.
14//!
15//! # Resilience Philosophy
16//!
17//! **WebSocket-based connectors** (Binance, Alpaca) implement auto-reconnection with
18//! fill recovery and deduplication. After reconnect, they query REST APIs for missed
19//! fills and deduplicate against the LRU cache to prevent duplicate processing.
20//!
21//! **IBKR** uses TCP to local TWS/Gateway. `ibapi` reconnects that socket itself; the
22//! account stream stays open across it and recovers the missed fills from TWS's executions,
23//! deduplicating against the LRU cache. Replacing a client that is gone for good requires IB
24//! Gateway availability and client ID coordination — decisions that belong in the caller's
25//! wrapper. See [`ibkr`] module docs for caller responsibilities.
26//!
27//! **Hyperliquid** delegates reconnection to the official SDK's `with_reconnect()` mechanism, but
28//! deduplicates fills itself: the SDK resubscribes on reconnect and the venue opens a `userFills`
29//! subscription with a snapshot, so every reconnect redelivers fills already seen. It does not
30//! recover fills missed while disconnected — callers needing that call
31//! [`ExecutionClient::fetch_trades`], whose results are not deduplicated against the stream.
32//!
33//! # Known Limitations
34//!
35//! No connector recovers the **order lifecycle events** (NEW, CANCELED, EXPIRED) it missed while
36//! disconnected; only fills are recovered (#370).
37//!
38//! The engine closes part of that gap from the account snapshot each reconnect produces: an order a
39//! complete list no longer shows is retired (see [`ExecutionClient::account_snapshot`]). That covers
40//! the connectors whose snapshot declares its orders complete — Binance Spot and Margin,
41//! Hyperliquid, Alpaca, and the mock venue — but not IBKR (#371), and it cannot tell how an order
42//! ended. A caller that needs more reconciles against
43//! [`ExecutionClient::fetch_open_orders`] after a reconnect.
44
45use crate::{
46    UnindexedAccountEvent, UnindexedAccountSnapshot,
47    balance::AssetBalance,
48    error::UnindexedClientError,
49    order::{
50        Order,
51        bracket::{BracketOrderRequest, BracketOrderResult},
52        request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
53        state::{Open, UnindexedOrderState},
54    },
55    trade::Trade,
56};
57use chrono::{DateTime, Utc};
58use futures::Stream;
59use rustrade_instrument::{
60    asset::name::AssetNameExchange,
61    exchange::ExchangeId,
62    instrument::{kind::InstrumentKindDiscriminant, name::InstrumentNameExchange},
63};
64use std::future::Future;
65
66// Account-event deduplication over rustrade's own event type. Gated on the clients that use it
67// so a build selecting none of them does not compile it unused. Alpaca deduplicates too, but
68// against a raw `SmolStr` fill key rather than this cache, so it is deliberately not in this list.
69#[cfg(any(feature = "binance", feature = "hyperliquid", feature = "ibkr"))]
70pub(crate) mod dedup;
71
72// Alpaca ExecutionClient implementation (options, equities, crypto — single unified API)
73#[cfg(feature = "alpaca")]
74pub mod alpaca;
75
76// Binance ExecutionClient implementations: Spot (live) and Cross Margin (identity/config scaffolding)
77#[cfg(feature = "binance")]
78pub mod binance;
79
80// Hyperliquid perpetual futures and spot ExecutionClient implementations
81#[cfg(feature = "hyperliquid")]
82pub mod hyperliquid;
83
84// Interactive Brokers ExecutionClient implementation (equities, futures, options, forex)
85#[cfg(feature = "ibkr")]
86pub mod ibkr;
87
88pub mod mock;
89
90// `+ Send` bounds on async method return types required for multi-threaded
91// Tokio runtime. This is a breaking change vs upstream — any `!Send` executor
92// implementation would fail to compile.
93pub trait ExecutionClient
94where
95    Self: Clone,
96{
97    const EXCHANGE: ExchangeId;
98
99    /// The [`InstrumentKindDiscriminant`]s this client can trade.
100    ///
101    /// A client works off [`InstrumentNameExchange`] strings and never inspects an instrument's
102    /// kind, so nothing downstream can infer this from the implementation — routing a `Future`
103    /// through a spot-only venue produces a rejected or, worse, misinterpreted order rather than a
104    /// type error. Declaring the set here lets [`ExecutionBuilder`] reject an unsupported
105    /// instrument set at build time, before any order is sent.
106    ///
107    /// Declare what the *venue and this implementation together* can actually route, not what the
108    /// exchange's product catalogue advertises. A kind the exchange offers but this client builds
109    /// no request for does not belong in the set.
110    ///
111    /// There is deliberately no default. A new client must state its capabilities rather than
112    /// inherit a permissive set that would silently admit every kind.
113    ///
114    /// It is a `const` and not a `fn supported_kinds(&self)` because [`ExecutionBuilder`] validates
115    /// the instrument set *before* [`Self::new`] is ever called — there is no instance to ask at the
116    /// point the answer is needed, and deferring validation until after construction would mean
117    /// standing up a client for a venue the engine is about to reject. A capability set that varies
118    /// with `Self::Config` therefore cannot be expressed here; it would need a separate
119    /// pre-construction hook on the config.
120    ///
121    /// [`ExecutionBuilder`]: https://docs.rs/rustrade/latest/rustrade/execution/builder/struct.ExecutionBuilder.html
122    const SUPPORTED_KINDS: &'static [InstrumentKindDiscriminant];
123
124    type Config: Clone;
125    // `+ Send` required so generic code (e.g. ExecutionManager) can pass
126    // the stream to tokio::spawn, which requires Send.
127    type AccountStream: Stream<Item = UnindexedAccountEvent> + Send;
128
129    fn new(config: Self::Config) -> Self;
130
131    /// Reads the account's balances, and its open orders and positions for `instruments`, from the
132    /// venue.
133    ///
134    /// Besides startup, the engine's `ExecutionManager` calls this again on every account-stream
135    /// reconnect, and emits the result ahead of the new stream's updates. It is how the engine
136    /// catches up on what changed while the stream was down.
137    ///
138    /// # Order completeness
139    ///
140    /// Each [`InstrumentAccountSnapshot`](crate::InstrumentAccountSnapshot) states through
141    /// `orders_complete` whether its `orders` list is every order open at the venue for that
142    /// instrument. Set it only when it is true: the engine retires a tracked order that a complete
143    /// list omits, so a list that can miss an open order (a capped page, an order dropped on
144    /// conversion, an order reported under an id other than the one it was placed with) must say
145    /// `false`. `false` is always safe; it only forgoes that reconciliation.
146    fn account_snapshot(
147        &self,
148        assets: &[AssetNameExchange],
149        instruments: &[InstrumentNameExchange],
150    ) -> impl Future<Output = Result<UnindexedAccountSnapshot, UnindexedClientError>> + Send;
151
152    /// Returns a live stream of account events (fills, order updates, balance changes).
153    ///
154    /// # Startup race window
155    ///
156    /// There is an unavoidable gap between the WebSocket subscribe response and the
157    /// first event being delivered: fills arriving in this window (typically milliseconds,
158    /// no sub-millisecond guarantee) are silently dropped. `account_snapshot` reconciles
159    /// open-order state, but TRADE fills in this window are not recoverable from the stream
160    /// alone. Callers that require fill completeness at startup **must** call
161    /// [`ExecutionClient::fetch_trades`] with at least a 1-second lookback after this method returns.
162    ///
163    /// # Backpressure
164    ///
165    /// Implementations use unbounded internal channels. If the consumer cannot keep up,
166    /// events queue in memory rather than being dropped — per library philosophy, OOM
167    /// crashes are preferable to silent data loss. Consumers requiring backpressure
168    /// should implement it at their boundary (e.g., bounded channel with overflow policy).
169    fn account_stream(
170        &self,
171        assets: &[AssetNameExchange],
172        instruments: &[InstrumentNameExchange],
173    ) -> impl Future<Output = Result<Self::AccountStream, UnindexedClientError>> + Send;
174
175    /// Cancel an order at the venue.
176    ///
177    /// # Return value
178    ///
179    /// - `Some` with `Ok(Cancelled)`: the venue took the cancel. Whether the order has *ended* by
180    ///   then depends on the venue; see below.
181    /// - `Some` with `Err`: the venue refused the cancel, or it could not be sent or answered. The
182    ///   order may still be open, or may have ended some other way, such as by filling. The account
183    ///   stream reports which.
184    /// - `None`: nothing to report, so the engine's `ExecutionManager` emits nothing for the
185    ///   request. No client in this crate returns it.
186    ///
187    /// # A taken cancel is not always an ended order
188    ///
189    /// Some venues answer a cancel before carrying it out. Until they do, the order is live and can
190    /// still fill. From those venues `Ok(Cancelled)` means only that the cancel was accepted, and
191    /// the account stream reports how the order actually ended, including any fill in between. The
192    /// engine stops tracking an order on `Ok(Cancelled)` from any venue.
193    ///
194    /// Binance, Hyperliquid and the mock venue answer once the order is cancelled. Alpaca answers
195    /// once it has accepted the cancel, and IBKR once the cancel is submitted. The
196    /// [connector comparison](crate::client#connector-comparison) lists the same for each
197    /// connector; the mock venue is not one, so it has no row there.
198    ///
199    /// # `filled_quantity` and `time_exchange`
200    ///
201    /// `Cancelled::filled_quantity` is the quantity the venue reported filled where the answer
202    /// carries one, which Binance's and the mock venue's do. Alpaca, IBKR and Hyperliquid answer
203    /// without it and report zero, which does not mean nothing filled. The fill total comes from
204    /// the account stream, or from [`Self::fetch_trades`]. Likewise `time_exchange` is the venue's
205    /// time where the answer carries one, and the local time the answer arrived otherwise.
206    fn cancel_order(
207        &self,
208        request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
209    ) -> impl Future<Output = Option<UnindexedOrderResponseCancel>> + Send;
210
211    // `+ Send` on default method return types for multi-threaded Tokio runtime
212    fn cancel_orders<'a>(
213        &self,
214        requests: impl IntoIterator<Item = OrderRequestCancel<ExchangeId, &'a InstrumentNameExchange>>,
215    ) -> impl Stream<Item = Option<UnindexedOrderResponseCancel>> + Send {
216        futures::stream::FuturesUnordered::from_iter(
217            requests
218                .into_iter()
219                .map(|request| self.cancel_order(request)),
220        )
221    }
222
223    /// Place an order on the exchange.
224    ///
225    /// # Return value
226    ///
227    /// Returns `OrderState` directly rather than `Result<Open, OrderError>`:
228    /// - `OrderState::Active(Open)` - order is resting on the order book
229    /// - `OrderState::Inactive(FullyFilled)` - order was immediately filled (includes `avg_price` when available)
230    /// - `OrderState::Inactive(OpenFailed)` - order placement failed (API error, connectivity, etc.)
231    ///
232    /// This design allows immediate fills to carry metadata (e.g., `avg_price`) that
233    /// would be lost if we had to infer terminal state from `Open::filled_quantity`.
234    fn open_order(
235        &self,
236        request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
237    ) -> impl Future<Output = Option<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>>>
238    + Send;
239
240    // `+ Send` on default method return types for multi-threaded Tokio runtime
241    fn open_orders<'a>(
242        &self,
243        requests: impl IntoIterator<Item = OrderRequestOpen<ExchangeId, &'a InstrumentNameExchange>>,
244    ) -> impl Stream<Item = Option<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>>> + Send
245    {
246        futures::stream::FuturesUnordered::from_iter(
247            requests.into_iter().map(|request| self.open_order(request)),
248        )
249    }
250
251    /// Fetch current balances for the specified assets.
252    ///
253    /// An empty `assets` slice is the "return all" sentinel: implementations must return
254    /// balances for every asset held. When non-empty, only the listed assets are returned.
255    fn fetch_balances(
256        &self,
257        assets: &[AssetNameExchange],
258    ) -> impl Future<Output = Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError>> + Send;
259
260    /// Fetch currently open orders, optionally filtered by instrument.
261    ///
262    /// An empty `instruments` slice is the "return all" sentinel: implementations must
263    /// return open orders across all instruments. When non-empty, only orders for the
264    /// listed instruments are returned.
265    fn fetch_open_orders(
266        &self,
267        instruments: &[InstrumentNameExchange],
268    ) -> impl Future<
269        Output = Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError>,
270    > + Send;
271
272    /// Fetch trades (fills) since `time_since`, optionally filtered by instrument.
273    ///
274    /// An empty `instruments` slice is the "return all" sentinel: implementations must
275    /// return trades across all instruments. When non-empty, only trades for the listed
276    /// instruments are returned.
277    ///
278    /// The fee asset (`AssetNameExchange`) may be quote, base, or third-party (e.g., BNB).
279    /// Use `fees.fees_quote` for quote-equivalent value when available.
280    ///
281    /// Note: `MockExecution` currently ignores `instruments` and always returns all trades.
282    fn fetch_trades(
283        &self,
284        time_since: DateTime<Utc>,
285        instruments: &[InstrumentNameExchange],
286    ) -> impl Future<
287        Output = Result<
288            Vec<Trade<AssetNameExchange, InstrumentNameExchange>>,
289            UnindexedClientError,
290        >,
291    > + Send;
292}
293
294/// Extension trait for exchanges that support native bracket orders.
295///
296/// A bracket order consists of three linked orders:
297/// 1. **Entry**: Limit order to enter the position
298/// 2. **Take Profit**: Limit order to exit at profit target
299/// 3. **Stop Loss**: Stop (or stop-limit) order to exit at loss limit
300///
301/// When either exit leg fills, the exchange automatically cancels the other.
302///
303/// # Type-Level Capability
304///
305/// This is a supertrait of [`ExecutionClient`], enabling compile-time capability checks:
306/// - `impl ExecutionClient` — basic order operations
307/// - `impl ExecutionClient + BracketOrderClient` — includes bracket orders
308///
309/// This follows Rust idioms like `Read + Seek` or `Iterator + ExactSizeIterator`.
310///
311/// # Why Supertrait Over Alternatives
312///
313/// **vs. associated types on `ExecutionClient`**: Callers can't construct
314/// `Self::BracketRequest` without knowing the concrete type — adds trait surface
315/// without enabling generic use.
316///
317/// **vs. default impl returning `Unsupported`**: Puts a "dead method" on every
318/// client (MockExecution, BinanceSpot, HyperliquidClient). Compile-time capability
319/// via trait bounds is better than runtime errors.
320///
321/// # Result Types
322///
323/// [`BracketOrderResult`] uses `Option<Order>` for child legs to document API divergence:
324///
325/// | Exchange | `take_profit` | `stop_loss` | Reason |
326/// |----------|---------------|-------------|--------|
327/// | IBKR     | `Some(...)` | `Some(...)` | Returns all three orders immediately |
328/// | Alpaca   | `None` | `None` | Child legs created server-side |
329///
330/// # Example
331///
332/// ```ignore
333/// use rustrade_execution::client::{ExecutionClient, BracketOrderClient};
334/// use rustrade_execution::order::bracket::{BracketOrderRequest, RequestOpenBracket};
335///
336/// async fn place_bracket<C: ExecutionClient + BracketOrderClient>(
337///     client: &C,
338///     request: BracketOrderRequest<ExchangeId, &InstrumentNameExchange>,
339/// ) -> BracketOrderResult {
340///     client.open_bracket_order(request).await
341/// }
342/// ```
343pub trait BracketOrderClient: ExecutionClient {
344    /// Place a bracket order (entry + take-profit + stop-loss).
345    ///
346    /// # Request
347    ///
348    /// The [`BracketOrderRequest`] contains:
349    /// - `key`: Order key (exchange, instrument, strategy, client order ID)
350    /// - `state`: [`RequestOpenBracket`](crate::order::bracket::RequestOpenBracket) with
351    ///   side, quantity, prices, and optional stop-loss limit price
352    ///
353    /// # Constraints
354    ///
355    /// - `time_in_force` must be `Day` or `GoodUntilCancelled` on most exchanges
356    /// - Entry order type is always `Limit`
357    /// - Price ordering must be valid for the side (see [`RequestOpenBracket`](crate::order::bracket::RequestOpenBracket))
358    ///
359    /// # Exchange-Specific Field Handling
360    ///
361    /// `RequestOpenBracket::stop_loss_limit_price` is **not honored uniformly**:
362    /// - **Alpaca**: When `Some`, the stop-loss leg becomes a stop-limit order at that price.
363    /// - **IBKR**: Silently ignored — the stop-loss leg is always a stop (market) order.
364    ///
365    /// Generic callers `T: BracketOrderClient` must treat this field as advisory.
366    ///
367    /// # Return Value
368    ///
369    /// Returns [`BracketOrderResult`] with:
370    /// - `parent`: Always present (entry order)
371    /// - `take_profit`: `Some` if exchange returns legs immediately (IBKR), `None` otherwise (Alpaca)
372    /// - `stop_loss`: `Some` if exchange returns legs immediately (IBKR), `None` otherwise (Alpaca)
373    ///
374    /// Either all orders are `Active(Open)` or all are `Inactive` (placement failed), with one
375    /// exception: a leg of a failed placement whose fate at the venue is unknown (no status
376    /// arrived, or it was accepted and its rollback cancel could not be sent) is returned
377    /// `Active(Open)`, so the account stream can resolve it. Only IBKR returns such legs today;
378    /// see its `open_bracket_order`.
379    fn open_bracket_order(
380        &self,
381        request: BracketOrderRequest<ExchangeId, &InstrumentNameExchange>,
382    ) -> impl Future<Output = BracketOrderResult> + Send;
383}
384
385/// The capability table, pinned.
386///
387/// [`ExecutionClient::SUPPORTED_KINDS`] is a `const`: widening one, or adding a kind a client builds
388/// no request for, compiles and passes clippy. The only thing it changes is which instruments
389/// [`ExecutionBuilder`](https://docs.rs/rustrade/latest/rustrade/execution/builder/struct.ExecutionBuilder.html)
390/// admits — so the failure mode is not a broken build but an instrument reaching a venue that cannot
391/// encode it, as a silently wrong order. These assertions exist so that changing a client's declared
392/// capabilities is a deliberate edit to a test that says what the venue can route, rather than a
393/// one-word diff nothing observes.
394///
395/// The real-client cases are each gated on their own feature, matching the module gates above; the
396/// mock case is ungated, like its module. So with `default = []`, a bare
397/// `cargo test -p rustrade-execution` compiles only the mock assertion — pinning a real client's
398/// capabilities requires `--features <name>` or `--all-features`.
399#[cfg(test)]
400mod tests {
401    use super::*;
402    use crate::client::mock::MockExecution;
403
404    #[test]
405    fn mock_supports_only_the_kinds_it_can_model() {
406        // No expiry, funding schedule or contract chain in `MockExchange`, so `Perpetual`, `Future`
407        // and `Option` have no faithful projection onto it. Kept in step with the projection in
408        // `generate_mock_exchange_instruments`, which panics on a kind it cannot project.
409        assert_eq!(
410            <MockExecution<fn() -> DateTime<Utc>> as ExecutionClient>::SUPPORTED_KINDS,
411            &[
412                InstrumentKindDiscriminant::Spot,
413                InstrumentKindDiscriminant::Cfd
414            ]
415        );
416    }
417
418    #[cfg(feature = "binance")]
419    #[test]
420    fn binance_spot_and_margin_are_spot_only() {
421        use crate::client::binance::{BinanceMargin, BinanceSpot};
422
423        assert_eq!(
424            BinanceSpot::SUPPORTED_KINDS,
425            &[InstrumentKindDiscriminant::Spot]
426        );
427        // Cross margin trades the same spot instruments on borrowed funds -- the leverage is in the
428        // account, not the instrument kind.
429        assert_eq!(
430            BinanceMargin::SUPPORTED_KINDS,
431            &[InstrumentKindDiscriminant::Spot]
432        );
433    }
434
435    #[cfg(feature = "hyperliquid")]
436    #[test]
437    fn hyperliquid_splits_perpetual_and_spot_across_two_clients() {
438        use crate::client::hyperliquid::{HyperliquidClient, spot::HyperliquidSpotClient};
439
440        assert_eq!(
441            HyperliquidClient::SUPPORTED_KINDS,
442            &[InstrumentKindDiscriminant::Perpetual]
443        );
444        assert_eq!(
445            HyperliquidSpotClient::SUPPORTED_KINDS,
446            &[InstrumentKindDiscriminant::Spot]
447        );
448    }
449
450    #[cfg(feature = "alpaca")]
451    #[test]
452    fn alpaca_trades_equities_and_options() {
453        use crate::client::alpaca::AlpacaClient;
454
455        assert_eq!(
456            AlpacaClient::SUPPORTED_KINDS,
457            &[
458                InstrumentKindDiscriminant::Spot,
459                InstrumentKindDiscriminant::Option
460            ]
461        );
462    }
463
464    #[cfg(feature = "ibkr")]
465    #[test]
466    fn ibkr_omits_cfd_because_it_builds_no_contract_for_one() {
467        use crate::client::ibkr::IbkrClient;
468
469        // `ContractConfig::to_contract` builds `STK`, `FUT`, `OPT` and `CASH` and nothing else, so a
470        // CFD routed here would be encoded as some other security type -- exactly the silent
471        // wrong-symbol order this const exists to reject. Adding `Cfd` to the list requires adding
472        // the arm first.
473        assert_eq!(
474            IbkrClient::SUPPORTED_KINDS,
475            &[
476                InstrumentKindDiscriminant::Spot,
477                InstrumentKindDiscriminant::Future,
478                InstrumentKindDiscriminant::Option
479            ]
480        );
481    }
482}