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}