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}