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, order::id::ClientOrderId};
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, and for
396 /// a spot margin borrow past an asset's cap
397 /// ([`SpotMarginConfig::with_max_borrow`](crate::margin::SpotMarginConfig::with_max_borrow)).
398 /// Other clients report such refusals as [`OrderRejected`](Self::OrderRejected) until their
399 /// venue's wording is confirmed.
400 ///
401 /// Not transient — the same request fails identically until the venue's lending changes.
402 #[error("borrow rejected: {0}")]
403 BorrowRejected(BorrowReject),
404}
405
406impl<AssetKey, InstrumentKey> ApiError<AssetKey, InstrumentKey> {
407 /// A [`RateLimit`](Self::RateLimit) refusal, with the venue's minimum wait when it sent one.
408 pub fn rate_limited(retry_after: Option<Duration>) -> Self {
409 Self::RateLimit { retry_after }
410 }
411
412 /// The venue's minimum wait before a retry: `Some` only for a
413 /// [`RateLimit`](Self::RateLimit) whose venue sent a hint the client could read.
414 pub fn retry_after(&self) -> Option<Duration> {
415 match self {
416 Self::RateLimit { retry_after } => *retry_after,
417 _ => None,
418 }
419 }
420}
421
422/// Formats a [`RateLimit`](ApiError::RateLimit)'s hint for its message: nothing when there is
423/// none.
424struct RetryAfter(Option<Duration>);
425
426impl fmt::Display for RetryAfter {
427 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
428 match self.0 {
429 Some(retry_after) => write!(f, "; retry after {retry_after:?}"),
430 None => Ok(()),
431 }
432 }
433}
434
435/// Why a venue refused to lend for an order, as carried by [`ApiError::BorrowRejected`].
436#[non_exhaustive]
437#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize)]
438pub struct BorrowReject {
439 /// The kind of refusal.
440 pub reason: BorrowRejectReason,
441 /// The venue's own code for the refusal, where it gave one (e.g. `-3045` on Binance,
442 /// `40310000` on Alpaca).
443 ///
444 /// For diagnostics only, not a stable contract: match on [`reason`](Self::reason) instead. A
445 /// venue may reuse a code for several refusals, as Alpaca's generic `40310000` is.
446 pub venue_code: Option<String>,
447 /// The venue's message. It may itself carry the code, as Binance's does.
448 pub message: String,
449 /// How much more the venue could still have lent, in the units the order was sized in, where
450 /// known: for a short sale, the most a smaller order could have sold short; for a spot margin
451 /// sell, the most it could have borrowed, on top of whatever free base the account holds. Set
452 /// by the simulated venue; no live client reports it yet.
453 pub available: Option<Decimal>,
454}
455
456impl BorrowReject {
457 /// A refusal for `reason`, with the venue's code where it gave one, and its message.
458 pub fn new(reason: BorrowRejectReason, venue_code: Option<String>, message: String) -> Self {
459 Self {
460 reason,
461 venue_code,
462 message,
463 available: None,
464 }
465 }
466
467 /// With how much the venue could still have lent.
468 #[must_use]
469 pub fn with_available(mut self, available: Decimal) -> Self {
470 self.available = Some(available);
471 self
472 }
473}
474
475impl fmt::Display for BorrowReject {
476 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
477 let Self {
478 reason,
479 venue_code,
480 message,
481 available,
482 } = self;
483 write!(f, "{reason}")?;
484 if let Some(code) = venue_code {
485 write!(f, " (code {code})")?;
486 }
487 write!(f, ": {message}")?;
488 if let Some(available) = available {
489 write!(f, " ({available} available)")?;
490 }
491 Ok(())
492 }
493}
494
495/// The kind of borrow refusal carried by a [`BorrowReject`].
496#[non_exhaustive]
497#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize)]
498pub enum BorrowRejectReason {
499 /// The instrument cannot be sold short at all, whoever asks (e.g. Alpaca's "asset X cannot be
500 /// sold short").
501 NotShortable,
502 /// Borrowing is barred for this account, or for this asset on this account (e.g. Binance
503 /// `-3008`, `-3012` and `-3014`, Alpaca's "account is not allowed to short").
504 BorrowDisabled,
505 /// The venue has too little of the asset to lend right now (e.g. Binance `-3045`). It may
506 /// lend again later.
507 InventoryUnavailable,
508 /// The borrow would exceed what the account may borrow (e.g. Binance `-3006`).
509 BorrowLimitExceeded,
510 /// A short-sale restriction is in effect, such as the SEC's Rule 201 circuit breaker after a
511 /// 10% fall, and the order would sell short at or below the best bid. A short sale priced
512 /// above the bid may still be accepted.
513 ShortSaleRestricted,
514 /// A borrow refusal of a kind not listed here. The [`BorrowReject`] message says which.
515 Other,
516}
517
518impl fmt::Display for BorrowRejectReason {
519 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
520 f.write_str(match self {
521 Self::NotShortable => "not shortable",
522 Self::BorrowDisabled => "borrow disabled",
523 Self::InventoryUnavailable => "inventory unavailable",
524 Self::BorrowLimitExceeded => "borrow limit exceeded",
525 Self::ShortSaleRestricted => "short sale restricted",
526 Self::Other => "other",
527 })
528 }
529}
530
531/// Represents all errors that can be generated when cancelling or opening orders.
532///
533/// This is a subset of [`ClientError`] for order-specific operations. Use
534/// [`is_transient()`](Self::is_transient) to determine retry eligibility.
535#[non_exhaustive]
536#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Error)]
537pub enum OrderError<AssetKey = AssetIndex, InstrumentKey = InstrumentIndex> {
538 /// Connectivity-based error (timeout, socket failure, exchange offline), or a venue failure
539 /// that leaves the order's status unknown.
540 ///
541 /// Transient — retry with backoff. See [`ConnectivityError`] for details. The order may or
542 /// may not have reached the matching engine, so before resubmitting an open request,
543 /// reconcile its state (open orders, fills); a blind retry can place it twice.
544 ///
545 /// For an open request, this means **the outcome is unknown**, not that the open failed: see
546 /// [`is_outcome_unknown`](Self::is_outcome_unknown). A client that knows the request never
547 /// left returns [`NotPlaced`](Self::NotPlaced) instead.
548 #[error("connectivity: {0}")]
549 Connectivity(#[from] ConnectivityError),
550
551 /// The order was not placed, and this is known rather than guessed: see [`NotPlacedReason`].
552 ///
553 /// Unlike [`Connectivity`](Self::Connectivity), it ends the order. Resubmitting the open
554 /// request cannot place the order twice.
555 #[error("not placed: {0}")]
556 NotPlaced(NotPlacedReason),
557
558 /// API-based error (rate limit, invalid instrument, order rejected, etc.).
559 ///
560 /// Retry semantics depend on the specific [`ApiError`] variant. Only
561 /// [`ApiError::RateLimit`] is transient; other variants are not.
562 #[error("order rejected: {0}")]
563 Rejected(#[from] ApiError<AssetKey, InstrumentKey>),
564
565 /// The order type is not supported by this connector, or the venue refused it as a type it
566 /// does not take for this account or instrument.
567 ///
568 /// Non-transient — place a different order type. A connector usually returns this without
569 /// sending the request (e.g., trailing stop orders on a connector that only supports
570 /// market/limit); when the venue's own refusal says so, it can also come back after the round
571 /// trip.
572 #[error("unsupported order type: {0}")]
573 UnsupportedOrderType(String),
574
575 /// A quantity or price in the request has more precision than the venue accepts, so the
576 /// client refused it without sending it.
577 ///
578 /// Non-transient — change the request. A client that refuses this way never rounds a value
579 /// itself, because rounding would place a different order than the one requested; it
580 /// documents how to read the venue's precision so the caller can round first.
581 #[error("invalid precision: {0}")]
582 InvalidPrecision(PrecisionViolation),
583
584 /// A value in the request is outside the range the venue accepts for the instrument, so the
585 /// client refused it without sending it.
586 ///
587 /// Non-transient — change the request. Unlike [`InvalidPrecision`](Self::InvalidPrecision),
588 /// which says a value is badly formed, this says a well-formed value is too small or too
589 /// large: below a minimum price, quantity, notional or trailing offset, or above a maximum.
590 /// The error carries the limit that was broken, so the caller can resize the order. A client
591 /// checks only the rules it has loaded (see [`OrderRules`](crate::rules::OrderRules)); a
592 /// request that passes can still be rejected by the venue.
593 #[error("out of bounds: {0}")]
594 OutOfBounds(BoundsViolation),
595
596 /// The request names a [`MarginEffect`] this client cannot honour, so it refused the order
597 /// without sending it.
598 ///
599 /// Non-transient — drop the effect (`RequestOpen::margin_effect` of `None` takes the client's
600 /// default) or send the order to a client that honours it. A client refuses rather than
601 /// ignores an effect it cannot honour, because placing the order without it would borrow, or
602 /// not repay, against the caller's intent. Which effects a client honours is on its type: a
603 /// spot or cash account takes only [`NoSideEffect`](MarginEffect::NoSideEffect), a venue
604 /// with account-level margin takes none.
605 #[error("unsupported margin effect: {0:?}")]
606 UnsupportedMarginEffect(MarginEffect),
607}
608
609/// How an order is known not to have been placed.
610///
611/// Carried by [`OrderError::NotPlaced`].
612#[non_exhaustive]
613#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Error)]
614pub enum NotPlacedReason {
615 /// The open request never left the client, because the connection failed before the send:
616 /// refused, unresolved, or a closed channel to a simulated venue.
617 ///
618 /// A client returns this only where the failure provably came before the send. A failure it
619 /// cannot place is [`OrderError::Connectivity`].
620 #[error("not sent: {0}")]
621 NotSent(ConnectivityError),
622
623 /// The open's outcome was unknown, and the venue, asked about the order by its client order
624 /// id several times since, does not know it.
625 ///
626 /// The engine's `ExecutionManager` reports this, after the lookups its policy sets out.
627 #[error("unknown to the venue")]
628 UnknownToVenue,
629
630 /// The open was held back behind the cancels of its
631 /// [`CancelThenOpen`](crate::order::request::CancelThenOpen) group, and was never sent because
632 /// one of them failed.
633 ///
634 /// `cancel` is the client order id of the first cancel in the group whose answer was an error,
635 /// a timeout included. That cancel's own answer says why it failed. Only a group whose
636 /// [`OnCancelFailure`](crate::order::request::OnCancelFailure) is
637 /// [`DropOpens`](crate::order::request::OnCancelFailure::DropOpens) drops its opens.
638 #[error("not sent: the preceding cancel of {cancel} failed")]
639 CancelFailed {
640 /// The client order id of the first cancel in the group that failed.
641 cancel: ClientOrderId,
642 },
643}
644
645/// A value in an order request with more precision than the venue accepts, or that is not
646/// positive.
647///
648/// Carried by [`OrderError::InvalidPrecision`].
649#[non_exhaustive]
650#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize)]
651pub struct PrecisionViolation {
652 /// Which value of the request broke the rule.
653 pub field: OrderField,
654 /// The value as requested.
655 pub value: Decimal,
656 /// The rule it broke.
657 pub limit: PrecisionLimit,
658}
659
660impl PrecisionViolation {
661 /// The request's `field`, of `value`, breaks `limit`.
662 pub fn new(field: OrderField, value: Decimal, limit: PrecisionLimit) -> Self {
663 Self {
664 field,
665 value,
666 limit,
667 }
668 }
669}
670
671impl fmt::Display for PrecisionViolation {
672 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
673 let Self {
674 field,
675 value,
676 limit,
677 } = self;
678 write!(f, "{field} {value} ")?;
679 match limit {
680 PrecisionLimit::DecimalPlaces { max } => {
681 write!(f, "has more than {max} decimal places")
682 }
683 PrecisionLimit::SignificantFigures { max } => {
684 write!(
685 f,
686 "is not an integer and has more than {max} significant figures"
687 )
688 }
689 PrecisionLimit::NotRepresentable => {
690 write!(f, "cannot be sent exactly in the venue's number format")
691 }
692 PrecisionLimit::NotPositive => write!(f, "is not positive"),
693 PrecisionLimit::NotOnGrid { step } => {
694 write!(f, "is not a multiple of {step}")
695 }
696 }
697 }
698}
699
700/// A value in an order request that is below a venue minimum or above a venue maximum.
701///
702/// Carried by [`OrderError::OutOfBounds`].
703#[non_exhaustive]
704#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize)]
705pub struct BoundsViolation {
706 /// Which value of the request broke the bound.
707 pub field: OrderField,
708 /// Whether `value` is below the minimum or above the maximum.
709 pub bound: Bound,
710 /// The limit `value` broke.
711 pub limit: Decimal,
712 /// The value as requested. For [`OrderField::Notional`] it is the price times the quantity.
713 pub value: Decimal,
714}
715
716impl BoundsViolation {
717 /// The request's `field`, of `value`, is beyond the `bound` at `limit`.
718 pub fn new(field: OrderField, bound: Bound, limit: Decimal, value: Decimal) -> Self {
719 Self {
720 field,
721 bound,
722 limit,
723 value,
724 }
725 }
726}
727
728impl fmt::Display for BoundsViolation {
729 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
730 let Self {
731 field,
732 bound,
733 limit,
734 value,
735 } = self;
736 let (relation, name) = match bound {
737 Bound::Min => ("below", "minimum"),
738 Bound::Max => ("above", "maximum"),
739 };
740 write!(f, "{field} {value} is {relation} the {name} {limit}")
741 }
742}
743
744/// Which end of a range a [`BoundsViolation`] broke.
745#[non_exhaustive]
746#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize)]
747pub enum Bound {
748 /// The value is below the minimum.
749 Min,
750 /// The value is above the maximum.
751 Max,
752}
753
754/// A value of an order request, as named by a [`PrecisionViolation`] or [`BoundsViolation`].
755#[non_exhaustive]
756#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize)]
757pub enum OrderField {
758 /// The order's quantity.
759 Quantity,
760 /// The order's limit price.
761 Price,
762 /// The price that triggers a stop or take-profit order.
763 TriggerPrice,
764 /// A trailing order's offset.
765 TrailingOffset,
766 /// The order's price times its quantity, in the quote asset. Never a request field of its
767 /// own: only a [`BoundsViolation`] names it.
768 Notional,
769}
770
771impl fmt::Display for OrderField {
772 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
773 f.write_str(match self {
774 Self::Quantity => "quantity",
775 Self::Price => "price",
776 Self::TriggerPrice => "trigger price",
777 Self::TrailingOffset => "trailing offset",
778 Self::Notional => "notional",
779 })
780 }
781}
782
783/// A venue's precision rule, as broken by a [`PrecisionViolation`].
784#[non_exhaustive]
785#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize)]
786pub enum PrecisionLimit {
787 /// The value may have at most `max` decimal places.
788 DecimalPlaces {
789 /// The most decimal places allowed.
790 max: u32,
791 },
792 /// The value may have at most `max` significant figures, unless it is an integer.
793 SignificantFigures {
794 /// The most significant figures allowed.
795 max: u32,
796 },
797 /// The value cannot be sent exactly: the venue's client library converts it to a number
798 /// format that would change it.
799 NotRepresentable,
800 /// The value is zero or negative. A quantity, price or trailing offset must be positive.
801 NotPositive,
802 /// The value is not a whole multiple of `step`: a price must sit on the instrument's tick
803 /// grid and a quantity on its lot grid, counted from zero.
804 NotOnGrid {
805 /// The grid's step: the tick size for a price, the lot step for a quantity.
806 step: Decimal,
807 },
808}
809
810impl<AssetKey, InstrumentKey> OrderError<AssetKey, InstrumentKey> {
811 /// Returns `true` if this error is likely transient and the operation
812 /// may succeed if retried after a suitable backoff.
813 ///
814 /// # Transient errors
815 /// - [`Connectivity`](Self::Connectivity) errors (timeout, socket, offline)
816 /// - [`Rejected(ApiError::RateLimit)`](ApiError::RateLimit), though not before its
817 /// `retry_after`
818 ///
819 /// # Non-transient errors
820 /// - Other [`Rejected`](Self::Rejected) errors (invalid instrument, insufficient balance,
821 /// refused borrow, etc.)
822 /// - [`UnsupportedOrderType`](Self::UnsupportedOrderType),
823 /// [`InvalidPrecision`](Self::InvalidPrecision),
824 /// [`OutOfBounds`](Self::OutOfBounds) and
825 /// [`UnsupportedMarginEffect`](Self::UnsupportedMarginEffect)
826 ///
827 /// [`NotPlaced`](Self::NotPlaced) is transient when the request was not sent for a connection
828 /// failure, since a later send may get through. It is not when the venue does not know the
829 /// order, or when the open was dropped because a cancel it waited on failed.
830 pub fn is_transient(&self) -> bool {
831 match self {
832 Self::Connectivity(e) | Self::NotPlaced(NotPlacedReason::NotSent(e)) => {
833 e.is_transient()
834 }
835 Self::NotPlaced(
836 NotPlacedReason::UnknownToVenue | NotPlacedReason::CancelFailed { .. },
837 ) => false,
838 Self::Rejected(ApiError::RateLimit { .. }) => true,
839 Self::Rejected(_) => false,
840 Self::UnsupportedOrderType(_)
841 | Self::InvalidPrecision(_)
842 | Self::OutOfBounds(_)
843 | Self::UnsupportedMarginEffect(_) => false,
844 }
845 }
846
847 /// Returns `true` if, as the answer to an open request, this error leaves the order's outcome
848 /// unknown: the order may be live at the venue.
849 ///
850 /// Only [`Connectivity`](Self::Connectivity) does. Every other error says the order was not
851 /// placed.
852 pub fn is_outcome_unknown(&self) -> bool {
853 matches!(self, Self::Connectivity(_))
854 }
855}
856
857/// Why an account event stream ended.
858///
859/// Delivered in-band as the payload of
860/// [`AccountEventKind::StreamTerminated`](crate::AccountEventKind::StreamTerminated) so that
861/// stream death is a programmatic signal on the account feed rather than a log-only event. A
862/// consumer can react (re-sync via REST, re-establish the stream, halt trading) according to its
863/// own policy — the library reports *why* the stream ended without prescribing the response.
864///
865/// # Only in-band-deliverable terminations are represented
866///
867/// Every variant here is a termination the library can actually deliver to a consumer that is
868/// still listening. A consumer-initiated drop is deliberately **not** a variant: by the time a
869/// stream task observes the drop, the receiving half of the channel is already gone, so a
870/// `StreamTerminated` send would be a guaranteed no-op. `StreamTerminated` therefore always means
871/// "the stream died while you were still listening".
872///
873/// `#[non_exhaustive]` so venues may distinguish further termination causes in future without a
874/// breaking change (e.g. a venue able to signal a graceful shutdown over a still-open channel).
875#[non_exhaustive]
876#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Error)]
877pub enum StreamTerminationReason {
878 /// A venue with built-in reconnection exhausted its retry budget and gave up.
879 ///
880 /// Emitted by venues that reconnect internally (e.g. Binance spot/margin, Alpaca) once the
881 /// final attempt fails. Each client documents its budget. `last_error` is the most recent
882 /// underlying failure; `attempts` is the number of reconnects tried before surrendering. The
883 /// stream will not recover on its own — the consumer must re-establish it.
884 #[error("reconnect budget exhausted after {attempts} attempts: {last_error}")]
885 ReconnectBudgetExhausted {
886 /// Number of reconnect attempts made before giving up.
887 attempts: u32,
888 /// The most recent underlying error that triggered surrender.
889 last_error: String,
890 },
891
892 /// The stream ended on an unrecoverable error with no automatic recovery.
893 ///
894 /// Emitted by venues without library-managed reconnection — e.g. IBKR (the underlying
895 /// subscription errored and the client does not retry), Hyperliquid (the SDK closed the
896 /// subscription's channel; its own reconnection never gives up), or the mock exchange (its
897 /// broadcast stream lagged past the buffer). The consumer is responsible for any
898 /// re-establishment.
899 #[error("unrecoverable stream error: {0}")]
900 Error(String),
901
902 /// The venue is refusing this client for exceeding its rate limits, for longer than the
903 /// stream waits through in place.
904 ///
905 /// Emitted by venues that reconnect internally (Binance spot/margin) when a reconnect faces a
906 /// rate-limit cooldown, or a ban, with more than a few seconds left. The stream ends at once
907 /// rather than spend its reconnect budget against a venue that refuses every attempt, and
908 /// reconnects that would lengthen a ban. `retry_after` is how long the cooldown had left: the
909 /// earliest the consumer should re-establish the stream. Opening it sooner fails with
910 /// [`ApiError::RateLimit`], unsent.
911 ///
912 /// Nothing that happens on the account until the stream is re-established reaches the
913 /// consumer through this stream, as after any other termination: re-sync from the new
914 /// stream's snapshot, and read fills of the gap with a trades query. An
915 /// `ExecutionManager` re-opens the stream itself, no sooner than `retry_after`, and reports
916 /// each refused attempt as a `ReinitFailed` carrying the time left.
917 #[error("rate-limited by the venue; retry after {retry_after:?}")]
918 RateLimited {
919 /// How long the venue's cooldown had left when the stream ended.
920 retry_after: Duration,
921 },
922}
923
924/// Type alias for an [`AccountStreamInitError`] that is keyed on [`AssetNameExchange`] and
925/// [`InstrumentNameExchange`] (yet to be indexed).
926pub type UnindexedAccountStreamInitError =
927 AccountStreamInitError<AssetNameExchange, InstrumentNameExchange>;
928
929/// Why an attempt to initialise an account stream, its snapshot and its updates, failed.
930#[non_exhaustive]
931#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Error)]
932pub enum AccountStreamInitError<AssetKey = AssetIndex, InstrumentKey = InstrumentIndex> {
933 /// The [`ExecutionClient`](crate::client::ExecutionClient) failed to fetch the account
934 /// snapshot or to open the account stream. See [`ClientError::is_transient`].
935 #[error("{0}")]
936 Client(#[from] ClientError<AssetKey, InstrumentKey>),
937
938 /// The account snapshot named an exchange, asset or instrument that is not indexed. This does
939 /// not resolve on retry while the venue keeps reporting it.
940 #[error("IndexError: {0}")]
941 Index(#[from] IndexError),
942}
943
944impl<AssetKey, InstrumentKey> AccountStreamInitError<AssetKey, InstrumentKey> {
945 /// The venue's minimum wait before the next attempt: `Some` only for a client's
946 /// [`Api::RateLimit`](ApiError::RateLimit) whose venue sent a hint the client could read. See
947 /// [`ClientError::retry_after`].
948 pub fn retry_after(&self) -> Option<Duration> {
949 match self {
950 Self::Client(error) => error.retry_after(),
951 Self::Index(_) => None,
952 }
953 }
954}
955
956/// A failed attempt to re-initialise an account stream after it ended.
957///
958/// Delivered in-band as the payload of
959/// [`AccountEventKind::ReinitFailed`](crate::AccountEventKind::ReinitFailed), once per failed
960/// attempt. Attempts continue with backoff; giving up is the consumer's decision.
961#[non_exhaustive]
962#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize, Serialize, Constructor)]
963pub struct AccountReinitFailure<AssetKey = AssetIndex, InstrumentKey = InstrumentIndex> {
964 /// Consecutive failed attempts since the stream last initialised, starting at 1.
965 pub attempt: u32,
966 /// Why this attempt failed.
967 pub error: AccountStreamInitError<AssetKey, InstrumentKey>,
968}
969
970/// Represents errors related to exchange, asset and instrument identifier key lookups.
971#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Error)]
972pub enum KeyError {
973 /// Indicates an [`ExchangeId`] was encountered that was not indexed, so does not have a
974 /// corresponding `ExchangeIndex`.
975 #[error("ExchangeId: {0}")]
976 ExchangeId(String),
977
978 /// Indicates an [`AssetNameExchange`] was encountered that was not indexed, so does not have a
979 /// corresponding [`AssetIndex`].
980 #[error("AssetKey: {0}")]
981 AssetKey(String),
982
983 /// Indicates an [`InstrumentNameExchange`] was encountered that was no indexed, so does
984 /// not have a corresponding [`InstrumentIndex`].
985 #[error("InstrumentKey: {0}")]
986 InstrumentKey(String),
987}
988
989#[cfg(test)]
990#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
991mod tests {
992 use super::*;
993 use rust_decimal_macros::dec;
994
995 #[test]
996 fn a_rate_limit_carries_and_shows_the_venues_wait() {
997 let hinted: UnindexedApiError = ApiError::rate_limited(Some(Duration::from_secs(7)));
998 assert_eq!(hinted.retry_after(), Some(Duration::from_secs(7)));
999 assert_eq!(hinted.to_string(), "rate limit exceeded; retry after 7s");
1000 assert!(UnindexedClientError::Api(hinted.clone()).is_transient());
1001 assert!(UnindexedOrderError::Rejected(hinted).is_transient());
1002
1003 let unhinted: UnindexedApiError = ApiError::rate_limited(None);
1004 assert_eq!(unhinted.retry_after(), None);
1005 assert_eq!(unhinted.to_string(), "rate limit exceeded");
1006
1007 let other: UnindexedApiError = ApiError::Unauthenticated("bad key".to_string());
1008 assert_eq!(other.retry_after(), None);
1009 }
1010
1011 #[test]
1012 fn a_client_or_init_error_reads_the_wait_of_its_rate_limit() {
1013 let wait = Some(Duration::from_secs(7));
1014 let hinted = UnindexedClientError::Api(ApiError::rate_limited(wait));
1015 assert_eq!(hinted.retry_after(), wait);
1016 assert_eq!(
1017 UnindexedAccountStreamInitError::Client(hinted).retry_after(),
1018 wait
1019 );
1020
1021 let unhinted = UnindexedClientError::Api(ApiError::rate_limited(None));
1022 assert_eq!(unhinted.retry_after(), None);
1023 let socket = UnindexedClientError::Connectivity(ConnectivityError::Timeout);
1024 assert_eq!(socket.retry_after(), None);
1025 assert_eq!(
1026 UnindexedAccountStreamInitError::Client(socket).retry_after(),
1027 None
1028 );
1029 }
1030
1031 #[test]
1032 fn a_rate_limited_termination_shows_and_round_trips_its_wait() {
1033 let reason = StreamTerminationReason::RateLimited {
1034 retry_after: Duration::from_secs(120),
1035 };
1036 assert_eq!(
1037 reason.to_string(),
1038 "rate-limited by the venue; retry after 120s"
1039 );
1040 let json = serde_json::to_string(&reason).unwrap();
1041 assert_eq!(
1042 serde_json::from_str::<StreamTerminationReason>(&json).unwrap(),
1043 reason
1044 );
1045 }
1046
1047 #[test]
1048 fn a_rate_limit_round_trips_through_serde() {
1049 for retry_after in [None, Some(Duration::from_millis(7_250))] {
1050 let error: UnindexedApiError = ApiError::rate_limited(retry_after);
1051 let json = serde_json::to_string(&error).unwrap();
1052 assert_eq!(
1053 serde_json::from_str::<UnindexedApiError>(&json).unwrap(),
1054 error,
1055 "{json}"
1056 );
1057 }
1058 }
1059
1060 #[test]
1061 fn test_connectivity_error_is_transient() {
1062 assert!(ConnectivityError::Timeout.is_transient());
1063 assert!(ConnectivityError::Socket("connection refused".into()).is_transient());
1064 assert!(ConnectivityError::ExchangeOffline(ExchangeId::BinanceSpot).is_transient());
1065 }
1066
1067 #[test]
1068 fn test_client_error_is_transient_connectivity() {
1069 let err: ClientError = ClientError::Connectivity(ConnectivityError::Timeout);
1070 assert!(err.is_transient());
1071
1072 let err: ClientError = ClientError::Connectivity(ConnectivityError::Socket("err".into()));
1073 assert!(err.is_transient());
1074 }
1075
1076 #[test]
1077 fn test_client_error_is_transient_rate_limit() {
1078 let err: ClientError = ClientError::Api(ApiError::rate_limited(None));
1079 assert!(err.is_transient());
1080 }
1081
1082 #[test]
1083 fn test_client_error_not_transient_api_errors() {
1084 let err: ClientError =
1085 ClientError::Api(ApiError::AssetInvalid(AssetIndex(0), "bad".into()));
1086 assert!(!err.is_transient(), "expected non-transient for {:?}", err);
1087
1088 let err: ClientError = ClientError::Api(ApiError::BalanceInsufficient(
1089 Some(AssetIndex(0)),
1090 "low".into(),
1091 ));
1092 assert!(!err.is_transient(), "expected non-transient for {:?}", err);
1093
1094 let err: ClientError = ClientError::Api(ApiError::InstrumentInvalid(
1095 InstrumentIndex(0),
1096 "bad".into(),
1097 ));
1098 assert!(!err.is_transient(), "expected non-transient for {:?}", err);
1099
1100 let err: ClientError = ClientError::Api(ApiError::OrderRejected("rejected".into()));
1101 assert!(!err.is_transient(), "expected non-transient for {:?}", err);
1102
1103 let err: ClientError = ClientError::Api(ApiError::OrderAlreadyCancelled);
1104 assert!(!err.is_transient(), "expected non-transient for {:?}", err);
1105
1106 let err: ClientError = ClientError::Api(ApiError::OrderAlreadyFullyFilled);
1107 assert!(!err.is_transient(), "expected non-transient for {:?}", err);
1108
1109 let err: ClientError =
1110 ClientError::Api(ApiError::Unauthenticated("invalid signature".into()));
1111 assert!(!err.is_transient(), "expected non-transient for {:?}", err);
1112
1113 let err: ClientError =
1114 ClientError::Api(ApiError::RequestRejected("-1127 More than 24 hours".into()));
1115 assert!(!err.is_transient(), "expected non-transient for {:?}", err);
1116
1117 let err: ClientError = ClientError::Api(ApiError::BorrowRejected(BorrowReject::new(
1118 BorrowRejectReason::InventoryUnavailable,
1119 Some("-3045".into()),
1120 "-3045 The system doesn't have enough asset now.".into(),
1121 )));
1122 assert!(!err.is_transient(), "expected non-transient for {:?}", err);
1123 }
1124
1125 #[test]
1126 fn borrow_rejected_shows_its_reason_and_code() {
1127 let coded: UnindexedApiError = ApiError::BorrowRejected(BorrowReject::new(
1128 BorrowRejectReason::BorrowDisabled,
1129 Some("40310000".into()),
1130 "account is not allowed to short".into(),
1131 ));
1132 assert_eq!(
1133 coded.to_string(),
1134 "borrow rejected: borrow disabled (code 40310000): account is not allowed to short"
1135 );
1136
1137 let uncoded: UnindexedApiError = ApiError::BorrowRejected(BorrowReject::new(
1138 BorrowRejectReason::Other,
1139 None,
1140 "no borrow".into(),
1141 ));
1142 assert_eq!(uncoded.to_string(), "borrow rejected: other: no borrow");
1143
1144 let short: UnindexedApiError = ApiError::BorrowRejected(BorrowReject::new(
1145 BorrowRejectReason::ShortSaleRestricted,
1146 None,
1147 "priced at the bid".into(),
1148 ));
1149 assert_eq!(
1150 short.to_string(),
1151 "borrow rejected: short sale restricted: priced at the bid"
1152 );
1153 }
1154
1155 #[test]
1156 fn borrow_rejected_shows_what_is_available_only_when_known() {
1157 let reject = BorrowReject::new(
1158 BorrowRejectReason::InventoryUnavailable,
1159 None,
1160 "too little to lend".into(),
1161 );
1162 assert_eq!(reject.available, None);
1163
1164 let reject = reject.with_available(dec!(2.5));
1165 assert_eq!(reject.available, Some(dec!(2.5)));
1166 assert_eq!(
1167 reject.to_string(),
1168 "inventory unavailable: too little to lend (2.5 available)"
1169 );
1170 }
1171
1172 #[test]
1173 fn balance_insufficient_names_its_asset_only_when_known() {
1174 let named: UnindexedApiError =
1175 ApiError::BalanceInsufficient(Some(AssetNameExchange::new("BTC")), "low".to_string());
1176 assert_eq!(named.to_string(), "balance insufficient for asset BTC: low");
1177
1178 let unnamed: UnindexedApiError = ApiError::BalanceInsufficient(None, "low".to_string());
1179 assert_eq!(unnamed.to_string(), "balance insufficient: low");
1180 }
1181
1182 #[test]
1183 fn test_client_error_not_transient_task_failed() {
1184 let err: ClientError = ClientError::TaskFailed("task panicked".into());
1185 assert!(!err.is_transient());
1186 }
1187
1188 #[test]
1189 fn test_client_error_not_transient_internal() {
1190 let err: ClientError = ClientError::Internal("unknown error".into());
1191 assert!(!err.is_transient());
1192 }
1193
1194 #[test]
1195 fn test_client_error_not_transient_truncated() {
1196 let err: ClientError = ClientError::Truncated { fills_read: 5_000 };
1197 assert!(!err.is_transient(), "expected non-transient for {:?}", err);
1198
1199 let err: ClientError = ClientError::TruncatedSnapshot { limit: 500 };
1200 assert!(!err.is_transient(), "expected non-transient for {:?}", err);
1201 }
1202
1203 #[test]
1204 fn test_client_error_is_transient_exchange_offline() {
1205 let err: ClientError =
1206 ClientError::Connectivity(ConnectivityError::ExchangeOffline(ExchangeId::BinanceSpot));
1207 assert!(err.is_transient(), "expected transient for {:?}", err);
1208 }
1209
1210 #[test]
1211 fn test_order_error_is_transient_connectivity() {
1212 let err: UnindexedOrderError = OrderError::Connectivity(ConnectivityError::Timeout);
1213 assert!(err.is_transient(), "expected transient for {:?}", err);
1214
1215 let err: UnindexedOrderError =
1216 OrderError::Connectivity(ConnectivityError::Socket("connection reset".into()));
1217 assert!(err.is_transient(), "expected transient for {:?}", err);
1218
1219 let err: UnindexedOrderError =
1220 OrderError::Connectivity(ConnectivityError::ExchangeOffline(ExchangeId::BinanceSpot));
1221 assert!(err.is_transient(), "expected transient for {:?}", err);
1222 }
1223
1224 #[test]
1225 fn test_order_error_not_placed() {
1226 let not_sent: UnindexedOrderError = OrderError::NotPlaced(NotPlacedReason::NotSent(
1227 ConnectivityError::Socket("connection refused".into()),
1228 ));
1229 assert!(not_sent.is_transient(), "a later send may get through");
1230 assert!(!not_sent.is_outcome_unknown());
1231
1232 let unknown: UnindexedOrderError = OrderError::NotPlaced(NotPlacedReason::UnknownToVenue);
1233 assert!(!unknown.is_transient());
1234 assert!(!unknown.is_outcome_unknown());
1235
1236 let dropped: UnindexedOrderError = OrderError::NotPlaced(NotPlacedReason::CancelFailed {
1237 cancel: ClientOrderId::new("stop"),
1238 });
1239 assert!(
1240 !dropped.is_transient(),
1241 "resending cannot help: the cancel it waited on failed"
1242 );
1243 assert!(!dropped.is_outcome_unknown(), "it was never sent");
1244 }
1245
1246 /// Only a connectivity error leaves an open's outcome unknown, and only as an open's answer
1247 /// does `OrderState` read it so.
1248 #[test]
1249 fn test_order_error_is_outcome_unknown() {
1250 use crate::order::state::{OrderState, UnindexedOrderState};
1251
1252 let timeout: UnindexedOrderError = OrderError::Connectivity(ConnectivityError::Timeout);
1253 assert!(timeout.is_outcome_unknown());
1254 let rejected: UnindexedOrderError =
1255 OrderError::Rejected(ApiError::OrderRejected("no".into()));
1256 assert!(!rejected.is_outcome_unknown());
1257
1258 let state: UnindexedOrderState = OrderState::inactive(timeout);
1259 assert!(state.is_outcome_unknown());
1260 assert!(state.is_failed());
1261 let state: UnindexedOrderState = OrderState::inactive(rejected);
1262 assert!(!state.is_outcome_unknown());
1263 }
1264
1265 #[test]
1266 fn test_order_error_is_transient_rate_limit() {
1267 let err: UnindexedOrderError = OrderError::Rejected(ApiError::rate_limited(None));
1268 assert!(err.is_transient(), "expected transient for {:?}", err);
1269 }
1270
1271 #[test]
1272 fn test_order_error_not_transient_api_errors() {
1273 let err: UnindexedOrderError =
1274 OrderError::Rejected(ApiError::OrderRejected("price out of range".into()));
1275 assert!(!err.is_transient(), "expected non-transient for {:?}", err);
1276
1277 let err: UnindexedOrderError = OrderError::Rejected(ApiError::OrderAlreadyCancelled);
1278 assert!(!err.is_transient(), "expected non-transient for {:?}", err);
1279
1280 let err: UnindexedOrderError =
1281 OrderError::Rejected(ApiError::BorrowRejected(BorrowReject::new(
1282 BorrowRejectReason::NotShortable,
1283 None,
1284 "asset XYZ cannot be sold short".into(),
1285 )));
1286 assert!(!err.is_transient(), "expected non-transient for {:?}", err);
1287
1288 let err: UnindexedOrderError = OrderError::Rejected(ApiError::BalanceInsufficient(
1289 Some(AssetNameExchange::from("BTC")),
1290 "insufficient".into(),
1291 ));
1292 assert!(!err.is_transient(), "expected non-transient for {:?}", err);
1293 }
1294}