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 | SDK-managed | N/A | 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 to the official SDK's `with_reconnect()` mechanism.
23//!
24//! # Known Limitations
25//!
26//! All connectors have a gap between reconnection and fill recovery: **order lifecycle
27//! events** (NEW, CANCELED, EXPIRED) during disconnect are NOT recovered. Callers must
28//! call [`ExecutionClient::fetch_open_orders`] after reconnect to reconcile state.
29
30use crate::{
31 UnindexedAccountEvent, UnindexedAccountSnapshot,
32 balance::AssetBalance,
33 error::UnindexedClientError,
34 order::{
35 Order,
36 bracket::{BracketOrderRequest, BracketOrderResult},
37 request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
38 state::{Open, UnindexedOrderState},
39 },
40 trade::Trade,
41};
42use chrono::{DateTime, Utc};
43use futures::Stream;
44use rustrade_instrument::{
45 asset::name::AssetNameExchange, exchange::ExchangeId, instrument::name::InstrumentNameExchange,
46};
47use std::future::Future;
48
49// Alpaca ExecutionClient implementation (options, equities, crypto — single unified API)
50#[cfg(feature = "alpaca")]
51pub mod alpaca;
52
53// BinanceSpot ExecutionClient implementation
54#[cfg(feature = "binance")]
55pub mod binance;
56
57// Hyperliquid perpetual futures and spot ExecutionClient implementations
58#[cfg(feature = "hyperliquid")]
59pub mod hyperliquid;
60
61// Interactive Brokers ExecutionClient implementation (equities, futures, options, forex)
62#[cfg(feature = "ibkr")]
63pub mod ibkr;
64
65pub mod mock;
66
67// `+ Send` bounds on async method return types required for multi-threaded
68// Tokio runtime. This is a breaking change vs upstream — any `!Send` executor
69// implementation would fail to compile.
70pub trait ExecutionClient
71where
72 Self: Clone,
73{
74 const EXCHANGE: ExchangeId;
75
76 type Config: Clone;
77 // `+ Send` required so generic code (e.g. ExecutionManager) can pass
78 // the stream to tokio::spawn, which requires Send.
79 type AccountStream: Stream<Item = UnindexedAccountEvent> + Send;
80
81 fn new(config: Self::Config) -> Self;
82
83 fn account_snapshot(
84 &self,
85 assets: &[AssetNameExchange],
86 instruments: &[InstrumentNameExchange],
87 ) -> impl Future<Output = Result<UnindexedAccountSnapshot, UnindexedClientError>> + Send;
88
89 /// Returns a live stream of account events (fills, order updates, balance changes).
90 ///
91 /// # Startup race window
92 ///
93 /// There is an unavoidable gap between the WebSocket subscribe response and the
94 /// first event being delivered: fills arriving in this window (typically milliseconds,
95 /// no sub-millisecond guarantee) are silently dropped. `account_snapshot` reconciles
96 /// open-order state, but TRADE fills in this window are not recoverable from the stream
97 /// alone. Callers that require fill completeness at startup **must** call
98 /// [`ExecutionClient::fetch_trades`] with at least a 1-second lookback after this method returns.
99 ///
100 /// # Backpressure
101 ///
102 /// Implementations use unbounded internal channels. If the consumer cannot keep up,
103 /// events queue in memory rather than being dropped — per library philosophy, OOM
104 /// crashes are preferable to silent data loss. Consumers requiring backpressure
105 /// should implement it at their boundary (e.g., bounded channel with overflow policy).
106 fn account_stream(
107 &self,
108 assets: &[AssetNameExchange],
109 instruments: &[InstrumentNameExchange],
110 ) -> impl Future<Output = Result<Self::AccountStream, UnindexedClientError>> + Send;
111
112 fn cancel_order(
113 &self,
114 request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
115 ) -> impl Future<Output = Option<UnindexedOrderResponseCancel>> + Send;
116
117 // `+ Send` on default method return types for multi-threaded Tokio runtime
118 fn cancel_orders<'a>(
119 &self,
120 requests: impl IntoIterator<Item = OrderRequestCancel<ExchangeId, &'a InstrumentNameExchange>>,
121 ) -> impl Stream<Item = Option<UnindexedOrderResponseCancel>> + Send {
122 futures::stream::FuturesUnordered::from_iter(
123 requests
124 .into_iter()
125 .map(|request| self.cancel_order(request)),
126 )
127 }
128
129 /// Place an order on the exchange.
130 ///
131 /// # Return value
132 ///
133 /// Returns `OrderState` directly rather than `Result<Open, OrderError>`:
134 /// - `OrderState::Active(Open)` - order is resting on the order book
135 /// - `OrderState::Inactive(FullyFilled)` - order was immediately filled (includes `avg_price` when available)
136 /// - `OrderState::Inactive(OpenFailed)` - order placement failed (API error, connectivity, etc.)
137 ///
138 /// This design allows immediate fills to carry metadata (e.g., `avg_price`) that
139 /// would be lost if we had to infer terminal state from `Open::filled_quantity`.
140 fn open_order(
141 &self,
142 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
143 ) -> impl Future<Output = Option<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>>>
144 + Send;
145
146 // `+ Send` on default method return types for multi-threaded Tokio runtime
147 fn open_orders<'a>(
148 &self,
149 requests: impl IntoIterator<Item = OrderRequestOpen<ExchangeId, &'a InstrumentNameExchange>>,
150 ) -> impl Stream<Item = Option<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>>> + Send
151 {
152 futures::stream::FuturesUnordered::from_iter(
153 requests.into_iter().map(|request| self.open_order(request)),
154 )
155 }
156
157 /// Fetch current balances for the specified assets.
158 ///
159 /// An empty `assets` slice is the "return all" sentinel: implementations must return
160 /// balances for every asset held. When non-empty, only the listed assets are returned.
161 fn fetch_balances(
162 &self,
163 assets: &[AssetNameExchange],
164 ) -> impl Future<Output = Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError>> + Send;
165
166 /// Fetch currently open orders, optionally filtered by instrument.
167 ///
168 /// An empty `instruments` slice is the "return all" sentinel: implementations must
169 /// return open orders across all instruments. When non-empty, only orders for the
170 /// listed instruments are returned.
171 fn fetch_open_orders(
172 &self,
173 instruments: &[InstrumentNameExchange],
174 ) -> impl Future<
175 Output = Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError>,
176 > + Send;
177
178 /// Fetch trades (fills) since `time_since`, optionally filtered by instrument.
179 ///
180 /// An empty `instruments` slice is the "return all" sentinel: implementations must
181 /// return trades across all instruments. When non-empty, only trades for the listed
182 /// instruments are returned.
183 ///
184 /// The fee asset (`AssetNameExchange`) may be quote, base, or third-party (e.g., BNB).
185 /// Use `fees.fees_quote` for quote-equivalent value when available.
186 ///
187 /// Note: `MockExecution` currently ignores `instruments` and always returns all trades.
188 fn fetch_trades(
189 &self,
190 time_since: DateTime<Utc>,
191 instruments: &[InstrumentNameExchange],
192 ) -> impl Future<
193 Output = Result<
194 Vec<Trade<AssetNameExchange, InstrumentNameExchange>>,
195 UnindexedClientError,
196 >,
197 > + Send;
198}
199
200/// Extension trait for exchanges that support native bracket orders.
201///
202/// A bracket order consists of three linked orders:
203/// 1. **Entry**: Limit order to enter the position
204/// 2. **Take Profit**: Limit order to exit at profit target
205/// 3. **Stop Loss**: Stop (or stop-limit) order to exit at loss limit
206///
207/// When either exit leg fills, the exchange automatically cancels the other.
208///
209/// # Type-Level Capability
210///
211/// This is a supertrait of [`ExecutionClient`], enabling compile-time capability checks:
212/// - `impl ExecutionClient` — basic order operations
213/// - `impl ExecutionClient + BracketOrderClient` — includes bracket orders
214///
215/// This follows Rust idioms like `Read + Seek` or `Iterator + ExactSizeIterator`.
216///
217/// # Why Supertrait Over Alternatives
218///
219/// **vs. associated types on `ExecutionClient`**: Callers can't construct
220/// `Self::BracketRequest` without knowing the concrete type — adds trait surface
221/// without enabling generic use.
222///
223/// **vs. default impl returning `Unsupported`**: Puts a "dead method" on every
224/// client (MockClient, BinanceClient, HyperliquidClient). Compile-time capability
225/// via trait bounds is better than runtime errors.
226///
227/// # Result Types
228///
229/// [`BracketOrderResult`] uses `Option<Order>` for child legs to document API divergence:
230///
231/// | Exchange | `take_profit` | `stop_loss` | Reason |
232/// |----------|---------------|-------------|--------|
233/// | IBKR | `Some(...)` | `Some(...)` | Returns all three orders immediately |
234/// | Alpaca | `None` | `None` | Child legs created server-side |
235///
236/// # Example
237///
238/// ```ignore
239/// use rustrade_execution::client::{ExecutionClient, BracketOrderClient};
240/// use rustrade_execution::order::bracket::{BracketOrderRequest, RequestOpenBracket};
241///
242/// async fn place_bracket<C: ExecutionClient + BracketOrderClient>(
243/// client: &C,
244/// request: BracketOrderRequest<ExchangeId, &InstrumentNameExchange>,
245/// ) -> BracketOrderResult {
246/// client.open_bracket_order(request).await
247/// }
248/// ```
249pub trait BracketOrderClient: ExecutionClient {
250 /// Place a bracket order (entry + take-profit + stop-loss).
251 ///
252 /// # Request
253 ///
254 /// The [`BracketOrderRequest`] contains:
255 /// - `key`: Order key (exchange, instrument, strategy, client order ID)
256 /// - `state`: [`RequestOpenBracket`](crate::order::bracket::RequestOpenBracket) with
257 /// side, quantity, prices, and optional stop-loss limit price
258 ///
259 /// # Constraints
260 ///
261 /// - `time_in_force` must be `Day` or `GoodUntilCancelled` on most exchanges
262 /// - Entry order type is always `Limit`
263 /// - Price ordering must be valid for the side (see [`RequestOpenBracket`](crate::order::bracket::RequestOpenBracket))
264 ///
265 /// # Exchange-Specific Field Handling
266 ///
267 /// `RequestOpenBracket::stop_loss_limit_price` is **not honored uniformly**:
268 /// - **Alpaca**: When `Some`, the stop-loss leg becomes a stop-limit order at that price.
269 /// - **IBKR**: Silently ignored — the stop-loss leg is always a stop (market) order.
270 ///
271 /// Generic callers `T: BracketOrderClient` must treat this field as advisory.
272 ///
273 /// # Return Value
274 ///
275 /// Returns [`BracketOrderResult`] with:
276 /// - `parent`: Always present (entry order)
277 /// - `take_profit`: `Some` if exchange returns legs immediately (IBKR), `None` otherwise (Alpaca)
278 /// - `stop_loss`: `Some` if exchange returns legs immediately (IBKR), `None` otherwise (Alpaca)
279 ///
280 /// Either all orders are `Active(Open)` or all are `Inactive` (placement failed).
281 fn open_bracket_order(
282 &self,
283 request: BracketOrderRequest<ExchangeId, &InstrumentNameExchange>,
284 ) -> impl Future<Output = BracketOrderResult> + Send;
285}