rustrade_execution/client/mod.rs
1//! Execution client implementations for various exchanges.
2//!
3//! # Connector Comparison
4//!
5//! | Connector | Reconnect | Dedup | Fill Recovery | Ended-order Recovery | Heartbeat | Cancel answers once |
6//! |-----------|-----------|-------|---------------|----------------------|-----------|---------------------|
7//! | [`binance`] | Auto (1s→30s backoff) | 10k LRU | REST after reconnect | REST after reconnect | 30s | cancelled |
8//! | [`alpaca`] | Auto (1s→30s backoff) | 2k LRU | REST after reconnect | REST after reconnect | 35s | accepted |
9//! | [`ibkr`] | ibapi-managed | 10k LRU, fills only | Executions request after reconnect | None | N/A | submitted |
10//! | [`hyperliquid`] | SDK-managed | 10k LRU, fills only | Venue snapshot on resubscribe | REST after reconnect | SDK-managed | cancelled |
11//!
12//! "Ended-order Recovery" is whether a reconnect reports how an order the client held as live
13//! ended while the stream was down, and only those clients implement [`OrderStatusClient`]. The
14//! last column is when [`ExecutionClient::cancel_order`] answers `Ok`. Only for "cancelled" has the
15//! order ended by then; see that method.
16//!
17//! # Resilience Philosophy
18//!
19//! **WebSocket-based connectors** (Binance, Alpaca) implement auto-reconnection with
20//! fill recovery and deduplication. After reconnect, they query REST APIs for missed
21//! fills and deduplicate against the LRU cache to prevent duplicate processing.
22//!
23//! **IBKR** uses TCP to local TWS/Gateway. `ibapi` reconnects that socket itself; the
24//! account stream stays open across it and recovers the missed fills from TWS's executions,
25//! deduplicating against the LRU cache. Replacing a client that is gone for good requires IB
26//! Gateway availability and client ID coordination — decisions that belong in the caller's
27//! wrapper. See [`ibkr`] module docs for caller responsibilities.
28//!
29//! **Hyperliquid** delegates reconnection to the official SDK's `with_reconnect()` mechanism, but
30//! deduplicates fills itself: the SDK resubscribes on reconnect and the venue opens a `userFills`
31//! subscription with a snapshot, so every reconnect redelivers fills already seen. That snapshot
32//! is the only fill recovery: it holds recent fills, how many Hyperliquid does not say, so callers
33//! needing older ones call [`ExecutionClient::fetch_trades`], whose results are not deduplicated
34//! against the stream. Once the snapshot has been sent on, a reconnect checks how the orders the
35//! client holds as live ended, like Binance's and Alpaca's.
36//!
37//! # Known Limitations
38//!
39//! Only Binance (Spot and Margin), Alpaca and Hyperliquid (perpetuals and spot) recover the **order
40//! lifecycle events** (cancelled, expired, rejected, a fill that completes an order) they missed
41//! while disconnected, and only for the orders the client holds as live; IBKR recovers fills only
42//! (#370). [`OrderStatusClient`] is the lookup that recovery is built on: given the orders a caller
43//! still holds as live, it says how each that has ended did end. Binance Spot, Binance Margin,
44//! Alpaca, both Hyperliquid clients and the mock client implement it.
45//!
46//! The engine closes part of that gap from the account snapshot each reconnect produces: an order a
47//! complete list no longer shows is retired (see [`ExecutionClient::account_snapshot`]). That covers
48//! the connectors whose snapshot declares its orders complete — Binance Spot and Margin,
49//! Hyperliquid, Alpaca, and the mock venue — but not IBKR (#371), and it cannot tell how an order
50//! ended. A caller that needs more reconciles against
51//! [`ExecutionClient::fetch_open_orders`] after a reconnect.
52
53use crate::{
54 UnindexedAccountEvent, UnindexedAccountSnapshot,
55 balance::AssetBalance,
56 error::UnindexedClientError,
57 order::{
58 Order, UnindexedInactiveOrder, UnindexedOrderKey,
59 bracket::{BracketOrderRequest, BracketOrderResult},
60 request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
61 state::{Open, UnindexedOrderState},
62 },
63 trade::TradesRead,
64};
65use chrono::{DateTime, Utc};
66use futures::Stream;
67use rustrade_instrument::{
68 asset::name::AssetNameExchange,
69 exchange::ExchangeId,
70 instrument::{kind::InstrumentKindDiscriminant, name::InstrumentNameExchange},
71};
72use std::future::Future;
73
74// Account-event deduplication over rustrade's own event type. Gated on the clients that use it
75// so a build selecting none of them does not compile it unused. Alpaca deduplicates too, but
76// against a raw `SmolStr` fill key rather than this cache, so it is deliberately not in this list.
77#[cfg(any(feature = "binance", feature = "hyperliquid", feature = "ibkr"))]
78pub(crate) mod dedup;
79
80// What a client knows of its live orders, and the reconnect check of how those orders ended while
81// its account stream was down. Gated on the clients that use it, like `dedup`.
82#[cfg(any(
83 feature = "alpaca",
84 feature = "binance",
85 feature = "hyperliquid",
86 feature = "ibkr"
87))]
88pub(crate) mod order_recovery;
89
90// Alpaca ExecutionClient implementation (options, equities, crypto — single unified API)
91#[cfg(feature = "alpaca")]
92pub mod alpaca;
93
94// Binance ExecutionClient implementations: Spot (live) and Cross Margin (identity/config scaffolding)
95#[cfg(feature = "binance")]
96pub mod binance;
97
98// Hyperliquid perpetual futures and spot ExecutionClient implementations
99#[cfg(feature = "hyperliquid")]
100pub mod hyperliquid;
101
102// Interactive Brokers ExecutionClient implementation (equities, futures, options, forex)
103#[cfg(feature = "ibkr")]
104pub mod ibkr;
105
106pub mod mock;
107
108/// An instrument an [`ExecutionClient`] is about to be built to trade, as
109/// [`ExecutionClient::validate_config`] sees it.
110///
111/// `#[non_exhaustive]`, so further detail can be added without breaking an implementation that
112/// reads these fields; construct one with [`Self::new`].
113#[derive(Debug, Clone, Copy, Eq, PartialEq)]
114#[non_exhaustive]
115pub struct ClientInstrument<'a> {
116 /// The name the venue knows the instrument by, which is what a client's requests carry.
117 pub name_exchange: &'a InstrumentNameExchange,
118 /// The instrument's kind.
119 pub kind: InstrumentKindDiscriminant,
120}
121
122impl<'a> ClientInstrument<'a> {
123 /// The view of an instrument named `name_exchange`, of kind `kind`.
124 pub fn new(
125 name_exchange: &'a InstrumentNameExchange,
126 kind: InstrumentKindDiscriminant,
127 ) -> Self {
128 Self {
129 name_exchange,
130 kind,
131 }
132 }
133}
134
135// `+ Send` bounds on async method return types required for multi-threaded
136// Tokio runtime. This is a breaking change vs upstream — any `!Send` executor
137// implementation would fail to compile.
138pub trait ExecutionClient
139where
140 Self: Clone,
141{
142 const EXCHANGE: ExchangeId;
143
144 /// The [`InstrumentKindDiscriminant`]s this client can trade.
145 ///
146 /// A client works off [`InstrumentNameExchange`] strings and never inspects an instrument's
147 /// kind, so nothing downstream can infer this from the implementation — routing a `Future`
148 /// through a spot-only venue produces a rejected or, worse, misinterpreted order rather than a
149 /// type error. Declaring the set here lets [`ExecutionBuilder`] reject an unsupported
150 /// instrument set at build time, before any order is sent.
151 ///
152 /// Declare what the *venue and this implementation together* can actually route, not what the
153 /// exchange's product catalogue advertises. A kind the exchange offers but this client builds
154 /// no request for does not belong in the set.
155 ///
156 /// There is deliberately no default. A new client must state its capabilities rather than
157 /// inherit a permissive set that would silently admit every kind.
158 ///
159 /// It is a `const` and not a `fn supported_kinds(&self)` because [`ExecutionBuilder`] validates
160 /// the instrument set *before* [`Self::new`] is ever called — there is no instance to ask at the
161 /// point the answer is needed, and deferring validation until after construction would mean
162 /// standing up a client for a venue the engine is about to reject. A capability set that varies
163 /// with `Self::Config` therefore cannot be expressed here; it would need a separate
164 /// pre-construction hook on the config.
165 ///
166 /// [`ExecutionBuilder`]: https://docs.rs/rustrade/latest/rustrade/execution/builder/struct.ExecutionBuilder.html
167 const SUPPORTED_KINDS: &'static [InstrumentKindDiscriminant];
168
169 type Config: Clone;
170
171 /// Checks `config` against the instruments this client is about to be built to trade, before
172 /// [`Self::new`] is called. `Err` names what is wrong.
173 ///
174 /// [`SUPPORTED_KINDS`](Self::SUPPORTED_KINDS) rejects a kind the client cannot route at all.
175 /// This catches what only the config knows: a per-instrument entry that is invalid, or that
176 /// disagrees with the instrument it is keyed to, such as a contract registered as a stock for
177 /// an instrument modelled as an option. Each such entry would otherwise be skipped or acted on
178 /// silently once the client runs.
179 ///
180 /// The default accepts every config. Override it when `Self::Config` carries per-instrument
181 /// detail.
182 ///
183 /// [`ExecutionBuilder`] calls it, after checking `SUPPORTED_KINDS`, with the instruments
184 /// registered on this client's exchange. A client constructed by calling `new` directly is not
185 /// checked; call this first to get the same check.
186 ///
187 /// [`ExecutionBuilder`]: https://docs.rs/rustrade/latest/rustrade/execution/builder/struct.ExecutionBuilder.html
188 fn validate_config(
189 config: &Self::Config,
190 instruments: &[ClientInstrument<'_>],
191 ) -> Result<(), String> {
192 let _ = (config, instruments);
193 Ok(())
194 }
195
196 // `+ Send` required so generic code (e.g. ExecutionManager) can pass
197 // the stream to tokio::spawn, which requires Send.
198 type AccountStream: Stream<Item = UnindexedAccountEvent> + Send;
199
200 fn new(config: Self::Config) -> Self;
201
202 /// Reads the account's balances, and its open orders and positions for `instruments`, from the
203 /// venue.
204 ///
205 /// Besides startup, the engine's `ExecutionManager` calls this again on every account-stream
206 /// reconnect, and emits the result ahead of the new stream's updates. It is how the engine
207 /// catches up on what changed while the stream was down.
208 ///
209 /// # Order completeness
210 ///
211 /// Each [`InstrumentAccountSnapshot`](crate::InstrumentAccountSnapshot) states through
212 /// `orders_complete` whether its `orders` list is every order open at the venue for that
213 /// instrument. Set it only when it is true: the engine retires a tracked order that a complete
214 /// list omits, so a list that can miss an open order (a capped page, an order dropped on
215 /// conversion, an order reported under an id other than the one it was placed with) must say
216 /// `false`. `false` is always safe; it only forgoes that reconciliation.
217 fn account_snapshot(
218 &self,
219 assets: &[AssetNameExchange],
220 instruments: &[InstrumentNameExchange],
221 ) -> impl Future<Output = Result<UnindexedAccountSnapshot, UnindexedClientError>> + Send;
222
223 /// Returns a live stream of account events (fills, order updates, balance changes).
224 ///
225 /// # Startup race window
226 ///
227 /// There is an unavoidable gap between the WebSocket subscribe response and the
228 /// first event being delivered: fills arriving in this window (typically milliseconds,
229 /// no sub-millisecond guarantee) are silently dropped. `account_snapshot` reconciles
230 /// open-order state, but TRADE fills in this window are not recoverable from the stream
231 /// alone. Callers that require fill completeness at startup **must** call
232 /// [`ExecutionClient::fetch_trades`] with at least a 1-second lookback after this method returns.
233 ///
234 /// # Backpressure
235 ///
236 /// Implementations use unbounded internal channels. If the consumer cannot keep up,
237 /// events queue in memory rather than being dropped — per library philosophy, OOM
238 /// crashes are preferable to silent data loss. Consumers requiring backpressure
239 /// should implement it at their boundary (e.g., bounded channel with overflow policy).
240 fn account_stream(
241 &self,
242 assets: &[AssetNameExchange],
243 instruments: &[InstrumentNameExchange],
244 ) -> impl Future<Output = Result<Self::AccountStream, UnindexedClientError>> + Send;
245
246 /// Cancel an order at the venue.
247 ///
248 /// # Return value
249 ///
250 /// Every request gets exactly one answer, which the engine's `ExecutionManager` forwards as the
251 /// order's response. An implementation must not swallow a request: a failure to send it, or to
252 /// read the venue's answer, is an `Err`.
253 ///
254 /// - `Ok(Cancelled)`: the venue took the cancel. Whether the order has *ended* by then depends
255 /// on the venue; see below.
256 /// - `Err`: the venue refused the cancel, or it could not be sent or answered. The order may
257 /// still be open, or may have ended some other way, such as by filling. The account stream
258 /// reports which.
259 ///
260 /// # A taken cancel is not always an ended order
261 ///
262 /// Some venues answer a cancel before carrying it out. Until they do, the order is live and can
263 /// still fill. From those venues `Ok(Cancelled)` means only that the cancel was accepted, and
264 /// the account stream reports how the order actually ended, including any fill in between. The
265 /// engine stops tracking an order on `Ok(Cancelled)` from any venue.
266 ///
267 /// Binance, Hyperliquid and the mock venue answer once the order is cancelled. Alpaca answers
268 /// once it has accepted the cancel, and IBKR once the cancel is submitted. The
269 /// [connector comparison](crate::client#connector-comparison) lists the same for each
270 /// connector; the mock venue is not one, so it has no row there.
271 ///
272 /// # `filled_quantity` and `time_exchange`
273 ///
274 /// `Cancelled::filled_quantity` is the quantity the venue reported filled where the answer
275 /// carries one, which Binance's and the mock venue's do. Alpaca, IBKR and Hyperliquid answer
276 /// without it and report zero, which does not mean nothing filled. The fill total comes from
277 /// the account stream, or from [`Self::fetch_trades`]. Likewise `time_exchange` is the venue's
278 /// time where the answer carries one, and the local time the answer arrived otherwise.
279 fn cancel_order(
280 &self,
281 request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
282 ) -> impl Future<Output = UnindexedOrderResponseCancel> + Send;
283
284 // `+ Send` on default method return types for multi-threaded Tokio runtime
285 fn cancel_orders<'a>(
286 &self,
287 requests: impl IntoIterator<Item = OrderRequestCancel<ExchangeId, &'a InstrumentNameExchange>>,
288 ) -> impl Stream<Item = UnindexedOrderResponseCancel> + Send {
289 futures::stream::FuturesUnordered::from_iter(
290 requests
291 .into_iter()
292 .map(|request| self.cancel_order(request)),
293 )
294 }
295
296 /// Place an order on the exchange.
297 ///
298 /// # Return value
299 ///
300 /// Every request gets exactly one answer, which the engine's `ExecutionManager` forwards as the
301 /// order's response. An implementation must not swallow a request: a failure to send it, or to
302 /// read the venue's answer, is `OpenFailed`.
303 ///
304 /// Returns `OrderState` directly rather than `Result<Open, OrderError>`:
305 /// - `OrderState::Active(Open)` - order is resting on the order book
306 /// - `OrderState::Inactive(FullyFilled)` - order was immediately filled (includes `avg_price` when available)
307 /// - `OrderState::Inactive(Expired)` - order ended in the response itself without filling
308 /// completely, carrying what filled: an IOC or FOK order that found too little liquidity,
309 /// or one expired by self-trade prevention
310 /// - `OrderState::Inactive(Cancelled)` - order the venue reports cancelled in the response
311 /// itself, carrying what filled
312 /// - `OrderState::Inactive(OpenFailed)` - order placement failed (API error, connectivity, a
313 /// rejection the venue reports in the response, etc.)
314 ///
315 /// An implementation returns `Expired` or `Cancelled` only when its venue reports the order's
316 /// status in the placement response (currently Binance Spot and Margin). The others return
317 /// `Open` for such an order, and their account stream reports how it ended.
318 ///
319 /// This design allows immediate fills to carry metadata (e.g., `avg_price`) that
320 /// would be lost if we had to infer terminal state from `Open::filled_quantity`.
321 fn open_order(
322 &self,
323 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
324 ) -> impl Future<Output = Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>> + Send;
325
326 // `+ Send` on default method return types for multi-threaded Tokio runtime
327 fn open_orders<'a>(
328 &self,
329 requests: impl IntoIterator<Item = OrderRequestOpen<ExchangeId, &'a InstrumentNameExchange>>,
330 ) -> impl Stream<Item = Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>> + Send
331 {
332 futures::stream::FuturesUnordered::from_iter(
333 requests.into_iter().map(|request| self.open_order(request)),
334 )
335 }
336
337 /// Fetch current balances for the specified assets.
338 ///
339 /// An empty `assets` slice is the "return all" sentinel: implementations must return
340 /// balances for every asset held. When non-empty, only the listed assets are returned.
341 fn fetch_balances(
342 &self,
343 assets: &[AssetNameExchange],
344 ) -> impl Future<Output = Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError>> + Send;
345
346 /// Fetch currently open orders, optionally filtered by instrument.
347 ///
348 /// An empty `instruments` slice is the "return all" sentinel: implementations must
349 /// return open orders across all instruments. When non-empty, only orders for the
350 /// listed instruments are returned.
351 fn fetch_open_orders(
352 &self,
353 instruments: &[InstrumentNameExchange],
354 ) -> impl Future<
355 Output = Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError>,
356 > + Send;
357
358 /// Read the trades (fills) from `start` to `end`, both inclusive, optionally filtered by
359 /// instrument.
360 ///
361 /// An empty `instruments` slice is the "return all" sentinel: implementations must
362 /// return trades across all instruments. When non-empty, only trades for the listed
363 /// instruments are returned.
364 ///
365 /// # Bounded, resumable reads
366 ///
367 /// One call is bounded by the venue's limits, so it may stop before `end`.
368 /// [`TradesRead::resume`] says whether it did:
369 /// - `None`: every fill in the span the venue still holds was read.
370 /// - `Some(t)`: call again with `start = t` and the same `end` to read on. That read can
371 /// return again fills the previous one returned, so match them by instrument and
372 /// [`TradeId`](crate::trade::TradeId).
373 ///
374 /// The caller owns the loop, and with it how many calls, and how long, it spends on a span.
375 /// A `start` after `end` is an empty span, read as no trades and `resume: None`.
376 ///
377 /// # Venue limits
378 ///
379 /// `resume: None` covers what the venue still holds, which can be less than the span:
380 /// - IBKR returns the current trading day's executions only.
381 /// - Hyperliquid keeps only each wallet's 10,000 most recent fills.
382 ///
383 /// Binance needs instruments: an empty slice reads nothing. Each client documents its limits.
384 ///
385 /// # Errors
386 ///
387 /// [`Truncated`](crate::error::ClientError::Truncated) when a read cannot advance, because
388 /// more fills share the span's first moment than one call can read past.
389 ///
390 /// The fee asset (`AssetNameExchange`) may be quote, base, or third-party (e.g., BNB).
391 /// Use `fees.fees_quote` for quote-equivalent value when available.
392 fn fetch_trades(
393 &self,
394 start: DateTime<Utc>,
395 end: DateTime<Utc>,
396 instruments: &[InstrumentNameExchange],
397 ) -> impl Future<
398 Output = Result<
399 TradesRead<AssetNameExchange, InstrumentNameExchange>,
400 UnindexedClientError,
401 >,
402 > + Send;
403}
404
405/// Extension trait for exchanges that support native bracket orders.
406///
407/// A bracket order consists of three linked orders:
408/// 1. **Entry**: Limit order to enter the position
409/// 2. **Take Profit**: Limit order to exit at profit target
410/// 3. **Stop Loss**: Stop (or stop-limit) order to exit at loss limit
411///
412/// When either exit leg fills, the exchange automatically cancels the other.
413///
414/// # Type-Level Capability
415///
416/// This is a supertrait of [`ExecutionClient`], enabling compile-time capability checks:
417/// - `impl ExecutionClient` — basic order operations
418/// - `impl ExecutionClient + BracketOrderClient` — includes bracket orders
419///
420/// This follows Rust idioms like `Read + Seek` or `Iterator + ExactSizeIterator`.
421///
422/// # Why Supertrait Over Alternatives
423///
424/// **vs. associated types on `ExecutionClient`**: Callers can't construct
425/// `Self::BracketRequest` without knowing the concrete type — adds trait surface
426/// without enabling generic use.
427///
428/// **vs. default impl returning `Unsupported`**: Puts a "dead method" on every
429/// client (MockExecution, BinanceSpot, HyperliquidClient). Compile-time capability
430/// via trait bounds is better than runtime errors.
431///
432/// # Result Types
433///
434/// [`BracketOrderResult`] uses `Option<Order>` for child legs to document API divergence:
435///
436/// | Exchange | `take_profit` | `stop_loss` | Reason |
437/// |----------|---------------|-------------|--------|
438/// | IBKR | `Some(...)` | `Some(...)` | Returns all three orders immediately |
439/// | Alpaca | `None` | `None` | Child legs created server-side |
440///
441/// # Example
442///
443/// ```ignore
444/// use rustrade_execution::client::{ExecutionClient, BracketOrderClient};
445/// use rustrade_execution::order::bracket::{BracketOrderRequest, RequestOpenBracket};
446///
447/// async fn place_bracket<C: ExecutionClient + BracketOrderClient>(
448/// client: &C,
449/// request: BracketOrderRequest<ExchangeId, &InstrumentNameExchange>,
450/// ) -> BracketOrderResult {
451/// client.open_bracket_order(request).await
452/// }
453/// ```
454pub trait BracketOrderClient: ExecutionClient {
455 /// Place a bracket order (entry + take-profit + stop-loss).
456 ///
457 /// # Request
458 ///
459 /// The [`BracketOrderRequest`] contains:
460 /// - `key`: Order key (exchange, instrument, strategy, client order ID)
461 /// - `state`: [`RequestOpenBracket`](crate::order::bracket::RequestOpenBracket) with
462 /// side, quantity, prices, and optional stop-loss limit price
463 ///
464 /// # Constraints
465 ///
466 /// - `time_in_force` must be `Day` or `GoodUntilCancelled` on most exchanges
467 /// - Entry order type is always `Limit`
468 /// - Price ordering must be valid for the side (see [`RequestOpenBracket`](crate::order::bracket::RequestOpenBracket))
469 ///
470 /// # Exchange-Specific Field Handling
471 ///
472 /// `RequestOpenBracket::stop_loss_limit_price` is **not honored uniformly**:
473 /// - **Alpaca**: When `Some`, the stop-loss leg becomes a stop-limit order at that price.
474 /// - **IBKR**: Silently ignored — the stop-loss leg is always a stop (market) order.
475 ///
476 /// Generic callers `T: BracketOrderClient` must treat this field as advisory.
477 ///
478 /// # Return Value
479 ///
480 /// Returns [`BracketOrderResult`] with:
481 /// - `parent`: Always present (entry order)
482 /// - `take_profit`: `Some` if exchange returns legs immediately (IBKR), `None` otherwise (Alpaca)
483 /// - `stop_loss`: `Some` if exchange returns legs immediately (IBKR), `None` otherwise (Alpaca)
484 ///
485 /// Either all orders are `Active(Open)` or all are `Inactive` (placement failed), with one
486 /// exception: a leg of a failed placement whose fate at the venue is unknown (no status
487 /// arrived, or it was accepted and its rollback cancel could not be sent) is returned
488 /// `Active(Open)`, so the account stream can resolve it. Only IBKR returns such legs today;
489 /// see its `open_bracket_order`.
490 fn open_bracket_order(
491 &self,
492 request: BracketOrderRequest<ExchangeId, &InstrumentNameExchange>,
493 ) -> impl Future<Output = BracketOrderResult> + Send;
494}
495
496/// Extension trait for clients that can say how an order ended at the venue.
497///
498/// A client that loses its account stream recovers the fills it missed when it reconnects, but not
499/// an order that was cancelled, expired or rejected in the meantime: the event that said so went
500/// out while nothing was listening. The engine then keeps such an order as live until a complete
501/// account snapshot no longer lists it, and even then cannot tell how it ended. This trait is the
502/// lookup that answers that: given the orders a caller still holds as live, it returns how each one
503/// that has ended did end.
504///
505/// # By order, not by time
506///
507/// The obvious alternative, asking the venue for every order closed since the disconnect, does not
508/// work. Binance's `allOrders` and Alpaca's `GET /v2/orders?status=closed` both filter their time
509/// window on when an order was **created**, so a window opening at the disconnect cannot see an
510/// order placed before it and cancelled during it — which is the order that matters, because the
511/// caller is tracking it. Looking each order up by its client order id finds it whenever it was
512/// placed.
513///
514/// # Type-Level Capability
515///
516/// A supertrait of [`ExecutionClient`], for the reason [`BracketOrderClient`] is one: a client that
517/// cannot look orders up does not implement it, rather than carrying a method that fails at run
518/// time. A caller that needs the lookup bounds on `ExecutionClient + OrderStatusClient`.
519pub trait OrderStatusClient: ExecutionClient {
520 /// How each of `orders` ended, for those that have.
521 ///
522 /// # Contract
523 ///
524 /// - One entry per order that has ended, in no particular order:
525 /// [`FullyFilled`](crate::order::state::InactiveOrderState::FullyFilled),
526 /// [`Cancelled`](crate::order::state::InactiveOrderState::Cancelled) carrying what filled
527 /// before it, [`Expired`](crate::order::state::InactiveOrderState::Expired), or
528 /// [`OpenFailed`](crate::order::state::InactiveOrderState::OpenFailed) for an order the venue
529 /// accepted for processing and later rejected (Binance `REJECTED`, Alpaca `rejected`). An
530 /// order rejected in the response to its open request was answered there and is not expected
531 /// here.
532 /// - An order still live is **omitted**, and so is one the venue does not know. Neither is an
533 /// error. A caller compares the result with what it asked for.
534 /// - An empty `orders` returns an empty list. Unlike
535 /// [`ExecutionClient::fetch_open_orders`]'s empty slice, it does not mean "all".
536 /// - An order is found by its [`cid`](crate::order::OrderKey::cid), and each returned order
537 /// carries the key it was asked for, so a `strategy` the venue does not record survives.
538 /// - A key whose `instrument` is not the one the order traded is treated as unknown. The
539 /// `exchange` is not checked: a client answers for its own venue.
540 /// - A `cid` asked about more than once is reported at most once, under the first key that
541 /// finds it.
542 ///
543 /// # Errors
544 ///
545 /// `Err` if the venue could not answer for every order. A partial list is never returned,
546 /// because an order missing from it would read as still live.
547 fn fetch_ended_orders(
548 &self,
549 orders: &[UnindexedOrderKey],
550 ) -> impl Future<Output = Result<Vec<UnindexedInactiveOrder>, UnindexedClientError>> + Send;
551}
552
553/// The capability table, pinned.
554///
555/// [`ExecutionClient::SUPPORTED_KINDS`] is a `const`: widening one, or adding a kind a client builds
556/// no request for, compiles and passes clippy. The only thing it changes is which instruments
557/// [`ExecutionBuilder`](https://docs.rs/rustrade/latest/rustrade/execution/builder/struct.ExecutionBuilder.html)
558/// admits — so the failure mode is not a broken build but an instrument reaching a venue that cannot
559/// encode it, as a silently wrong order. These assertions exist so that changing a client's declared
560/// capabilities is a deliberate edit to a test that says what the venue can route, rather than a
561/// one-word diff nothing observes.
562///
563/// The real-client cases are each gated on their own feature, matching the module gates above; the
564/// mock case is ungated, like its module. So with `default = []`, a bare
565/// `cargo test -p rustrade-execution` compiles only the mock assertion — pinning a real client's
566/// capabilities requires `--features <name>` or `--all-features`.
567#[cfg(test)]
568mod tests {
569 use super::*;
570 use crate::client::mock::MockExecution;
571
572 #[test]
573 fn mock_supports_only_the_kinds_it_can_model() {
574 // No expiry, funding schedule or contract chain in `MockExchange`, so `Perpetual`, `Future`
575 // and `Option` have no faithful projection onto it. Kept in step with the projection in
576 // `generate_mock_exchange_instruments`, which panics on a kind it cannot project.
577 assert_eq!(
578 <MockExecution<fn() -> DateTime<Utc>> as ExecutionClient>::SUPPORTED_KINDS,
579 &[
580 InstrumentKindDiscriminant::Spot,
581 InstrumentKindDiscriminant::Cfd
582 ]
583 );
584 }
585
586 #[cfg(feature = "binance")]
587 #[test]
588 fn binance_spot_and_margin_are_spot_only() {
589 use crate::client::binance::{BinanceMargin, BinanceSpot};
590
591 assert_eq!(
592 BinanceSpot::SUPPORTED_KINDS,
593 &[InstrumentKindDiscriminant::Spot]
594 );
595 // Cross margin trades the same spot instruments on borrowed funds -- the leverage is in the
596 // account, not the instrument kind.
597 assert_eq!(
598 BinanceMargin::SUPPORTED_KINDS,
599 &[InstrumentKindDiscriminant::Spot]
600 );
601 }
602
603 #[cfg(feature = "hyperliquid")]
604 #[test]
605 fn hyperliquid_splits_perpetual_and_spot_across_two_clients() {
606 use crate::client::hyperliquid::{HyperliquidClient, spot::HyperliquidSpotClient};
607
608 assert_eq!(
609 HyperliquidClient::SUPPORTED_KINDS,
610 &[InstrumentKindDiscriminant::Perpetual]
611 );
612 assert_eq!(
613 HyperliquidSpotClient::SUPPORTED_KINDS,
614 &[InstrumentKindDiscriminant::Spot]
615 );
616 }
617
618 #[cfg(feature = "alpaca")]
619 #[test]
620 fn alpaca_trades_equities_and_options() {
621 use crate::client::alpaca::AlpacaClient;
622
623 assert_eq!(
624 AlpacaClient::SUPPORTED_KINDS,
625 &[
626 InstrumentKindDiscriminant::Spot,
627 InstrumentKindDiscriminant::Option
628 ]
629 );
630 }
631
632 #[cfg(feature = "ibkr")]
633 #[test]
634 fn ibkr_omits_cfd_because_it_builds_no_contract_for_one() {
635 use crate::client::ibkr::IbkrClient;
636
637 // `ContractConfig::to_contract` builds `STK`, `FUT`, `OPT` and `CASH` and nothing else, so a
638 // CFD routed here would be encoded as some other security type -- exactly the silent
639 // wrong-symbol order this const exists to reject. Adding `Cfd` to the list requires adding
640 // the arm first.
641 assert_eq!(
642 IbkrClient::SUPPORTED_KINDS,
643 &[
644 InstrumentKindDiscriminant::Spot,
645 InstrumentKindDiscriminant::Future,
646 InstrumentKindDiscriminant::Option
647 ]
648 );
649 }
650}