Skip to main content

bot_core/
exchange.rs

1//! Exchange adapter trait and exchange-facing DTOs.
2//!
3//! Adapters implement [`Exchange`] to translate canonical bot commands into
4//! concrete exchange API calls. The engine owns retries, state transitions, and
5//! event generation; adapters return structured results and errors.
6//!
7//! # Example
8//!
9//! ```
10//! use bot_core::{
11//!     ClientOrderId, InstrumentId, MarketIndex, OrderInput, OrderSide, Price, Qty, TimeInForce,
12//! };
13//! use rust_decimal::Decimal;
14//!
15//! let input = OrderInput {
16//!     instrument: InstrumentId::new("BTC-PERP"),
17//!     market_index: MarketIndex::new(0),
18//!     client_id: ClientOrderId::new("client-1"),
19//!     side: OrderSide::Buy,
20//!     price: Price::new(Decimal::new(65_000, 0)),
21//!     qty: Qty::new(Decimal::new(1, 3)),
22//!     tif: TimeInForce::Gtc,
23//!     post_only: true,
24//!     reduce_only: false,
25//! };
26//!
27//! assert!(input.post_only);
28//! ```
29
30use crate::types::*;
31use rust_decimal::Decimal;
32use serde::{Deserialize, Serialize};
33use thiserror::Error;
34
35/// Input parameters for placing an order.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct OrderInput {
38    /// Canonical instrument ID used by the engine.
39    pub instrument: InstrumentId,
40    /// Exchange-specific market index or encoded asset ID.
41    pub market_index: MarketIndex,
42    /// Client order ID generated by the bot.
43    pub client_id: ClientOrderId,
44    /// Buy or sell side.
45    pub side: OrderSide,
46    /// Limit price.
47    pub price: Price,
48    /// Order quantity.
49    pub qty: Qty,
50    /// Time-in-force instruction.
51    pub tif: TimeInForce,
52    /// Whether the exchange must reject the order if it would take liquidity.
53    pub post_only: bool,
54    /// Whether the order may only reduce an existing position.
55    pub reduce_only: bool,
56}
57
58/// Errors returned by exchange operations.
59#[derive(Debug, Error, Clone)]
60pub enum ExchangeError {
61    /// HTTP status or response-level failure that does not fit a narrower case.
62    #[error("HTTP error: {0}")]
63    Http(String),
64
65    /// Network transport failure such as DNS, connection reset, or TLS failure.
66    #[error("Network error: {0}")]
67    Network(String),
68
69    /// Exchange rejected the request because a rate limit was hit.
70    #[error("Rate limited (429)")]
71    RateLimited,
72
73    /// Exchange action budget is too low for the requested batch.
74    #[error("Would exceed user action limit (needed {needed}, retry after {retry_after_ms}ms)")]
75    WouldExceedUserActionLimit {
76        /// Milliseconds to wait before retrying.
77        retry_after_ms: u64,
78        /// Number of action credits the attempted operation needed.
79        needed: u32,
80    },
81
82    /// Exchange is temporarily unavailable.
83    #[error("Exchange unavailable (502/503)")]
84    Unavailable,
85
86    /// Request exceeded its configured deadline.
87    #[error("Request timeout")]
88    Timeout,
89
90    /// Exchange accepted the request shape but rejected the order semantics.
91    #[error("Order rejected: {0}")]
92    Rejected(String),
93
94    /// Adapter could not parse an exchange response.
95    #[error("Parse error: {0}")]
96    Parse(String),
97
98    /// Request signing failed before submission.
99    #[error("Signing error: {0}")]
100    Signing(String),
101
102    /// Adapter was configured with invalid or missing settings.
103    #[error("Configuration error: {0}")]
104    Configuration(String),
105
106    /// Fallback for failures that should be made more specific when possible.
107    #[error("Unknown error: {0}")]
108    Unknown(String),
109}
110
111impl ExchangeError {
112    /// Is this error transient (should retry)?
113    pub fn is_transient(&self) -> bool {
114        matches!(
115            self,
116            Self::RateLimited
117                | Self::WouldExceedUserActionLimit { .. }
118                | Self::Unavailable
119                | Self::Timeout
120                | Self::Network(_)
121        )
122    }
123
124    /// Is this a 502 (exchange halted)?
125    pub fn is_502(&self) -> bool {
126        matches!(self, Self::Unavailable)
127    }
128}
129
130/// Result of placing an order
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub enum PlaceOrderResult {
133    /// Order accepted by exchange
134    Accepted {
135        /// Exchange-assigned order ID, if the API returned one.
136        exchange_order_id: Option<ExchangeOrderId>,
137        /// For IOC orders that are immediately filled, includes fill info
138        filled_qty: Option<Qty>,
139        /// Average execution price for immediately filled quantity.
140        avg_fill_px: Option<Price>,
141    },
142    /// Order rejected by exchange
143    Rejected {
144        /// Human-readable rejection reason.
145        reason: String,
146    },
147}
148
149/// A fill from userFills
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct Fill {
152    /// Trade ID (from exchange, or derived stable key)
153    pub trade_id: TradeId,
154    /// Client order ID (if returned by exchange)
155    pub client_id: Option<ClientOrderId>,
156    /// Exchange order ID
157    pub exchange_order_id: Option<ExchangeOrderId>,
158    /// Instrument
159    pub instrument: InstrumentId,
160    /// Side
161    pub side: OrderSide,
162    /// Fill price
163    pub price: Price,
164    /// Fill quantity
165    pub qty: Qty,
166    /// Fee
167    pub fee: Fee,
168    /// Exchange timestamp (millis)
169    pub ts: i64,
170}
171
172/// Account balance reported by an exchange.
173#[derive(Debug, Clone, Serialize, Deserialize)]
174pub struct AccountBalance {
175    /// Asset whose balance is reported.
176    pub asset: AssetId,
177    /// Total balance, including unavailable/reserved funds.
178    pub total: Decimal,
179    /// Balance available for new orders or withdrawals.
180    pub available: Decimal,
181}
182
183/// Position reported by an exchange.
184#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct ExchangePosition {
186    /// Instrument for this position.
187    pub instrument: InstrumentId,
188    /// Signed quantity: positive is long, negative is short.
189    pub qty: Decimal, // signed
190    /// Average entry price when supplied by the exchange.
191    pub avg_entry_px: Option<Price>,
192    /// Current unrealized PnL when supplied by the exchange.
193    pub unrealized_pnl: Option<Decimal>,
194}
195
196/// Exchange adapter trait - what each exchange must implement.
197///
198/// All methods are async and return Results to handle network errors.
199/// The engine calls these methods and translates results into canonical events.
200#[async_trait::async_trait]
201pub trait Exchange: Send + Sync + 'static {
202    /// Get the exchange ID
203    fn exchange_id(&self) -> &ExchangeId;
204
205    /// Get the environment (mainnet/testnet)
206    fn environment(&self) -> Environment;
207
208    /// Get the exchange instance key
209    fn instance(&self) -> ExchangeInstance {
210        ExchangeInstance::new(self.exchange_id().clone(), self.environment())
211    }
212
213    /// Initialize the exchange connection and perform startup validations.
214    /// Called by the runner before entering the main loop.
215    /// Implementations can validate: connectivity, vault ownership, account balance, etc.
216    async fn init(&self) -> Result<(), ExchangeError> {
217        Ok(()) // default no-op for paper/mock exchanges
218    }
219
220    // -------------------------------------------------------------------------
221    // Write operations (trading)
222    // -------------------------------------------------------------------------
223
224    /// Place multiple limit orders in a single batch API call.
225    /// Returns a Vec of results, one for each order in the same order as the input.
226    async fn place_orders(
227        &self,
228        orders: &[OrderInput],
229    ) -> Result<Vec<PlaceOrderResult>, ExchangeError>;
230
231    /// Cancel an order by client_id or exchange_order_id.
232    async fn cancel_order(
233        &self,
234        instrument: &InstrumentId,
235        market_index: &MarketIndex,
236        client_id: &ClientOrderId,
237        exchange_order_id: Option<&ExchangeOrderId>,
238    ) -> Result<(), ExchangeError>;
239
240    /// Cancel all orders for an instrument.
241    /// Returns number of orders canceled.
242    async fn cancel_all_orders(
243        &self,
244        instrument: &InstrumentId,
245        market_index: &MarketIndex,
246    ) -> Result<u32, ExchangeError>;
247
248    // -------------------------------------------------------------------------
249    // Read operations (polling)
250    // -------------------------------------------------------------------------
251
252    /// Poll user fills since cursor.
253    /// This is the primary execution event source.
254    /// cursor is opaque string (implementation-specific).
255    async fn poll_user_fills(&self, cursor: Option<&str>) -> Result<Vec<Fill>, ExchangeError>;
256
257    /// Poll current quotes/prices.
258    async fn poll_quotes(
259        &self,
260        instruments: &[InstrumentId],
261    ) -> Result<Vec<crate::types::Quote>, ExchangeError>;
262
263    /// Poll account state (absolute).
264    /// Used for snapshot-based synchronization.
265    async fn poll_account_state(&self) -> Result<AccountState, ExchangeError>;
266}