use derive_more::Constructor;
use rust_decimal::Decimal;
use rustrade_instrument::{
asset::{AssetIndex, name::AssetNameExchange},
exchange::ExchangeId,
index::error::IndexError,
instrument::{InstrumentIndex, name::InstrumentNameExchange},
};
use rustrade_integration::error::SocketError;
use serde::{Deserialize, Serialize};
use std::fmt;
use thiserror::Error;
pub type UnindexedClientError = ClientError<AssetNameExchange, InstrumentNameExchange>;
pub type UnindexedApiError = ApiError<AssetNameExchange, InstrumentNameExchange>;
pub type UnindexedOrderError = OrderError<AssetNameExchange, InstrumentNameExchange>;
#[non_exhaustive]
#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Error)]
pub enum ClientError<AssetKey = AssetIndex, InstrumentKey = InstrumentIndex> {
#[error("Connectivity: {0}")]
Connectivity(#[from] ConnectivityError),
#[error("API: {0}")]
Api(#[from] ApiError<AssetKey, InstrumentKey>),
#[error("task failed: {0}")]
TaskFailed(String),
#[error("internal error: {0}")]
Internal(String),
#[error("trade read stopped after {fills_read} fills without advancing past its start")]
Truncated {
fills_read: usize,
},
#[error("open orders snapshot truncated at {limit} results — data may be incomplete")]
TruncatedSnapshot {
limit: usize,
},
}
impl<AssetKey, InstrumentKey> ClientError<AssetKey, InstrumentKey> {
pub fn is_transient(&self) -> bool {
match self {
Self::Connectivity(e) => e.is_transient(),
Self::Api(ApiError::RateLimit) => true,
Self::Api(_) => false,
Self::TaskFailed(_) => false,
Self::Internal(_) => false,
Self::Truncated { .. } => false,
Self::TruncatedSnapshot { .. } => false,
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Error)]
pub enum ConnectivityError {
#[error("Exchange offline: {0}")]
ExchangeOffline(ExchangeId),
#[error("request timed out")]
Timeout,
#[error("{0}")]
Socket(String),
}
impl From<SocketError> for ConnectivityError {
fn from(value: SocketError) -> Self {
Self::Socket(value.to_string())
}
}
impl ConnectivityError {
pub fn is_transient(&self) -> bool {
match self {
Self::ExchangeOffline(_) => true,
Self::Timeout => true,
Self::Socket(_) => true,
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Error)]
pub enum ApiError<AssetKey = AssetIndex, InstrumentKey = InstrumentIndex> {
#[error("asset {0} invalid: {1}")]
AssetInvalid(AssetKey, String),
#[error("instrument {0} invalid: {1}")]
InstrumentInvalid(InstrumentKey, String),
#[error("rate limit exceeded")]
RateLimit,
#[error("authentication failed: {0}")]
Unauthenticated(String),
#[error(
"balance insufficient{asset}: {1}",
asset = .0.as_ref().map(|asset| format!(" for asset {asset}")).unwrap_or_default()
)]
BalanceInsufficient(Option<AssetKey>, String),
#[error("order rejected: {0}")]
OrderRejected(String),
#[error("order already cancelled")]
OrderAlreadyCancelled,
#[error("order already fully filled")]
OrderAlreadyFullyFilled,
#[error("order already expired")]
OrderAlreadyExpired,
#[error("client order id in use: {0}")]
DuplicateClientOrderId(String),
#[error("request rejected: {0}")]
RequestRejected(String),
}
#[non_exhaustive]
#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Error)]
pub enum OrderError<AssetKey = AssetIndex, InstrumentKey = InstrumentIndex> {
#[error("connectivity: {0}")]
Connectivity(#[from] ConnectivityError),
#[error("order rejected: {0}")]
Rejected(#[from] ApiError<AssetKey, InstrumentKey>),
#[error("unsupported order type: {0}")]
UnsupportedOrderType(String),
#[error("invalid precision: {0}")]
InvalidPrecision(PrecisionViolation),
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize)]
pub struct PrecisionViolation {
pub field: OrderField,
pub value: Decimal,
pub limit: PrecisionLimit,
}
impl PrecisionViolation {
pub fn new(field: OrderField, value: Decimal, limit: PrecisionLimit) -> Self {
Self {
field,
value,
limit,
}
}
}
impl fmt::Display for PrecisionViolation {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let Self {
field,
value,
limit,
} = self;
write!(f, "{field} {value} ")?;
match limit {
PrecisionLimit::DecimalPlaces { max } => {
write!(f, "has more than {max} decimal places")
}
PrecisionLimit::SignificantFigures { max } => {
write!(
f,
"is not an integer and has more than {max} significant figures"
)
}
PrecisionLimit::NotRepresentable => {
write!(f, "cannot be sent exactly in the venue's number format")
}
PrecisionLimit::NotPositive => write!(f, "is not positive"),
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize)]
pub enum OrderField {
Quantity,
Price,
TriggerPrice,
}
impl fmt::Display for OrderField {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Quantity => "quantity",
Self::Price => "price",
Self::TriggerPrice => "trigger price",
})
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize)]
pub enum PrecisionLimit {
DecimalPlaces {
max: u32,
},
SignificantFigures {
max: u32,
},
NotRepresentable,
NotPositive,
}
impl<AssetKey, InstrumentKey> OrderError<AssetKey, InstrumentKey> {
pub fn is_transient(&self) -> bool {
match self {
Self::Connectivity(e) => e.is_transient(),
Self::Rejected(ApiError::RateLimit) => true,
Self::Rejected(_) => false,
Self::UnsupportedOrderType(_) | Self::InvalidPrecision(_) => false,
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Error)]
pub enum StreamTerminationReason {
#[error("reconnect budget exhausted after {attempts} attempts: {last_error}")]
ReconnectBudgetExhausted {
attempts: u32,
last_error: String,
},
#[error("unrecoverable stream error: {0}")]
Error(String),
}
pub type UnindexedAccountStreamInitError =
AccountStreamInitError<AssetNameExchange, InstrumentNameExchange>;
#[non_exhaustive]
#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Error)]
pub enum AccountStreamInitError<AssetKey = AssetIndex, InstrumentKey = InstrumentIndex> {
#[error("{0}")]
Client(#[from] ClientError<AssetKey, InstrumentKey>),
#[error("IndexError: {0}")]
Index(#[from] IndexError),
}
#[non_exhaustive]
#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize, Serialize, Constructor)]
pub struct AccountReinitFailure<AssetKey = AssetIndex, InstrumentKey = InstrumentIndex> {
pub attempt: u32,
pub error: AccountStreamInitError<AssetKey, InstrumentKey>,
}
#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Error)]
pub enum KeyError {
#[error("ExchangeId: {0}")]
ExchangeId(String),
#[error("AssetKey: {0}")]
AssetKey(String),
#[error("InstrumentKey: {0}")]
InstrumentKey(String),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_connectivity_error_is_transient() {
assert!(ConnectivityError::Timeout.is_transient());
assert!(ConnectivityError::Socket("connection refused".into()).is_transient());
assert!(ConnectivityError::ExchangeOffline(ExchangeId::BinanceSpot).is_transient());
}
#[test]
fn test_client_error_is_transient_connectivity() {
let err: ClientError = ClientError::Connectivity(ConnectivityError::Timeout);
assert!(err.is_transient());
let err: ClientError = ClientError::Connectivity(ConnectivityError::Socket("err".into()));
assert!(err.is_transient());
}
#[test]
fn test_client_error_is_transient_rate_limit() {
let err: ClientError = ClientError::Api(ApiError::RateLimit);
assert!(err.is_transient());
}
#[test]
fn test_client_error_not_transient_api_errors() {
let err: ClientError =
ClientError::Api(ApiError::AssetInvalid(AssetIndex(0), "bad".into()));
assert!(!err.is_transient(), "expected non-transient for {:?}", err);
let err: ClientError = ClientError::Api(ApiError::BalanceInsufficient(
Some(AssetIndex(0)),
"low".into(),
));
assert!(!err.is_transient(), "expected non-transient for {:?}", err);
let err: ClientError = ClientError::Api(ApiError::InstrumentInvalid(
InstrumentIndex(0),
"bad".into(),
));
assert!(!err.is_transient(), "expected non-transient for {:?}", err);
let err: ClientError = ClientError::Api(ApiError::OrderRejected("rejected".into()));
assert!(!err.is_transient(), "expected non-transient for {:?}", err);
let err: ClientError = ClientError::Api(ApiError::OrderAlreadyCancelled);
assert!(!err.is_transient(), "expected non-transient for {:?}", err);
let err: ClientError = ClientError::Api(ApiError::OrderAlreadyFullyFilled);
assert!(!err.is_transient(), "expected non-transient for {:?}", err);
let err: ClientError =
ClientError::Api(ApiError::Unauthenticated("invalid signature".into()));
assert!(!err.is_transient(), "expected non-transient for {:?}", err);
let err: ClientError =
ClientError::Api(ApiError::RequestRejected("-1127 More than 24 hours".into()));
assert!(!err.is_transient(), "expected non-transient for {:?}", err);
}
#[test]
fn balance_insufficient_names_its_asset_only_when_known() {
let named: UnindexedApiError =
ApiError::BalanceInsufficient(Some(AssetNameExchange::new("BTC")), "low".to_string());
assert_eq!(named.to_string(), "balance insufficient for asset BTC: low");
let unnamed: UnindexedApiError = ApiError::BalanceInsufficient(None, "low".to_string());
assert_eq!(unnamed.to_string(), "balance insufficient: low");
}
#[test]
fn test_client_error_not_transient_task_failed() {
let err: ClientError = ClientError::TaskFailed("task panicked".into());
assert!(!err.is_transient());
}
#[test]
fn test_client_error_not_transient_internal() {
let err: ClientError = ClientError::Internal("unknown error".into());
assert!(!err.is_transient());
}
#[test]
fn test_client_error_not_transient_truncated() {
let err: ClientError = ClientError::Truncated { fills_read: 5_000 };
assert!(!err.is_transient(), "expected non-transient for {:?}", err);
let err: ClientError = ClientError::TruncatedSnapshot { limit: 500 };
assert!(!err.is_transient(), "expected non-transient for {:?}", err);
}
#[test]
fn test_client_error_is_transient_exchange_offline() {
let err: ClientError =
ClientError::Connectivity(ConnectivityError::ExchangeOffline(ExchangeId::BinanceSpot));
assert!(err.is_transient(), "expected transient for {:?}", err);
}
#[test]
fn test_order_error_is_transient_connectivity() {
let err: UnindexedOrderError = OrderError::Connectivity(ConnectivityError::Timeout);
assert!(err.is_transient(), "expected transient for {:?}", err);
let err: UnindexedOrderError =
OrderError::Connectivity(ConnectivityError::Socket("connection reset".into()));
assert!(err.is_transient(), "expected transient for {:?}", err);
let err: UnindexedOrderError =
OrderError::Connectivity(ConnectivityError::ExchangeOffline(ExchangeId::BinanceSpot));
assert!(err.is_transient(), "expected transient for {:?}", err);
}
#[test]
fn test_order_error_is_transient_rate_limit() {
let err: UnindexedOrderError = OrderError::Rejected(ApiError::RateLimit);
assert!(err.is_transient(), "expected transient for {:?}", err);
}
#[test]
fn test_order_error_not_transient_api_errors() {
let err: UnindexedOrderError =
OrderError::Rejected(ApiError::OrderRejected("price out of range".into()));
assert!(!err.is_transient(), "expected non-transient for {:?}", err);
let err: UnindexedOrderError = OrderError::Rejected(ApiError::OrderAlreadyCancelled);
assert!(!err.is_transient(), "expected non-transient for {:?}", err);
let err: UnindexedOrderError = OrderError::Rejected(ApiError::BalanceInsufficient(
Some(AssetNameExchange::from("BTC")),
"insufficient".into(),
));
assert!(!err.is_transient(), "expected non-transient for {:?}", err);
}
}