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}