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