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