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