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`
651    ///
652    /// # A fee asset that is not in the map
653    /// The trade is kept, with its fee zeroed: `fees` is `0` in the instrument's quote asset and
654    /// `fees_quote` is `None`. A `warn` names the fee's asset and amount. Dropping the trade would
655    /// leave the engine's position different from the venue's, which costs far more than one
656    /// unknown fee. The `warn` is per trade, so an account that pays every fee in an unregistered
657    /// asset logs one per fill until the asset is registered. Two cases reach this:
658    /// - An asset the account pays fees in but no instrument implies, such as BNB on Binance spot
659    ///   with "pay fees with BNB" turned on. Register it with
660    ///   [`IndexedInstrumentsBuilder::add_asset`](rustrade_instrument::index::builder::IndexedInstrumentsBuilder::add_asset)
661    ///   to keep its fee.
662    /// - The `"UNKNOWN"` placeholder some integrations use when the venue gave no fee asset: IBKR
663    ///   `fetch_trades` for an execution IB sent no commission report for, and Binance when a
664    ///   report omits `commissionAsset`.
665    ///
666    /// # Errors
667    /// Returns `IndexError` if the trade's instrument is not in the map.
668    pub fn trade(
669        &self,
670        trade: Trade<AssetNameExchange, InstrumentNameExchange>,
671    ) -> Result<Trade<AssetIndex, InstrumentIndex>, IndexError> {
672        let Trade {
673            id,
674            order_id,
675            instrument,
676            strategy,
677            time_exchange,
678            side,
679            price: trade_price,
680            quantity,
681            order_filled_quantity,
682            fees,
683            origin,
684            position_side,
685            client_order_id,
686        } = trade;
687
688        let instrument_index = self.map.find_instrument_index(&instrument)?;
689        // Invariant check: `find_instrument_index` resolves only names built from `instruments`,
690        // so the index is always present. Failing loudly beats indexing a fee against nothing.
691        let underlying = &self
692            .map
693            .instruments
694            .get_index(instrument_index.index())
695            .ok_or_else(|| {
696                IndexError::InstrumentIndex(format!(
697                    "ExecutionInstrumentMap has no instrument at {instrument_index} for {instrument}"
698                ))
699            })?
700            .underlying;
701
702        let fees = match self.map.find_asset_index(&fees.asset) {
703            Ok(fee_asset) => {
704                let fees_quote = if fee_asset == underlying.quote {
705                    // Fee is in quote asset — no conversion needed
706                    Some(fees.fees)
707                } else if fee_asset == underlying.base {
708                    // Fee is in base asset — convert using trade price
709                    Some(fees.fees * trade_price)
710                } else {
711                    // Fee is in third-party asset (e.g., BNB) — needs external price
712                    None
713                };
714                AssetFees::new(fee_asset, fees.fees, fees_quote)
715            }
716            Err(error) => {
717                warn!(
718                    exchange = %self.map.exchange.value,
719                    %instrument,
720                    trade_id = %id,
721                    fee_asset = %fees.asset,
722                    fee = %fees.fees,
723                    %error,
724                    "trade's fee asset is not in the map, keeping the trade with its fee zeroed; \
725                     register the asset with IndexedInstrumentsBuilder::add_asset to keep its fee",
726                );
727                AssetFees::new(underlying.quote, Decimal::ZERO, None)
728            }
729        };
730
731        Ok(Trade {
732            id,
733            order_id,
734            instrument: instrument_index,
735            strategy,
736            time_exchange,
737            side,
738            price: trade_price,
739            quantity,
740            order_filled_quantity,
741            fees,
742            origin,
743            position_side,
744            client_order_id,
745        })
746    }
747}
748
749#[cfg(test)]
750#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
751mod tests {
752    use super::*;
753    use crate::{
754        error::StreamTerminationReason, fill_recovery::FillRecoveryFailure,
755        map::generate_execution_instrument_map,
756    };
757    use chrono::{DateTime, Utc};
758    use rustrade_instrument::{index::IndexedInstruments, test_utils};
759
760    fn binance_indexer() -> AccountEventIndexer {
761        let instruments = IndexedInstruments::new(vec![test_utils::instrument(
762            ExchangeId::BinanceSpot,
763            "BTC",
764            "USDT",
765        )]);
766        let map = generate_execution_instrument_map(&instruments, ExchangeId::BinanceSpot).unwrap();
767        AccountEventIndexer::new(Arc::new(map))
768    }
769
770    /// `StreamTerminated` carries no asset/instrument keys, so it must index by mapping only the
771    /// exchange and passing the reason through verbatim.
772    #[test]
773    fn account_event_passes_stream_terminated_through_verbatim() {
774        let indexer = binance_indexer();
775        let reason = StreamTerminationReason::ReconnectBudgetExhausted {
776            attempts: 3,
777            last_error: "socket reset".to_string(),
778        };
779
780        let indexed = indexer
781            .account_event(UnindexedAccountEvent::new(
782                ExchangeId::BinanceSpot,
783                AccountEventKind::StreamTerminated(reason.clone()),
784            ))
785            .unwrap();
786
787        assert_eq!(indexed.exchange, indexer.map.exchange.key);
788        assert!(
789            matches!(indexed.kind, AccountEventKind::StreamTerminated(r) if r == reason),
790            "expected StreamTerminated to pass through unchanged",
791        );
792    }
793
794    /// A failed re-init never fails to index: its client error is indexed as
795    /// [`AccountEventIndexer::client_error`] indexes one, and an index error passes through.
796    #[test]
797    fn account_event_indexes_a_reinit_failure() {
798        let indexer = binance_indexer();
799        let btc = indexer
800            .map
801            .find_asset_index(&AssetNameExchange::new("BTC"))
802            .unwrap();
803        let unindexed_client = AccountStreamInitError::Client(ClientError::Api(
804            ApiError::BalanceInsufficient(Some(AssetNameExchange::new("BTC")), "low".to_owned()),
805        ));
806        let indexed_client = AccountStreamInitError::Client(ClientError::Api(
807            ApiError::BalanceInsufficient(Some(btc), "low".to_owned()),
808        ));
809        let index_error = IndexError::AssetIndex("ETH".to_owned());
810
811        for (error, expected) in [
812            (unindexed_client, indexed_client),
813            (
814                AccountStreamInitError::Index(index_error.clone()),
815                AccountStreamInitError::Index(index_error),
816            ),
817        ] {
818            let indexed = indexer
819                .account_event(UnindexedAccountEvent::new(
820                    ExchangeId::BinanceSpot,
821                    AccountEventKind::ReinitFailed(AccountReinitFailure::new(2, error)),
822                ))
823                .unwrap();
824
825            assert_eq!(indexed.exchange, indexer.map.exchange.key);
826            assert_eq!(
827                indexed.kind,
828                AccountEventKind::ReinitFailed(AccountReinitFailure::new(2, expected))
829            );
830        }
831    }
832
833    /// A fill recovery give-up indexes each instrument it names, and fails as a whole when one
834    /// is not in the map rather than understating what is missing. `AllInstruments` names none.
835    #[test]
836    fn account_event_indexes_a_fill_recovery_give_up_by_its_instruments() {
837        let indexer = binance_indexer();
838        let start = DateTime::<Utc>::MIN_UTC;
839        let end = DateTime::<Utc>::MAX_UTC;
840        let give_up = |scope| {
841            UnindexedAccountEvent::new(
842                ExchangeId::BinanceSpot,
843                AccountEventKind::FillRecoveryGaveUp(FillRecoveryGap::new(
844                    scope,
845                    start,
846                    end,
847                    6,
848                    FillRecoveryFailure::TimedOut { timeout_secs: 30 },
849                )),
850            )
851        };
852        let index_of = |scope| match indexer.account_event(give_up(scope)) {
853            Ok(AccountEvent {
854                kind: AccountEventKind::FillRecoveryGaveUp(gap),
855                ..
856            }) => Ok(gap),
857            Ok(other) => panic!("expected FillRecoveryGaveUp, got {other:?}"),
858            Err(error) => Err(error),
859        };
860        let Ok(btc) = indexer
861            .map
862            .find_instrument_index(&InstrumentNameExchange::new("BTC_USDT"))
863        else {
864            panic!("BTC_USDT is mapped");
865        };
866
867        assert_eq!(
868            index_of(FillRecoveryScope::Instruments(vec![
869                InstrumentNameExchange::new("BTC_USDT")
870            ])),
871            Ok(FillRecoveryGap::new(
872                FillRecoveryScope::Instruments(vec![btc]),
873                start,
874                end,
875                6,
876                FillRecoveryFailure::TimedOut { timeout_secs: 30 },
877            ))
878        );
879        assert_eq!(
880            index_of(FillRecoveryScope::AllInstruments).map(|gap| gap.scope),
881            Ok(FillRecoveryScope::AllInstruments)
882        );
883        assert!(
884            index_of(FillRecoveryScope::Instruments(vec![
885                InstrumentNameExchange::new("BTC_USDT"),
886                InstrumentNameExchange::new("ETHUSDT"),
887            ]))
888            .is_err(),
889            "an unmapped instrument fails the whole give-up"
890        );
891    }
892
893    /// A trade amendment indexes its instrument, and a corrected one its replacement as a trade
894    /// is indexed. An unmapped instrument fails it.
895    #[test]
896    fn account_event_indexes_a_trade_amendment() {
897        use crate::{
898            order::id::{OrderId, StrategyId},
899            trade::TradeId,
900        };
901        use rust_decimal::Decimal;
902        use rustrade_instrument::Side;
903
904        let indexer = binance_indexer();
905        let time = DateTime::<Utc>::MIN_UTC;
906        let amended = |instrument: &str, kind| {
907            UnindexedAccountEvent::new(
908                ExchangeId::BinanceSpot,
909                AccountEventKind::TradeAmended(TradeAmendment::new(
910                    InstrumentNameExchange::new(instrument),
911                    OrderId::new("ord-1"),
912                    time,
913                    Some(TradeId::new("t-1")),
914                    kind,
915                )),
916            )
917        };
918        let index_of = |event| match indexer.account_event(event) {
919            Ok(AccountEvent {
920                kind: AccountEventKind::TradeAmended(amendment),
921                ..
922            }) => Ok(amendment),
923            Ok(other) => panic!("expected TradeAmended, got {other:?}"),
924            Err(error) => Err(error),
925        };
926        let Ok(btc) = indexer
927            .map
928            .find_instrument_index(&InstrumentNameExchange::new("BTC_USDT"))
929        else {
930            panic!("BTC_USDT is mapped");
931        };
932
933        assert_eq!(
934            index_of(amended(
935                "BTC_USDT",
936                TradeAmendmentKind::Busted {
937                    quantity: Some(Decimal::ONE)
938                }
939            )),
940            Ok(TradeAmendment::new(
941                btc,
942                OrderId::new("ord-1"),
943                time,
944                Some(TradeId::new("t-1")),
945                TradeAmendmentKind::Busted {
946                    quantity: Some(Decimal::ONE)
947                },
948            ))
949        );
950
951        let replacement = Trade::new(
952            TradeId::new("t-2"),
953            OrderId::new("ord-1"),
954            InstrumentNameExchange::new("BTC_USDT"),
955            StrategyId::unknown(),
956            time,
957            Side::Buy,
958            Decimal::TEN,
959            Decimal::ONE,
960            None,
961            AssetFees::new(AssetNameExchange::new("USDT"), Decimal::ONE, None),
962        );
963        let Ok(TradeAmendment {
964            kind: TradeAmendmentKind::Corrected { replacement },
965            ..
966        }) = index_of(amended(
967            "BTC_USDT",
968            TradeAmendmentKind::Corrected { replacement },
969        ))
970        else {
971            panic!("a correction indexes to a correction");
972        };
973        assert_eq!(replacement.instrument, btc);
974        assert_eq!(
975            replacement.fees.fees_quote,
976            Some(Decimal::ONE),
977            "indexed as a trade is, fees and all"
978        );
979
980        assert!(
981            index_of(amended(
982                "ETHUSDT",
983                TradeAmendmentKind::Busted { quantity: None }
984            ))
985            .is_err(),
986            "an unmapped instrument fails the amendment"
987        );
988    }
989
990    /// A fill is never dropped for its fee: an unmapped fee asset, or the `"UNKNOWN"` placeholder,
991    /// keeps the trade with its fee zeroed in quote and `fees_quote: None`, with one `warn`. A fee
992    /// asset registered with `add_asset` indexes as a third-party fee.
993    #[test]
994    fn trade_keeps_a_fill_whose_fee_asset_is_unmapped() {
995        use crate::{
996            order::id::{OrderId, StrategyId},
997            trade::TradeId,
998        };
999        use rust_decimal_macros::dec;
1000        use rustrade_instrument::{Side, index::builder::IndexedInstrumentsBuilder};
1001
1002        let fill = |fee_asset: &str, fee| {
1003            Trade::new(
1004                TradeId::new("t-1"),
1005                OrderId::new("ord-1"),
1006                InstrumentNameExchange::new("BTC_USDT"),
1007                StrategyId::unknown(),
1008                DateTime::<Utc>::MIN_UTC,
1009                Side::Buy,
1010                dec!(50000),
1011                dec!(0.01),
1012                None,
1013                AssetFees::new(AssetNameExchange::new(fee_asset), fee, None),
1014            )
1015        };
1016        let asset = |indexer: &AccountEventIndexer, name: &str| {
1017            indexer
1018                .map
1019                .find_asset_index(&AssetNameExchange::new(name))
1020                .unwrap()
1021        };
1022
1023        let indexer = binance_indexer();
1024        let usdt = asset(&indexer, "USDT");
1025        let btc = asset(&indexer, "BTC");
1026
1027        let (quote, warnings) = count_warnings(|| indexer.trade(fill("USDT", dec!(0.5))));
1028        assert_eq!(
1029            quote.unwrap().fees,
1030            AssetFees::new(usdt, dec!(0.5), Some(dec!(0.5)))
1031        );
1032        assert_eq!(warnings, 0);
1033
1034        let (base, warnings) = count_warnings(|| indexer.trade(fill("BTC", dec!(0.00001))));
1035        assert_eq!(
1036            base.unwrap().fees,
1037            AssetFees::new(btc, dec!(0.00001), Some(dec!(0.5)))
1038        );
1039        assert_eq!(warnings, 0);
1040
1041        for unmapped in ["BNB", "UNKNOWN"] {
1042            let (kept, warnings) = count_warnings(|| indexer.trade(fill(unmapped, dec!(0.001))));
1043            let kept = kept.unwrap_or_else(|error| panic!("{unmapped}: dropped: {error}"));
1044            assert_eq!(
1045                kept.quantity,
1046                dec!(0.01),
1047                "{unmapped}: the fill itself is intact"
1048            );
1049            assert_eq!(
1050                kept.fees,
1051                AssetFees::new(usdt, Decimal::ZERO, None),
1052                "{unmapped}: fee zeroed in quote, with no quote equivalent"
1053            );
1054            assert_eq!(warnings, 1, "{unmapped}: the zeroed fee is reported");
1055        }
1056
1057        let instruments = IndexedInstrumentsBuilder::new()
1058            .add_instrument(test_utils::instrument(
1059                ExchangeId::BinanceSpot,
1060                "BTC",
1061                "USDT",
1062            ))
1063            .add_asset(test_utils::exchange_asset(ExchangeId::BinanceSpot, "BNB"))
1064            .build();
1065        let with_bnb = AccountEventIndexer::new(Arc::new(
1066            generate_execution_instrument_map(&instruments, ExchangeId::BinanceSpot).unwrap(),
1067        ));
1068        let bnb = asset(&with_bnb, "BNB");
1069        let (third_party, warnings) = count_warnings(|| with_bnb.trade(fill("BNB", dec!(0.001))));
1070        assert_eq!(
1071            third_party.unwrap().fees,
1072            AssetFees::new(bnb, dec!(0.001), None),
1073            "a registered fee asset keeps its fee, with no quote equivalent"
1074        );
1075        assert_eq!(warnings, 0);
1076
1077        let mut unmapped_instrument = fill("USDT", dec!(0.5));
1078        unmapped_instrument.instrument = InstrumentNameExchange::new("ETH_USDT");
1079        assert!(
1080            indexer.trade(unmapped_instrument).is_err(),
1081            "an unmapped instrument still fails"
1082        );
1083    }
1084
1085    /// Run `index`, returning its value and the number of `WARN` events it emitted on this
1086    /// thread. A degrade is reported only by its log line, so asserting on the value alone could
1087    /// not tell a logged degrade from a silent one.
1088    fn count_warnings<T>(index: impl FnOnce() -> T) -> (T, usize) {
1089        use std::sync::atomic::{AtomicUsize, Ordering};
1090        use tracing_subscriber::layer::SubscriberExt;
1091
1092        #[derive(Default)]
1093        struct CountWarnings(Arc<AtomicUsize>);
1094
1095        impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for CountWarnings {
1096            fn on_event(
1097                &self,
1098                event: &tracing::Event<'_>,
1099                _: tracing_subscriber::layer::Context<'_, S>,
1100            ) {
1101                if *event.metadata().level() == tracing::Level::WARN {
1102                    self.0.fetch_add(1, Ordering::Relaxed);
1103                }
1104            }
1105        }
1106
1107        let layer = CountWarnings::default();
1108        let count = Arc::clone(&layer.0);
1109        // Thread-local, so other tests running in parallel are not counted.
1110        let value =
1111            tracing::subscriber::with_default(tracing_subscriber::registry().with(layer), index);
1112        (value, count.load(Ordering::Relaxed))
1113    }
1114
1115    fn btc_usdt() -> InstrumentNameExchange {
1116        binance_indexer()
1117            .map
1118            .exchange_instruments()
1119            .next()
1120            .cloned()
1121            .unwrap_or_else(|| panic!("the map holds one instrument"))
1122    }
1123
1124    #[test]
1125    fn balance_insufficient_keeps_a_held_asset_and_drops_an_unheld_one() {
1126        let indexer = binance_indexer();
1127        let btc = indexer
1128            .map
1129            .find_asset_index(&AssetNameExchange::new("BTC"))
1130            .unwrap();
1131
1132        for (asset, expected, warnings) in [
1133            (Some("BTC"), Some(btc), 0),
1134            // An instrument name, as Binance's error mapping once put there.
1135            (Some("ETH_USDT"), None, 1),
1136            (None, None, 0),
1137        ] {
1138            let (indexed, warned) = count_warnings(|| {
1139                indexer.api_error(ApiError::BalanceInsufficient(
1140                    asset.map(AssetNameExchange::new),
1141                    "low".to_string(),
1142                ))
1143            });
1144            assert_eq!(
1145                indexed,
1146                ApiError::BalanceInsufficient(expected, "low".to_string()),
1147                "{asset:?}"
1148            );
1149            assert_eq!(warned, warnings, "{asset:?}: a dropped asset is logged");
1150        }
1151    }
1152
1153    /// An invalid asset or instrument the map does not hold has no index to carry, so it becomes
1154    /// a rejection keeping its own message: `OrderRejected` for an order, `RequestRejected` for
1155    /// any other request.
1156    #[test]
1157    fn an_unresolvable_invalid_name_degrades_to_a_rejection_keeping_its_text() {
1158        let indexer = binance_indexer();
1159        for (error, text) in [
1160            (
1161                ApiError::AssetInvalid(AssetNameExchange::new("XYZ"), "bad".to_string()),
1162                "asset XYZ invalid: bad",
1163            ),
1164            (
1165                ApiError::InstrumentInvalid(
1166                    InstrumentNameExchange::new("XYZUSDT"),
1167                    "bad".to_string(),
1168                ),
1169                "instrument XYZUSDT invalid: bad",
1170            ),
1171        ] {
1172            let (indexed, warned) = count_warnings(|| indexer.api_error(error.clone()));
1173            assert_eq!(indexed, ApiError::OrderRejected(text.to_string()));
1174            assert_eq!(warned, 1, "{text}: a degrade is logged");
1175
1176            let (indexed, warned) =
1177                count_warnings(|| indexer.client_error(ClientError::Api(error)));
1178            assert_eq!(
1179                indexed,
1180                ClientError::Api(ApiError::RequestRejected(text.to_string()))
1181            );
1182            assert_eq!(warned, 1, "{text}: a degrade is logged");
1183        }
1184
1185        // A name the map holds still indexes, and nothing is logged.
1186        let (indexed, warned) = count_warnings(|| {
1187            indexer.api_error(ApiError::InstrumentInvalid(btc_usdt(), "bad".to_string()))
1188        });
1189        assert!(matches!(indexed, ApiError::InstrumentInvalid(_, _)));
1190        assert_eq!(warned, 0);
1191    }
1192
1193    /// A response whose rejection names something the map does not hold still indexes, so the
1194    /// order is settled rather than left in flight.
1195    #[test]
1196    fn a_response_whose_rejection_names_an_unresolvable_asset_still_indexes() {
1197        let indexer = binance_indexer();
1198        let rejection = || {
1199            OrderError::Rejected(ApiError::BalanceInsufficient(
1200                Some(AssetNameExchange::new("ETH_USDT")),
1201                "low".to_string(),
1202            ))
1203        };
1204        let indexed = OrderError::Rejected(ApiError::BalanceInsufficient(None, "low".to_string()));
1205
1206        assert_eq!(
1207            indexer.order_state(OrderState::inactive(rejection())),
1208            OrderState::inactive(indexed.clone())
1209        );
1210
1211        let key = OrderKey {
1212            exchange: ExchangeId::BinanceSpot,
1213            instrument: btc_usdt(),
1214            strategy: crate::order::id::StrategyId::new("test"),
1215            cid: crate::order::id::ClientOrderId::random(),
1216        };
1217        let cancel = indexer
1218            .order_response_cancel(OrderResponseCancel {
1219                key,
1220                state: Err(rejection()),
1221            })
1222            .unwrap();
1223        assert_eq!(cancel.state, Err(indexed));
1224    }
1225
1226    /// A cash flow indexes its asset and, when it names one, its instrument, and fails when
1227    /// either is not in the map.
1228    #[test]
1229    fn account_event_indexes_a_cash_flow() {
1230        use crate::cash_flow::{CashFlowId, CashFlowKind};
1231        use rust_decimal_macros::dec;
1232
1233        let indexer = binance_indexer();
1234        let time = DateTime::<Utc>::MIN_UTC;
1235        let kind = CashFlowKind::Funding {
1236            rate: Some(dec!(0.0001)),
1237            position_quantity: Some(dec!(-2)),
1238        };
1239        let flow = |asset: &str, instrument: Option<&str>| {
1240            UnindexedAccountEvent::new(
1241                ExchangeId::BinanceSpot,
1242                AccountEventKind::CashFlow(CashFlow::new(
1243                    kind.clone(),
1244                    AssetNameExchange::new(asset),
1245                    dec!(1.5),
1246                    instrument.map(InstrumentNameExchange::new),
1247                    time,
1248                    Some(CashFlowId::new("cf-1")),
1249                )),
1250            )
1251        };
1252        let index_of = |event| match indexer.account_event(event) {
1253            Ok(AccountEvent {
1254                kind: AccountEventKind::CashFlow(flow),
1255                ..
1256            }) => Ok(flow),
1257            Ok(other) => panic!("expected CashFlow, got {other:?}"),
1258            Err(error) => Err(error),
1259        };
1260        let (Ok(usdt), Ok(btc_usdt)) = (
1261            indexer
1262                .map
1263                .find_asset_index(&AssetNameExchange::new("USDT")),
1264            indexer
1265                .map
1266                .find_instrument_index(&InstrumentNameExchange::new("BTC_USDT")),
1267        ) else {
1268            panic!("USDT and BTC_USDT are mapped");
1269        };
1270
1271        assert_eq!(
1272            index_of(flow("USDT", Some("BTC_USDT"))),
1273            Ok(CashFlow::new(
1274                kind.clone(),
1275                usdt,
1276                dec!(1.5),
1277                Some(btc_usdt),
1278                time,
1279                Some(CashFlowId::new("cf-1")),
1280            ))
1281        );
1282        assert_eq!(
1283            index_of(flow("USDT", None)).map(|flow| flow.instrument),
1284            Ok(None)
1285        );
1286        assert!(
1287            index_of(flow("ETH", None)).is_err(),
1288            "an unmapped asset fails"
1289        );
1290        assert!(
1291            index_of(flow("USDT", Some("ETHUSDT"))).is_err(),
1292            "an unmapped instrument fails"
1293        );
1294    }
1295
1296    /// A notice indexes its instrument when it names a mapped one, and is kept unattributed,
1297    /// rather than dropped, when it names one that is not mapped.
1298    #[test]
1299    fn account_event_indexes_a_notice() {
1300        use crate::notice::{AccountNotice, NoticeKind};
1301        use rust_decimal_macros::dec;
1302        use smol_str::SmolStr;
1303
1304        let indexer = binance_indexer();
1305        let time = DateTime::<Utc>::MIN_UTC;
1306        let notice = |instrument: Option<&str>| {
1307            UnindexedAccountEvent::new(
1308                ExchangeId::BinanceSpot,
1309                AccountEventKind::Notice(AccountNotice::new(
1310                    NoticeKind::MarginCall,
1311                    instrument.map(InstrumentNameExchange::new),
1312                    time,
1313                    Some(dec!(1.1)),
1314                    SmolStr::new("MARGIN_CALL"),
1315                )),
1316            )
1317        };
1318        let index_of = |event| match indexer.account_event(event) {
1319            Ok(AccountEvent {
1320                kind: AccountEventKind::Notice(notice),
1321                ..
1322            }) => notice,
1323            other => panic!("expected Notice, got {other:?}"),
1324        };
1325        let Ok(btc_usdt) = indexer
1326            .map
1327            .find_instrument_index(&InstrumentNameExchange::new("BTC_USDT"))
1328        else {
1329            panic!("BTC_USDT is mapped");
1330        };
1331
1332        assert_eq!(
1333            index_of(notice(Some("BTC_USDT"))),
1334            AccountNotice::new(
1335                NoticeKind::MarginCall,
1336                Some(btc_usdt),
1337                time,
1338                Some(dec!(1.1)),
1339                SmolStr::new("MARGIN_CALL"),
1340            )
1341        );
1342        assert_eq!(index_of(notice(None)).instrument, None);
1343        assert_eq!(
1344            index_of(notice(Some("ETHUSDT"))).instrument,
1345            None,
1346            "an unmapped instrument is dropped from the notice, not the notice itself"
1347        );
1348    }
1349}