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