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