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            UnindexedApiError::RequestRejected(reason) => ApiError::RequestRejected(reason),
320        })
321    }
322
323    pub fn order_request<Kind>(
324        &self,
325        order: &OrderEvent<Kind, ExchangeIndex, InstrumentIndex>,
326    ) -> Result<OrderEvent<Kind, ExchangeId, &InstrumentNameExchange>, KeyError>
327    where
328        Kind: Clone,
329    {
330        let OrderEvent {
331            key:
332                OrderKey {
333                    exchange,
334                    instrument,
335                    strategy,
336                    cid,
337                },
338            state,
339        } = order;
340
341        let exchange = self.map.find_exchange_id(*exchange)?;
342        let instrument = self.map.find_instrument_name_exchange(*instrument)?;
343
344        Ok(OrderEvent {
345            key: OrderKey {
346                exchange,
347                instrument,
348                strategy: strategy.clone(),
349                cid: cid.clone(),
350            },
351            state: state.clone(),
352        })
353    }
354
355    pub fn order_error(&self, error: UnindexedOrderError) -> Result<OrderError, IndexError> {
356        Ok(match error {
357            UnindexedOrderError::Connectivity(error) => OrderError::Connectivity(error),
358            UnindexedOrderError::Rejected(error) => OrderError::Rejected(self.api_error(error)?),
359            UnindexedOrderError::UnsupportedOrderType(msg) => OrderError::UnsupportedOrderType(msg),
360        })
361    }
362
363    pub fn client_error(&self, error: UnindexedClientError) -> Result<ClientError, IndexError> {
364        Ok(match error {
365            UnindexedClientError::Connectivity(error) => ClientError::Connectivity(error),
366            UnindexedClientError::Api(error) => ClientError::Api(self.api_error(error)?),
367            UnindexedClientError::TaskFailed(value) => ClientError::TaskFailed(value),
368            UnindexedClientError::Internal(value) => ClientError::Internal(value),
369            UnindexedClientError::Truncated { limit } => ClientError::Truncated { limit },
370            UnindexedClientError::TruncatedSnapshot { limit } => {
371                ClientError::TruncatedSnapshot { limit }
372            }
373        })
374    }
375
376    /// Index a trade, converting fee asset and computing `fees_quote`.
377    ///
378    /// Computes `fees_quote` based on fee asset relationship to instrument:
379    /// - Fee in quote asset: `fees_quote = Some(fees)`
380    /// - Fee in base asset: `fees_quote = Some(fees * price)`
381    /// - Fee in third-party asset (e.g., BNB): `fees_quote = None`
382    ///
383    /// # Errors
384    /// Returns `IndexError` if fee asset is not in the map. Some integrations use
385    /// "UNKNOWN" as a placeholder when fee data is unavailable (e.g., IBKR `fetch_trades`,
386    /// Binance when API omits `commission_asset`). These trades will fail indexing.
387    pub fn trade(
388        &self,
389        trade: Trade<AssetNameExchange, InstrumentNameExchange>,
390    ) -> Result<Trade<AssetIndex, InstrumentIndex>, IndexError> {
391        let Trade {
392            id,
393            order_id,
394            instrument,
395            strategy,
396            time_exchange,
397            side,
398            price: trade_price,
399            quantity,
400            order_filled_quantity,
401            fees,
402        } = trade;
403
404        let instrument_index = self.map.find_instrument_index(&instrument)?;
405        let fee_asset_index = self.map.find_asset_index(&fees.asset)?;
406
407        // Compute fees_quote based on fee asset relationship to instrument
408        let fees_quote = self
409            .map
410            .instruments
411            .get_index(instrument_index.index())
412            .and_then(|instr| {
413                if fee_asset_index == instr.underlying.quote {
414                    // Fee is in quote asset — no conversion needed
415                    Some(fees.fees)
416                } else if fee_asset_index == instr.underlying.base {
417                    // Fee is in base asset — convert using trade price
418                    Some(fees.fees * trade_price)
419                } else {
420                    // Fee is in third-party asset (e.g., BNB) — needs external price
421                    None
422                }
423            });
424
425        Ok(Trade {
426            id,
427            order_id,
428            instrument: instrument_index,
429            strategy,
430            time_exchange,
431            side,
432            price: trade_price,
433            quantity,
434            order_filled_quantity,
435            fees: AssetFees {
436                asset: fee_asset_index,
437                fees: fees.fees,
438                fees_quote,
439            },
440        })
441    }
442}
443
444#[cfg(test)]
445#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
446mod tests {
447    use super::*;
448    use crate::{error::StreamTerminationReason, map::generate_execution_instrument_map};
449    use rustrade_instrument::{index::IndexedInstruments, test_utils};
450
451    fn binance_indexer() -> AccountEventIndexer {
452        let instruments = IndexedInstruments::new(vec![test_utils::instrument(
453            ExchangeId::BinanceSpot,
454            "BTC",
455            "USDT",
456        )]);
457        let map = generate_execution_instrument_map(&instruments, ExchangeId::BinanceSpot).unwrap();
458        AccountEventIndexer::new(Arc::new(map))
459    }
460
461    /// `StreamTerminated` carries no asset/instrument keys, so it must index by mapping only the
462    /// exchange and passing the reason through verbatim.
463    #[test]
464    fn account_event_passes_stream_terminated_through_verbatim() {
465        let indexer = binance_indexer();
466        let reason = StreamTerminationReason::ReconnectBudgetExhausted {
467            attempts: 3,
468            last_error: "socket reset".to_string(),
469        };
470
471        let indexed = indexer
472            .account_event(UnindexedAccountEvent::new(
473                ExchangeId::BinanceSpot,
474                AccountEventKind::StreamTerminated(reason.clone()),
475            ))
476            .unwrap();
477
478        assert_eq!(indexed.exchange, indexer.map.exchange.key);
479        assert!(
480            matches!(indexed.kind, AccountEventKind::StreamTerminated(r) if r == reason),
481            "expected StreamTerminated to pass through unchanged",
482        );
483    }
484}