Skip to main content

rustrade_execution/
indexer.rs

1use crate::{
2    AccountEvent, AccountEventKind, AccountSnapshot, InstrumentAccountSnapshot,
3    InstrumentBalanceUpdate, IsolatedInstrumentState, UnindexedAccountEvent,
4    UnindexedAccountSnapshot,
5    balance::{AssetBalance, AssetBalanceUpdate},
6    error::{
7        ApiError, ClientError, KeyError, OrderError, UnindexedApiError, UnindexedClientError,
8        UnindexedOrderError,
9    },
10    map::ExecutionInstrumentMap,
11    order::{
12        Order, OrderEvent, OrderKey, OrderSnapshot, UnindexedOrderKey, UnindexedOrderSnapshot,
13        request::OrderResponseCancel,
14        state::{InactiveOrderState, OrderState, UnindexedOrderState},
15    },
16    trade::{AssetFees, Trade},
17};
18use derive_more::Constructor;
19use rustrade_instrument::{
20    asset::{AssetIndex, name::AssetNameExchange},
21    exchange::{ExchangeId, ExchangeIndex},
22    index::error::IndexError,
23    instrument::{InstrumentIndex, name::InstrumentNameExchange},
24};
25use rustrade_integration::{
26    collection::snapshot::Snapshot,
27    stream::ext::indexed::{IndexedStream, Indexer},
28};
29use std::sync::Arc;
30
31pub type IndexedAccountStream<St> = IndexedStream<St, AccountEventIndexer>;
32
33#[derive(Debug, Clone, Constructor)]
34pub struct AccountEventIndexer {
35    pub map: Arc<ExecutionInstrumentMap>,
36}
37
38impl Indexer for AccountEventIndexer {
39    type Unindexed = UnindexedAccountEvent;
40    type Indexed = AccountEvent;
41
42    fn index(&self, item: Self::Unindexed) -> Result<Self::Indexed, IndexError> {
43        self.account_event(item)
44    }
45}
46
47impl AccountEventIndexer {
48    pub fn account_event(&self, event: UnindexedAccountEvent) -> Result<AccountEvent, IndexError> {
49        let UnindexedAccountEvent { exchange, kind } = event;
50
51        let exchange = self.map.find_exchange_index(exchange)?;
52
53        let kind = match kind {
54            AccountEventKind::Snapshot(snapshot) => {
55                AccountEventKind::Snapshot(self.snapshot(snapshot)?)
56            }
57            AccountEventKind::BalanceSnapshot(snapshot) => {
58                AccountEventKind::BalanceSnapshot(self.asset_balance(snapshot.0).map(Snapshot)?)
59            }
60            AccountEventKind::BalanceStreamUpdate(snapshot) => {
61                AccountEventKind::BalanceStreamUpdate(
62                    self.asset_balance_update(snapshot.0).map(Snapshot)?,
63                )
64            }
65            AccountEventKind::InstrumentBalanceUpdate(update) => {
66                AccountEventKind::InstrumentBalanceUpdate(self.instrument_balance_update(update)?)
67            }
68            AccountEventKind::OrderSnapshot(snapshot) => {
69                AccountEventKind::OrderSnapshot(self.order_snapshot(snapshot.0).map(Snapshot)?)
70            }
71            AccountEventKind::OrderCancelled(response) => {
72                AccountEventKind::OrderCancelled(self.order_response_cancel(response)?)
73            }
74            AccountEventKind::Trade(trade) => AccountEventKind::Trade(self.trade(trade)?),
75            // Termination reason carries no exchange/asset/instrument keys — pass through verbatim.
76            AccountEventKind::StreamTerminated(reason) => {
77                AccountEventKind::StreamTerminated(reason)
78            }
79        };
80
81        Ok(AccountEvent { exchange, kind })
82    }
83
84    pub fn snapshot(
85        &self,
86        snapshot: UnindexedAccountSnapshot,
87    ) -> Result<AccountSnapshot, IndexError> {
88        let UnindexedAccountSnapshot {
89            exchange,
90            balances,
91            instruments,
92        } = snapshot;
93
94        let exchange = self.map.find_exchange_index(exchange)?;
95
96        let balances = balances
97            .into_iter()
98            .map(|balance| self.asset_balance(balance))
99            .collect::<Result<Vec<_>, _>>()?;
100
101        let instruments = instruments
102            .into_iter()
103            .map(|snapshot| {
104                let InstrumentAccountSnapshot {
105                    instrument,
106                    orders,
107                    orders_complete,
108                    position,
109                    isolated,
110                } = snapshot;
111
112                let instrument = self.map.find_instrument_index(&instrument)?;
113
114                let orders = orders
115                    .into_iter()
116                    .map(|order| self.order_snapshot(order))
117                    .collect::<Result<Vec<_>, _>>()?;
118
119                // Per-pair isolated balances are generic over `AssetKey`, so (unlike `position`)
120                // their base/quote asset names must be mapped to indices. A pair whose asset is
121                // unregistered fails the snapshot index, matching top-level-balance behaviour.
122                let isolated = isolated
123                    .map(|state| self.isolated_instrument_state(state))
124                    .transpose()?;
125
126                Ok(InstrumentAccountSnapshot {
127                    instrument,
128                    orders,
129                    orders_complete,
130                    position,
131                    isolated,
132                })
133            })
134            .collect::<Result<Vec<_>, _>>()?;
135
136        Ok(AccountSnapshot {
137            exchange,
138            balances,
139            instruments,
140        })
141    }
142
143    pub fn asset_balance(
144        &self,
145        balance: AssetBalance<AssetNameExchange>,
146    ) -> Result<AssetBalance<AssetIndex>, IndexError> {
147        let AssetBalance {
148            asset,
149            balance,
150            time_exchange,
151        } = balance;
152        let asset = self.map.find_asset_index(&asset)?;
153
154        Ok(AssetBalance {
155            asset,
156            balance,
157            time_exchange,
158        })
159    }
160
161    pub fn asset_balance_update(
162        &self,
163        update: AssetBalanceUpdate<AssetNameExchange>,
164    ) -> Result<AssetBalanceUpdate<AssetIndex>, IndexError> {
165        let AssetBalanceUpdate {
166            asset,
167            update,
168            time_exchange,
169        } = update;
170        let asset = self.map.find_asset_index(&asset)?;
171
172        Ok(AssetBalanceUpdate {
173            asset,
174            update,
175            time_exchange,
176        })
177    }
178
179    /// Index the per-pair isolated state's `base`/`quote` asset names to indices.
180    ///
181    /// # Errors
182    /// Returns `IndexError` if either the base or quote asset is not registered in the map —
183    /// matching the fail-fast behaviour of top-level [`Self::asset_balance`].
184    pub fn isolated_instrument_state(
185        &self,
186        state: IsolatedInstrumentState<AssetNameExchange>,
187    ) -> Result<IsolatedInstrumentState<AssetIndex>, IndexError> {
188        let IsolatedInstrumentState { base, quote, risk } = state;
189
190        Ok(IsolatedInstrumentState {
191            base: self.asset_balance(base)?,
192            quote: self.asset_balance(quote)?,
193            risk,
194        })
195    }
196
197    /// Index an [`InstrumentBalanceUpdate`]'s instrument and `base`/`quote` asset keys.
198    ///
199    /// # Errors
200    /// Returns `IndexError` if the instrument or either asset is not registered in the map.
201    pub fn instrument_balance_update(
202        &self,
203        update: InstrumentBalanceUpdate<AssetNameExchange, InstrumentNameExchange>,
204    ) -> Result<InstrumentBalanceUpdate, IndexError> {
205        let InstrumentBalanceUpdate {
206            instrument,
207            base,
208            quote,
209        } = update;
210
211        Ok(InstrumentBalanceUpdate {
212            instrument: self.map.find_instrument_index(&instrument)?,
213            base: self.asset_balance_update(base)?,
214            quote: self.asset_balance_update(quote)?,
215        })
216    }
217
218    pub fn order_snapshot(
219        &self,
220        order: UnindexedOrderSnapshot,
221    ) -> Result<OrderSnapshot, IndexError> {
222        let Order {
223            key,
224            side,
225            price,
226            quantity,
227            kind,
228            time_in_force,
229            state,
230        } = order;
231
232        let key = self.order_key(key)?;
233        let state = self.order_state(state)?;
234
235        Ok(Order {
236            key,
237            side,
238            price,
239            quantity,
240            kind,
241            time_in_force,
242            state,
243        })
244    }
245
246    pub fn order_response_cancel(
247        &self,
248        response: OrderResponseCancel<ExchangeId, AssetNameExchange, InstrumentNameExchange>,
249    ) -> Result<OrderResponseCancel, IndexError> {
250        let OrderResponseCancel { key, state } = response;
251
252        Ok(OrderResponseCancel {
253            key: self.order_key(key)?,
254            state: match state {
255                Ok(cancelled) => Ok(cancelled),
256                Err(error) => Err(self.order_error(error)?),
257            },
258        })
259    }
260
261    pub fn order_key(&self, key: UnindexedOrderKey) -> Result<OrderKey, IndexError> {
262        let UnindexedOrderKey {
263            exchange,
264            instrument,
265            strategy,
266            cid,
267        } = key;
268
269        Ok(OrderKey {
270            exchange: self.map.find_exchange_index(exchange)?,
271            instrument: self.map.find_instrument_index(&instrument)?,
272            strategy,
273            cid,
274        })
275    }
276
277    /// Index an [`UnindexedOrderState`] to an [`OrderState`].
278    ///
279    /// Used by `ExecutionManager` to index `open_order` responses.
280    pub fn order_state(&self, state: UnindexedOrderState) -> Result<OrderState, IndexError> {
281        Ok(match state {
282            UnindexedOrderState::Active(active) => OrderState::Active(active),
283            UnindexedOrderState::Inactive(inactive) => match inactive {
284                InactiveOrderState::OpenFailed(failed) => match failed {
285                    OrderError::Rejected(rejected) => {
286                        OrderState::inactive(OrderError::Rejected(self.api_error(rejected)?))
287                    }
288                    OrderError::Connectivity(error) => {
289                        OrderState::inactive(OrderError::Connectivity(error))
290                    }
291                    OrderError::UnsupportedOrderType(msg) => {
292                        OrderState::inactive(OrderError::UnsupportedOrderType(msg))
293                    }
294                },
295                InactiveOrderState::Cancelled(cancelled) => OrderState::inactive(cancelled),
296                InactiveOrderState::FullyFilled(filled) => OrderState::fully_filled(filled),
297                InactiveOrderState::Expired(expired) => OrderState::expired(expired),
298            },
299        })
300    }
301
302    pub fn api_error(&self, error: UnindexedApiError) -> Result<ApiError, IndexError> {
303        Ok(match error {
304            UnindexedApiError::RateLimit => ApiError::RateLimit,
305            UnindexedApiError::Unauthenticated(msg) => ApiError::Unauthenticated(msg),
306            UnindexedApiError::AssetInvalid(asset, value) => {
307                ApiError::AssetInvalid(self.map.find_asset_index(&asset)?, value)
308            }
309            UnindexedApiError::InstrumentInvalid(instrument, value) => {
310                ApiError::InstrumentInvalid(self.map.find_instrument_index(&instrument)?, value)
311            }
312            UnindexedApiError::BalanceInsufficient(asset, value) => {
313                ApiError::BalanceInsufficient(self.map.find_asset_index(&asset)?, value)
314            }
315            UnindexedApiError::OrderRejected(reason) => ApiError::OrderRejected(reason),
316            UnindexedApiError::OrderAlreadyCancelled => ApiError::OrderAlreadyCancelled,
317            UnindexedApiError::OrderAlreadyFullyFilled => ApiError::OrderAlreadyFullyFilled,
318            UnindexedApiError::OrderAlreadyExpired => ApiError::OrderAlreadyExpired,
319        })
320    }
321
322    pub fn order_request<Kind>(
323        &self,
324        order: &OrderEvent<Kind, ExchangeIndex, InstrumentIndex>,
325    ) -> Result<OrderEvent<Kind, ExchangeId, &InstrumentNameExchange>, KeyError>
326    where
327        Kind: Clone,
328    {
329        let OrderEvent {
330            key:
331                OrderKey {
332                    exchange,
333                    instrument,
334                    strategy,
335                    cid,
336                },
337            state,
338        } = order;
339
340        let exchange = self.map.find_exchange_id(*exchange)?;
341        let instrument = self.map.find_instrument_name_exchange(*instrument)?;
342
343        Ok(OrderEvent {
344            key: OrderKey {
345                exchange,
346                instrument,
347                strategy: strategy.clone(),
348                cid: cid.clone(),
349            },
350            state: state.clone(),
351        })
352    }
353
354    pub fn order_error(&self, error: UnindexedOrderError) -> Result<OrderError, IndexError> {
355        Ok(match error {
356            UnindexedOrderError::Connectivity(error) => OrderError::Connectivity(error),
357            UnindexedOrderError::Rejected(error) => OrderError::Rejected(self.api_error(error)?),
358            UnindexedOrderError::UnsupportedOrderType(msg) => OrderError::UnsupportedOrderType(msg),
359        })
360    }
361
362    pub fn client_error(&self, error: UnindexedClientError) -> Result<ClientError, IndexError> {
363        Ok(match error {
364            UnindexedClientError::Connectivity(error) => ClientError::Connectivity(error),
365            UnindexedClientError::Api(error) => ClientError::Api(self.api_error(error)?),
366            UnindexedClientError::TaskFailed(value) => ClientError::TaskFailed(value),
367            UnindexedClientError::Internal(value) => ClientError::Internal(value),
368            UnindexedClientError::Truncated { limit } => ClientError::Truncated { limit },
369            UnindexedClientError::TruncatedSnapshot { limit } => {
370                ClientError::TruncatedSnapshot { limit }
371            }
372        })
373    }
374
375    /// Index a trade, converting fee asset and computing `fees_quote`.
376    ///
377    /// Computes `fees_quote` based on fee asset relationship to instrument:
378    /// - Fee in quote asset: `fees_quote = Some(fees)`
379    /// - Fee in base asset: `fees_quote = Some(fees * price)`
380    /// - Fee in third-party asset (e.g., BNB): `fees_quote = None`
381    ///
382    /// # Errors
383    /// Returns `IndexError` if fee asset is not in the map. Some integrations use
384    /// "UNKNOWN" as a placeholder when fee data is unavailable (e.g., IBKR `fetch_trades`,
385    /// Binance when API omits `commission_asset`). These trades will fail indexing.
386    pub fn trade(
387        &self,
388        trade: Trade<AssetNameExchange, InstrumentNameExchange>,
389    ) -> Result<Trade<AssetIndex, InstrumentIndex>, IndexError> {
390        let Trade {
391            id,
392            order_id,
393            instrument,
394            strategy,
395            time_exchange,
396            side,
397            price: trade_price,
398            quantity,
399            order_filled_quantity,
400            fees,
401        } = trade;
402
403        let instrument_index = self.map.find_instrument_index(&instrument)?;
404        let fee_asset_index = self.map.find_asset_index(&fees.asset)?;
405
406        // Compute fees_quote based on fee asset relationship to instrument
407        let fees_quote = self
408            .map
409            .instruments
410            .get_index(instrument_index.index())
411            .and_then(|instr| {
412                if fee_asset_index == instr.underlying.quote {
413                    // Fee is in quote asset — no conversion needed
414                    Some(fees.fees)
415                } else if fee_asset_index == instr.underlying.base {
416                    // Fee is in base asset — convert using trade price
417                    Some(fees.fees * trade_price)
418                } else {
419                    // Fee is in third-party asset (e.g., BNB) — needs external price
420                    None
421                }
422            });
423
424        Ok(Trade {
425            id,
426            order_id,
427            instrument: instrument_index,
428            strategy,
429            time_exchange,
430            side,
431            price: trade_price,
432            quantity,
433            order_filled_quantity,
434            fees: AssetFees {
435                asset: fee_asset_index,
436                fees: fees.fees,
437                fees_quote,
438            },
439        })
440    }
441}
442
443#[cfg(test)]
444#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
445mod tests {
446    use super::*;
447    use crate::{error::StreamTerminationReason, map::generate_execution_instrument_map};
448    use rustrade_instrument::{index::IndexedInstruments, test_utils};
449
450    fn binance_indexer() -> AccountEventIndexer {
451        let instruments = IndexedInstruments::new(vec![test_utils::instrument(
452            ExchangeId::BinanceSpot,
453            "BTC",
454            "USDT",
455        )]);
456        let map = generate_execution_instrument_map(&instruments, ExchangeId::BinanceSpot).unwrap();
457        AccountEventIndexer::new(Arc::new(map))
458    }
459
460    /// `StreamTerminated` carries no asset/instrument keys, so it must index by mapping only the
461    /// exchange and passing the reason through verbatim.
462    #[test]
463    fn account_event_passes_stream_terminated_through_verbatim() {
464        let indexer = binance_indexer();
465        let reason = StreamTerminationReason::ReconnectBudgetExhausted {
466            attempts: 3,
467            last_error: "socket reset".to_string(),
468        };
469
470        let indexed = indexer
471            .account_event(UnindexedAccountEvent::new(
472                ExchangeId::BinanceSpot,
473                AccountEventKind::StreamTerminated(reason.clone()),
474            ))
475            .unwrap();
476
477        assert_eq!(indexed.exchange, indexer.map.exchange.key);
478        assert!(
479            matches!(indexed.kind, AccountEventKind::StreamTerminated(r) if r == reason),
480            "expected StreamTerminated to pass through unchanged",
481        );
482    }
483}