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#[derive(Debug, Clone, PartialEq)]
44pub enum AccountIndexError {
45 UntrackedBalance(AssetNameExchange),
53 Rejected(IndexError),
55}
56
57#[derive(Debug, Clone, PartialEq)]
60#[non_exhaustive]
61pub struct PartitionedSnapshot {
62 pub snapshot: AccountSnapshot,
64 pub untracked: Vec<AssetNameExchange>,
66}
67
68impl AccountEventIndexer {
69 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 (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 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 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 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 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 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 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 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 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 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 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 pub fn api_error(&self, error: UnindexedApiError) -> ApiError {
533 self.index_api_error(error, ApiError::OrderRejected)
534 }
535
536 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 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 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 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 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 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 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 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 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 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 Some(fees.fees)
824 } else if fee_asset == underlying.base {
825 Some(fees.fees * trade_price)
827 } else {
828 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)] mod 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 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 #[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 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 #[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 #[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 #[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 #[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 #[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 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 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 (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 #[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 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 #[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 #[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 #[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}