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::NotPlaced(reason) => OrderError::NotPlaced(reason),
666            UnindexedOrderError::Rejected(error) => OrderError::Rejected(self.api_error(error)),
667            UnindexedOrderError::UnsupportedOrderType(msg) => OrderError::UnsupportedOrderType(msg),
668            UnindexedOrderError::InvalidPrecision(violation) => {
669                OrderError::InvalidPrecision(violation)
670            }
671            UnindexedOrderError::OutOfBounds(violation) => OrderError::OutOfBounds(violation),
672            UnindexedOrderError::UnsupportedMarginEffect(effect) => {
673                OrderError::UnsupportedMarginEffect(effect)
674            }
675        }
676    }
677
678    /// Index an [`UnindexedClientError`]. Never fails: like [`Self::api_error`], but an
679    /// unresolvable [`ApiError::AssetInvalid`] or [`ApiError::InstrumentInvalid`] becomes
680    /// [`ApiError::RequestRejected`], since the request need not have been an order.
681    pub fn client_error(&self, error: UnindexedClientError) -> ClientError {
682        match error {
683            UnindexedClientError::Connectivity(error) => ClientError::Connectivity(error),
684            UnindexedClientError::Api(error) => {
685                ClientError::Api(self.index_api_error(error, ApiError::RequestRejected))
686            }
687            UnindexedClientError::TaskFailed(value) => ClientError::TaskFailed(value),
688            UnindexedClientError::Internal(value) => ClientError::Internal(value),
689            UnindexedClientError::Truncated { fills_read } => ClientError::Truncated { fills_read },
690            UnindexedClientError::TruncatedSnapshot { limit } => {
691                ClientError::TruncatedSnapshot { limit }
692            }
693        }
694    }
695
696    /// Index an [`UnindexedAccountStreamInitError`]. Never fails, as [`Self::client_error`].
697    pub fn account_stream_init_error(
698        &self,
699        error: UnindexedAccountStreamInitError,
700    ) -> AccountStreamInitError {
701        match error {
702            AccountStreamInitError::Client(error) => {
703                AccountStreamInitError::Client(self.client_error(error))
704            }
705            AccountStreamInitError::Index(error) => AccountStreamInitError::Index(error),
706        }
707    }
708
709    /// Index an [`AccountReinitFailure`]. Never fails, as [`Self::client_error`].
710    pub fn reinit_failure(
711        &self,
712        failure: AccountReinitFailure<AssetNameExchange, InstrumentNameExchange>,
713    ) -> AccountReinitFailure {
714        let AccountReinitFailure { attempt, error } = failure;
715        AccountReinitFailure {
716            attempt,
717            error: self.account_stream_init_error(error),
718        }
719    }
720
721    /// Index a [`TradeAmendment`]. A replacement trade is indexed as [`trade`](Self::trade) indexes
722    /// one, so an amendment fails where its trade would.
723    pub fn trade_amendment(
724        &self,
725        amendment: TradeAmendment<AssetNameExchange, InstrumentNameExchange>,
726    ) -> Result<TradeAmendment<AssetIndex, InstrumentIndex>, IndexError> {
727        let TradeAmendment {
728            instrument,
729            order_id,
730            time_exchange,
731            original,
732            kind,
733        } = amendment;
734
735        let instrument = self.map.find_instrument_index(&instrument)?;
736        let kind = match kind {
737            TradeAmendmentKind::Busted { quantity } => TradeAmendmentKind::Busted { quantity },
738            TradeAmendmentKind::Corrected { replacement } => TradeAmendmentKind::Corrected {
739                replacement: self.trade(replacement)?,
740            },
741            TradeAmendmentKind::CorrectedUnresolved {
742                id,
743                price,
744                quantity,
745            } => TradeAmendmentKind::CorrectedUnresolved {
746                id,
747                price,
748                quantity,
749            },
750        };
751
752        Ok(TradeAmendment {
753            instrument,
754            order_id,
755            time_exchange,
756            original,
757            kind,
758        })
759    }
760
761    /// Index a trade, converting fee asset and computing `fees_quote`.
762    ///
763    /// Computes `fees_quote` based on fee asset relationship to instrument:
764    /// - Fee in quote asset: `fees_quote = Some(fees)`
765    /// - Fee in base asset: `fees_quote = Some(fees * price)`
766    /// - Fee in third-party asset (e.g., BNB): `fees_quote = None`, since the indexer holds no
767    ///   prices. The `rustrade` engine values it when it prices that asset.
768    ///
769    /// # A fee asset that is not in the map
770    /// The trade is kept, with its fee zeroed: `fees` is `0` in the instrument's quote asset and
771    /// `fees_quote` is `None`. A `warn` names the fee's asset and amount. Dropping the trade would
772    /// leave the engine's position different from the venue's, which costs far more than one
773    /// unknown fee. The `warn` is per trade, so an account that pays every fee in an unregistered
774    /// asset logs one per fill until the asset is registered. Two cases reach this:
775    /// - An asset the account pays fees in but no instrument implies, such as BNB on Binance spot
776    ///   with "pay fees with BNB" turned on. Register it with
777    ///   [`IndexedInstrumentsBuilder::add_asset`](rustrade_instrument::index::builder::IndexedInstrumentsBuilder::add_asset)
778    ///   to keep its fee.
779    /// - The `"UNKNOWN"` placeholder some integrations use when the venue gave no fee asset: IBKR
780    ///   `fetch_trades` for an execution IB sent no commission report for, and Binance when a
781    ///   report omits `commissionAsset`.
782    ///
783    /// # Errors
784    /// Returns `IndexError` if the trade's instrument is not in the map.
785    pub fn trade(
786        &self,
787        trade: Trade<AssetNameExchange, InstrumentNameExchange>,
788    ) -> Result<Trade<AssetIndex, InstrumentIndex>, IndexError> {
789        let Trade {
790            id,
791            order_id,
792            instrument,
793            strategy,
794            time_exchange,
795            side,
796            price: trade_price,
797            quantity,
798            order_filled_quantity,
799            fees,
800            origin,
801            position_side,
802            client_order_id,
803        } = trade;
804
805        let instrument_index = self.map.find_instrument_index(&instrument)?;
806        // Invariant check: `find_instrument_index` resolves only names built from `instruments`,
807        // so the index is always present. Failing loudly beats indexing a fee against nothing.
808        let underlying = &self
809            .map
810            .instruments
811            .get_index(instrument_index.index())
812            .ok_or_else(|| {
813                IndexError::InstrumentIndex(format!(
814                    "ExecutionInstrumentMap has no instrument at {instrument_index} for {instrument}"
815                ))
816            })?
817            .underlying;
818
819        let fees = match self.map.find_asset_index(&fees.asset) {
820            Ok(fee_asset) => {
821                let fees_quote = if fee_asset == underlying.quote {
822                    // Fee is in quote asset — no conversion needed
823                    Some(fees.fees)
824                } else if fee_asset == underlying.base {
825                    // Fee is in base asset — convert using trade price
826                    Some(fees.fees * trade_price)
827                } else {
828                    // Fee is in third-party asset (e.g., BNB) — needs external price
829                    None
830                };
831                AssetFees::new(fee_asset, fees.fees, fees_quote)
832            }
833            Err(error) => {
834                warn!(
835                    exchange = %self.map.exchange.value,
836                    %instrument,
837                    trade_id = %id,
838                    fee_asset = %fees.asset,
839                    fee = %fees.fees,
840                    %error,
841                    "trade's fee asset is not in the map, keeping the trade with its fee zeroed; \
842                     register the asset with IndexedInstrumentsBuilder::add_asset to keep its fee",
843                );
844                AssetFees::new(underlying.quote, Decimal::ZERO, None)
845            }
846        };
847
848        Ok(Trade {
849            id,
850            order_id,
851            instrument: instrument_index,
852            strategy,
853            time_exchange,
854            side,
855            price: trade_price,
856            quantity,
857            order_filled_quantity,
858            fees,
859            origin,
860            position_side,
861            client_order_id,
862        })
863    }
864}
865
866#[cfg(test)]
867#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
868mod tests {
869    use super::*;
870    use crate::{
871        error::StreamTerminationReason, fill_recovery::FillRecoveryFailure,
872        map::generate_execution_instrument_map,
873    };
874    use chrono::{DateTime, Utc};
875    use rustrade_instrument::{index::IndexedInstruments, test_utils};
876
877    #[test]
878    fn a_partitioned_snapshot_skips_an_unregistered_balance_and_the_strict_one_fails() {
879        let indexer = binance_indexer();
880        let snapshot = || {
881            UnindexedAccountSnapshot::new(
882                ExchangeId::BinanceSpot,
883                ["usdt", "bnb", "btc", "sol"]
884                    .map(|asset| {
885                        AssetBalance::new(
886                            AssetNameExchange::new(asset),
887                            crate::balance::Balance::new(Decimal::ONE, Decimal::ONE),
888                            DateTime::<Utc>::MIN_UTC,
889                        )
890                    })
891                    .to_vec(),
892                vec![],
893            )
894        };
895
896        let partitioned = indexer.snapshot_partitioned(snapshot()).unwrap();
897        assert_eq!(partitioned.snapshot.balances.len(), 2);
898        assert_eq!(
899            partitioned.untracked,
900            vec![AssetNameExchange::new("bnb"), AssetNameExchange::new("sol")]
901        );
902        assert!(matches!(
903            indexer.snapshot(snapshot()),
904            Err(IndexError::AssetIndex(_))
905        ));
906    }
907
908    #[test]
909    fn a_partitioned_snapshot_still_fails_on_an_unregistered_instrument_or_exchange() {
910        let indexer = binance_indexer();
911        let instrument = |name: &str| {
912            InstrumentAccountSnapshot::new(
913                InstrumentNameExchange::new(name),
914                vec![],
915                false,
916                crate::position::PositionReport::Unreported,
917                None,
918            )
919        };
920        let unknown_instrument = UnindexedAccountSnapshot::new(
921            ExchangeId::BinanceSpot,
922            vec![],
923            vec![instrument("ETHUSDT")],
924        );
925        assert!(matches!(
926            indexer.snapshot_partitioned(unknown_instrument),
927            Err(IndexError::InstrumentIndex(_))
928        ));
929        // A skipped balance does not hide a failure elsewhere in the same snapshot.
930        let skipped_and_unknown_instrument = UnindexedAccountSnapshot::new(
931            ExchangeId::BinanceSpot,
932            vec![AssetBalance::new(
933                AssetNameExchange::new("bnb"),
934                crate::balance::Balance::new(Decimal::ONE, Decimal::ONE),
935                DateTime::<Utc>::MIN_UTC,
936            )],
937            vec![instrument("ETHUSDT")],
938        );
939        assert!(matches!(
940            indexer.snapshot_partitioned(skipped_and_unknown_instrument),
941            Err(IndexError::InstrumentIndex(_))
942        ));
943        let other_exchange = UnindexedAccountSnapshot::new(ExchangeId::Ibkr, vec![], vec![]);
944        assert!(matches!(
945            indexer.snapshot_partitioned(other_exchange),
946            Err(IndexError::ExchangeIndex(_))
947        ));
948    }
949
950    /// An order naming an instrument listed on another exchange does not resolve, though the plain
951    /// instrument lookup would find it.
952    #[test]
953    fn an_order_request_for_another_exchanges_instrument_fails() {
954        use crate::order::id::{ClientOrderId, StrategyId};
955        let instruments = IndexedInstruments::new(vec![
956            test_utils::instrument(ExchangeId::Kraken, "BTC", "USDT"),
957            test_utils::instrument(ExchangeId::BinanceSpot, "BTC", "USDT"),
958        ]);
959        let binance = generate_execution_instrument_map(&instruments, ExchangeId::BinanceSpot)
960            .map(Arc::new)
961            .map(AccountEventIndexer::new)
962            .unwrap();
963        let instrument_on = |exchange| {
964            instruments
965                .find_instrument_index(
966                    exchange,
967                    &test_utils::instrument(exchange, "BTC", "USDT").name_internal,
968                )
969                .unwrap()
970        };
971        let order = |exchange, instrument| OrderEvent {
972            key: OrderKey {
973                exchange,
974                instrument,
975                strategy: StrategyId::new("test"),
976                cid: ClientOrderId::random(),
977            },
978            state: (),
979        };
980        let binance_index = instruments
981            .find_exchange_index(ExchangeId::BinanceSpot)
982            .unwrap();
983
984        let own = binance
985            .order_request(&order(
986                binance_index,
987                instrument_on(ExchangeId::BinanceSpot),
988            ))
989            .unwrap();
990        assert_eq!(own.key.exchange, ExchangeId::BinanceSpot);
991        assert_eq!(own.key.instrument.as_ref(), "BTC_USDT");
992
993        let foreign =
994            binance.order_request(&order(binance_index, instrument_on(ExchangeId::Kraken)));
995        assert!(
996            matches!(foreign, Err(KeyError::InstrumentKey(_))),
997            "{foreign:?}"
998        );
999    }
1000
1001    fn binance_indexer() -> AccountEventIndexer {
1002        let instruments = IndexedInstruments::new(vec![test_utils::instrument(
1003            ExchangeId::BinanceSpot,
1004            "BTC",
1005            "USDT",
1006        )]);
1007        let map = generate_execution_instrument_map(&instruments, ExchangeId::BinanceSpot).unwrap();
1008        AccountEventIndexer::new(Arc::new(map))
1009    }
1010
1011    fn balance_update(asset: &str) -> UnindexedAccountEvent {
1012        UnindexedAccountEvent::new(
1013            ExchangeId::BinanceSpot,
1014            AccountEventKind::BalanceStreamUpdate(Snapshot(AssetBalanceUpdate::new(
1015                AssetNameExchange::new(asset),
1016                crate::balance::BalanceUpdate::new(rust_decimal::Decimal::ONE, Default::default()),
1017                DateTime::<Utc>::MIN_UTC,
1018            ))),
1019        )
1020    }
1021
1022    #[test]
1023    fn a_balance_for_an_unregistered_asset_is_an_untracked_balance_not_a_rejection() {
1024        let indexer = binance_indexer();
1025
1026        assert!(indexer.index_account_event(balance_update("usdt")).is_ok());
1027        assert_eq!(
1028            indexer.index_account_event(balance_update("bnb")),
1029            Err(AccountIndexError::UntrackedBalance(AssetNameExchange::new(
1030                "bnb"
1031            )))
1032        );
1033
1034        let snapshot = UnindexedAccountEvent::new(
1035            ExchangeId::BinanceSpot,
1036            AccountEventKind::BalanceSnapshot(Snapshot(AssetBalance::new(
1037                AssetNameExchange::new("bnb"),
1038                crate::balance::Balance::new(Default::default(), Default::default()),
1039                DateTime::<Utc>::MIN_UTC,
1040            ))),
1041        );
1042        assert!(matches!(
1043            indexer.index_account_event(snapshot),
1044            Err(AccountIndexError::UntrackedBalance(_))
1045        ));
1046    }
1047
1048    #[test]
1049    fn an_unregistered_asset_on_any_other_event_is_still_a_rejection() {
1050        let indexer = binance_indexer();
1051        let cash_flow = UnindexedAccountEvent::new(
1052            ExchangeId::BinanceSpot,
1053            AccountEventKind::CashFlow(CashFlow {
1054                kind: crate::cash_flow::CashFlowKind::Rebate,
1055                asset: AssetNameExchange::new("bnb"),
1056                amount: rust_decimal::Decimal::ONE,
1057                instrument: None,
1058                time_exchange: DateTime::<Utc>::MIN_UTC,
1059                id: None,
1060            }),
1061        );
1062        assert!(matches!(
1063            indexer.index_account_event(cash_flow),
1064            Err(AccountIndexError::Rejected(IndexError::AssetIndex(_)))
1065        ));
1066
1067        // A balance on an exchange the map does not hold fails on the exchange, not the asset.
1068        let mut wrong_exchange = balance_update("bnb");
1069        wrong_exchange.exchange = ExchangeId::Kraken;
1070        assert!(matches!(
1071            indexer.index_account_event(wrong_exchange),
1072            Err(AccountIndexError::Rejected(IndexError::ExchangeIndex(_)))
1073        ));
1074    }
1075
1076    /// `StreamTerminated` carries no asset/instrument keys, so it must index by mapping only the
1077    /// exchange and passing the reason through verbatim.
1078    #[test]
1079    fn account_event_passes_stream_terminated_through_verbatim() {
1080        let indexer = binance_indexer();
1081        let reason = StreamTerminationReason::ReconnectBudgetExhausted {
1082            attempts: 3,
1083            last_error: "socket reset".to_string(),
1084        };
1085
1086        let indexed = indexer
1087            .account_event(UnindexedAccountEvent::new(
1088                ExchangeId::BinanceSpot,
1089                AccountEventKind::StreamTerminated(reason.clone()),
1090            ))
1091            .unwrap();
1092
1093        assert_eq!(indexed.exchange, indexer.map.exchange.key);
1094        assert!(
1095            matches!(indexed.kind, AccountEventKind::StreamTerminated(r) if r == reason),
1096            "expected StreamTerminated to pass through unchanged",
1097        );
1098    }
1099
1100    /// A failed re-init never fails to index: its client error is indexed as
1101    /// [`AccountEventIndexer::client_error`] indexes one, and an index error passes through.
1102    #[test]
1103    fn account_event_indexes_a_reinit_failure() {
1104        let indexer = binance_indexer();
1105        let btc = indexer
1106            .map
1107            .find_asset_index(&AssetNameExchange::new("BTC"))
1108            .unwrap();
1109        let unindexed_client = AccountStreamInitError::Client(ClientError::Api(
1110            ApiError::BalanceInsufficient(Some(AssetNameExchange::new("BTC")), "low".to_owned()),
1111        ));
1112        let indexed_client = AccountStreamInitError::Client(ClientError::Api(
1113            ApiError::BalanceInsufficient(Some(btc), "low".to_owned()),
1114        ));
1115        let index_error = IndexError::AssetIndex("ETH".to_owned());
1116
1117        for (error, expected) in [
1118            (unindexed_client, indexed_client),
1119            (
1120                AccountStreamInitError::Index(index_error.clone()),
1121                AccountStreamInitError::Index(index_error),
1122            ),
1123        ] {
1124            let indexed = indexer
1125                .account_event(UnindexedAccountEvent::new(
1126                    ExchangeId::BinanceSpot,
1127                    AccountEventKind::ReinitFailed(AccountReinitFailure::new(2, error)),
1128                ))
1129                .unwrap();
1130
1131            assert_eq!(indexed.exchange, indexer.map.exchange.key);
1132            assert_eq!(
1133                indexed.kind,
1134                AccountEventKind::ReinitFailed(AccountReinitFailure::new(2, expected))
1135            );
1136        }
1137    }
1138
1139    /// A fill recovery give-up indexes each instrument it names, and fails as a whole when one
1140    /// is not in the map rather than understating what is missing. `AllInstruments` names none.
1141    #[test]
1142    fn account_event_indexes_a_fill_recovery_give_up_by_its_instruments() {
1143        let indexer = binance_indexer();
1144        let start = DateTime::<Utc>::MIN_UTC;
1145        let end = DateTime::<Utc>::MAX_UTC;
1146        let give_up = |scope| {
1147            UnindexedAccountEvent::new(
1148                ExchangeId::BinanceSpot,
1149                AccountEventKind::FillRecoveryGaveUp(FillRecoveryGap::new(
1150                    scope,
1151                    start,
1152                    end,
1153                    6,
1154                    FillRecoveryFailure::TimedOut { timeout_secs: 30 },
1155                )),
1156            )
1157        };
1158        let index_of = |scope| match indexer.account_event(give_up(scope)) {
1159            Ok(AccountEvent {
1160                kind: AccountEventKind::FillRecoveryGaveUp(gap),
1161                ..
1162            }) => Ok(gap),
1163            Ok(other) => panic!("expected FillRecoveryGaveUp, got {other:?}"),
1164            Err(error) => Err(error),
1165        };
1166        let Ok(btc) = indexer
1167            .map
1168            .find_instrument_index(&InstrumentNameExchange::new("BTC_USDT"))
1169        else {
1170            panic!("BTC_USDT is mapped");
1171        };
1172
1173        assert_eq!(
1174            index_of(FillRecoveryScope::Instruments(vec![
1175                InstrumentNameExchange::new("BTC_USDT")
1176            ])),
1177            Ok(FillRecoveryGap::new(
1178                FillRecoveryScope::Instruments(vec![btc]),
1179                start,
1180                end,
1181                6,
1182                FillRecoveryFailure::TimedOut { timeout_secs: 30 },
1183            ))
1184        );
1185        assert_eq!(
1186            index_of(FillRecoveryScope::AllInstruments).map(|gap| gap.scope),
1187            Ok(FillRecoveryScope::AllInstruments)
1188        );
1189        assert!(
1190            index_of(FillRecoveryScope::Instruments(vec![
1191                InstrumentNameExchange::new("BTC_USDT"),
1192                InstrumentNameExchange::new("ETHUSDT"),
1193            ]))
1194            .is_err(),
1195            "an unmapped instrument fails the whole give-up"
1196        );
1197    }
1198
1199    /// A trade amendment indexes its instrument, and a corrected one its replacement as a trade
1200    /// is indexed. An unmapped instrument fails it.
1201    #[test]
1202    fn account_event_indexes_a_trade_amendment() {
1203        use crate::{
1204            order::id::{OrderId, StrategyId},
1205            trade::TradeId,
1206        };
1207        use rust_decimal::Decimal;
1208        use rustrade_instrument::Side;
1209
1210        let indexer = binance_indexer();
1211        let time = DateTime::<Utc>::MIN_UTC;
1212        let amended = |instrument: &str, kind| {
1213            UnindexedAccountEvent::new(
1214                ExchangeId::BinanceSpot,
1215                AccountEventKind::TradeAmended(TradeAmendment::new(
1216                    InstrumentNameExchange::new(instrument),
1217                    OrderId::new("ord-1"),
1218                    time,
1219                    Some(TradeId::new("t-1")),
1220                    kind,
1221                )),
1222            )
1223        };
1224        let index_of = |event| match indexer.account_event(event) {
1225            Ok(AccountEvent {
1226                kind: AccountEventKind::TradeAmended(amendment),
1227                ..
1228            }) => Ok(amendment),
1229            Ok(other) => panic!("expected TradeAmended, got {other:?}"),
1230            Err(error) => Err(error),
1231        };
1232        let Ok(btc) = indexer
1233            .map
1234            .find_instrument_index(&InstrumentNameExchange::new("BTC_USDT"))
1235        else {
1236            panic!("BTC_USDT is mapped");
1237        };
1238
1239        assert_eq!(
1240            index_of(amended(
1241                "BTC_USDT",
1242                TradeAmendmentKind::Busted {
1243                    quantity: Some(Decimal::ONE)
1244                }
1245            )),
1246            Ok(TradeAmendment::new(
1247                btc,
1248                OrderId::new("ord-1"),
1249                time,
1250                Some(TradeId::new("t-1")),
1251                TradeAmendmentKind::Busted {
1252                    quantity: Some(Decimal::ONE)
1253                },
1254            ))
1255        );
1256
1257        let replacement = Trade::new(
1258            TradeId::new("t-2"),
1259            OrderId::new("ord-1"),
1260            InstrumentNameExchange::new("BTC_USDT"),
1261            StrategyId::unknown(),
1262            time,
1263            Side::Buy,
1264            Decimal::TEN,
1265            Decimal::ONE,
1266            None,
1267            AssetFees::new(AssetNameExchange::new("USDT"), Decimal::ONE, None),
1268        );
1269        let Ok(TradeAmendment {
1270            kind: TradeAmendmentKind::Corrected { replacement },
1271            ..
1272        }) = index_of(amended(
1273            "BTC_USDT",
1274            TradeAmendmentKind::Corrected { replacement },
1275        ))
1276        else {
1277            panic!("a correction indexes to a correction");
1278        };
1279        assert_eq!(replacement.instrument, btc);
1280        assert_eq!(
1281            replacement.fees.fees_quote,
1282            Some(Decimal::ONE),
1283            "indexed as a trade is, fees and all"
1284        );
1285
1286        assert!(
1287            index_of(amended(
1288                "ETHUSDT",
1289                TradeAmendmentKind::Busted { quantity: None }
1290            ))
1291            .is_err(),
1292            "an unmapped instrument fails the amendment"
1293        );
1294    }
1295
1296    /// A fill is never dropped for its fee: an unmapped fee asset, or the `"UNKNOWN"` placeholder,
1297    /// keeps the trade with its fee zeroed in quote and `fees_quote: None`, with one `warn`. A fee
1298    /// asset registered with `add_asset` indexes as a third-party fee.
1299    #[test]
1300    fn trade_keeps_a_fill_whose_fee_asset_is_unmapped() {
1301        use crate::{
1302            order::id::{OrderId, StrategyId},
1303            trade::TradeId,
1304        };
1305        use rust_decimal_macros::dec;
1306        use rustrade_instrument::{Side, index::builder::IndexedInstrumentsBuilder};
1307
1308        let fill = |fee_asset: &str, fee| {
1309            Trade::new(
1310                TradeId::new("t-1"),
1311                OrderId::new("ord-1"),
1312                InstrumentNameExchange::new("BTC_USDT"),
1313                StrategyId::unknown(),
1314                DateTime::<Utc>::MIN_UTC,
1315                Side::Buy,
1316                dec!(50000),
1317                dec!(0.01),
1318                None,
1319                AssetFees::new(AssetNameExchange::new(fee_asset), fee, None),
1320            )
1321        };
1322        let asset = |indexer: &AccountEventIndexer, name: &str| {
1323            indexer
1324                .map
1325                .find_asset_index(&AssetNameExchange::new(name))
1326                .unwrap()
1327        };
1328
1329        let indexer = binance_indexer();
1330        let usdt = asset(&indexer, "USDT");
1331        let btc = asset(&indexer, "BTC");
1332
1333        let (quote, warnings) = count_warnings(|| indexer.trade(fill("USDT", dec!(0.5))));
1334        assert_eq!(
1335            quote.unwrap().fees,
1336            AssetFees::new(usdt, dec!(0.5), Some(dec!(0.5)))
1337        );
1338        assert_eq!(warnings, 0);
1339
1340        let (base, warnings) = count_warnings(|| indexer.trade(fill("BTC", dec!(0.00001))));
1341        assert_eq!(
1342            base.unwrap().fees,
1343            AssetFees::new(btc, dec!(0.00001), Some(dec!(0.5)))
1344        );
1345        assert_eq!(warnings, 0);
1346
1347        for unmapped in ["BNB", "UNKNOWN"] {
1348            let (kept, warnings) = count_warnings(|| indexer.trade(fill(unmapped, dec!(0.001))));
1349            let kept = kept.unwrap_or_else(|error| panic!("{unmapped}: dropped: {error}"));
1350            assert_eq!(
1351                kept.quantity,
1352                dec!(0.01),
1353                "{unmapped}: the fill itself is intact"
1354            );
1355            assert_eq!(
1356                kept.fees,
1357                AssetFees::new(usdt, Decimal::ZERO, None),
1358                "{unmapped}: fee zeroed in quote, with no quote equivalent"
1359            );
1360            assert_eq!(warnings, 1, "{unmapped}: the zeroed fee is reported");
1361        }
1362
1363        let instruments = IndexedInstrumentsBuilder::new()
1364            .add_instrument(test_utils::instrument(
1365                ExchangeId::BinanceSpot,
1366                "BTC",
1367                "USDT",
1368            ))
1369            .add_asset(test_utils::exchange_asset(ExchangeId::BinanceSpot, "BNB"))
1370            .build();
1371        let with_bnb = AccountEventIndexer::new(Arc::new(
1372            generate_execution_instrument_map(&instruments, ExchangeId::BinanceSpot).unwrap(),
1373        ));
1374        let bnb = asset(&with_bnb, "BNB");
1375        let (third_party, warnings) = count_warnings(|| with_bnb.trade(fill("BNB", dec!(0.001))));
1376        assert_eq!(
1377            third_party.unwrap().fees,
1378            AssetFees::new(bnb, dec!(0.001), None),
1379            "a registered fee asset keeps its fee, with no quote equivalent"
1380        );
1381        assert_eq!(warnings, 0);
1382
1383        let mut unmapped_instrument = fill("USDT", dec!(0.5));
1384        unmapped_instrument.instrument = InstrumentNameExchange::new("ETH_USDT");
1385        assert!(
1386            indexer.trade(unmapped_instrument).is_err(),
1387            "an unmapped instrument still fails"
1388        );
1389    }
1390
1391    /// Run `index`, returning its value and the number of `WARN` events it emitted on this
1392    /// thread. A degrade is reported only by its log line, so asserting on the value alone could
1393    /// not tell a logged degrade from a silent one.
1394    fn count_warnings<T>(index: impl FnOnce() -> T) -> (T, usize) {
1395        use std::sync::atomic::{AtomicUsize, Ordering};
1396        use tracing_subscriber::layer::SubscriberExt;
1397
1398        #[derive(Default)]
1399        struct CountWarnings(Arc<AtomicUsize>);
1400
1401        impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for CountWarnings {
1402            fn on_event(
1403                &self,
1404                event: &tracing::Event<'_>,
1405                _: tracing_subscriber::layer::Context<'_, S>,
1406            ) {
1407                if *event.metadata().level() == tracing::Level::WARN {
1408                    self.0.fetch_add(1, Ordering::Relaxed);
1409                }
1410            }
1411        }
1412
1413        let layer = CountWarnings::default();
1414        let count = Arc::clone(&layer.0);
1415        // Thread-local, so other tests running in parallel are not counted.
1416        let value =
1417            tracing::subscriber::with_default(tracing_subscriber::registry().with(layer), index);
1418        (value, count.load(Ordering::Relaxed))
1419    }
1420
1421    fn btc_usdt() -> InstrumentNameExchange {
1422        binance_indexer()
1423            .map
1424            .exchange_instruments()
1425            .next()
1426            .cloned()
1427            .unwrap_or_else(|| panic!("the map holds one instrument"))
1428    }
1429
1430    #[test]
1431    fn balance_insufficient_keeps_a_held_asset_and_drops_an_unheld_one() {
1432        let indexer = binance_indexer();
1433        let btc = indexer
1434            .map
1435            .find_asset_index(&AssetNameExchange::new("BTC"))
1436            .unwrap();
1437
1438        for (asset, expected, warnings) in [
1439            (Some("BTC"), Some(btc), 0),
1440            // An instrument name, as Binance's error mapping once put there.
1441            (Some("ETH_USDT"), None, 1),
1442            (None, None, 0),
1443        ] {
1444            let (indexed, warned) = count_warnings(|| {
1445                indexer.api_error(ApiError::BalanceInsufficient(
1446                    asset.map(AssetNameExchange::new),
1447                    "low".to_string(),
1448                ))
1449            });
1450            assert_eq!(
1451                indexed,
1452                ApiError::BalanceInsufficient(expected, "low".to_string()),
1453                "{asset:?}"
1454            );
1455            assert_eq!(warned, warnings, "{asset:?}: a dropped asset is logged");
1456        }
1457    }
1458
1459    /// An invalid asset or instrument the map does not hold has no index to carry, so it becomes
1460    /// a rejection keeping its own message: `OrderRejected` for an order, `RequestRejected` for
1461    /// any other request.
1462    #[test]
1463    fn an_unresolvable_invalid_name_degrades_to_a_rejection_keeping_its_text() {
1464        let indexer = binance_indexer();
1465        for (error, text) in [
1466            (
1467                ApiError::AssetInvalid(AssetNameExchange::new("XYZ"), "bad".to_string()),
1468                "asset XYZ invalid: bad",
1469            ),
1470            (
1471                ApiError::InstrumentInvalid(
1472                    InstrumentNameExchange::new("XYZUSDT"),
1473                    "bad".to_string(),
1474                ),
1475                "instrument XYZUSDT invalid: bad",
1476            ),
1477        ] {
1478            let (indexed, warned) = count_warnings(|| indexer.api_error(error.clone()));
1479            assert_eq!(indexed, ApiError::OrderRejected(text.to_string()));
1480            assert_eq!(warned, 1, "{text}: a degrade is logged");
1481
1482            let (indexed, warned) =
1483                count_warnings(|| indexer.client_error(ClientError::Api(error)));
1484            assert_eq!(
1485                indexed,
1486                ClientError::Api(ApiError::RequestRejected(text.to_string()))
1487            );
1488            assert_eq!(warned, 1, "{text}: a degrade is logged");
1489        }
1490
1491        // A name the map holds still indexes, and nothing is logged.
1492        let (indexed, warned) = count_warnings(|| {
1493            indexer.api_error(ApiError::InstrumentInvalid(btc_usdt(), "bad".to_string()))
1494        });
1495        assert!(matches!(indexed, ApiError::InstrumentInvalid(_, _)));
1496        assert_eq!(warned, 0);
1497    }
1498
1499    /// A response whose rejection names something the map does not hold still indexes, so the
1500    /// order is settled rather than left in flight.
1501    #[test]
1502    fn a_response_whose_rejection_names_an_unresolvable_asset_still_indexes() {
1503        let indexer = binance_indexer();
1504        let rejection = || {
1505            OrderError::Rejected(ApiError::BalanceInsufficient(
1506                Some(AssetNameExchange::new("ETH_USDT")),
1507                "low".to_string(),
1508            ))
1509        };
1510        let indexed = OrderError::Rejected(ApiError::BalanceInsufficient(None, "low".to_string()));
1511
1512        assert_eq!(
1513            indexer.order_state(OrderState::inactive(rejection())),
1514            OrderState::inactive(indexed.clone())
1515        );
1516
1517        let key = OrderKey {
1518            exchange: ExchangeId::BinanceSpot,
1519            instrument: btc_usdt(),
1520            strategy: crate::order::id::StrategyId::new("test"),
1521            cid: crate::order::id::ClientOrderId::random(),
1522        };
1523        let cancel = indexer
1524            .order_response_cancel(OrderResponseCancel {
1525                key,
1526                state: Err(rejection()),
1527            })
1528            .unwrap();
1529        assert_eq!(cancel.state, Err(indexed));
1530    }
1531
1532    /// A cash flow indexes its asset and, when it names one, its instrument, and fails when
1533    /// either is not in the map.
1534    #[test]
1535    fn account_event_indexes_a_cash_flow() {
1536        use crate::cash_flow::{CashFlowId, CashFlowKind};
1537        use rust_decimal_macros::dec;
1538
1539        let indexer = binance_indexer();
1540        let time = DateTime::<Utc>::MIN_UTC;
1541        let kind = CashFlowKind::Funding {
1542            rate: Some(dec!(0.0001)),
1543            position_quantity: Some(dec!(-2)),
1544        };
1545        let flow = |asset: &str, instrument: Option<&str>| {
1546            UnindexedAccountEvent::new(
1547                ExchangeId::BinanceSpot,
1548                AccountEventKind::CashFlow(CashFlow::new(
1549                    kind.clone(),
1550                    AssetNameExchange::new(asset),
1551                    dec!(1.5),
1552                    instrument.map(InstrumentNameExchange::new),
1553                    time,
1554                    Some(CashFlowId::new("cf-1")),
1555                )),
1556            )
1557        };
1558        let index_of = |event| match indexer.account_event(event) {
1559            Ok(AccountEvent {
1560                kind: AccountEventKind::CashFlow(flow),
1561                ..
1562            }) => Ok(flow),
1563            Ok(other) => panic!("expected CashFlow, got {other:?}"),
1564            Err(error) => Err(error),
1565        };
1566        let (Ok(usdt), Ok(btc_usdt)) = (
1567            indexer
1568                .map
1569                .find_asset_index(&AssetNameExchange::new("USDT")),
1570            indexer
1571                .map
1572                .find_instrument_index(&InstrumentNameExchange::new("BTC_USDT")),
1573        ) else {
1574            panic!("USDT and BTC_USDT are mapped");
1575        };
1576
1577        assert_eq!(
1578            index_of(flow("USDT", Some("BTC_USDT"))),
1579            Ok(CashFlow::new(
1580                kind.clone(),
1581                usdt,
1582                dec!(1.5),
1583                Some(btc_usdt),
1584                time,
1585                Some(CashFlowId::new("cf-1")),
1586            ))
1587        );
1588        assert_eq!(
1589            index_of(flow("USDT", None)).map(|flow| flow.instrument),
1590            Ok(None)
1591        );
1592        assert!(
1593            index_of(flow("ETH", None)).is_err(),
1594            "an unmapped asset fails"
1595        );
1596        assert!(
1597            index_of(flow("USDT", Some("ETHUSDT"))).is_err(),
1598            "an unmapped instrument fails"
1599        );
1600    }
1601
1602    /// A notice indexes its instrument when it names a mapped one, and is kept unattributed,
1603    /// rather than dropped, when it names one that is not mapped.
1604    #[test]
1605    fn account_event_indexes_a_notice() {
1606        use crate::notice::{AccountNotice, NoticeKind};
1607        use rust_decimal_macros::dec;
1608        use smol_str::SmolStr;
1609
1610        let indexer = binance_indexer();
1611        let time = DateTime::<Utc>::MIN_UTC;
1612        let notice = |instrument: Option<&str>| {
1613            UnindexedAccountEvent::new(
1614                ExchangeId::BinanceSpot,
1615                AccountEventKind::Notice(AccountNotice::new(
1616                    NoticeKind::MarginCall,
1617                    instrument.map(InstrumentNameExchange::new),
1618                    time,
1619                    Some(dec!(1.1)),
1620                    SmolStr::new("MARGIN_CALL"),
1621                )),
1622            )
1623        };
1624        let index_of = |event| match indexer.account_event(event) {
1625            Ok(AccountEvent {
1626                kind: AccountEventKind::Notice(notice),
1627                ..
1628            }) => notice,
1629            other => panic!("expected Notice, got {other:?}"),
1630        };
1631        let Ok(btc_usdt) = indexer
1632            .map
1633            .find_instrument_index(&InstrumentNameExchange::new("BTC_USDT"))
1634        else {
1635            panic!("BTC_USDT is mapped");
1636        };
1637
1638        assert_eq!(
1639            index_of(notice(Some("BTC_USDT"))),
1640            AccountNotice::new(
1641                NoticeKind::MarginCall,
1642                Some(btc_usdt),
1643                time,
1644                Some(dec!(1.1)),
1645                SmolStr::new("MARGIN_CALL"),
1646            )
1647        );
1648        assert_eq!(index_of(notice(None)).instrument, None);
1649        assert_eq!(
1650            index_of(notice(Some("ETHUSDT"))).instrument,
1651            None,
1652            "an unmapped instrument is dropped from the notice, not the notice itself"
1653        );
1654    }
1655}