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