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    cash_flow::CashFlow,
7    error::{
8        AccountReinitFailure, AccountStreamInitError, ApiError, ClientError, KeyError, OrderError,
9        UnindexedAccountStreamInitError, UnindexedApiError, UnindexedClientError,
10        UnindexedOrderError,
11    },
12    fill_recovery::{FillRecoveryGap, FillRecoveryScope},
13    map::ExecutionInstrumentMap,
14    notice::AccountNotice,
15    order::{
16        Order, OrderEvent, OrderKey, OrderSnapshot, UnindexedOrderKey, UnindexedOrderSnapshot,
17        request::OrderResponseCancel,
18        state::{InactiveOrderState, OrderState, UnindexedOrderState},
19    },
20    trade::{AssetFees, Trade, TradeAmendment, TradeAmendmentKind},
21};
22use derive_more::Constructor;
23use rustrade_instrument::{
24    asset::{AssetIndex, name::AssetNameExchange},
25    exchange::{ExchangeId, ExchangeIndex},
26    index::error::IndexError,
27    instrument::{InstrumentIndex, name::InstrumentNameExchange},
28};
29use rustrade_integration::{
30    collection::snapshot::Snapshot,
31    stream::ext::indexed::{IndexedStream, Indexer},
32};
33use std::sync::Arc;
34use tracing::warn;
35
36pub type IndexedAccountStream<St> = IndexedStream<St, AccountEventIndexer>;
37
38#[derive(Debug, Clone, Constructor)]
39pub struct AccountEventIndexer {
40    pub map: Arc<ExecutionInstrumentMap>,
41}
42
43impl Indexer for AccountEventIndexer {
44    type Unindexed = UnindexedAccountEvent;
45    type Indexed = AccountEvent;
46
47    fn index(&self, item: Self::Unindexed) -> Result<Self::Indexed, IndexError> {
48        self.account_event(item)
49    }
50}
51
52impl AccountEventIndexer {
53    pub fn account_event(&self, event: UnindexedAccountEvent) -> Result<AccountEvent, IndexError> {
54        let UnindexedAccountEvent { exchange, kind } = event;
55
56        let exchange = self.map.find_exchange_index(exchange)?;
57
58        let kind = match kind {
59            AccountEventKind::Snapshot(snapshot) => {
60                AccountEventKind::Snapshot(self.snapshot(snapshot)?)
61            }
62            AccountEventKind::BalanceSnapshot(snapshot) => {
63                AccountEventKind::BalanceSnapshot(self.asset_balance(snapshot.0).map(Snapshot)?)
64            }
65            AccountEventKind::BalanceStreamUpdate(snapshot) => {
66                AccountEventKind::BalanceStreamUpdate(
67                    self.asset_balance_update(snapshot.0).map(Snapshot)?,
68                )
69            }
70            AccountEventKind::InstrumentBalanceUpdate(update) => {
71                AccountEventKind::InstrumentBalanceUpdate(self.instrument_balance_update(update)?)
72            }
73            AccountEventKind::OrderSnapshot(snapshot) => {
74                AccountEventKind::OrderSnapshot(self.order_snapshot(snapshot.0).map(Snapshot)?)
75            }
76            AccountEventKind::OrderCancelled(response) => {
77                AccountEventKind::OrderCancelled(self.order_response_cancel(response)?)
78            }
79            AccountEventKind::Trade(trade) => AccountEventKind::Trade(self.trade(trade)?),
80            // Termination reason carries no exchange/asset/instrument keys — pass through verbatim.
81            AccountEventKind::StreamTerminated(reason) => {
82                AccountEventKind::StreamTerminated(reason)
83            }
84            AccountEventKind::FillRecoveryGaveUp(gap) => {
85                AccountEventKind::FillRecoveryGaveUp(self.fill_recovery_gap(gap)?)
86            }
87            AccountEventKind::TradeAmended(amendment) => {
88                AccountEventKind::TradeAmended(self.trade_amendment(amendment)?)
89            }
90            AccountEventKind::ReinitFailed(failure) => {
91                AccountEventKind::ReinitFailed(self.reinit_failure(failure))
92            }
93            AccountEventKind::CashFlow(flow) => AccountEventKind::CashFlow(self.cash_flow(flow)?),
94            AccountEventKind::Notice(notice) => {
95                AccountEventKind::Notice(self.notice(exchange, notice))
96            }
97        };
98
99        Ok(AccountEvent { exchange, kind })
100    }
101
102    pub fn snapshot(
103        &self,
104        snapshot: UnindexedAccountSnapshot,
105    ) -> Result<AccountSnapshot, IndexError> {
106        let UnindexedAccountSnapshot {
107            exchange,
108            balances,
109            instruments,
110        } = snapshot;
111
112        let exchange = self.map.find_exchange_index(exchange)?;
113
114        let balances = balances
115            .into_iter()
116            .map(|balance| self.asset_balance(balance))
117            .collect::<Result<Vec<_>, _>>()?;
118
119        let instruments = instruments
120            .into_iter()
121            .map(|snapshot| {
122                let InstrumentAccountSnapshot {
123                    instrument,
124                    orders,
125                    orders_complete,
126                    position,
127                    isolated,
128                } = snapshot;
129
130                let instrument = self.map.find_instrument_index(&instrument)?;
131
132                let orders = orders
133                    .into_iter()
134                    .map(|order| self.order_snapshot(order))
135                    .collect::<Result<Vec<_>, _>>()?;
136
137                // Per-pair isolated balances are generic over `AssetKey`, so (unlike `position`)
138                // their base/quote asset names must be mapped to indices. A pair whose asset is
139                // unregistered fails the snapshot index, matching top-level-balance behaviour.
140                let isolated = isolated
141                    .map(|state| self.isolated_instrument_state(state))
142                    .transpose()?;
143
144                Ok(InstrumentAccountSnapshot {
145                    instrument,
146                    orders,
147                    orders_complete,
148                    position,
149                    isolated,
150                })
151            })
152            .collect::<Result<Vec<_>, _>>()?;
153
154        Ok(AccountSnapshot {
155            exchange,
156            balances,
157            instruments,
158        })
159    }
160
161    pub fn asset_balance(
162        &self,
163        balance: AssetBalance<AssetNameExchange>,
164    ) -> Result<AssetBalance<AssetIndex>, IndexError> {
165        let AssetBalance {
166            asset,
167            balance,
168            time_exchange,
169        } = balance;
170        let asset = self.map.find_asset_index(&asset)?;
171
172        Ok(AssetBalance {
173            asset,
174            balance,
175            time_exchange,
176        })
177    }
178
179    pub fn asset_balance_update(
180        &self,
181        update: AssetBalanceUpdate<AssetNameExchange>,
182    ) -> Result<AssetBalanceUpdate<AssetIndex>, IndexError> {
183        let AssetBalanceUpdate {
184            asset,
185            update,
186            time_exchange,
187        } = update;
188        let asset = self.map.find_asset_index(&asset)?;
189
190        Ok(AssetBalanceUpdate {
191            asset,
192            update,
193            time_exchange,
194        })
195    }
196
197    /// Index the per-pair isolated state's `base`/`quote` asset names to indices.
198    ///
199    /// # Errors
200    /// Returns `IndexError` if either the base or quote asset is not registered in the map —
201    /// matching the fail-fast behaviour of top-level [`Self::asset_balance`].
202    pub fn isolated_instrument_state(
203        &self,
204        state: IsolatedInstrumentState<AssetNameExchange>,
205    ) -> Result<IsolatedInstrumentState<AssetIndex>, IndexError> {
206        let IsolatedInstrumentState { base, quote, risk } = state;
207
208        Ok(IsolatedInstrumentState {
209            base: self.asset_balance(base)?,
210            quote: self.asset_balance(quote)?,
211            risk,
212        })
213    }
214
215    /// Index a [`FillRecoveryGap`]'s instruments.
216    ///
217    /// # Errors
218    /// Returns `IndexError` if any instrument is not registered in the map. The whole gap fails
219    /// rather than dropping that instrument, which would understate what is missing. A venue names
220    /// only the instruments its stream was opened with, so this fails only for a stream opened
221    /// with an instrument the map does not hold.
222    pub fn fill_recovery_gap(
223        &self,
224        gap: FillRecoveryGap<InstrumentNameExchange>,
225    ) -> Result<FillRecoveryGap<InstrumentIndex>, IndexError> {
226        let FillRecoveryGap {
227            scope,
228            start,
229            end,
230            attempts,
231            reason,
232        } = gap;
233
234        let scope = match scope {
235            FillRecoveryScope::Instruments(instruments) => FillRecoveryScope::Instruments(
236                instruments
237                    .iter()
238                    .map(|instrument| self.map.find_instrument_index(instrument))
239                    .collect::<Result<_, _>>()?,
240            ),
241            FillRecoveryScope::AllInstruments => FillRecoveryScope::AllInstruments,
242        };
243
244        Ok(FillRecoveryGap {
245            scope,
246            start,
247            end,
248            attempts,
249            reason,
250        })
251    }
252
253    /// Index a [`CashFlow`]'s asset and, when it names one, instrument.
254    ///
255    /// # Errors
256    /// Returns `IndexError` if the asset, or a named instrument, is not registered in the map.
257    pub fn cash_flow(
258        &self,
259        flow: CashFlow<AssetNameExchange, InstrumentNameExchange>,
260    ) -> Result<CashFlow<AssetIndex, InstrumentIndex>, IndexError> {
261        let CashFlow {
262            kind,
263            asset,
264            amount,
265            instrument,
266            time_exchange,
267            id,
268        } = flow;
269
270        Ok(CashFlow {
271            kind,
272            asset: self.map.find_asset_index(&asset)?,
273            amount,
274            instrument: instrument
275                .map(|instrument| self.map.find_instrument_index(&instrument))
276                .transpose()?,
277            time_exchange,
278            id,
279        })
280    }
281
282    /// Index an [`AccountNotice`]'s instrument, when it names one.
283    ///
284    /// Never fails. A notice naming an instrument that is not in the map is kept, with its
285    /// instrument set to `None` and a `warn`: a notice changes no state, so dropping one about a
286    /// margin call or liquidation would cost more than losing its attribution.
287    pub fn notice(
288        &self,
289        exchange: ExchangeIndex,
290        notice: AccountNotice<InstrumentNameExchange>,
291    ) -> AccountNotice<InstrumentIndex> {
292        let AccountNotice {
293            kind,
294            instrument,
295            time_exchange,
296            margin_level,
297            status,
298        } = notice;
299
300        let instrument = instrument.and_then(|instrument| {
301            self.map
302                .find_instrument_index(&instrument)
303                .inspect_err(|error| {
304                    warn!(
305                        ?exchange,
306                        %instrument,
307                        %kind,
308                        %status,
309                        %error,
310                        "account notice names an instrument that is not in the map, \
311                         delivering it unattributed",
312                    );
313                })
314                .ok()
315        });
316
317        AccountNotice {
318            kind,
319            instrument,
320            time_exchange,
321            margin_level,
322            status,
323        }
324    }
325
326    /// Index an [`InstrumentBalanceUpdate`]'s instrument and `base`/`quote` asset keys.
327    ///
328    /// # Errors
329    /// Returns `IndexError` if the instrument or either asset is not registered in the map.
330    pub fn instrument_balance_update(
331        &self,
332        update: InstrumentBalanceUpdate<AssetNameExchange, InstrumentNameExchange>,
333    ) -> Result<InstrumentBalanceUpdate, IndexError> {
334        let InstrumentBalanceUpdate {
335            instrument,
336            base,
337            quote,
338        } = update;
339
340        Ok(InstrumentBalanceUpdate {
341            instrument: self.map.find_instrument_index(&instrument)?,
342            base: self.asset_balance_update(base)?,
343            quote: self.asset_balance_update(quote)?,
344        })
345    }
346
347    pub fn order_snapshot(
348        &self,
349        order: UnindexedOrderSnapshot,
350    ) -> Result<OrderSnapshot, IndexError> {
351        let Order {
352            key,
353            side,
354            price,
355            quantity,
356            kind,
357            time_in_force,
358            state,
359        } = order;
360
361        let key = self.order_key(key)?;
362        let state = self.order_state(state);
363
364        Ok(Order {
365            key,
366            side,
367            price,
368            quantity,
369            kind,
370            time_in_force,
371            state,
372        })
373    }
374
375    pub fn order_response_cancel(
376        &self,
377        response: OrderResponseCancel<ExchangeId, AssetNameExchange, InstrumentNameExchange>,
378    ) -> Result<OrderResponseCancel, IndexError> {
379        let OrderResponseCancel { key, state } = response;
380
381        Ok(OrderResponseCancel {
382            key: self.order_key(key)?,
383            state: match state {
384                Ok(cancelled) => Ok(cancelled),
385                Err(error) => Err(self.order_error(error)),
386            },
387        })
388    }
389
390    pub fn order_key(&self, key: UnindexedOrderKey) -> Result<OrderKey, IndexError> {
391        let UnindexedOrderKey {
392            exchange,
393            instrument,
394            strategy,
395            cid,
396        } = key;
397
398        Ok(OrderKey {
399            exchange: self.map.find_exchange_index(exchange)?,
400            instrument: self.map.find_instrument_index(&instrument)?,
401            strategy,
402            cid,
403        })
404    }
405
406    /// Index an [`UnindexedOrderState`] to an [`OrderState`].
407    ///
408    /// Used by `ExecutionManager` to index `open_order` responses. Never fails: an order state
409    /// carries no key, and the error of a failed open is indexed by [`Self::order_error`].
410    pub fn order_state(&self, state: UnindexedOrderState) -> OrderState {
411        match state {
412            UnindexedOrderState::Active(active) => OrderState::Active(active),
413            UnindexedOrderState::Inactive(inactive) => match inactive {
414                InactiveOrderState::OpenFailed(failed) => {
415                    OrderState::inactive(self.order_error(failed))
416                }
417                InactiveOrderState::Cancelled(cancelled) => OrderState::inactive(cancelled),
418                InactiveOrderState::FullyFilled(filled) => OrderState::fully_filled(filled),
419                InactiveOrderState::Expired(expired) => OrderState::expired(expired),
420            },
421        }
422    }
423
424    /// Index an [`UnindexedApiError`] returned for an order request (open or cancel).
425    ///
426    /// Never fails. An error carries the venue's answer to a request, and the caller must
427    /// receive that answer, so a name the [`ExecutionInstrumentMap`] does not hold degrades the
428    /// error instead of discarding it (and the order response or client error carrying it). Each
429    /// degrade logs a `warn!` naming what was dropped:
430    /// - [`ApiError::BalanceInsufficient`] keeps its variant, with the asset `None`.
431    /// - [`ApiError::AssetInvalid`] and [`ApiError::InstrumentInvalid`] have no index to carry,
432    ///   so they become [`ApiError::OrderRejected`] holding their own message, which keeps the
433    ///   name and the venue's text.
434    ///
435    /// For an error from any other request, use [`Self::client_error`], which degrades to
436    /// [`ApiError::RequestRejected`] instead.
437    pub fn api_error(&self, error: UnindexedApiError) -> ApiError {
438        self.index_api_error(error, ApiError::OrderRejected)
439    }
440
441    /// Index an [`UnindexedApiError`], degrading a name the map does not hold as
442    /// [`Self::api_error`] describes. `rejected` builds the rejection that an unresolvable
443    /// [`ApiError::AssetInvalid`] or [`ApiError::InstrumentInvalid`] becomes, which depends on
444    /// whether the request was an order.
445    fn index_api_error(
446        &self,
447        error: UnindexedApiError,
448        rejected: fn(String) -> ApiError,
449    ) -> ApiError {
450        match error {
451            UnindexedApiError::RateLimit { retry_after } => ApiError::rate_limited(retry_after),
452            UnindexedApiError::CapacityExceeded(msg) => ApiError::CapacityExceeded(msg),
453            UnindexedApiError::Unauthenticated(msg) => ApiError::Unauthenticated(msg),
454            UnindexedApiError::AssetInvalid(asset, value) => {
455                match self.map.find_asset_index(&asset) {
456                    Ok(asset) => ApiError::AssetInvalid(asset, value),
457                    Err(_) => self.degrade_unresolvable(
458                        UnindexedApiError::AssetInvalid(asset, value),
459                        rejected,
460                    ),
461                }
462            }
463            UnindexedApiError::InstrumentInvalid(instrument, value) => {
464                match self.map.find_instrument_index(&instrument) {
465                    Ok(instrument) => ApiError::InstrumentInvalid(instrument, value),
466                    Err(_) => self.degrade_unresolvable(
467                        UnindexedApiError::InstrumentInvalid(instrument, value),
468                        rejected,
469                    ),
470                }
471            }
472            UnindexedApiError::BalanceInsufficient(asset, value) => {
473                let asset = asset.and_then(|asset| {
474                    self.map
475                        .find_asset_index(&asset)
476                        .inspect_err(|_| {
477                            warn!(
478                                exchange = %self.map.exchange.value,
479                                %asset,
480                                message = %value,
481                                "AccountEventIndexer dropping the asset of a BalanceInsufficient \
482                                 error: the instrument map does not hold it"
483                            )
484                        })
485                        .ok()
486                });
487                ApiError::BalanceInsufficient(asset, value)
488            }
489            UnindexedApiError::OrderRejected(reason) => ApiError::OrderRejected(reason),
490            UnindexedApiError::OrderAlreadyCancelled => ApiError::OrderAlreadyCancelled,
491            UnindexedApiError::OrderAlreadyFullyFilled => ApiError::OrderAlreadyFullyFilled,
492            UnindexedApiError::OrderAlreadyExpired => ApiError::OrderAlreadyExpired,
493            UnindexedApiError::DuplicateClientOrderId(message) => {
494                ApiError::DuplicateClientOrderId(message)
495            }
496            UnindexedApiError::RequestRejected(reason) => ApiError::RequestRejected(reason),
497            UnindexedApiError::BorrowRejected(reject) => ApiError::BorrowRejected(reject),
498        }
499    }
500
501    /// Degrade an error naming an asset or instrument the map does not hold to the rejection
502    /// `rejected` builds, carrying the error's own message so neither the name nor the venue's
503    /// text is lost.
504    fn degrade_unresolvable(
505        &self,
506        error: UnindexedApiError,
507        rejected: fn(String) -> ApiError,
508    ) -> ApiError {
509        let error = error.to_string();
510        warn!(
511            exchange = %self.map.exchange.value,
512            %error,
513            "AccountEventIndexer degrading an API error to a rejection: the instrument map does \
514             not hold the asset or instrument it names"
515        );
516        rejected(error)
517    }
518
519    pub fn order_request<Kind>(
520        &self,
521        order: &OrderEvent<Kind, ExchangeIndex, InstrumentIndex>,
522    ) -> Result<OrderEvent<Kind, ExchangeId, &InstrumentNameExchange>, KeyError>
523    where
524        Kind: Clone,
525    {
526        let OrderEvent {
527            key:
528                OrderKey {
529                    exchange,
530                    instrument,
531                    strategy,
532                    cid,
533                },
534            state,
535        } = order;
536
537        let exchange = self.map.find_exchange_id(*exchange)?;
538        let instrument = self.map.find_instrument_name_exchange(*instrument)?;
539
540        Ok(OrderEvent {
541            key: OrderKey {
542                exchange,
543                instrument,
544                strategy: strategy.clone(),
545                cid: cid.clone(),
546            },
547            state: state.clone(),
548        })
549    }
550
551    /// Index an [`UnindexedOrderError`]. Never fails: see [`Self::api_error`].
552    pub fn order_error(&self, error: UnindexedOrderError) -> OrderError {
553        match error {
554            UnindexedOrderError::Connectivity(error) => OrderError::Connectivity(error),
555            UnindexedOrderError::Rejected(error) => OrderError::Rejected(self.api_error(error)),
556            UnindexedOrderError::UnsupportedOrderType(msg) => OrderError::UnsupportedOrderType(msg),
557            UnindexedOrderError::InvalidPrecision(violation) => {
558                OrderError::InvalidPrecision(violation)
559            }
560        }
561    }
562
563    /// Index an [`UnindexedClientError`]. Never fails: like [`Self::api_error`], but an
564    /// unresolvable [`ApiError::AssetInvalid`] or [`ApiError::InstrumentInvalid`] becomes
565    /// [`ApiError::RequestRejected`], since the request need not have been an order.
566    pub fn client_error(&self, error: UnindexedClientError) -> ClientError {
567        match error {
568            UnindexedClientError::Connectivity(error) => ClientError::Connectivity(error),
569            UnindexedClientError::Api(error) => {
570                ClientError::Api(self.index_api_error(error, ApiError::RequestRejected))
571            }
572            UnindexedClientError::TaskFailed(value) => ClientError::TaskFailed(value),
573            UnindexedClientError::Internal(value) => ClientError::Internal(value),
574            UnindexedClientError::Truncated { fills_read } => ClientError::Truncated { fills_read },
575            UnindexedClientError::TruncatedSnapshot { limit } => {
576                ClientError::TruncatedSnapshot { limit }
577            }
578        }
579    }
580
581    /// Index an [`UnindexedAccountStreamInitError`]. Never fails, as [`Self::client_error`].
582    pub fn account_stream_init_error(
583        &self,
584        error: UnindexedAccountStreamInitError,
585    ) -> AccountStreamInitError {
586        match error {
587            AccountStreamInitError::Client(error) => {
588                AccountStreamInitError::Client(self.client_error(error))
589            }
590            AccountStreamInitError::Index(error) => AccountStreamInitError::Index(error),
591        }
592    }
593
594    /// Index an [`AccountReinitFailure`]. Never fails, as [`Self::client_error`].
595    pub fn reinit_failure(
596        &self,
597        failure: AccountReinitFailure<AssetNameExchange, InstrumentNameExchange>,
598    ) -> AccountReinitFailure {
599        let AccountReinitFailure { attempt, error } = failure;
600        AccountReinitFailure {
601            attempt,
602            error: self.account_stream_init_error(error),
603        }
604    }
605
606    /// Index a [`TradeAmendment`]. A replacement trade is indexed as [`trade`](Self::trade) indexes
607    /// one, so an amendment fails where its trade would.
608    pub fn trade_amendment(
609        &self,
610        amendment: TradeAmendment<AssetNameExchange, InstrumentNameExchange>,
611    ) -> Result<TradeAmendment<AssetIndex, InstrumentIndex>, IndexError> {
612        let TradeAmendment {
613            instrument,
614            order_id,
615            time_exchange,
616            original,
617            kind,
618        } = amendment;
619
620        let instrument = self.map.find_instrument_index(&instrument)?;
621        let kind = match kind {
622            TradeAmendmentKind::Busted { quantity } => TradeAmendmentKind::Busted { quantity },
623            TradeAmendmentKind::Corrected { replacement } => TradeAmendmentKind::Corrected {
624                replacement: self.trade(replacement)?,
625            },
626            TradeAmendmentKind::CorrectedUnresolved {
627                id,
628                price,
629                quantity,
630            } => TradeAmendmentKind::CorrectedUnresolved {
631                id,
632                price,
633                quantity,
634            },
635        };
636
637        Ok(TradeAmendment {
638            instrument,
639            order_id,
640            time_exchange,
641            original,
642            kind,
643        })
644    }
645
646    /// Index a trade, converting fee asset and computing `fees_quote`.
647    ///
648    /// Computes `fees_quote` based on fee asset relationship to instrument:
649    /// - Fee in quote asset: `fees_quote = Some(fees)`
650    /// - Fee in base asset: `fees_quote = Some(fees * price)`
651    /// - Fee in third-party asset (e.g., BNB): `fees_quote = None`
652    ///
653    /// # Errors
654    /// Returns `IndexError` if fee asset is not in the map. Some integrations use
655    /// "UNKNOWN" as a placeholder when fee data is unavailable (e.g., IBKR `fetch_trades` for an
656    /// execution IB sent no commission report for, Binance when API omits `commission_asset`).
657    /// These trades will fail indexing.
658    pub fn trade(
659        &self,
660        trade: Trade<AssetNameExchange, InstrumentNameExchange>,
661    ) -> Result<Trade<AssetIndex, InstrumentIndex>, IndexError> {
662        let Trade {
663            id,
664            order_id,
665            instrument,
666            strategy,
667            time_exchange,
668            side,
669            price: trade_price,
670            quantity,
671            order_filled_quantity,
672            fees,
673            origin,
674            position_side,
675            client_order_id,
676        } = trade;
677
678        let instrument_index = self.map.find_instrument_index(&instrument)?;
679        let fee_asset_index = self.map.find_asset_index(&fees.asset)?;
680
681        // Compute fees_quote based on fee asset relationship to instrument
682        let fees_quote = self
683            .map
684            .instruments
685            .get_index(instrument_index.index())
686            .and_then(|instr| {
687                if fee_asset_index == instr.underlying.quote {
688                    // Fee is in quote asset — no conversion needed
689                    Some(fees.fees)
690                } else if fee_asset_index == instr.underlying.base {
691                    // Fee is in base asset — convert using trade price
692                    Some(fees.fees * trade_price)
693                } else {
694                    // Fee is in third-party asset (e.g., BNB) — needs external price
695                    None
696                }
697            });
698
699        Ok(Trade {
700            id,
701            order_id,
702            instrument: instrument_index,
703            strategy,
704            time_exchange,
705            side,
706            price: trade_price,
707            quantity,
708            order_filled_quantity,
709            fees: AssetFees {
710                asset: fee_asset_index,
711                fees: fees.fees,
712                fees_quote,
713            },
714            origin,
715            position_side,
716            client_order_id,
717        })
718    }
719}
720
721#[cfg(test)]
722#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
723mod tests {
724    use super::*;
725    use crate::{
726        error::StreamTerminationReason, fill_recovery::FillRecoveryFailure,
727        map::generate_execution_instrument_map,
728    };
729    use chrono::{DateTime, Utc};
730    use rustrade_instrument::{index::IndexedInstruments, test_utils};
731
732    fn binance_indexer() -> AccountEventIndexer {
733        let instruments = IndexedInstruments::new(vec![test_utils::instrument(
734            ExchangeId::BinanceSpot,
735            "BTC",
736            "USDT",
737        )]);
738        let map = generate_execution_instrument_map(&instruments, ExchangeId::BinanceSpot).unwrap();
739        AccountEventIndexer::new(Arc::new(map))
740    }
741
742    /// `StreamTerminated` carries no asset/instrument keys, so it must index by mapping only the
743    /// exchange and passing the reason through verbatim.
744    #[test]
745    fn account_event_passes_stream_terminated_through_verbatim() {
746        let indexer = binance_indexer();
747        let reason = StreamTerminationReason::ReconnectBudgetExhausted {
748            attempts: 3,
749            last_error: "socket reset".to_string(),
750        };
751
752        let indexed = indexer
753            .account_event(UnindexedAccountEvent::new(
754                ExchangeId::BinanceSpot,
755                AccountEventKind::StreamTerminated(reason.clone()),
756            ))
757            .unwrap();
758
759        assert_eq!(indexed.exchange, indexer.map.exchange.key);
760        assert!(
761            matches!(indexed.kind, AccountEventKind::StreamTerminated(r) if r == reason),
762            "expected StreamTerminated to pass through unchanged",
763        );
764    }
765
766    /// A failed re-init never fails to index: its client error is indexed as
767    /// [`AccountEventIndexer::client_error`] indexes one, and an index error passes through.
768    #[test]
769    fn account_event_indexes_a_reinit_failure() {
770        let indexer = binance_indexer();
771        let btc = indexer
772            .map
773            .find_asset_index(&AssetNameExchange::new("BTC"))
774            .unwrap();
775        let unindexed_client = AccountStreamInitError::Client(ClientError::Api(
776            ApiError::BalanceInsufficient(Some(AssetNameExchange::new("BTC")), "low".to_owned()),
777        ));
778        let indexed_client = AccountStreamInitError::Client(ClientError::Api(
779            ApiError::BalanceInsufficient(Some(btc), "low".to_owned()),
780        ));
781        let index_error = IndexError::AssetIndex("ETH".to_owned());
782
783        for (error, expected) in [
784            (unindexed_client, indexed_client),
785            (
786                AccountStreamInitError::Index(index_error.clone()),
787                AccountStreamInitError::Index(index_error),
788            ),
789        ] {
790            let indexed = indexer
791                .account_event(UnindexedAccountEvent::new(
792                    ExchangeId::BinanceSpot,
793                    AccountEventKind::ReinitFailed(AccountReinitFailure::new(2, error)),
794                ))
795                .unwrap();
796
797            assert_eq!(indexed.exchange, indexer.map.exchange.key);
798            assert_eq!(
799                indexed.kind,
800                AccountEventKind::ReinitFailed(AccountReinitFailure::new(2, expected))
801            );
802        }
803    }
804
805    /// A fill recovery give-up indexes each instrument it names, and fails as a whole when one
806    /// is not in the map rather than understating what is missing. `AllInstruments` names none.
807    #[test]
808    fn account_event_indexes_a_fill_recovery_give_up_by_its_instruments() {
809        let indexer = binance_indexer();
810        let start = DateTime::<Utc>::MIN_UTC;
811        let end = DateTime::<Utc>::MAX_UTC;
812        let give_up = |scope| {
813            UnindexedAccountEvent::new(
814                ExchangeId::BinanceSpot,
815                AccountEventKind::FillRecoveryGaveUp(FillRecoveryGap::new(
816                    scope,
817                    start,
818                    end,
819                    6,
820                    FillRecoveryFailure::TimedOut { timeout_secs: 30 },
821                )),
822            )
823        };
824        let index_of = |scope| match indexer.account_event(give_up(scope)) {
825            Ok(AccountEvent {
826                kind: AccountEventKind::FillRecoveryGaveUp(gap),
827                ..
828            }) => Ok(gap),
829            Ok(other) => panic!("expected FillRecoveryGaveUp, got {other:?}"),
830            Err(error) => Err(error),
831        };
832        let Ok(btc) = indexer
833            .map
834            .find_instrument_index(&InstrumentNameExchange::new("BTC_USDT"))
835        else {
836            panic!("BTC_USDT is mapped");
837        };
838
839        assert_eq!(
840            index_of(FillRecoveryScope::Instruments(vec![
841                InstrumentNameExchange::new("BTC_USDT")
842            ])),
843            Ok(FillRecoveryGap::new(
844                FillRecoveryScope::Instruments(vec![btc]),
845                start,
846                end,
847                6,
848                FillRecoveryFailure::TimedOut { timeout_secs: 30 },
849            ))
850        );
851        assert_eq!(
852            index_of(FillRecoveryScope::AllInstruments).map(|gap| gap.scope),
853            Ok(FillRecoveryScope::AllInstruments)
854        );
855        assert!(
856            index_of(FillRecoveryScope::Instruments(vec![
857                InstrumentNameExchange::new("BTC_USDT"),
858                InstrumentNameExchange::new("ETHUSDT"),
859            ]))
860            .is_err(),
861            "an unmapped instrument fails the whole give-up"
862        );
863    }
864
865    /// A trade amendment indexes its instrument, and a corrected one its replacement as a trade
866    /// is indexed. An unmapped instrument fails it.
867    #[test]
868    fn account_event_indexes_a_trade_amendment() {
869        use crate::{
870            order::id::{OrderId, StrategyId},
871            trade::TradeId,
872        };
873        use rust_decimal::Decimal;
874        use rustrade_instrument::Side;
875
876        let indexer = binance_indexer();
877        let time = DateTime::<Utc>::MIN_UTC;
878        let amended = |instrument: &str, kind| {
879            UnindexedAccountEvent::new(
880                ExchangeId::BinanceSpot,
881                AccountEventKind::TradeAmended(TradeAmendment::new(
882                    InstrumentNameExchange::new(instrument),
883                    OrderId::new("ord-1"),
884                    time,
885                    Some(TradeId::new("t-1")),
886                    kind,
887                )),
888            )
889        };
890        let index_of = |event| match indexer.account_event(event) {
891            Ok(AccountEvent {
892                kind: AccountEventKind::TradeAmended(amendment),
893                ..
894            }) => Ok(amendment),
895            Ok(other) => panic!("expected TradeAmended, got {other:?}"),
896            Err(error) => Err(error),
897        };
898        let Ok(btc) = indexer
899            .map
900            .find_instrument_index(&InstrumentNameExchange::new("BTC_USDT"))
901        else {
902            panic!("BTC_USDT is mapped");
903        };
904
905        assert_eq!(
906            index_of(amended(
907                "BTC_USDT",
908                TradeAmendmentKind::Busted {
909                    quantity: Some(Decimal::ONE)
910                }
911            )),
912            Ok(TradeAmendment::new(
913                btc,
914                OrderId::new("ord-1"),
915                time,
916                Some(TradeId::new("t-1")),
917                TradeAmendmentKind::Busted {
918                    quantity: Some(Decimal::ONE)
919                },
920            ))
921        );
922
923        let replacement = Trade::new(
924            TradeId::new("t-2"),
925            OrderId::new("ord-1"),
926            InstrumentNameExchange::new("BTC_USDT"),
927            StrategyId::unknown(),
928            time,
929            Side::Buy,
930            Decimal::TEN,
931            Decimal::ONE,
932            None,
933            AssetFees::new(AssetNameExchange::new("USDT"), Decimal::ONE, None),
934        );
935        let Ok(TradeAmendment {
936            kind: TradeAmendmentKind::Corrected { replacement },
937            ..
938        }) = index_of(amended(
939            "BTC_USDT",
940            TradeAmendmentKind::Corrected { replacement },
941        ))
942        else {
943            panic!("a correction indexes to a correction");
944        };
945        assert_eq!(replacement.instrument, btc);
946        assert_eq!(
947            replacement.fees.fees_quote,
948            Some(Decimal::ONE),
949            "indexed as a trade is, fees and all"
950        );
951
952        assert!(
953            index_of(amended(
954                "ETHUSDT",
955                TradeAmendmentKind::Busted { quantity: None }
956            ))
957            .is_err(),
958            "an unmapped instrument fails the amendment"
959        );
960    }
961
962    /// Run `index`, returning its value and the number of `WARN` events it emitted on this
963    /// thread. A degrade is reported only by its log line, so asserting on the value alone could
964    /// not tell a logged degrade from a silent one.
965    fn count_warnings<T>(index: impl FnOnce() -> T) -> (T, usize) {
966        use std::sync::atomic::{AtomicUsize, Ordering};
967        use tracing_subscriber::layer::SubscriberExt;
968
969        #[derive(Default)]
970        struct CountWarnings(Arc<AtomicUsize>);
971
972        impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for CountWarnings {
973            fn on_event(
974                &self,
975                event: &tracing::Event<'_>,
976                _: tracing_subscriber::layer::Context<'_, S>,
977            ) {
978                if *event.metadata().level() == tracing::Level::WARN {
979                    self.0.fetch_add(1, Ordering::Relaxed);
980                }
981            }
982        }
983
984        let layer = CountWarnings::default();
985        let count = Arc::clone(&layer.0);
986        // Thread-local, so other tests running in parallel are not counted.
987        let value =
988            tracing::subscriber::with_default(tracing_subscriber::registry().with(layer), index);
989        (value, count.load(Ordering::Relaxed))
990    }
991
992    fn btc_usdt() -> InstrumentNameExchange {
993        binance_indexer()
994            .map
995            .exchange_instruments()
996            .next()
997            .cloned()
998            .unwrap_or_else(|| panic!("the map holds one instrument"))
999    }
1000
1001    #[test]
1002    fn balance_insufficient_keeps_a_held_asset_and_drops_an_unheld_one() {
1003        let indexer = binance_indexer();
1004        let btc = indexer
1005            .map
1006            .find_asset_index(&AssetNameExchange::new("BTC"))
1007            .unwrap();
1008
1009        for (asset, expected, warnings) in [
1010            (Some("BTC"), Some(btc), 0),
1011            // An instrument name, as Binance's error mapping once put there.
1012            (Some("ETH_USDT"), None, 1),
1013            (None, None, 0),
1014        ] {
1015            let (indexed, warned) = count_warnings(|| {
1016                indexer.api_error(ApiError::BalanceInsufficient(
1017                    asset.map(AssetNameExchange::new),
1018                    "low".to_string(),
1019                ))
1020            });
1021            assert_eq!(
1022                indexed,
1023                ApiError::BalanceInsufficient(expected, "low".to_string()),
1024                "{asset:?}"
1025            );
1026            assert_eq!(warned, warnings, "{asset:?}: a dropped asset is logged");
1027        }
1028    }
1029
1030    /// An invalid asset or instrument the map does not hold has no index to carry, so it becomes
1031    /// a rejection keeping its own message: `OrderRejected` for an order, `RequestRejected` for
1032    /// any other request.
1033    #[test]
1034    fn an_unresolvable_invalid_name_degrades_to_a_rejection_keeping_its_text() {
1035        let indexer = binance_indexer();
1036        for (error, text) in [
1037            (
1038                ApiError::AssetInvalid(AssetNameExchange::new("XYZ"), "bad".to_string()),
1039                "asset XYZ invalid: bad",
1040            ),
1041            (
1042                ApiError::InstrumentInvalid(
1043                    InstrumentNameExchange::new("XYZUSDT"),
1044                    "bad".to_string(),
1045                ),
1046                "instrument XYZUSDT invalid: bad",
1047            ),
1048        ] {
1049            let (indexed, warned) = count_warnings(|| indexer.api_error(error.clone()));
1050            assert_eq!(indexed, ApiError::OrderRejected(text.to_string()));
1051            assert_eq!(warned, 1, "{text}: a degrade is logged");
1052
1053            let (indexed, warned) =
1054                count_warnings(|| indexer.client_error(ClientError::Api(error)));
1055            assert_eq!(
1056                indexed,
1057                ClientError::Api(ApiError::RequestRejected(text.to_string()))
1058            );
1059            assert_eq!(warned, 1, "{text}: a degrade is logged");
1060        }
1061
1062        // A name the map holds still indexes, and nothing is logged.
1063        let (indexed, warned) = count_warnings(|| {
1064            indexer.api_error(ApiError::InstrumentInvalid(btc_usdt(), "bad".to_string()))
1065        });
1066        assert!(matches!(indexed, ApiError::InstrumentInvalid(_, _)));
1067        assert_eq!(warned, 0);
1068    }
1069
1070    /// A response whose rejection names something the map does not hold still indexes, so the
1071    /// order is settled rather than left in flight.
1072    #[test]
1073    fn a_response_whose_rejection_names_an_unresolvable_asset_still_indexes() {
1074        let indexer = binance_indexer();
1075        let rejection = || {
1076            OrderError::Rejected(ApiError::BalanceInsufficient(
1077                Some(AssetNameExchange::new("ETH_USDT")),
1078                "low".to_string(),
1079            ))
1080        };
1081        let indexed = OrderError::Rejected(ApiError::BalanceInsufficient(None, "low".to_string()));
1082
1083        assert_eq!(
1084            indexer.order_state(OrderState::inactive(rejection())),
1085            OrderState::inactive(indexed.clone())
1086        );
1087
1088        let key = OrderKey {
1089            exchange: ExchangeId::BinanceSpot,
1090            instrument: btc_usdt(),
1091            strategy: crate::order::id::StrategyId::new("test"),
1092            cid: crate::order::id::ClientOrderId::random(),
1093        };
1094        let cancel = indexer
1095            .order_response_cancel(OrderResponseCancel {
1096                key,
1097                state: Err(rejection()),
1098            })
1099            .unwrap();
1100        assert_eq!(cancel.state, Err(indexed));
1101    }
1102
1103    /// A cash flow indexes its asset and, when it names one, its instrument, and fails when
1104    /// either is not in the map.
1105    #[test]
1106    fn account_event_indexes_a_cash_flow() {
1107        use crate::cash_flow::{CashFlowId, CashFlowKind};
1108        use rust_decimal_macros::dec;
1109
1110        let indexer = binance_indexer();
1111        let time = DateTime::<Utc>::MIN_UTC;
1112        let kind = CashFlowKind::Funding {
1113            rate: Some(dec!(0.0001)),
1114            position_quantity: Some(dec!(-2)),
1115        };
1116        let flow = |asset: &str, instrument: Option<&str>| {
1117            UnindexedAccountEvent::new(
1118                ExchangeId::BinanceSpot,
1119                AccountEventKind::CashFlow(CashFlow::new(
1120                    kind.clone(),
1121                    AssetNameExchange::new(asset),
1122                    dec!(1.5),
1123                    instrument.map(InstrumentNameExchange::new),
1124                    time,
1125                    Some(CashFlowId::new("cf-1")),
1126                )),
1127            )
1128        };
1129        let index_of = |event| match indexer.account_event(event) {
1130            Ok(AccountEvent {
1131                kind: AccountEventKind::CashFlow(flow),
1132                ..
1133            }) => Ok(flow),
1134            Ok(other) => panic!("expected CashFlow, got {other:?}"),
1135            Err(error) => Err(error),
1136        };
1137        let (Ok(usdt), Ok(btc_usdt)) = (
1138            indexer
1139                .map
1140                .find_asset_index(&AssetNameExchange::new("USDT")),
1141            indexer
1142                .map
1143                .find_instrument_index(&InstrumentNameExchange::new("BTC_USDT")),
1144        ) else {
1145            panic!("USDT and BTC_USDT are mapped");
1146        };
1147
1148        assert_eq!(
1149            index_of(flow("USDT", Some("BTC_USDT"))),
1150            Ok(CashFlow::new(
1151                kind.clone(),
1152                usdt,
1153                dec!(1.5),
1154                Some(btc_usdt),
1155                time,
1156                Some(CashFlowId::new("cf-1")),
1157            ))
1158        );
1159        assert_eq!(
1160            index_of(flow("USDT", None)).map(|flow| flow.instrument),
1161            Ok(None)
1162        );
1163        assert!(
1164            index_of(flow("ETH", None)).is_err(),
1165            "an unmapped asset fails"
1166        );
1167        assert!(
1168            index_of(flow("USDT", Some("ETHUSDT"))).is_err(),
1169            "an unmapped instrument fails"
1170        );
1171    }
1172
1173    /// A notice indexes its instrument when it names a mapped one, and is kept unattributed,
1174    /// rather than dropped, when it names one that is not mapped.
1175    #[test]
1176    fn account_event_indexes_a_notice() {
1177        use crate::notice::{AccountNotice, NoticeKind};
1178        use rust_decimal_macros::dec;
1179        use smol_str::SmolStr;
1180
1181        let indexer = binance_indexer();
1182        let time = DateTime::<Utc>::MIN_UTC;
1183        let notice = |instrument: Option<&str>| {
1184            UnindexedAccountEvent::new(
1185                ExchangeId::BinanceSpot,
1186                AccountEventKind::Notice(AccountNotice::new(
1187                    NoticeKind::MarginCall,
1188                    instrument.map(InstrumentNameExchange::new),
1189                    time,
1190                    Some(dec!(1.1)),
1191                    SmolStr::new("MARGIN_CALL"),
1192                )),
1193            )
1194        };
1195        let index_of = |event| match indexer.account_event(event) {
1196            Ok(AccountEvent {
1197                kind: AccountEventKind::Notice(notice),
1198                ..
1199            }) => notice,
1200            other => panic!("expected Notice, got {other:?}"),
1201        };
1202        let Ok(btc_usdt) = indexer
1203            .map
1204            .find_instrument_index(&InstrumentNameExchange::new("BTC_USDT"))
1205        else {
1206            panic!("BTC_USDT is mapped");
1207        };
1208
1209        assert_eq!(
1210            index_of(notice(Some("BTC_USDT"))),
1211            AccountNotice::new(
1212                NoticeKind::MarginCall,
1213                Some(btc_usdt),
1214                time,
1215                Some(dec!(1.1)),
1216                SmolStr::new("MARGIN_CALL"),
1217            )
1218        );
1219        assert_eq!(index_of(notice(None)).instrument, None);
1220        assert_eq!(
1221            index_of(notice(Some("ETHUSDT"))).instrument,
1222            None,
1223            "an unmapped instrument is dropped from the notice, not the notice itself"
1224        );
1225    }
1226}