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, 10 tries) | 10k LRU | REST after reconnect | REST after reconnect | 30s | cancelled |
8//! | [`alpaca`] | Auto (1s→30s backoff, 10 tries) | 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 (every 1s, no limit) | 10k LRU, fills only | Venue snapshot on resubscribe | REST after reconnect | SDK-managed | cancelled |
11//!
12//! "Reconnect" is how the account stream comes back after its connection drops, and when it gives
13//! up: Binance and Alpaca end the stream once 10 reconnects in a row have failed (see
14//! [`binance`](binance#account-stream-reconnection) and
15//! [`AlpacaClient`](alpaca::AlpacaClient)'s `account_stream`).
16//!
17//! "Ended-order Recovery" is whether a reconnect reports how an order the client held as live
18//! ended while the stream was down, and only those clients implement [`OrderStatusClient`]. The
19//! last column is when [`ExecutionClient::cancel_order`] answers `Ok`. Only for "cancelled" has the
20//! order ended by then; see that method.
21//!
22//! # Resilience Philosophy
23//!
24//! **WebSocket-based connectors** (Binance, Alpaca) implement auto-reconnection with
25//! fill recovery and deduplication. After reconnect, they query REST APIs for missed
26//! fills and deduplicate against the LRU cache to prevent duplicate processing.
27//!
28//! **IBKR** uses TCP to local TWS/Gateway. `ibapi` reconnects that socket itself; the
29//! account stream stays open across it and recovers the missed fills from TWS's executions,
30//! deduplicating against the LRU cache. Replacing a client that is gone for good requires IB
31//! Gateway availability and client ID coordination — decisions that belong in the caller's
32//! wrapper. See [`ibkr`] module docs for caller responsibilities.
33//!
34//! **Hyperliquid** delegates reconnection to the official SDK's `with_reconnect()` mechanism, but
35//! deduplicates fills itself: the SDK resubscribes on reconnect and the venue opens a `userFills`
36//! subscription with a snapshot, so every reconnect redelivers fills already seen. That snapshot
37//! is the only fill recovery: it holds recent fills, how many Hyperliquid does not say, so callers
38//! needing older ones call [`ExecutionClient::fetch_trades`], whose results are not deduplicated
39//! against the stream. Once the snapshot has been sent on, a reconnect checks how the orders the
40//! client holds as live ended, like Binance's and Alpaca's.
41//!
42//! # Known Limitations
43//!
44//! Only Binance (Spot and Margin), Alpaca and Hyperliquid (perpetuals and spot) recover the **order
45//! lifecycle events** (cancelled, expired, rejected, a fill that completes an order) they missed
46//! while disconnected, and only for the orders the client holds as live; IBKR recovers fills only
47//! (#370). [`OrderStatusClient`] is the lookup that recovery is built on: given the orders a caller
48//! still holds as live, it says how each that has ended did end. Binance Spot, Binance Margin,
49//! Alpaca, both Hyperliquid clients and the mock client implement it.
50//!
51//! The engine closes part of that gap from the account snapshot each reconnect produces: an order a
52//! complete list no longer shows is retired (see [`ExecutionClient::account_snapshot`]). That covers
53//! the connectors whose snapshot declares its orders complete — Binance Spot and Margin,
54//! Hyperliquid, Alpaca, and the mock venue — but not IBKR (#371), and it cannot tell how an order
55//! ended. A caller that needs more reconciles against
56//! [`ExecutionClient::fetch_open_orders`] after a reconnect.
57
58use crate::{
59 UnindexedAccountEvent, UnindexedAccountSnapshot,
60 balance::AssetBalance,
61 borrow_capacity::BorrowCapacity,
62 error::UnindexedClientError,
63 order::{
64 Order, UnindexedInactiveOrder, UnindexedOrderKey,
65 bracket::{BracketOrderRequest, BracketOrderResult},
66 request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
67 state::{Open, UnindexedOrderState},
68 },
69 shortability::Shortability,
70 trade::TradesRead,
71};
72use chrono::{DateTime, Utc};
73use futures::{Stream, stream::BoxStream};
74use rustrade_instrument::{
75 Side,
76 asset::name::AssetNameExchange,
77 exchange::ExchangeId,
78 instrument::{kind::InstrumentKindDiscriminant, name::InstrumentNameExchange},
79};
80use std::future::Future;
81
82// Account-event deduplication over rustrade's own event type. Alpaca deduplicates too, but against
83// a raw `SmolStr` fill key rather than this cache. Always compiled: `AccountEventDeduper` is public.
84pub mod dedup;
85
86// What a client knows of its live orders, and the reconnect check of how those orders ended while
87// its account stream was down. Gated on the clients that use it.
88#[cfg(any(
89 feature = "alpaca",
90 feature = "binance",
91 feature = "hyperliquid",
92 feature = "ibkr"
93))]
94pub(crate) mod order_recovery;
95
96// An estimate of a venue's clock from the times it stamps on its events. Always compiled: the
97// execution manager's fill recovery uses it whatever the client.
98pub mod venue_clock;
99
100// The account streams' reconnect budget. Gated on the clients that use it.
101#[cfg(any(feature = "alpaca", feature = "binance"))]
102pub(crate) mod reconnect_budget;
103
104// A REST stub on the test's own runtime, for the reconnect tests on a paused clock.
105#[cfg(all(test, any(feature = "alpaca", feature = "binance")))]
106#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
107pub(crate) mod test_http;
108
109// Alpaca ExecutionClient implementation (options, equities, crypto — single unified API)
110#[cfg(feature = "alpaca")]
111pub mod alpaca;
112
113// Binance ExecutionClient implementations: Spot (live) and Cross Margin (identity/config scaffolding)
114#[cfg(feature = "binance")]
115pub mod binance;
116
117// Hyperliquid perpetual futures and spot ExecutionClient implementations
118#[cfg(feature = "hyperliquid")]
119pub mod hyperliquid;
120
121// Interactive Brokers ExecutionClient implementation (equities, futures, options, forex)
122#[cfg(feature = "ibkr")]
123pub mod ibkr;
124
125pub mod mock;
126
127/// An instrument an [`ExecutionClient`] is about to be built to trade, as
128/// [`ExecutionClient::validate_config`] sees it.
129///
130/// `#[non_exhaustive]`, so further detail can be added without breaking an implementation that
131/// reads these fields; construct one with [`Self::new`].
132#[derive(Debug, Clone, Copy, Eq, PartialEq)]
133#[non_exhaustive]
134pub struct ClientInstrument<'a> {
135 /// The name the venue knows the instrument by, which is what a client's requests carry.
136 pub name_exchange: &'a InstrumentNameExchange,
137 /// The instrument's kind.
138 pub kind: InstrumentKindDiscriminant,
139}
140
141impl<'a> ClientInstrument<'a> {
142 /// The view of an instrument named `name_exchange`, of kind `kind`.
143 pub fn new(
144 name_exchange: &'a InstrumentNameExchange,
145 kind: InstrumentKindDiscriminant,
146 ) -> Self {
147 Self {
148 name_exchange,
149 kind,
150 }
151 }
152}
153
154// `+ Send` bounds on async method return types required for multi-threaded
155// Tokio runtime. This is a breaking change vs upstream — any `!Send` executor
156// implementation would fail to compile.
157/// A venue's account and order API, as the engine's `ExecutionManager` drives it.
158///
159/// # Wrapping a client
160///
161/// To add behaviour around a venue client without reimplementing it, wrap it in a generic
162/// decorator that implements `ExecutionClient` itself. Typical uses are choosing which fills on a
163/// shared account reach the engine, logging, and metrics.
164///
165/// - **Take the inner client's identity.** Set `const EXCHANGE: ExchangeId = C::EXCHANGE` and
166/// `SUPPORTED_KINDS = C::SUPPORTED_KINDS`. The `ExecutionManager` refuses a venue client whose
167/// `EXCHANGE` differs from its instrument map's exchange (only the `Mock` and `Simulated` ids
168/// are exempt), so one generic wrapper then serves every venue client.
169/// - **Forward every method, the provided ones included.** That is
170/// [`open_orders`](Self::open_orders), [`cancel_orders`](Self::cancel_orders),
171/// [`validate_config`](Self::validate_config) and
172/// [`process_account_events`](Self::process_account_events): left to the trait's defaults, they
173/// would bypass any batching, config check or processing the inner client has. Implement the
174/// extension traits the inner client implements as well, with impls bounded on it, forwarding
175/// their provided methods too:
176/// - [`OrderStatusClient`], which the `ExecutionManager` requires of every client it runs;
177/// - [`BracketOrderClient`]
178/// - [`ShortabilityClient`]
179/// - [`BorrowCapacityClient`]
180/// - **Withhold a fill from [`account_stream`](Self::account_stream) and
181/// [`fetch_trades`](Self::fetch_trades) alike, by the same rule.** One fill can reach the engine
182/// either way: live on the stream, or in a read made after a disconnect. A fill withheld from
183/// only one of them still reaches the engine once.
184/// - **Choose a key both paths carry.** [`Trade::client_order_id`] marks the orders a consumer
185/// placed, but not every venue reports it on `fetch_trades` (its rustdoc lists which do).
186/// Binance's is one that does not. A rule keyed on it has to decide what to do with a fill it
187/// cannot classify. Keeping it, as the example below does, delivers too much rather than too
188/// little: on such a venue every fill a `fetch_trades` read returns is kept, and only the
189/// stream is filtered. To withhold those fills as well, match them another way, such as by
190/// [`Trade::order_id`] against the orders the wrapper placed.
191/// - **Keep fills the venue forced** ([`TradeOrigin::is_forced`]). They moved the position,
192/// whoever placed the order.
193/// - **Leave `resume` alone.** Filtering a [`TradesRead`] keeps its `resume` as it was: it marks
194/// where the venue's read stopped, not which fills the wrapper kept.
195/// - **Keep state over the fills in [`process_account_events`](Self::process_account_events).**
196/// A wrapper that tracks what the engine holds, attributes fills, or acts on each one needs to
197/// see each fill once. `account_stream` and `fetch_trades` overlap on purpose, around a
198/// reconnect, so a fill can pass through both; `process_account_events` sees it once, after the
199/// engine's `ExecutionManager` drops the copy, with the recovered fills and the snapshot in
200/// order. The method takes `&self` and returns a `'static` stream, so hold that state in an
201/// `Arc` and move a clone into the stream. A rule that only withholds fills can stay on the two
202/// paths above.
203/// - **One inner client per engine.** A wrapper that owns one client counts as that one client.
204/// Two clients on one account whose account streams both reach one engine deliver every fill
205/// twice.
206/// - **Withhold here, not later.** The engine applies a fill that matches none of its orders too.
207/// In `OmsMode::Netting` it moves the netting position like any other fill, and the engine
208/// cannot tell it from a fill of its own. In `OmsMode::Hedging` an ordinary fill
209/// ([`TradeOrigin::Order`] or [`TradeOrigin::External`]) opens a position of its own, and a
210/// settlement fill is handled as [`InstrumentState::update_from_trade`] describes.
211///
212/// ```
213/// use chrono::{DateTime, Utc};
214/// use futures::{Stream, StreamExt, stream::BoxStream};
215/// use rustrade_execution::{
216/// AccountEventKind, UnindexedAccountEvent, UnindexedAccountSnapshot,
217/// balance::AssetBalance,
218/// client::{ClientInstrument, ExecutionClient, OrderStatusClient, VenueOrderStatus},
219/// error::UnindexedClientError,
220/// order::{
221/// Order, UnindexedInactiveOrder, UnindexedOrderKey,
222/// request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
223/// state::{Open, UnindexedOrderState},
224/// },
225/// trade::{Trade, TradesRead},
226/// };
227/// use rustrade_instrument::{
228/// asset::name::AssetNameExchange,
229/// exchange::ExchangeId,
230/// instrument::{kind::InstrumentKindDiscriminant, name::InstrumentNameExchange},
231/// };
232///
233/// /// Delivers the fills of orders whose client order id starts with `prefix`, fills whose
234/// /// client order id the venue did not report, and every fill the venue forced.
235/// ///
236/// /// Keeping a fill without a client order id is a choice: on a venue whose `fetch_trades`
237/// /// reports none, this filters the account stream only.
238/// #[derive(Debug, Clone)]
239/// pub struct OwnFills<C> {
240/// inner: C,
241/// prefix: String,
242/// }
243///
244/// #[derive(Debug, Clone)]
245/// pub struct OwnFillsConfig<Config> {
246/// pub inner: Config,
247/// pub prefix: String,
248/// }
249///
250/// fn keeps<AssetKey, InstrumentKey>(prefix: &str, trade: &Trade<AssetKey, InstrumentKey>) -> bool {
251/// trade.origin.is_forced()
252/// || trade
253/// .client_order_id
254/// .as_ref()
255/// .is_none_or(|cid| cid.0.starts_with(prefix))
256/// }
257///
258/// // `Sync` makes the wrapper's method futures `Send`, as the trait requires. `'static` lets the
259/// // filtered account stream be boxed.
260/// impl<C> ExecutionClient for OwnFills<C>
261/// where
262/// C: ExecutionClient + Sync,
263/// C::AccountStream: 'static,
264/// {
265/// const EXCHANGE: ExchangeId = C::EXCHANGE;
266/// const SUPPORTED_KINDS: &'static [InstrumentKindDiscriminant] = C::SUPPORTED_KINDS;
267/// type Config = OwnFillsConfig<C::Config>;
268/// type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
269///
270/// fn validate_config(
271/// config: &Self::Config,
272/// instruments: &[ClientInstrument<'_>],
273/// ) -> Result<(), String> {
274/// C::validate_config(&config.inner, instruments)
275/// }
276///
277/// fn new(config: Self::Config) -> Self {
278/// Self {
279/// inner: C::new(config.inner),
280/// prefix: config.prefix,
281/// }
282/// }
283///
284/// async fn account_stream(
285/// &self,
286/// assets: &[AssetNameExchange],
287/// instruments: &[InstrumentNameExchange],
288/// ) -> Result<Self::AccountStream, UnindexedClientError> {
289/// let prefix = self.prefix.clone();
290/// let stream = self.inner.account_stream(assets, instruments).await?;
291/// Ok(stream
292/// .filter(move |event| {
293/// let keep = match &event.kind {
294/// AccountEventKind::Trade(trade) => keeps(&prefix, trade),
295/// _ => true,
296/// };
297/// std::future::ready(keep)
298/// })
299/// .boxed())
300/// }
301///
302/// async fn fetch_trades(
303/// &self,
304/// start: DateTime<Utc>,
305/// end: DateTime<Utc>,
306/// instruments: &[InstrumentNameExchange],
307/// ) -> Result<TradesRead<AssetNameExchange, InstrumentNameExchange>, UnindexedClientError> {
308/// let mut read = self.inner.fetch_trades(start, end, instruments).await?;
309/// read.trades.retain(|trade| keeps(&self.prefix, trade));
310/// Ok(read)
311/// }
312///
313/// // Everything else is forwarded unchanged.
314///
315/// fn process_account_events(
316/// &self,
317/// events: BoxStream<'static, UnindexedAccountEvent>,
318/// ) -> BoxStream<'static, UnindexedAccountEvent> {
319/// self.inner.process_account_events(events)
320/// }
321///
322/// async fn account_snapshot(
323/// &self,
324/// assets: &[AssetNameExchange],
325/// instruments: &[InstrumentNameExchange],
326/// ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
327/// self.inner.account_snapshot(assets, instruments).await
328/// }
329///
330/// async fn cancel_order(
331/// &self,
332/// request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
333/// ) -> UnindexedOrderResponseCancel {
334/// self.inner.cancel_order(request).await
335/// }
336///
337/// fn cancel_orders<'a>(
338/// &self,
339/// requests: impl IntoIterator<Item = OrderRequestCancel<ExchangeId, &'a InstrumentNameExchange>>,
340/// ) -> impl Stream<Item = UnindexedOrderResponseCancel> + Send {
341/// self.inner.cancel_orders(requests)
342/// }
343///
344/// async fn open_order(
345/// &self,
346/// request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
347/// ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
348/// self.inner.open_order(request).await
349/// }
350///
351/// fn open_orders<'a>(
352/// &self,
353/// requests: impl IntoIterator<Item = OrderRequestOpen<ExchangeId, &'a InstrumentNameExchange>>,
354/// ) -> impl Stream<Item = Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>> + Send
355/// {
356/// self.inner.open_orders(requests)
357/// }
358///
359/// async fn fetch_balances(
360/// &self,
361/// assets: &[AssetNameExchange],
362/// ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
363/// self.inner.fetch_balances(assets).await
364/// }
365///
366/// async fn fetch_open_orders(
367/// &self,
368/// instruments: &[InstrumentNameExchange],
369/// ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
370/// self.inner.fetch_open_orders(instruments).await
371/// }
372/// }
373///
374/// impl<C> OrderStatusClient for OwnFills<C>
375/// where
376/// C: OrderStatusClient + Sync,
377/// C::AccountStream: 'static,
378/// {
379/// async fn fetch_ended_orders(
380/// &self,
381/// orders: &[UnindexedOrderKey],
382/// ) -> Result<Vec<UnindexedInactiveOrder>, UnindexedClientError> {
383/// self.inner.fetch_ended_orders(orders).await
384/// }
385///
386/// async fn fetch_order_status(
387/// &self,
388/// key: &UnindexedOrderKey,
389/// ) -> Result<VenueOrderStatus, UnindexedClientError> {
390/// self.inner.fetch_order_status(key).await
391/// }
392/// }
393/// ```
394///
395/// [`Trade::client_order_id`]: crate::trade::Trade::client_order_id
396/// [`Trade::order_id`]: crate::trade::Trade::order_id
397/// [`TradeOrigin::is_forced`]: crate::trade::TradeOrigin::is_forced
398/// [`TradeOrigin::Order`]: crate::trade::TradeOrigin::Order
399/// [`TradeOrigin::External`]: crate::trade::TradeOrigin::External
400/// [`TradesRead`]: crate::trade::TradesRead
401/// [`InstrumentState::update_from_trade`]: https://docs.rs/rustrade/latest/rustrade/engine/state/instrument/struct.InstrumentState.html#method.update_from_trade
402pub trait ExecutionClient
403where
404 Self: Clone,
405{
406 const EXCHANGE: ExchangeId;
407
408 /// The [`InstrumentKindDiscriminant`]s this client can trade.
409 ///
410 /// A client works off [`InstrumentNameExchange`] strings and never inspects an instrument's
411 /// kind, so nothing downstream can infer this from the implementation — routing a `Future`
412 /// through a spot-only venue produces a rejected or, worse, misinterpreted order rather than a
413 /// type error. Declaring the set here lets [`ExecutionBuilder`] reject an unsupported
414 /// instrument set at build time, before any order is sent.
415 ///
416 /// Declare what the *venue and this implementation together* can actually route, not what the
417 /// exchange's product catalogue advertises. A kind the exchange offers but this client builds
418 /// no request for does not belong in the set.
419 ///
420 /// There is deliberately no default. A new client must state its capabilities rather than
421 /// inherit a permissive set that would silently admit every kind.
422 ///
423 /// It is a `const` and not a `fn supported_kinds(&self)` because [`ExecutionBuilder`] validates
424 /// the instrument set *before* [`Self::new`] is ever called — there is no instance to ask at the
425 /// point the answer is needed, and deferring validation until after construction would mean
426 /// standing up a client for a venue the engine is about to reject. A capability set that varies
427 /// with `Self::Config` therefore cannot be expressed here; it would need a separate
428 /// pre-construction hook on the config.
429 ///
430 /// [`ExecutionBuilder`]: https://docs.rs/rustrade/latest/rustrade/execution/builder/struct.ExecutionBuilder.html
431 const SUPPORTED_KINDS: &'static [InstrumentKindDiscriminant];
432
433 type Config: Clone;
434
435 /// Checks `config` against the instruments this client is about to be built to trade, before
436 /// [`Self::new`] is called. `Err` names what is wrong.
437 ///
438 /// [`SUPPORTED_KINDS`](Self::SUPPORTED_KINDS) rejects a kind the client cannot route at all.
439 /// This catches what only the config knows: a per-instrument entry that is invalid, or that
440 /// disagrees with the instrument it is keyed to, such as a contract registered as a stock for
441 /// an instrument modelled as an option. Each such entry would otherwise be skipped or acted on
442 /// silently once the client runs.
443 ///
444 /// The default accepts every config. Override it when `Self::Config` carries per-instrument
445 /// detail.
446 ///
447 /// [`ExecutionBuilder`] calls it, after checking `SUPPORTED_KINDS`, with the instruments
448 /// registered on this client's exchange. A client constructed by calling `new` directly is not
449 /// checked; call this first to get the same check.
450 ///
451 /// [`ExecutionBuilder`]: https://docs.rs/rustrade/latest/rustrade/execution/builder/struct.ExecutionBuilder.html
452 fn validate_config(
453 config: &Self::Config,
454 instruments: &[ClientInstrument<'_>],
455 ) -> Result<(), String> {
456 let _ = (config, instruments);
457 Ok(())
458 }
459
460 // `+ Send` required so generic code (e.g. ExecutionManager) can pass
461 // the stream to tokio::spawn, which requires Send.
462 type AccountStream: Stream<Item = UnindexedAccountEvent> + Send;
463
464 fn new(config: Self::Config) -> Self;
465
466 /// Reads the account's balances, and its open orders and positions for `instruments`, from the
467 /// venue.
468 ///
469 /// Besides startup, the engine's `ExecutionManager` calls this again on every account-stream
470 /// reconnect, and emits the result ahead of the new stream's updates. It is how the engine
471 /// catches up on what changed while the stream was down.
472 ///
473 /// # Assets
474 ///
475 /// `assets` are the ones the caller registered. Return balances for those only when the venue
476 /// lets you ask, which saves bandwidth. A balance for another asset is not an error: the
477 /// `ExecutionManager` drops it and logs the asset names once per snapshot.
478 ///
479 /// Balances are stamped as [`fetch_balances`](Self::fetch_balances) describes.
480 ///
481 /// # Order completeness
482 ///
483 /// Each [`InstrumentAccountSnapshot`](crate::InstrumentAccountSnapshot) states through
484 /// `orders_complete` whether its `orders` list is every order open at the venue for that
485 /// instrument. Set it only when it is true: the engine retires a tracked order that a complete
486 /// list omits, so a list that can miss an open order (a capped page, an order dropped on
487 /// conversion, an order reported under an id other than the one it was placed with) must say
488 /// `false`. `false` is always safe; it only forgoes that reconciliation.
489 fn account_snapshot(
490 &self,
491 assets: &[AssetNameExchange],
492 instruments: &[InstrumentNameExchange],
493 ) -> impl Future<Output = Result<UnindexedAccountSnapshot, UnindexedClientError>> + Send;
494
495 /// Returns a live stream of account events (fills, order updates, balance changes).
496 ///
497 /// # Startup race window
498 ///
499 /// There is an unavoidable gap between the WebSocket subscribe response and the
500 /// first event being delivered: fills arriving in this window (typically milliseconds,
501 /// no sub-millisecond guarantee) are silently dropped. `account_snapshot` reconciles
502 /// open-order state, but TRADE fills in this window are not recoverable from the stream
503 /// alone. Callers that require fill completeness at startup **must** call
504 /// [`ExecutionClient::fetch_trades`] with at least a 1-second lookback after this method returns.
505 ///
506 /// # Backpressure
507 ///
508 /// Implementations use unbounded internal channels. If the consumer cannot keep up,
509 /// events queue in memory rather than being dropped — per library philosophy, OOM
510 /// crashes are preferable to silent data loss. Consumers requiring backpressure
511 /// should implement it at their boundary (e.g., bounded channel with overflow policy).
512 fn account_stream(
513 &self,
514 assets: &[AssetNameExchange],
515 instruments: &[InstrumentNameExchange],
516 ) -> impl Future<Output = Result<Self::AccountStream, UnindexedClientError>> + Send;
517
518 /// Processes the account events of one open of the account stream, after the engine's
519 /// `ExecutionManager` has dropped the fills it delivered before, and before it indexes them for
520 /// the engine. The default passes them on unchanged.
521 ///
522 /// The manager calls it each time it opens the account stream, the first time and on every
523 /// re-initialisation, with that open's events in the order it delivers them: the fills it read
524 /// for the span the stream was down, an [`AccountEventKind::FillRecoveryGaveUp`] for any span
525 /// it could not read, the [`AccountEventKind::Snapshot`], and then the stream's updates,
526 /// [`AccountEventKind::StreamTerminated`] included. Its own reports of the reconnect
527 /// (`Reconnecting`, [`AccountEventKind::ReinitFailed`]) do not pass through it.
528 ///
529 /// A fill reaches it once, whichever path brought it. [`account_stream`](Self::account_stream)
530 /// does not have that property: the manager opens the stream before it reads the span the
531 /// stream was down, so a fill made during the read arrives both ways, and a venue can replay
532 /// fills when a stream opens. Processing that keeps state over the fills the engine receives
533 /// belongs here (see [Wrapping a client](Self#wrapping-a-client)).
534 ///
535 /// # Contract
536 /// - **Keep the order** of the events passed on: process them with `then`, `flat_map` or
537 /// `buffered`, not `buffer_unordered`.
538 /// - **End only when `events` ends.** When the returned stream ends, the manager opens the
539 /// account stream again. Each fill `events` yielded is recorded as seen by then, so one this
540 /// stage took and did not pass on is not read again: it is lost.
541 /// - **Retry a step that fails here, with backoff**, such as a REST call to attribute a fill.
542 /// To give up on a fill, report it as an [`AccountEventKind::FillRecoveryGaveUp`] whose scope
543 /// is its instrument, whose span is its time, and whose reason is
544 /// [`FillRecoveryFailure::Processing`]. A fill dropped without a report is lost.
545 /// - **Pass on the events it does not process**, the snapshot included.
546 /// - The manager sets no time limit: a slow step delays every event behind it.
547 ///
548 /// # What the manager does around it
549 /// - Every fill the returned stream yields is recorded as seen, and none is dropped. A fill
550 /// this stage adds is recorded as it leaves the stage, so a copy the venue sends after that
551 /// is dropped before it reaches the stage; one it adds that was delivered before is
552 /// delivered again. A copy the stage has already taken from `events` is not caught: a stage
553 /// that adds fills while it reads ahead, as `buffered` does, drops the venue's copy itself.
554 /// - Where the next read of a missed span starts is measured on `events`, so a fill this stage
555 /// drops or holds does not widen it, and the stage's own delay does not skew it.
556 /// - The returned events are indexed. A snapshot this stage changes so that it no longer
557 /// indexes is dropped and logged as an error, as is any other event the indexer rejects.
558 /// - Events this stage holds when the manager stops are not delivered.
559 ///
560 /// [`FillRecoveryFailure::Processing`]: crate::fill_recovery::FillRecoveryFailure::Processing
561 /// [`AccountEventKind::FillRecoveryGaveUp`]: crate::AccountEventKind::FillRecoveryGaveUp
562 /// [`AccountEventKind::Snapshot`]: crate::AccountEventKind::Snapshot
563 /// [`AccountEventKind::StreamTerminated`]: crate::AccountEventKind::StreamTerminated
564 /// [`AccountEventKind::ReinitFailed`]: crate::AccountEventKind::ReinitFailed
565 fn process_account_events(
566 &self,
567 events: BoxStream<'static, UnindexedAccountEvent>,
568 ) -> BoxStream<'static, UnindexedAccountEvent> {
569 events
570 }
571
572 /// Cancel an order at the venue.
573 ///
574 /// # Return value
575 ///
576 /// Every request gets exactly one answer, which the engine's `ExecutionManager` forwards as the
577 /// order's response. An implementation must not swallow a request: a failure to send it, or to
578 /// read the venue's answer, is an `Err`.
579 ///
580 /// - `Ok(Cancelled)`: the venue took the cancel. Whether the order has *ended* by then depends
581 /// on the venue; see below.
582 /// - `Err`: the venue refused the cancel, or it could not be sent or answered. The order may
583 /// still be open, or may have ended some other way, such as by filling. The account stream
584 /// reports which.
585 ///
586 /// # A taken cancel is not always an ended order
587 ///
588 /// Some venues answer a cancel before carrying it out. Until they do, the order is live and can
589 /// still fill. From those venues `Ok(Cancelled)` means only that the cancel was accepted, and
590 /// the account stream reports how the order actually ended, including any fill in between. The
591 /// engine stops tracking an order on `Ok(Cancelled)` from any venue.
592 ///
593 /// Binance, Hyperliquid and the mock venue answer once the order is cancelled. Alpaca answers
594 /// once it has accepted the cancel, and IBKR once the cancel is submitted. The
595 /// [connector comparison](crate::client#connector-comparison) lists the same for each
596 /// connector; the mock venue is not one, so it has no row there.
597 ///
598 /// # `filled_quantity` and `time_exchange`
599 ///
600 /// `Cancelled::filled_quantity` is the quantity the venue reported filled where the answer
601 /// carries one, which Binance's and the mock venue's do. Alpaca, IBKR and Hyperliquid answer
602 /// without it and report zero, which does not mean nothing filled. The fill total comes from
603 /// the account stream, or from [`Self::fetch_trades`]. Likewise `time_exchange` is the venue's
604 /// time where the answer carries one, and the local time the answer arrived otherwise.
605 fn cancel_order(
606 &self,
607 request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
608 ) -> impl Future<Output = UnindexedOrderResponseCancel> + Send;
609
610 // `+ Send` on default method return types for multi-threaded Tokio runtime
611 fn cancel_orders<'a>(
612 &self,
613 requests: impl IntoIterator<Item = OrderRequestCancel<ExchangeId, &'a InstrumentNameExchange>>,
614 ) -> impl Stream<Item = UnindexedOrderResponseCancel> + Send {
615 futures::stream::FuturesUnordered::from_iter(
616 requests
617 .into_iter()
618 .map(|request| self.cancel_order(request)),
619 )
620 }
621
622 /// Place an order on the exchange.
623 ///
624 /// # Return value
625 ///
626 /// Every request gets exactly one answer, which the engine's `ExecutionManager` forwards as the
627 /// order's response. An implementation must not swallow a request: a failure to send it, or to
628 /// read the venue's answer, is `OpenFailed`.
629 ///
630 /// Returns `OrderState` directly rather than `Result<Open, OrderError>`:
631 /// - `OrderState::Active(Open)` - order is resting on the order book
632 /// - `OrderState::Inactive(FullyFilled)` - order was immediately filled (includes `avg_price` when available)
633 /// - `OrderState::Inactive(Expired)` - order ended in the response itself without filling
634 /// completely, carrying what filled: an IOC or FOK order that found too little liquidity,
635 /// or one expired by self-trade prevention
636 /// - `OrderState::Inactive(Cancelled)` - order the venue reports cancelled in the response
637 /// itself, carrying what filled
638 /// - `OrderState::Inactive(OpenFailed)` - order placement failed (API error, connectivity, a
639 /// rejection the venue reports in the response, etc.)
640 ///
641 /// An implementation returns `Expired` or `Cancelled` only when its venue reports the order's
642 /// status in the placement response (currently Binance Spot and Margin). The others return
643 /// `Open` for such an order, and their account stream reports how it ended.
644 ///
645 /// This design allows immediate fills to carry metadata (e.g., `avg_price`) that
646 /// would be lost if we had to infer terminal state from `Open::filled_quantity`.
647 fn open_order(
648 &self,
649 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
650 ) -> impl Future<Output = Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>> + Send;
651
652 // `+ Send` on default method return types for multi-threaded Tokio runtime
653 fn open_orders<'a>(
654 &self,
655 requests: impl IntoIterator<Item = OrderRequestOpen<ExchangeId, &'a InstrumentNameExchange>>,
656 ) -> impl Stream<Item = Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>> + Send
657 {
658 futures::stream::FuturesUnordered::from_iter(
659 requests.into_iter().map(|request| self.open_order(request)),
660 )
661 }
662
663 /// Fetch current balances for the specified assets.
664 ///
665 /// An empty `assets` slice is the "return all" sentinel: implementations must return
666 /// balances for every asset held. When non-empty, only the listed assets are returned.
667 ///
668 /// # Balance time
669 ///
670 /// Each balance's `time_exchange` must be no later than the state it describes, because the
671 /// engine orders balance and debt state by it: a balance stamped later than its state would
672 /// block the stream's balance and debt events from between the two (see
673 /// [`AssetBalance::time_exchange`]). Stamp a balance with this host's time taken before the
674 /// request that returned it was sent, less how far the venue's REST view can trail its
675 /// account stream where the stream carries balances, never with a time taken after the
676 /// response. A time the venue states for the account's state can be used instead.
677 ///
678 /// A host clock ahead of the venue's carries into a stamp taken from this host's time. An
679 /// implementation that keeps an estimate of the venue's clock that is never ahead of it takes
680 /// the earlier of the two times instead, and says so in its docs. Otherwise keeping the two
681 /// clocks within bounds is the caller's obligation.
682 fn fetch_balances(
683 &self,
684 assets: &[AssetNameExchange],
685 ) -> impl Future<Output = Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError>> + Send;
686
687 /// Fetch currently open orders, optionally filtered by instrument.
688 ///
689 /// An empty `instruments` slice is the "return all" sentinel: implementations must
690 /// return open orders across all instruments. When non-empty, only orders for the
691 /// listed instruments are returned.
692 fn fetch_open_orders(
693 &self,
694 instruments: &[InstrumentNameExchange],
695 ) -> impl Future<
696 Output = Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError>,
697 > + Send;
698
699 /// Read the trades (fills) from `start` to `end`, both inclusive, optionally filtered by
700 /// instrument.
701 ///
702 /// An empty `instruments` slice is the "return all" sentinel: implementations must
703 /// return trades across all instruments. When non-empty, only trades for the listed
704 /// instruments are returned.
705 ///
706 /// # Bounded, resumable reads
707 ///
708 /// One call is bounded by the venue's limits, so it may stop before `end`.
709 /// [`TradesRead::resume`] says whether it did:
710 /// - `None`: every fill in the span the venue still holds was read.
711 /// - `Some(t)`: call again with `start = t` and the same `end` to read on. That read can
712 /// return again fills the previous one returned, so match them by instrument and
713 /// [`TradeId`](crate::trade::TradeId).
714 ///
715 /// The caller owns the loop, and with it how many calls, and how long, it spends on a span.
716 /// A `start` after `end` is an empty span, read as no trades and `resume: None`.
717 ///
718 /// `end` can lie past this host's present. A read placed on the venue's clock, as the
719 /// execution manager's missed-fill recovery is, ends past it to cover a venue clock ahead of
720 /// this host's. Implementations read to `end` or the venue's present, whichever is earlier,
721 /// and must not cut `end` to this host's clock.
722 ///
723 /// # Venue limits
724 ///
725 /// `resume: None` covers what the venue still holds, which can be less than the span:
726 /// - IBKR lists the last 7 days' executions at most.
727 /// - Hyperliquid keeps only each wallet's 10,000 most recent fills.
728 ///
729 /// Binance needs instruments: an empty slice reads nothing. Each client documents its limits.
730 ///
731 /// # Errors
732 ///
733 /// [`Truncated`](crate::error::ClientError::Truncated) when a read cannot advance, because
734 /// more fills share the span's first moment than one call can read past.
735 ///
736 /// The fee asset (`AssetNameExchange`) may be quote, base, or third-party (e.g., BNB).
737 /// Use `fees.fees_quote` for quote-equivalent value when available.
738 fn fetch_trades(
739 &self,
740 start: DateTime<Utc>,
741 end: DateTime<Utc>,
742 instruments: &[InstrumentNameExchange],
743 ) -> impl Future<
744 Output = Result<
745 TradesRead<AssetNameExchange, InstrumentNameExchange>,
746 UnindexedClientError,
747 >,
748 > + Send;
749}
750
751/// Extension trait for exchanges that support native bracket orders.
752///
753/// A bracket order consists of three linked orders:
754/// 1. **Entry**: Limit order to enter the position
755/// 2. **Take Profit**: Limit order to exit at profit target
756/// 3. **Stop Loss**: Stop (or stop-limit) order to exit at loss limit
757///
758/// When either exit leg fills, the exchange automatically cancels the other.
759///
760/// # Type-Level Capability
761///
762/// This is a supertrait of [`ExecutionClient`], enabling compile-time capability checks:
763/// - `impl ExecutionClient` — basic order operations
764/// - `impl ExecutionClient + BracketOrderClient` — includes bracket orders
765///
766/// This follows Rust idioms like `Read + Seek` or `Iterator + ExactSizeIterator`.
767///
768/// # Why Supertrait Over Alternatives
769///
770/// **vs. associated types on `ExecutionClient`**: Callers can't construct
771/// `Self::BracketRequest` without knowing the concrete type — adds trait surface
772/// without enabling generic use.
773///
774/// **vs. default impl returning `Unsupported`**: Puts a "dead method" on every
775/// client (MockExecution, BinanceSpot, HyperliquidClient). Compile-time capability
776/// via trait bounds is better than runtime errors.
777///
778/// # Result Types
779///
780/// [`BracketOrderResult`] uses `Option<Order>` for child legs to document API divergence:
781///
782/// | Exchange | `take_profit` | `stop_loss` | Reason |
783/// |----------|---------------|-------------|--------|
784/// | IBKR | `Some(...)` | `Some(...)` | Returns all three orders immediately |
785/// | Alpaca | `None` | `None` | Child legs created server-side |
786///
787/// # Example
788///
789/// ```ignore
790/// use rustrade_execution::client::{ExecutionClient, BracketOrderClient};
791/// use rustrade_execution::order::bracket::{BracketOrderRequest, RequestOpenBracket};
792///
793/// async fn place_bracket<C: ExecutionClient + BracketOrderClient>(
794/// client: &C,
795/// request: BracketOrderRequest<ExchangeId, &InstrumentNameExchange>,
796/// ) -> BracketOrderResult {
797/// client.open_bracket_order(request).await
798/// }
799/// ```
800pub trait BracketOrderClient: ExecutionClient {
801 /// Place a bracket order (entry + take-profit + stop-loss).
802 ///
803 /// # Request
804 ///
805 /// The [`BracketOrderRequest`] contains:
806 /// - `key`: Order key (exchange, instrument, strategy, client order ID)
807 /// - `state`: [`RequestOpenBracket`](crate::order::bracket::RequestOpenBracket) with
808 /// side, quantity, prices, and optional stop-loss limit price
809 ///
810 /// # Constraints
811 ///
812 /// - `time_in_force` must be `Day` or `GoodUntilCancelled` on most exchanges
813 /// - Entry order type is always `Limit`
814 /// - Price ordering must be valid for the side (see [`RequestOpenBracket`](crate::order::bracket::RequestOpenBracket))
815 ///
816 /// # Exchange-Specific Field Handling
817 ///
818 /// `RequestOpenBracket::stop_loss_limit_price` is **not honored uniformly**:
819 /// - **Alpaca**: When `Some`, the stop-loss leg becomes a stop-limit order at that price.
820 /// - **IBKR**: Silently ignored — the stop-loss leg is always a stop (market) order.
821 ///
822 /// Generic callers `T: BracketOrderClient` must treat this field as advisory.
823 ///
824 /// # Return Value
825 ///
826 /// Returns [`BracketOrderResult`] with:
827 /// - `parent`: Always present (entry order)
828 /// - `take_profit`: `Some` if exchange returns legs immediately (IBKR), `None` otherwise (Alpaca)
829 /// - `stop_loss`: `Some` if exchange returns legs immediately (IBKR), `None` otherwise (Alpaca)
830 ///
831 /// Either all orders are `Active(Open)` or all are `Inactive` (placement failed), with one
832 /// exception: a leg of a failed placement whose fate at the venue is unknown (no status
833 /// arrived, or it was accepted and its rollback cancel could not be sent) is returned
834 /// `Active(Open)`, so the account stream can resolve it. Only IBKR returns such legs today;
835 /// see its `open_bracket_order`.
836 fn open_bracket_order(
837 &self,
838 request: BracketOrderRequest<ExchangeId, &InstrumentNameExchange>,
839 ) -> impl Future<Output = BracketOrderResult> + Send;
840}
841
842/// Extension trait for clients that can say how an order ended at the venue.
843///
844/// A client that loses its account stream recovers the fills it missed when it reconnects, but not
845/// an order that was cancelled, expired or rejected in the meantime: the event that said so went
846/// out while nothing was listening. The engine then keeps such an order as live until a complete
847/// account snapshot no longer lists it, and even then cannot tell how it ended. This trait is the
848/// lookup that answers that: given the orders a caller still holds as live, it returns how each one
849/// that has ended did end.
850///
851/// # By order, not by time
852///
853/// The obvious alternative, asking the venue for every order closed since the disconnect, does not
854/// work. Binance's `allOrders` and Alpaca's `GET /v2/orders?status=closed` both filter their time
855/// window on when an order was **created**, so a window opening at the disconnect cannot see an
856/// order placed before it and cancelled during it — which is the order that matters, because the
857/// caller is tracking it. Looking each order up by its client order id finds it whenever it was
858/// placed.
859///
860/// # Type-Level Capability
861///
862/// A supertrait of [`ExecutionClient`], for the reason [`BracketOrderClient`] is one: a client that
863/// cannot look orders up does not implement it, rather than carrying a method that fails at run
864/// time. A caller that needs the lookup bounds on `ExecutionClient + OrderStatusClient`.
865pub trait OrderStatusClient: ExecutionClient {
866 /// How each of `orders` ended, for those that have.
867 ///
868 /// # Contract
869 ///
870 /// - One entry per order that has ended, in no particular order:
871 /// [`FullyFilled`](crate::order::state::InactiveOrderState::FullyFilled),
872 /// [`Cancelled`](crate::order::state::InactiveOrderState::Cancelled) carrying what filled
873 /// before it, [`Expired`](crate::order::state::InactiveOrderState::Expired), or
874 /// [`OpenFailed`](crate::order::state::InactiveOrderState::OpenFailed) for an order the venue
875 /// accepted for processing and later rejected (Binance `REJECTED`, Alpaca `rejected`). An
876 /// order rejected in the response to its open request was answered there and is not expected
877 /// here.
878 /// - An order still live is **omitted**, and so is one the venue does not know. Neither is an
879 /// error. A caller compares the result with what it asked for.
880 /// - An empty `orders` returns an empty list. Unlike
881 /// [`ExecutionClient::fetch_open_orders`]'s empty slice, it does not mean "all".
882 /// - An order is found by its [`cid`](crate::order::OrderKey::cid), and each returned order
883 /// carries the key it was asked for, so a `strategy` the venue does not record survives.
884 /// - A key whose `instrument` is not the one the order traded is treated as unknown. The
885 /// `exchange` is not checked: a client answers for its own venue.
886 /// - A `cid` asked about more than once is reported at most once, under the first key that
887 /// finds it.
888 ///
889 /// # Errors
890 ///
891 /// `Err` if the venue could not answer for every order. A partial list is never returned,
892 /// because an order missing from it would read as still live.
893 fn fetch_ended_orders(
894 &self,
895 orders: &[UnindexedOrderKey],
896 ) -> impl Future<Output = Result<Vec<UnindexedInactiveOrder>, UnindexedClientError>> + Send;
897
898 /// What the venue knows of the order under `key`: live, ended, or nothing.
899 ///
900 /// This is how an open request whose outcome is unknown is settled (see
901 /// [`OrderError::is_outcome_unknown`](crate::error::OrderError::is_outcome_unknown)).
902 ///
903 /// # Contract
904 ///
905 /// - [`VenueOrderStatus::Unknown`] means the venue lists the order neither as live nor as
906 /// ended, under the key's `instrument`. A venue may take a moment to list an order it has
907 /// just accepted, so one `Unknown` does not prove the order was never placed. A caller asks
908 /// again before it concludes that.
909 /// - The order is found by its [`cid`](crate::order::OrderKey::cid), and a found order
910 /// carries `key`.
911 ///
912 /// The provided method asks [`fetch_ended_orders`](Self::fetch_ended_orders) about `key`
913 /// first, then lists the open orders of its instrument with
914 /// [`ExecutionClient::fetch_open_orders`]. In that order, an order that is placed between
915 /// the two reads is listed as live, so it is not missed. An order that ends between them is
916 /// found by neither, and is reported `Unknown` once; the next lookup finds it ended. A client
917 /// whose venue answers in one request may override it.
918 ///
919 /// # Errors
920 ///
921 /// `Err` if either read fails. A failed read is never reported as `Unknown`.
922 fn fetch_order_status(
923 &self,
924 key: &UnindexedOrderKey,
925 ) -> impl Future<Output = Result<VenueOrderStatus, UnindexedClientError>> + Send
926 where
927 Self: Sync,
928 {
929 async move {
930 let ended = self.fetch_ended_orders(std::slice::from_ref(key)).await?;
931 if let Some(order) = ended.into_iter().find(|order| order.key.cid == key.cid) {
932 return Ok(VenueOrderStatus::Ended(Box::new(Order {
933 key: key.clone(),
934 ..order
935 })));
936 }
937
938 let open = self
939 .fetch_open_orders(std::slice::from_ref(&key.instrument))
940 .await?;
941 Ok(open
942 .into_iter()
943 .find(|order| order.key.cid == key.cid && order.key.instrument == key.instrument)
944 .map_or(VenueOrderStatus::Unknown, |order| {
945 VenueOrderStatus::Live(Box::new(Order {
946 key: key.clone(),
947 ..order
948 }))
949 }))
950 }
951 }
952}
953
954/// What a venue knows of one order, as
955/// [`OrderStatusClient::fetch_order_status`] reads it.
956#[derive(Debug, Clone, Eq, PartialEq)]
957pub enum VenueOrderStatus {
958 /// The order is live at the venue, in this state. Boxed, as `Unknown` carries nothing.
959 Live(Box<Order<ExchangeId, InstrumentNameExchange, Open>>),
960 /// The order has ended, as this says.
961 Ended(Box<UnindexedInactiveOrder>),
962 /// The venue lists the order neither as live nor as ended.
963 Unknown,
964}
965
966/// Extension trait for clients that can say what the venue would lend for a short sale.
967///
968/// Answers, before an order is sent, the question a short sale otherwise learns only from its
969/// rejection: whether the instrument may be sold short, whether it is easy to borrow, how much the
970/// venue has to lend and at what fee. Each venue reports a different subset, so each field of the
971/// [`Shortability`] it returns is optional; the implementations say which fields they fill.
972///
973/// # Type-Level Capability
974///
975/// A supertrait of [`ExecutionClient`], for the reason [`BracketOrderClient`] is one: a client
976/// whose venue has nothing to lend, or that does not report it, does not implement it. A simulated
977/// venue reads the same [`Shortability`] from a
978/// [`ShortabilityProvider`](crate::shortability::ShortabilityProvider) instead.
979pub trait ShortabilityClient: ExecutionClient {
980 /// What the venue says about lending `instrument` for a short sale.
981 ///
982 /// # Contract
983 ///
984 /// - **Advisory.** `Ok` describes the venue's lending as it reported it, not a promise to lend:
985 /// a short sale must still handle
986 /// [`ApiError::BorrowRejected`](crate::error::ApiError::BorrowRejected).
987 /// - **Not this account's capacity.** [`Shortability::available`] is what the venue has to
988 /// lend, not what is left of it for this account; that is
989 /// [`BorrowCapacityClient::fetch_borrow_capacity`], where a venue reports it.
990 /// - **May be expensive.** An implementation may make several requests, some of them heavy
991 /// against the venue's rate limit, or hold a scarce resource such as a market data line;
992 /// each says what it costs. Fetch it when deciding on a short, not on every tick.
993 /// - **May be slow.** Where the venue streams its answer rather than replying to a request,
994 /// the call waits for it, up to a timeout the implementation documents: seconds, not
995 /// milliseconds.
996 /// - **May outlive its future.** An implementation that reads on a blocking thread finishes
997 /// that read, and releases what it holds, even when the returned future is dropped first;
998 /// each says so.
999 ///
1000 /// # Errors
1001 ///
1002 /// - [`ApiError::InstrumentInvalid`](crate::error::ApiError::InstrumentInvalid) for an
1003 /// instrument the venue does not know, or one of a kind that is not sold short by borrowing
1004 /// it, such as an option.
1005 /// - An error, rather than a [`Shortability`] with nothing known, when the venue does not
1006 /// answer, including when the account is not entitled to the answer, such as one without the
1007 /// market data subscription a venue sends it through.
1008 fn fetch_shortability(
1009 &self,
1010 instrument: &InstrumentNameExchange,
1011 ) -> impl Future<Output = Result<Shortability, UnindexedClientError>> + Send;
1012}
1013
1014/// Extension trait for clients that can say how much more this account can borrow.
1015///
1016/// Where [`ShortabilityClient`] says what the venue has to lend, this says how much of it this
1017/// account could take now, given its collateral, its limit and what it has already borrowed. The
1018/// two are separate types because they are different facts; see [`BorrowCapacity`].
1019///
1020/// # Type-Level Capability
1021///
1022/// A supertrait of [`ExecutionClient`], for the reason [`BracketOrderClient`] is one. Only a venue
1023/// that lends against an account's collateral and reports what is left of it implements it. The
1024/// mock client implements it whether or not its simulated venue lends, and answers
1025/// [`ApiError::InstrumentInvalid`](crate::error::ApiError::InstrumentInvalid) when it does not.
1026pub trait BorrowCapacityClient: ExecutionClient {
1027 /// How much this account can borrow now for an order on `instrument` on `side`.
1028 ///
1029 /// A sell borrows the instrument's base asset, which is a short; a buy borrows its quote asset,
1030 /// which is a leveraged long. The result names that asset, and its amounts are in its units.
1031 ///
1032 /// # Contract
1033 ///
1034 /// **Advisory**, as [`BorrowCapacity`] says: the amounts are not reserved, so an order sized to
1035 /// them must still handle [`ApiError::BorrowRejected`](crate::error::ApiError::BorrowRejected).
1036 ///
1037 /// # Errors
1038 ///
1039 /// [`ApiError::InstrumentInvalid`](crate::error::ApiError::InstrumentInvalid) for an instrument
1040 /// the client cannot borrow against, such as a pair the venue does not trade on margin.
1041 fn fetch_borrow_capacity(
1042 &self,
1043 instrument: &InstrumentNameExchange,
1044 side: Side,
1045 ) -> impl Future<Output = Result<BorrowCapacity, UnindexedClientError>> + Send;
1046}
1047
1048/// The capability table, pinned.
1049///
1050/// [`ExecutionClient::SUPPORTED_KINDS`] is a `const`: widening one, or adding a kind a client builds
1051/// no request for, compiles and passes clippy. The only thing it changes is which instruments
1052/// [`ExecutionBuilder`](https://docs.rs/rustrade/latest/rustrade/execution/builder/struct.ExecutionBuilder.html)
1053/// admits — so the failure mode is not a broken build but an instrument reaching a venue that cannot
1054/// encode it, as a silently wrong order. These assertions exist so that changing a client's declared
1055/// capabilities is a deliberate edit to a test that says what the venue can route, rather than a
1056/// one-word diff nothing observes.
1057///
1058/// The real-client cases are each gated on their own feature, matching the module gates above; the
1059/// mock case is ungated, like its module. So with `default = []`, a bare
1060/// `cargo test -p rustrade-execution` compiles only the mock assertion — pinning a real client's
1061/// capabilities requires `--features <name>` or `--all-features`.
1062#[cfg(test)]
1063mod tests {
1064 use super::*;
1065 use crate::client::mock::MockExecution;
1066 use futures::StreamExt;
1067
1068 /// The provided stage passes each event on unchanged, in order.
1069 #[test]
1070 fn the_provided_account_event_stage_passes_events_on_unchanged() {
1071 use crate::{AccountEventKind, error::StreamTerminationReason};
1072 use rustrade_instrument::exchange::ExchangeId;
1073 use std::time::Duration;
1074
1075 let (_event_tx, event_rx) = tokio::sync::broadcast::channel(1);
1076 let (request_tx, _request_rx) = tokio::sync::mpsc::unbounded_channel();
1077 let client = MockExecution::new(ExchangeId::Mock, Utc::now, request_tx, event_rx);
1078 let terminated = |secs| UnindexedAccountEvent {
1079 exchange: ExchangeId::Mock,
1080 kind: AccountEventKind::StreamTerminated(StreamTerminationReason::RateLimited {
1081 retry_after: Duration::from_secs(secs),
1082 }),
1083 };
1084 let events = vec![terminated(1), terminated(2)];
1085
1086 let processed = futures::executor::block_on(
1087 client
1088 .process_account_events(futures::stream::iter(events.clone()).boxed())
1089 .collect::<Vec<_>>(),
1090 );
1091
1092 assert_eq!(processed, events);
1093 }
1094
1095 #[test]
1096 fn mock_supports_only_the_kinds_it_can_model() {
1097 // No expiry, funding schedule or contract chain in `MockExchange`, so `Perpetual`, `Future`
1098 // and `Option` have no faithful projection onto it. Kept in step with the projection in
1099 // `generate_mock_exchange_instruments`, which panics on a kind it cannot project.
1100 assert_eq!(
1101 <MockExecution<fn() -> DateTime<Utc>> as ExecutionClient>::SUPPORTED_KINDS,
1102 &[
1103 InstrumentKindDiscriminant::Spot,
1104 InstrumentKindDiscriminant::Cfd
1105 ]
1106 );
1107 }
1108
1109 #[cfg(feature = "binance")]
1110 #[test]
1111 fn binance_spot_and_margin_are_spot_only() {
1112 use crate::client::binance::{BinanceMargin, BinanceSpot};
1113
1114 assert_eq!(
1115 BinanceSpot::SUPPORTED_KINDS,
1116 &[InstrumentKindDiscriminant::Spot]
1117 );
1118 // Cross margin trades the same spot instruments on borrowed funds -- the leverage is in the
1119 // account, not the instrument kind.
1120 assert_eq!(
1121 BinanceMargin::SUPPORTED_KINDS,
1122 &[InstrumentKindDiscriminant::Spot]
1123 );
1124 }
1125
1126 #[cfg(feature = "hyperliquid")]
1127 #[test]
1128 fn hyperliquid_splits_perpetual_and_spot_across_two_clients() {
1129 use crate::client::hyperliquid::{HyperliquidClient, spot::HyperliquidSpotClient};
1130
1131 assert_eq!(
1132 HyperliquidClient::SUPPORTED_KINDS,
1133 &[InstrumentKindDiscriminant::Perpetual]
1134 );
1135 assert_eq!(
1136 HyperliquidSpotClient::SUPPORTED_KINDS,
1137 &[InstrumentKindDiscriminant::Spot]
1138 );
1139 }
1140
1141 #[cfg(feature = "alpaca")]
1142 #[test]
1143 fn alpaca_trades_equities_and_options() {
1144 use crate::client::alpaca::AlpacaClient;
1145
1146 assert_eq!(
1147 AlpacaClient::SUPPORTED_KINDS,
1148 &[
1149 InstrumentKindDiscriminant::Spot,
1150 InstrumentKindDiscriminant::Option
1151 ]
1152 );
1153 }
1154
1155 #[cfg(feature = "ibkr")]
1156 #[test]
1157 fn ibkr_omits_cfd_because_it_builds_no_contract_for_one() {
1158 use crate::client::ibkr::IbkrClient;
1159
1160 // `ContractConfig::to_contract` builds `STK`, `FUT`, `OPT` and `CASH` and nothing else, so a
1161 // CFD routed here would be encoded as some other security type -- exactly the silent
1162 // wrong-symbol order this const exists to reject. Adding `Cfd` to the list requires adding
1163 // the arm first.
1164 assert_eq!(
1165 IbkrClient::SUPPORTED_KINDS,
1166 &[
1167 InstrumentKindDiscriminant::Spot,
1168 InstrumentKindDiscriminant::Future,
1169 InstrumentKindDiscriminant::Option
1170 ]
1171 );
1172 }
1173
1174 /// Answers `fetch_ended_orders` and `fetch_open_orders` with fixed lists, whatever is asked.
1175 #[derive(Debug, Clone, Default)]
1176 struct ListingClient {
1177 ended: Vec<UnindexedInactiveOrder>,
1178 open: Vec<Order<ExchangeId, InstrumentNameExchange, Open>>,
1179 }
1180
1181 impl ExecutionClient for ListingClient {
1182 const EXCHANGE: ExchangeId = ExchangeId::Simulated;
1183 const SUPPORTED_KINDS: &'static [InstrumentKindDiscriminant] = &[];
1184 type Config = ();
1185 type AccountStream = futures::stream::Empty<UnindexedAccountEvent>;
1186
1187 fn new(_: Self::Config) -> Self {
1188 Self::default()
1189 }
1190
1191 async fn account_snapshot(
1192 &self,
1193 _: &[AssetNameExchange],
1194 _: &[InstrumentNameExchange],
1195 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
1196 unreachable!("not a lookup")
1197 }
1198
1199 async fn account_stream(
1200 &self,
1201 _: &[AssetNameExchange],
1202 _: &[InstrumentNameExchange],
1203 ) -> Result<Self::AccountStream, UnindexedClientError> {
1204 unreachable!("not a lookup")
1205 }
1206
1207 async fn cancel_order(
1208 &self,
1209 _: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
1210 ) -> UnindexedOrderResponseCancel {
1211 unreachable!("not a lookup")
1212 }
1213
1214 async fn open_order(
1215 &self,
1216 _: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
1217 ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
1218 unreachable!("not a lookup")
1219 }
1220
1221 async fn fetch_balances(
1222 &self,
1223 _: &[AssetNameExchange],
1224 ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
1225 unreachable!("not a lookup")
1226 }
1227
1228 async fn fetch_open_orders(
1229 &self,
1230 _: &[InstrumentNameExchange],
1231 ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError>
1232 {
1233 Ok(self.open.clone())
1234 }
1235
1236 async fn fetch_trades(
1237 &self,
1238 _: DateTime<Utc>,
1239 _: DateTime<Utc>,
1240 _: &[InstrumentNameExchange],
1241 ) -> Result<TradesRead<AssetNameExchange, InstrumentNameExchange>, UnindexedClientError>
1242 {
1243 unreachable!("not a lookup")
1244 }
1245 }
1246
1247 impl OrderStatusClient for ListingClient {
1248 async fn fetch_ended_orders(
1249 &self,
1250 _: &[UnindexedOrderKey],
1251 ) -> Result<Vec<UnindexedInactiveOrder>, UnindexedClientError> {
1252 Ok(self.ended.clone())
1253 }
1254 }
1255
1256 /// An order under `cid` on `instrument`, as a venue lists it: with no strategy.
1257 fn listed<State>(
1258 instrument: &str,
1259 cid: &str,
1260 quantity: rust_decimal::Decimal,
1261 state: State,
1262 ) -> Order<ExchangeId, InstrumentNameExchange, State> {
1263 Order {
1264 key: crate::order::OrderKey {
1265 exchange: ExchangeId::Simulated,
1266 instrument: InstrumentNameExchange::new(instrument),
1267 strategy: crate::order::id::StrategyId::unknown(),
1268 cid: crate::order::id::ClientOrderId::new(cid),
1269 },
1270 side: Side::Buy,
1271 price: None,
1272 quantity,
1273 kind: crate::order::OrderKind::Market,
1274 time_in_force: crate::order::TimeInForce::ImmediateOrCancel,
1275 state,
1276 }
1277 }
1278
1279 fn open_state() -> Open {
1280 Open::new(
1281 crate::order::id::VenueOrderId::Assigned(crate::order::id::OrderId::new("v")),
1282 DateTime::<Utc>::MIN_UTC,
1283 rust_decimal::Decimal::ZERO,
1284 )
1285 }
1286
1287 fn cancelled_state() -> crate::order::state::UnindexedInactiveOrderState {
1288 crate::order::state::InactiveOrderState::Cancelled(crate::order::state::Cancelled::new(
1289 crate::order::id::OrderId::new("v"),
1290 DateTime::<Utc>::MIN_UTC,
1291 None,
1292 ))
1293 }
1294
1295 /// The provided lookup picks the order under the asked client order id and instrument out of
1296 /// what each read lists, ended first, and reports it under the asked key.
1297 #[tokio::test]
1298 async fn fetch_order_status_finds_the_order_asked_about_under_its_key() {
1299 use rust_decimal::Decimal;
1300
1301 let key = UnindexedOrderKey {
1302 exchange: ExchangeId::Simulated,
1303 instrument: InstrumentNameExchange::new("btc_usdt"),
1304 strategy: crate::order::id::StrategyId::new("asked"),
1305 cid: crate::order::id::ClientOrderId::new("target"),
1306 };
1307 // Decoys come first: another order on the instrument, and the id on another instrument.
1308 let open = vec![
1309 listed("btc_usdt", "other", Decimal::ONE, open_state()),
1310 listed("eth_usdt", "target", Decimal::TWO, open_state()),
1311 listed("btc_usdt", "target", Decimal::TEN, open_state()),
1312 ];
1313
1314 let live = ListingClient {
1315 ended: Vec::new(),
1316 open: open.clone(),
1317 };
1318 match live.fetch_order_status(&key).await {
1319 Ok(VenueOrderStatus::Live(order)) => {
1320 assert_eq!(order.key, key, "reported under the asked key");
1321 assert_eq!(order.quantity, Decimal::TEN, "the asked order: {order:?}");
1322 }
1323 other => panic!("expected live, got {other:?}"),
1324 }
1325
1326 let ended = ListingClient {
1327 ended: vec![
1328 listed("btc_usdt", "other", Decimal::ONE, cancelled_state()),
1329 listed("btc_usdt", "target", Decimal::TEN, cancelled_state()),
1330 ],
1331 open,
1332 };
1333 match ended.fetch_order_status(&key).await {
1334 Ok(VenueOrderStatus::Ended(order)) => {
1335 assert_eq!(order.key, key, "reported under the asked key");
1336 assert_eq!(order.quantity, Decimal::TEN, "the asked order: {order:?}");
1337 }
1338 other => panic!("expected ended, got {other:?}"),
1339 }
1340
1341 let unknown = ListingClient {
1342 ended: vec![listed("btc_usdt", "other", Decimal::ONE, cancelled_state())],
1343 open: vec![
1344 listed("btc_usdt", "other", Decimal::ONE, open_state()),
1345 listed("eth_usdt", "target", Decimal::TWO, open_state()),
1346 ],
1347 };
1348 assert_eq!(
1349 unknown.fetch_order_status(&key).await,
1350 Ok(VenueOrderStatus::Unknown)
1351 );
1352 }
1353}