Skip to main content

rustrade_execution/
error.rs

1//! Error types for [`ExecutionClient`](super::client::ExecutionClient) operations.
2//!
3//! # Retry Semantics
4//!
5//! Use [`ClientError::is_transient`] to determine if an operation should be retried.
6//! Transient errors (connectivity issues, rate limits) may succeed on retry with
7//! appropriate backoff. Non-transient errors (invalid instrument, insufficient
8//! balance) will fail identically on retry — the caller must change the request.
9//!
10//! The `is_transient()` method is the stable contract for retry decisions. Prefer
11//! it over pattern matching on specific variants, as the internal taxonomy may
12//! evolve while `is_transient()` semantics remain stable.
13
14use derive_more::Constructor;
15use rust_decimal::Decimal;
16use rustrade_instrument::{
17    asset::{AssetIndex, name::AssetNameExchange},
18    exchange::ExchangeId,
19    index::error::IndexError,
20    instrument::{InstrumentIndex, name::InstrumentNameExchange},
21};
22use rustrade_integration::error::SocketError;
23use serde::{Deserialize, Serialize};
24use std::{fmt, time::Duration};
25use thiserror::Error;
26
27/// Type alias for a [`ClientError`] that is keyed on [`AssetNameExchange`] and
28/// [`InstrumentNameExchange`] (yet to be indexed).
29pub type UnindexedClientError = ClientError<AssetNameExchange, InstrumentNameExchange>;
30
31/// Type alias for a [`ApiError`] that is keyed on [`AssetNameExchange`] and
32/// [`InstrumentNameExchange`] (yet to be indexed).
33pub type UnindexedApiError = ApiError<AssetNameExchange, InstrumentNameExchange>;
34
35/// Type alias for a [`OrderError`] that is keyed on [`AssetNameExchange`] and
36/// [`InstrumentNameExchange`] (yet to be indexed).
37pub type UnindexedOrderError = OrderError<AssetNameExchange, InstrumentNameExchange>;
38
39/// Represents all errors produced by an [`ExecutionClient`](super::client::ExecutionClient).
40#[non_exhaustive]
41#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Error)]
42pub enum ClientError<AssetKey = AssetIndex, InstrumentKey = InstrumentIndex> {
43    /// Connectivity based error.
44    ///
45    /// eg/ Timeout.
46    #[error("Connectivity: {0}")]
47    Connectivity(#[from] ConnectivityError),
48
49    /// API based error.
50    ///
51    /// eg/ RateLimit.
52    #[error("API: {0}")]
53    Api(#[from] ApiError<AssetKey, InstrumentKey>),
54
55    /// A background task panicked or was cancelled during an operation.
56    ///
57    /// This indicates a bug or unexpected runtime condition (e.g., a tokio
58    /// `spawn_blocking` task panicked). The operation was not retried and
59    /// the caller should treat this as non-recoverable, requiring operator
60    /// attention.
61    #[error("task failed: {0}")]
62    TaskFailed(String),
63
64    /// An opaque error from an upstream library that cannot be further classified.
65    ///
66    /// This is a catch-all for errors that don't fit into [`Self::Connectivity`] or
67    /// [`Self::Api`] categories — typically because the upstream library (e.g., ibapi,
68    /// binance-sdk) returns unstructured errors.
69    ///
70    /// Conservatively treated as non-transient. If you encounter this error
71    /// frequently, consider filing an issue to improve error classification.
72    #[error("internal error: {0}")]
73    Internal(String),
74
75    /// A bounded read of fills could not advance past its start: more fills share the span's
76    /// first moment than one call can read, so a read from the same start would stop at the same
77    /// place.
78    ///
79    /// [`ExecutionClient::fetch_trades`](crate::client::ExecutionClient::fetch_trades) returns it
80    /// rather than a [`TradesRead`](crate::trade::TradesRead) whose `resume` could only loop. The
81    /// fills it read are not returned. Callers should alert operators and reconcile the span
82    /// another way.
83    #[error("trade read stopped after {fills_read} fills without advancing past its start")]
84    Truncated {
85        /// How many fills the read returned before it stopped.
86        fills_read: usize,
87    },
88
89    /// Open orders snapshot was truncated at the API's row limit.
90    ///
91    /// Unlike [`Self::Truncated`] (which applies to paginated fill reads), this
92    /// error indicates a single-request endpoint hit its maximum row count.
93    /// Alpaca's `/v2/orders` endpoint caps results at 500; accounts with more
94    /// concurrent open orders will have an incomplete snapshot.
95    ///
96    /// Callers should alert operators — an incomplete order snapshot can cause
97    /// duplicate submissions, missed cancellations, or incorrect position sizing.
98    #[error("open orders snapshot truncated at {limit} results — data may be incomplete")]
99    TruncatedSnapshot {
100        /// Maximum number of rows returned by the single-request endpoint.
101        limit: usize,
102    },
103}
104
105impl<AssetKey, InstrumentKey> ClientError<AssetKey, InstrumentKey> {
106    /// Returns `true` if this error is likely transient and the operation
107    /// may succeed if retried after a suitable backoff.
108    ///
109    /// The caller is responsible for retry limits and backoff strategy.
110    /// This method classifies the error only — it does not implement policy.
111    ///
112    /// # Transient errors
113    /// - [`Connectivity`](Self::Connectivity) errors (timeout, socket, offline)
114    /// - [`Api::RateLimit`](ApiError::RateLimit), though not before its `retry_after`
115    ///
116    /// # Non-transient errors
117    /// - Other [`Api`](Self::Api) errors (invalid instrument, insufficient balance, etc.)
118    /// - [`TaskFailed`](Self::TaskFailed) (indicates a bug)
119    /// - [`Internal`](Self::Internal) (unknown — conservatively non-transient)
120    /// - [`Truncated`](Self::Truncated) / [`TruncatedSnapshot`](Self::TruncatedSnapshot)
121    pub fn is_transient(&self) -> bool {
122        match self {
123            Self::Connectivity(e) => e.is_transient(),
124            Self::Api(ApiError::RateLimit { .. }) => true,
125            Self::Api(_) => false,
126            Self::TaskFailed(_) => false,
127            Self::Internal(_) => false,
128            Self::Truncated { .. } => false,
129            Self::TruncatedSnapshot { .. } => false,
130        }
131    }
132
133    /// The venue's minimum wait before a retry: `Some` only for an
134    /// [`Api::RateLimit`](ApiError::RateLimit) whose venue sent a hint the client could read. See
135    /// [`ApiError::retry_after`].
136    pub fn retry_after(&self) -> Option<Duration> {
137        match self {
138            Self::Api(error) => error.retry_after(),
139            _ => None,
140        }
141    }
142}
143
144/// Represents all connectivity-centric errors.
145///
146/// Connectivity errors are generally intermittent / non-deterministic (eg/ Timeout).
147/// All variants are transient — retry with exponential backoff (typically 1-30s).
148#[non_exhaustive]
149#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Error)]
150pub enum ConnectivityError {
151    /// Exchange is offline, likely due to scheduled maintenance.
152    ///
153    /// Transient — retry with backoff. Maintenance windows typically last minutes
154    /// to hours; consider longer backoff intervals (30s-5min) to avoid log spam.
155    #[error("Exchange offline: {0}")]
156    ExchangeOffline(ExchangeId),
157
158    /// Request timed out before a response was received.
159    ///
160    /// Transient — retry with backoff. May indicate network congestion, server
161    /// overload, or an overly aggressive timeout. Consider increasing timeout
162    /// on subsequent attempts.
163    #[error("request timed out")]
164    Timeout,
165
166    /// Network-level socket error (connection refused, reset, DNS failure, etc.).
167    ///
168    /// Transient — retry with backoff. If persistent, may indicate firewall
169    /// issues, incorrect endpoint configuration, or prolonged server outage.
170    #[error("{0}")]
171    Socket(String),
172}
173
174impl From<SocketError> for ConnectivityError {
175    fn from(value: SocketError) -> Self {
176        Self::Socket(value.to_string())
177    }
178}
179
180impl ConnectivityError {
181    /// Returns `true` if this connectivity error is transient.
182    ///
183    /// All connectivity errors are considered transient — they represent
184    /// temporary network or server conditions that may resolve with retry.
185    pub fn is_transient(&self) -> bool {
186        match self {
187            Self::ExchangeOffline(_) => true,
188            Self::Timeout => true,
189            Self::Socket(_) => true,
190        }
191    }
192}
193
194/// Represents all API errors generated by an exchange.
195///
196/// These typically indicate a request is invalid for some reason (eg/ BalanceInsufficient).
197/// Most variants are **not transient** — the same request will fail identically on retry.
198/// The exception is [`RateLimit`](Self::RateLimit), which is transient.
199#[non_exhaustive]
200#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Error)]
201pub enum ApiError<AssetKey = AssetIndex, InstrumentKey = InstrumentIndex> {
202    /// Provided asset identifier is invalid or not supported.
203    ///
204    /// For example:
205    /// - The [`AssetNameExchange`] was an invalid format.
206    ///
207    /// Not transient — do not retry. The asset identifier must be corrected.
208    ///
209    /// Indexed only when the [`ExecutionInstrumentMap`] holds the asset. Otherwise there is no
210    /// index to carry, and [`AccountEventIndexer`] reports it as
211    /// [`OrderRejected`](Self::OrderRejected) (from an order request) or
212    /// [`RequestRejected`](Self::RequestRejected) (from any other request), holding this error's
213    /// own message.
214    ///
215    /// [`ExecutionInstrumentMap`]: crate::map::ExecutionInstrumentMap
216    /// [`AccountEventIndexer`]: crate::indexer::AccountEventIndexer
217    #[error("asset {0} invalid: {1}")]
218    AssetInvalid(AssetKey, String),
219
220    /// Provided instrument identifier is invalid or not supported.
221    ///
222    /// For example:
223    /// - The exchange does not have a market for an instrument.
224    /// - The [`InstrumentNameExchange`] was an invalid format.
225    ///
226    /// Not transient — do not retry. The instrument identifier must be corrected.
227    ///
228    /// Indexed only when the [`ExecutionInstrumentMap`] holds the instrument; otherwise reported
229    /// as [`AssetInvalid`](Self::AssetInvalid) describes.
230    ///
231    /// [`ExecutionInstrumentMap`]: crate::map::ExecutionInstrumentMap
232    #[error("instrument {0} invalid: {1}")]
233    InstrumentInvalid(InstrumentKey, String),
234
235    /// Request was rejected due to rate limiting.
236    ///
237    /// The exchange enforces request quotas and the caller has exceeded them. The client may
238    /// already have waited and retried within its own bounded retries before surfacing this.
239    ///
240    /// `retry_after` is the venue's own minimum wait, measured from when the client read the
241    /// refusal: do not retry sooner. `None` means the venue sent no hint, or the client cannot
242    /// read it, and is not permission to retry at once. Which clients fill it:
243    /// - **Alpaca:** from a 429's `Retry-After`, or else `X-RateLimit-Reset`, once the client's
244    ///   own retries are exhausted.
245    /// - **Binance:** from a REST 429's or 418's `Retry-After`, or the time a WebSocket API
246    ///   refusal's `retryAfter` names, measured against the local clock. Uncapped, since a ban can
247    ///   last days, so add it to an `Instant` with `checked_add`. A request the client held back
248    ///   unsent, because an earlier refusal's wait had longer left than the client sleeps
249    ///   through, reports the time left.
250    /// - **Hyperliquid:** never: its SDK keeps no headers, and Hyperliquid documents no hint.
251    ///
252    /// A venue ban (Binance's HTTP 418) is reported the same way: its hint, when readable, says
253    /// how long it lasts.
254    ///
255    /// Transient — retry after `retry_after` when given, otherwise with backoff.
256    ///
257    /// The variant is `#[non_exhaustive]`: outside this crate, build one with
258    /// [`ApiError::rate_limited`], match it as `RateLimit { .. }`, and read the wait with
259    /// [`ApiError::retry_after`].
260    #[error("rate limit exceeded{}", RetryAfter(*retry_after))]
261    #[non_exhaustive]
262    RateLimit { retry_after: Option<Duration> },
263
264    /// The venue refused because a cap on something held at once is full, such as IBKR's market
265    /// data lines, which every TWS session and API client on the account shares.
266    ///
267    /// Unlike [`RateLimit`](Self::RateLimit), waiting does not clear it: releasing something
268    /// does, such as cancelling another subscription, whether this client holds it or another
269    /// session on the same account does. The message carries the venue's words.
270    ///
271    /// Not transient: the same request retried with backoff alone meets the same refusal while
272    /// the cap stays full.
273    #[error("venue capacity exceeded: {0}")]
274    CapacityExceeded(String),
275
276    /// Authentication failed (invalid credentials, expired key, bad signature).
277    ///
278    /// Unlike other API errors which affect a single request, authentication
279    /// failures indicate that **all** subsequent requests will fail until
280    /// credentials are corrected. Callers should halt trading and alert operators.
281    ///
282    /// Not transient — do not retry. Fix credentials and restart.
283    #[error("authentication failed: {0}")]
284    Unauthenticated(String),
285
286    /// Balance of an asset is insufficient to execute the requested operation.
287    ///
288    /// The asset is `None` when it is not known which asset ran short:
289    /// - the venue's rejection does not name it (e.g. Binance's "Account has insufficient
290    ///   balance for requested action", or any Hyperliquid margin or spot-balance refusal), and
291    ///   a client does not guess it from the order;
292    /// - the venue named an asset that the [`ExecutionInstrumentMap`] does not hold, so it has no
293    ///   index. [`AccountEventIndexer::api_error`] logs the name it dropped.
294    ///
295    /// The `String` carries the venue's message.
296    ///
297    /// Not transient — do not retry the same request. Reduce order size or
298    /// deposit additional funds.
299    ///
300    /// [`ExecutionInstrumentMap`]: crate::map::ExecutionInstrumentMap
301    /// [`AccountEventIndexer::api_error`]: crate::indexer::AccountEventIndexer::api_error
302    #[error(
303        "balance insufficient{asset}: {1}",
304        asset = .0.as_ref().map(|asset| format!(" for asset {asset}")).unwrap_or_default()
305    )]
306    BalanceInsufficient(Option<AssetKey>, String),
307
308    /// Order was rejected by the exchange for a business rule violation.
309    ///
310    /// Common causes include: price outside allowed range, quantity below
311    /// minimum, post-only order would cross, reduce-only with no position.
312    ///
313    /// Not transient — do not retry the same request. Adjust order parameters.
314    #[error("order rejected: {0}")]
315    OrderRejected(String),
316
317    /// Cancel request failed because the order was already cancelled.
318    ///
319    /// This is a state conflict, not an error per se — the desired end state
320    /// (order cancelled) has already been achieved.
321    ///
322    /// Not transient — do not retry. The order is already in the cancelled state.
323    #[error("order already cancelled")]
324    OrderAlreadyCancelled,
325
326    /// Cancel request failed because the order was already fully filled.
327    ///
328    /// This is a state conflict — the order completed before the cancel arrived.
329    /// The caller should reconcile their local state with the fill.
330    ///
331    /// Not transient — do not retry. The order no longer exists to cancel.
332    #[error("order already fully filled")]
333    OrderAlreadyFullyFilled,
334
335    /// Cancel request failed because the order had already expired.
336    ///
337    /// This is a state conflict — the order's own deadline retired it before the cancel arrived,
338    /// so the desired end state (order no longer working) has already been achieved. Distinct from
339    /// [`OrderAlreadyCancelled`](Self::OrderAlreadyCancelled) because nobody asked for it: a
340    /// time-in-force such as [`GoodTillDate`](crate::order::TimeInForce::GoodTillDate) retired it
341    /// unprompted, and a caller reconciling its local state needs to know which of the two happened.
342    ///
343    /// Not transient — do not retry. The order no longer exists to cancel.
344    #[error("order already expired")]
345    OrderAlreadyExpired,
346
347    /// Open request refused because its [`ClientOrderId`] is already in use at the venue.
348    ///
349    /// The refusal concerns this request alone: the order already under the id is **unaffected**,
350    /// and is not reported ended by it. What "in use" means is the venue's rule. On the
351    /// [`SimulatedVenue`] and the IBKR client it is an order still working under the id, and an id
352    /// is free again once its order has ended. Binance documents an id as unique among open
353    /// orders. The `String` carries the venue's message, or the client's own where it refused the
354    /// request before sending it.
355    ///
356    /// Reported by the [`SimulatedVenue`], by the IBKR client, which refuses the request before it
357    /// reaches TWS, since TWS never sees a client order id, and by the Binance spot and margin
358    /// clients, from Binance's `"Duplicate order sent."`. Other clients report a venue's duplicate
359    /// rejection as [`OrderRejected`](Self::OrderRejected) until its message has been confirmed.
360    ///
361    /// Not transient — the same request fails identically while the order under the id works.
362    /// Use another id, or wait for that order to end.
363    ///
364    /// [`ClientOrderId`]: crate::order::id::ClientOrderId
365    /// [`SimulatedVenue`]: crate::exchange::mock::SimulatedVenue
366    #[error("client order id in use: {0}")]
367    DuplicateClientOrderId(String),
368
369    /// The exchange refused the request itself, rather than an order it carried.
370    ///
371    /// The request was malformed or asked for something the venue does not serve — a missing,
372    /// malformed or out-of-range parameter, such as a query time window longer than the venue
373    /// allows. Distinct from [`OrderRejected`](Self::OrderRejected), which is an order failing a
374    /// business rule; this variant arises from queries and other non-order requests. The message
375    /// carries the venue's own error code where it provides one (e.g. `-1127 ...` on Binance).
376    ///
377    /// Not transient — the same request fails identically on retry. Change the request.
378    #[error("request rejected: {0}")]
379    RequestRejected(String),
380
381    /// Order refused because the venue would not lend what it needed: a short sale, or a margin
382    /// order that borrows to cover itself.
383    ///
384    /// Distinct from [`OrderRejected`](Self::OrderRejected) so a caller can tell a refused borrow
385    /// apart from a malformed order, and from [`BalanceInsufficient`](Self::BalanceInsufficient),
386    /// which is the account's own funds running short. A borrow can be refused for a long too: on
387    /// Binance margin, an order that borrows to buy is refused when the venue has too little of
388    /// the asset to lend or the borrow would exceed the account's limit. [`BorrowReject`] says
389    /// why.
390    ///
391    /// Reported by the Binance margin client, by Binance's code, by the Alpaca client, by the
392    /// text of a 403 or 422, and by the
393    /// [`SimulatedVenue`](crate::exchange::mock::SimulatedVenue), for a CFD short its
394    /// [`ShortabilityProvider`](crate::shortability::ShortabilityProvider) does not allow. Other
395    /// clients report such refusals as [`OrderRejected`](Self::OrderRejected) until their venue's
396    /// wording is confirmed.
397    ///
398    /// Not transient — the same request fails identically until the venue's lending changes.
399    #[error("borrow rejected: {0}")]
400    BorrowRejected(BorrowReject),
401}
402
403impl<AssetKey, InstrumentKey> ApiError<AssetKey, InstrumentKey> {
404    /// A [`RateLimit`](Self::RateLimit) refusal, with the venue's minimum wait when it sent one.
405    pub fn rate_limited(retry_after: Option<Duration>) -> Self {
406        Self::RateLimit { retry_after }
407    }
408
409    /// The venue's minimum wait before a retry: `Some` only for a
410    /// [`RateLimit`](Self::RateLimit) whose venue sent a hint the client could read.
411    pub fn retry_after(&self) -> Option<Duration> {
412        match self {
413            Self::RateLimit { retry_after } => *retry_after,
414            _ => None,
415        }
416    }
417}
418
419/// Formats a [`RateLimit`](ApiError::RateLimit)'s hint for its message: nothing when there is
420/// none.
421struct RetryAfter(Option<Duration>);
422
423impl fmt::Display for RetryAfter {
424    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
425        match self.0 {
426            Some(retry_after) => write!(f, "; retry after {retry_after:?}"),
427            None => Ok(()),
428        }
429    }
430}
431
432/// Why a venue refused to lend for an order, as carried by [`ApiError::BorrowRejected`].
433#[non_exhaustive]
434#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize)]
435pub struct BorrowReject {
436    /// The kind of refusal.
437    pub reason: BorrowRejectReason,
438    /// The venue's own code for the refusal, where it gave one (e.g. `-3045` on Binance,
439    /// `40310000` on Alpaca).
440    ///
441    /// For diagnostics only, not a stable contract: match on [`reason`](Self::reason) instead. A
442    /// venue may reuse a code for several refusals, as Alpaca's generic `40310000` is.
443    pub venue_code: Option<String>,
444    /// The venue's message. It may itself carry the code, as Binance's does.
445    pub message: String,
446    /// How much the venue could still have lent, in the units the order was sized in, where
447    /// known: the most a smaller order could have sold short. Set by the simulated venue; no live
448    /// client reports it yet.
449    pub available: Option<Decimal>,
450}
451
452impl BorrowReject {
453    /// A refusal for `reason`, with the venue's code where it gave one, and its message.
454    pub fn new(reason: BorrowRejectReason, venue_code: Option<String>, message: String) -> Self {
455        Self {
456            reason,
457            venue_code,
458            message,
459            available: None,
460        }
461    }
462
463    /// With how much the venue could still have lent.
464    #[must_use]
465    pub fn with_available(mut self, available: Decimal) -> Self {
466        self.available = Some(available);
467        self
468    }
469}
470
471impl fmt::Display for BorrowReject {
472    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
473        let Self {
474            reason,
475            venue_code,
476            message,
477            available,
478        } = self;
479        write!(f, "{reason}")?;
480        if let Some(code) = venue_code {
481            write!(f, " (code {code})")?;
482        }
483        write!(f, ": {message}")?;
484        if let Some(available) = available {
485            write!(f, " ({available} available)")?;
486        }
487        Ok(())
488    }
489}
490
491/// The kind of borrow refusal carried by a [`BorrowReject`].
492#[non_exhaustive]
493#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize)]
494pub enum BorrowRejectReason {
495    /// The instrument cannot be sold short at all, whoever asks (e.g. Alpaca's "asset X cannot be
496    /// sold short").
497    NotShortable,
498    /// Borrowing is barred for this account, or for this asset on this account (e.g. Binance
499    /// `-3008`, `-3012` and `-3014`, Alpaca's "account is not allowed to short").
500    BorrowDisabled,
501    /// The venue has too little of the asset to lend right now (e.g. Binance `-3045`). It may
502    /// lend again later.
503    InventoryUnavailable,
504    /// The borrow would exceed what the account may borrow (e.g. Binance `-3006`).
505    BorrowLimitExceeded,
506    /// A short-sale restriction is in effect, such as the SEC's Rule 201 circuit breaker after a
507    /// 10% fall, and the order would sell short at or below the best bid. A short sale priced
508    /// above the bid may still be accepted.
509    ShortSaleRestricted,
510    /// A borrow refusal of a kind not listed here. The [`BorrowReject`] message says which.
511    Other,
512}
513
514impl fmt::Display for BorrowRejectReason {
515    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
516        f.write_str(match self {
517            Self::NotShortable => "not shortable",
518            Self::BorrowDisabled => "borrow disabled",
519            Self::InventoryUnavailable => "inventory unavailable",
520            Self::BorrowLimitExceeded => "borrow limit exceeded",
521            Self::ShortSaleRestricted => "short sale restricted",
522            Self::Other => "other",
523        })
524    }
525}
526
527/// Represents all errors that can be generated when cancelling or opening orders.
528///
529/// This is a subset of [`ClientError`] for order-specific operations. Use
530/// [`is_transient()`](Self::is_transient) to determine retry eligibility.
531#[non_exhaustive]
532#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Error)]
533pub enum OrderError<AssetKey = AssetIndex, InstrumentKey = InstrumentIndex> {
534    /// Connectivity-based error (timeout, socket failure, exchange offline), or a venue failure
535    /// that leaves the order's status unknown.
536    ///
537    /// Transient — retry with backoff. See [`ConnectivityError`] for details. The order may or
538    /// may not have reached the matching engine, so before resubmitting an open request,
539    /// reconcile its state (open orders, fills); a blind retry can place it twice.
540    #[error("connectivity: {0}")]
541    Connectivity(#[from] ConnectivityError),
542
543    /// API-based error (rate limit, invalid instrument, order rejected, etc.).
544    ///
545    /// Retry semantics depend on the specific [`ApiError`] variant. Only
546    /// [`ApiError::RateLimit`] is transient; other variants are not.
547    #[error("order rejected: {0}")]
548    Rejected(#[from] ApiError<AssetKey, InstrumentKey>),
549
550    /// The order type is not supported by this connector.
551    ///
552    /// Non-transient — the connector does not support this order type (e.g.,
553    /// trailing stop orders on a connector that only supports market/limit).
554    #[error("unsupported order type: {0}")]
555    UnsupportedOrderType(String),
556
557    /// A quantity or price in the request has more precision than the venue accepts, so the
558    /// client refused it without sending it.
559    ///
560    /// Non-transient — change the request. A client that refuses this way never rounds a value
561    /// itself, because rounding would place a different order than the one requested; it
562    /// documents how to read the venue's precision so the caller can round first.
563    #[error("invalid precision: {0}")]
564    InvalidPrecision(PrecisionViolation),
565}
566
567/// A value in an order request with more precision than the venue accepts, or that is not
568/// positive.
569///
570/// Carried by [`OrderError::InvalidPrecision`].
571#[non_exhaustive]
572#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize)]
573pub struct PrecisionViolation {
574    /// Which value of the request broke the rule.
575    pub field: OrderField,
576    /// The value as requested.
577    pub value: Decimal,
578    /// The rule it broke.
579    pub limit: PrecisionLimit,
580}
581
582impl PrecisionViolation {
583    /// The request's `field`, of `value`, breaks `limit`.
584    pub fn new(field: OrderField, value: Decimal, limit: PrecisionLimit) -> Self {
585        Self {
586            field,
587            value,
588            limit,
589        }
590    }
591}
592
593impl fmt::Display for PrecisionViolation {
594    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
595        let Self {
596            field,
597            value,
598            limit,
599        } = self;
600        write!(f, "{field} {value} ")?;
601        match limit {
602            PrecisionLimit::DecimalPlaces { max } => {
603                write!(f, "has more than {max} decimal places")
604            }
605            PrecisionLimit::SignificantFigures { max } => {
606                write!(
607                    f,
608                    "is not an integer and has more than {max} significant figures"
609                )
610            }
611            PrecisionLimit::NotRepresentable => {
612                write!(f, "cannot be sent exactly in the venue's number format")
613            }
614            PrecisionLimit::NotPositive => write!(f, "is not positive"),
615        }
616    }
617}
618
619/// A value of an order request, as named by a [`PrecisionViolation`].
620#[non_exhaustive]
621#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize)]
622pub enum OrderField {
623    /// The order's quantity.
624    Quantity,
625    /// The order's limit price.
626    Price,
627    /// The price that triggers a stop or take-profit order.
628    TriggerPrice,
629    /// A trailing order's offset.
630    TrailingOffset,
631}
632
633impl fmt::Display for OrderField {
634    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
635        f.write_str(match self {
636            Self::Quantity => "quantity",
637            Self::Price => "price",
638            Self::TriggerPrice => "trigger price",
639            Self::TrailingOffset => "trailing offset",
640        })
641    }
642}
643
644/// A venue's precision rule, as broken by a [`PrecisionViolation`].
645#[non_exhaustive]
646#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize)]
647pub enum PrecisionLimit {
648    /// The value may have at most `max` decimal places.
649    DecimalPlaces {
650        /// The most decimal places allowed.
651        max: u32,
652    },
653    /// The value may have at most `max` significant figures, unless it is an integer.
654    SignificantFigures {
655        /// The most significant figures allowed.
656        max: u32,
657    },
658    /// The value cannot be sent exactly: the venue's client library converts it to a number
659    /// format that would change it.
660    NotRepresentable,
661    /// The value is zero or negative. A quantity, price or trailing offset must be positive.
662    NotPositive,
663}
664
665impl<AssetKey, InstrumentKey> OrderError<AssetKey, InstrumentKey> {
666    /// Returns `true` if this error is likely transient and the operation
667    /// may succeed if retried after a suitable backoff.
668    ///
669    /// # Transient errors
670    /// - [`Connectivity`](Self::Connectivity) errors (timeout, socket, offline)
671    /// - [`Rejected(ApiError::RateLimit)`](ApiError::RateLimit), though not before its
672    ///   `retry_after`
673    ///
674    /// # Non-transient errors
675    /// - Other [`Rejected`](Self::Rejected) errors (invalid instrument, insufficient balance,
676    ///   refused borrow, etc.)
677    /// - [`UnsupportedOrderType`](Self::UnsupportedOrderType) and
678    ///   [`InvalidPrecision`](Self::InvalidPrecision)
679    pub fn is_transient(&self) -> bool {
680        match self {
681            Self::Connectivity(e) => e.is_transient(),
682            Self::Rejected(ApiError::RateLimit { .. }) => true,
683            Self::Rejected(_) => false,
684            Self::UnsupportedOrderType(_) | Self::InvalidPrecision(_) => false,
685        }
686    }
687}
688
689/// Why an account event stream ended.
690///
691/// Delivered in-band as the payload of
692/// [`AccountEventKind::StreamTerminated`](crate::AccountEventKind::StreamTerminated) so that
693/// stream death is a programmatic signal on the account feed rather than a log-only event. A
694/// consumer can react (re-sync via REST, re-establish the stream, halt trading) according to its
695/// own policy — the library reports *why* the stream ended without prescribing the response.
696///
697/// # Only in-band-deliverable terminations are represented
698///
699/// Every variant here is a termination the library can actually deliver to a consumer that is
700/// still listening. A consumer-initiated drop is deliberately **not** a variant: by the time a
701/// stream task observes the drop, the receiving half of the channel is already gone, so a
702/// `StreamTerminated` send would be a guaranteed no-op. `StreamTerminated` therefore always means
703/// "the stream died while you were still listening".
704///
705/// `#[non_exhaustive]` so venues may distinguish further termination causes in future without a
706/// breaking change (e.g. a venue able to signal a graceful shutdown over a still-open channel).
707#[non_exhaustive]
708#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Error)]
709pub enum StreamTerminationReason {
710    /// A venue with built-in reconnection exhausted its retry budget and gave up.
711    ///
712    /// Emitted by venues that reconnect internally (e.g. Binance spot/margin, Alpaca) once the
713    /// final attempt fails. Each client documents its budget. `last_error` is the most recent
714    /// underlying failure; `attempts` is the number of reconnects tried before surrendering. The
715    /// stream will not recover on its own — the consumer must re-establish it.
716    #[error("reconnect budget exhausted after {attempts} attempts: {last_error}")]
717    ReconnectBudgetExhausted {
718        /// Number of reconnect attempts made before giving up.
719        attempts: u32,
720        /// The most recent underlying error that triggered surrender.
721        last_error: String,
722    },
723
724    /// The stream ended on an unrecoverable error with no automatic recovery.
725    ///
726    /// Emitted by venues without library-managed reconnection — e.g. IBKR (the underlying
727    /// subscription errored and the client does not retry), Hyperliquid (the SDK closed the
728    /// subscription's channel; its own reconnection never gives up), or the mock exchange (its
729    /// broadcast stream lagged past the buffer). The consumer is responsible for any
730    /// re-establishment.
731    #[error("unrecoverable stream error: {0}")]
732    Error(String),
733
734    /// The venue is refusing this client for exceeding its rate limits, for longer than the
735    /// stream waits through in place.
736    ///
737    /// Emitted by venues that reconnect internally (Binance spot/margin) when a reconnect faces a
738    /// rate-limit cooldown, or a ban, with more than a few seconds left. The stream ends at once
739    /// rather than spend its reconnect budget against a venue that refuses every attempt, and
740    /// reconnects that would lengthen a ban. `retry_after` is how long the cooldown had left: the
741    /// earliest the consumer should re-establish the stream. Opening it sooner fails with
742    /// [`ApiError::RateLimit`], unsent.
743    ///
744    /// Nothing that happens on the account until the stream is re-established reaches the
745    /// consumer through this stream, as after any other termination: re-sync from the new
746    /// stream's snapshot, and read fills of the gap with a trades query. An
747    /// `ExecutionManager` re-opens the stream itself, no sooner than `retry_after`, and reports
748    /// each refused attempt as a `ReinitFailed` carrying the time left.
749    #[error("rate-limited by the venue; retry after {retry_after:?}")]
750    RateLimited {
751        /// How long the venue's cooldown had left when the stream ended.
752        retry_after: Duration,
753    },
754}
755
756/// Type alias for an [`AccountStreamInitError`] that is keyed on [`AssetNameExchange`] and
757/// [`InstrumentNameExchange`] (yet to be indexed).
758pub type UnindexedAccountStreamInitError =
759    AccountStreamInitError<AssetNameExchange, InstrumentNameExchange>;
760
761/// Why an attempt to initialise an account stream, its snapshot and its updates, failed.
762#[non_exhaustive]
763#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Error)]
764pub enum AccountStreamInitError<AssetKey = AssetIndex, InstrumentKey = InstrumentIndex> {
765    /// The [`ExecutionClient`](crate::client::ExecutionClient) failed to fetch the account
766    /// snapshot or to open the account stream. See [`ClientError::is_transient`].
767    #[error("{0}")]
768    Client(#[from] ClientError<AssetKey, InstrumentKey>),
769
770    /// The account snapshot named an exchange, asset or instrument that is not indexed. This does
771    /// not resolve on retry while the venue keeps reporting it.
772    #[error("IndexError: {0}")]
773    Index(#[from] IndexError),
774}
775
776impl<AssetKey, InstrumentKey> AccountStreamInitError<AssetKey, InstrumentKey> {
777    /// The venue's minimum wait before the next attempt: `Some` only for a client's
778    /// [`Api::RateLimit`](ApiError::RateLimit) whose venue sent a hint the client could read. See
779    /// [`ClientError::retry_after`].
780    pub fn retry_after(&self) -> Option<Duration> {
781        match self {
782            Self::Client(error) => error.retry_after(),
783            Self::Index(_) => None,
784        }
785    }
786}
787
788/// A failed attempt to re-initialise an account stream after it ended.
789///
790/// Delivered in-band as the payload of
791/// [`AccountEventKind::ReinitFailed`](crate::AccountEventKind::ReinitFailed), once per failed
792/// attempt. Attempts continue with backoff; giving up is the consumer's decision.
793#[non_exhaustive]
794#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize, Serialize, Constructor)]
795pub struct AccountReinitFailure<AssetKey = AssetIndex, InstrumentKey = InstrumentIndex> {
796    /// Consecutive failed attempts since the stream last initialised, starting at 1.
797    pub attempt: u32,
798    /// Why this attempt failed.
799    pub error: AccountStreamInitError<AssetKey, InstrumentKey>,
800}
801
802/// Represents errors related to exchange, asset and instrument identifier key lookups.
803#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Error)]
804pub enum KeyError {
805    /// Indicates an [`ExchangeId`] was encountered that was not indexed, so does not have a
806    /// corresponding `ExchangeIndex`.
807    #[error("ExchangeId: {0}")]
808    ExchangeId(String),
809
810    /// Indicates an [`AssetNameExchange`] was encountered that was not indexed, so does not have a
811    /// corresponding [`AssetIndex`].
812    #[error("AssetKey: {0}")]
813    AssetKey(String),
814
815    /// Indicates an [`InstrumentNameExchange`] was encountered that was no indexed, so does
816    /// not have a corresponding [`InstrumentIndex`].
817    #[error("InstrumentKey: {0}")]
818    InstrumentKey(String),
819}
820
821#[cfg(test)]
822#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
823mod tests {
824    use super::*;
825    use rust_decimal_macros::dec;
826
827    #[test]
828    fn a_rate_limit_carries_and_shows_the_venues_wait() {
829        let hinted: UnindexedApiError = ApiError::rate_limited(Some(Duration::from_secs(7)));
830        assert_eq!(hinted.retry_after(), Some(Duration::from_secs(7)));
831        assert_eq!(hinted.to_string(), "rate limit exceeded; retry after 7s");
832        assert!(UnindexedClientError::Api(hinted.clone()).is_transient());
833        assert!(UnindexedOrderError::Rejected(hinted).is_transient());
834
835        let unhinted: UnindexedApiError = ApiError::rate_limited(None);
836        assert_eq!(unhinted.retry_after(), None);
837        assert_eq!(unhinted.to_string(), "rate limit exceeded");
838
839        let other: UnindexedApiError = ApiError::Unauthenticated("bad key".to_string());
840        assert_eq!(other.retry_after(), None);
841    }
842
843    #[test]
844    fn a_client_or_init_error_reads_the_wait_of_its_rate_limit() {
845        let wait = Some(Duration::from_secs(7));
846        let hinted = UnindexedClientError::Api(ApiError::rate_limited(wait));
847        assert_eq!(hinted.retry_after(), wait);
848        assert_eq!(
849            UnindexedAccountStreamInitError::Client(hinted).retry_after(),
850            wait
851        );
852
853        let unhinted = UnindexedClientError::Api(ApiError::rate_limited(None));
854        assert_eq!(unhinted.retry_after(), None);
855        let socket = UnindexedClientError::Connectivity(ConnectivityError::Timeout);
856        assert_eq!(socket.retry_after(), None);
857        assert_eq!(
858            UnindexedAccountStreamInitError::Client(socket).retry_after(),
859            None
860        );
861    }
862
863    #[test]
864    fn a_rate_limited_termination_shows_and_round_trips_its_wait() {
865        let reason = StreamTerminationReason::RateLimited {
866            retry_after: Duration::from_secs(120),
867        };
868        assert_eq!(
869            reason.to_string(),
870            "rate-limited by the venue; retry after 120s"
871        );
872        let json = serde_json::to_string(&reason).unwrap();
873        assert_eq!(
874            serde_json::from_str::<StreamTerminationReason>(&json).unwrap(),
875            reason
876        );
877    }
878
879    #[test]
880    fn a_rate_limit_round_trips_through_serde() {
881        for retry_after in [None, Some(Duration::from_millis(7_250))] {
882            let error: UnindexedApiError = ApiError::rate_limited(retry_after);
883            let json = serde_json::to_string(&error).unwrap();
884            assert_eq!(
885                serde_json::from_str::<UnindexedApiError>(&json).unwrap(),
886                error,
887                "{json}"
888            );
889        }
890    }
891
892    #[test]
893    fn test_connectivity_error_is_transient() {
894        assert!(ConnectivityError::Timeout.is_transient());
895        assert!(ConnectivityError::Socket("connection refused".into()).is_transient());
896        assert!(ConnectivityError::ExchangeOffline(ExchangeId::BinanceSpot).is_transient());
897    }
898
899    #[test]
900    fn test_client_error_is_transient_connectivity() {
901        let err: ClientError = ClientError::Connectivity(ConnectivityError::Timeout);
902        assert!(err.is_transient());
903
904        let err: ClientError = ClientError::Connectivity(ConnectivityError::Socket("err".into()));
905        assert!(err.is_transient());
906    }
907
908    #[test]
909    fn test_client_error_is_transient_rate_limit() {
910        let err: ClientError = ClientError::Api(ApiError::rate_limited(None));
911        assert!(err.is_transient());
912    }
913
914    #[test]
915    fn test_client_error_not_transient_api_errors() {
916        let err: ClientError =
917            ClientError::Api(ApiError::AssetInvalid(AssetIndex(0), "bad".into()));
918        assert!(!err.is_transient(), "expected non-transient for {:?}", err);
919
920        let err: ClientError = ClientError::Api(ApiError::BalanceInsufficient(
921            Some(AssetIndex(0)),
922            "low".into(),
923        ));
924        assert!(!err.is_transient(), "expected non-transient for {:?}", err);
925
926        let err: ClientError = ClientError::Api(ApiError::InstrumentInvalid(
927            InstrumentIndex(0),
928            "bad".into(),
929        ));
930        assert!(!err.is_transient(), "expected non-transient for {:?}", err);
931
932        let err: ClientError = ClientError::Api(ApiError::OrderRejected("rejected".into()));
933        assert!(!err.is_transient(), "expected non-transient for {:?}", err);
934
935        let err: ClientError = ClientError::Api(ApiError::OrderAlreadyCancelled);
936        assert!(!err.is_transient(), "expected non-transient for {:?}", err);
937
938        let err: ClientError = ClientError::Api(ApiError::OrderAlreadyFullyFilled);
939        assert!(!err.is_transient(), "expected non-transient for {:?}", err);
940
941        let err: ClientError =
942            ClientError::Api(ApiError::Unauthenticated("invalid signature".into()));
943        assert!(!err.is_transient(), "expected non-transient for {:?}", err);
944
945        let err: ClientError =
946            ClientError::Api(ApiError::RequestRejected("-1127 More than 24 hours".into()));
947        assert!(!err.is_transient(), "expected non-transient for {:?}", err);
948
949        let err: ClientError = ClientError::Api(ApiError::BorrowRejected(BorrowReject::new(
950            BorrowRejectReason::InventoryUnavailable,
951            Some("-3045".into()),
952            "-3045 The system doesn't have enough asset now.".into(),
953        )));
954        assert!(!err.is_transient(), "expected non-transient for {:?}", err);
955    }
956
957    #[test]
958    fn borrow_rejected_shows_its_reason_and_code() {
959        let coded: UnindexedApiError = ApiError::BorrowRejected(BorrowReject::new(
960            BorrowRejectReason::BorrowDisabled,
961            Some("40310000".into()),
962            "account is not allowed to short".into(),
963        ));
964        assert_eq!(
965            coded.to_string(),
966            "borrow rejected: borrow disabled (code 40310000): account is not allowed to short"
967        );
968
969        let uncoded: UnindexedApiError = ApiError::BorrowRejected(BorrowReject::new(
970            BorrowRejectReason::Other,
971            None,
972            "no borrow".into(),
973        ));
974        assert_eq!(uncoded.to_string(), "borrow rejected: other: no borrow");
975
976        let short: UnindexedApiError = ApiError::BorrowRejected(BorrowReject::new(
977            BorrowRejectReason::ShortSaleRestricted,
978            None,
979            "priced at the bid".into(),
980        ));
981        assert_eq!(
982            short.to_string(),
983            "borrow rejected: short sale restricted: priced at the bid"
984        );
985    }
986
987    #[test]
988    fn borrow_rejected_shows_what_is_available_only_when_known() {
989        let reject = BorrowReject::new(
990            BorrowRejectReason::InventoryUnavailable,
991            None,
992            "too little to lend".into(),
993        );
994        assert_eq!(reject.available, None);
995
996        let reject = reject.with_available(dec!(2.5));
997        assert_eq!(reject.available, Some(dec!(2.5)));
998        assert_eq!(
999            reject.to_string(),
1000            "inventory unavailable: too little to lend (2.5 available)"
1001        );
1002    }
1003
1004    #[test]
1005    fn balance_insufficient_names_its_asset_only_when_known() {
1006        let named: UnindexedApiError =
1007            ApiError::BalanceInsufficient(Some(AssetNameExchange::new("BTC")), "low".to_string());
1008        assert_eq!(named.to_string(), "balance insufficient for asset BTC: low");
1009
1010        let unnamed: UnindexedApiError = ApiError::BalanceInsufficient(None, "low".to_string());
1011        assert_eq!(unnamed.to_string(), "balance insufficient: low");
1012    }
1013
1014    #[test]
1015    fn test_client_error_not_transient_task_failed() {
1016        let err: ClientError = ClientError::TaskFailed("task panicked".into());
1017        assert!(!err.is_transient());
1018    }
1019
1020    #[test]
1021    fn test_client_error_not_transient_internal() {
1022        let err: ClientError = ClientError::Internal("unknown error".into());
1023        assert!(!err.is_transient());
1024    }
1025
1026    #[test]
1027    fn test_client_error_not_transient_truncated() {
1028        let err: ClientError = ClientError::Truncated { fills_read: 5_000 };
1029        assert!(!err.is_transient(), "expected non-transient for {:?}", err);
1030
1031        let err: ClientError = ClientError::TruncatedSnapshot { limit: 500 };
1032        assert!(!err.is_transient(), "expected non-transient for {:?}", err);
1033    }
1034
1035    #[test]
1036    fn test_client_error_is_transient_exchange_offline() {
1037        let err: ClientError =
1038            ClientError::Connectivity(ConnectivityError::ExchangeOffline(ExchangeId::BinanceSpot));
1039        assert!(err.is_transient(), "expected transient for {:?}", err);
1040    }
1041
1042    #[test]
1043    fn test_order_error_is_transient_connectivity() {
1044        let err: UnindexedOrderError = OrderError::Connectivity(ConnectivityError::Timeout);
1045        assert!(err.is_transient(), "expected transient for {:?}", err);
1046
1047        let err: UnindexedOrderError =
1048            OrderError::Connectivity(ConnectivityError::Socket("connection reset".into()));
1049        assert!(err.is_transient(), "expected transient for {:?}", err);
1050
1051        let err: UnindexedOrderError =
1052            OrderError::Connectivity(ConnectivityError::ExchangeOffline(ExchangeId::BinanceSpot));
1053        assert!(err.is_transient(), "expected transient for {:?}", err);
1054    }
1055
1056    #[test]
1057    fn test_order_error_is_transient_rate_limit() {
1058        let err: UnindexedOrderError = OrderError::Rejected(ApiError::rate_limited(None));
1059        assert!(err.is_transient(), "expected transient for {:?}", err);
1060    }
1061
1062    #[test]
1063    fn test_order_error_not_transient_api_errors() {
1064        let err: UnindexedOrderError =
1065            OrderError::Rejected(ApiError::OrderRejected("price out of range".into()));
1066        assert!(!err.is_transient(), "expected non-transient for {:?}", err);
1067
1068        let err: UnindexedOrderError = OrderError::Rejected(ApiError::OrderAlreadyCancelled);
1069        assert!(!err.is_transient(), "expected non-transient for {:?}", err);
1070
1071        let err: UnindexedOrderError =
1072            OrderError::Rejected(ApiError::BorrowRejected(BorrowReject::new(
1073                BorrowRejectReason::NotShortable,
1074                None,
1075                "asset XYZ cannot be sold short".into(),
1076            )));
1077        assert!(!err.is_transient(), "expected non-transient for {:?}", err);
1078
1079        let err: UnindexedOrderError = OrderError::Rejected(ApiError::BalanceInsufficient(
1080            Some(AssetNameExchange::from("BTC")),
1081            "insufficient".into(),
1082        ));
1083        assert!(!err.is_transient(), "expected non-transient for {:?}", err);
1084    }
1085}