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