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}