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