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 rustrade_instrument::{
24 asset::{AssetIndex, name::AssetNameExchange},
25 exchange::{ExchangeId, ExchangeIndex},
26 index::error::IndexError,
27 instrument::{InstrumentIndex, name::InstrumentNameExchange},
28};
29use rustrade_integration::{
30 collection::snapshot::Snapshot,
31 stream::ext::indexed::{IndexedStream, Indexer},
32};
33use std::sync::Arc;
34use tracing::warn;
35
36pub type IndexedAccountStream<St> = IndexedStream<St, AccountEventIndexer>;
37
38#[derive(Debug, Clone, Constructor)]
39pub struct AccountEventIndexer {
40 pub map: Arc<ExecutionInstrumentMap>,
41}
42
43impl Indexer for AccountEventIndexer {
44 type Unindexed = UnindexedAccountEvent;
45 type Indexed = AccountEvent;
46
47 fn index(&self, item: Self::Unindexed) -> Result<Self::Indexed, IndexError> {
48 self.account_event(item)
49 }
50}
51
52impl AccountEventIndexer {
53 pub fn account_event(&self, event: UnindexedAccountEvent) -> Result<AccountEvent, IndexError> {
54 let UnindexedAccountEvent { exchange, kind } = event;
55
56 let exchange = self.map.find_exchange_index(exchange)?;
57
58 let kind = match kind {
59 AccountEventKind::Snapshot(snapshot) => {
60 AccountEventKind::Snapshot(self.snapshot(snapshot)?)
61 }
62 AccountEventKind::BalanceSnapshot(snapshot) => {
63 AccountEventKind::BalanceSnapshot(self.asset_balance(snapshot.0).map(Snapshot)?)
64 }
65 AccountEventKind::BalanceStreamUpdate(snapshot) => {
66 AccountEventKind::BalanceStreamUpdate(
67 self.asset_balance_update(snapshot.0).map(Snapshot)?,
68 )
69 }
70 AccountEventKind::InstrumentBalanceUpdate(update) => {
71 AccountEventKind::InstrumentBalanceUpdate(self.instrument_balance_update(update)?)
72 }
73 AccountEventKind::OrderSnapshot(snapshot) => {
74 AccountEventKind::OrderSnapshot(self.order_snapshot(snapshot.0).map(Snapshot)?)
75 }
76 AccountEventKind::OrderCancelled(response) => {
77 AccountEventKind::OrderCancelled(self.order_response_cancel(response)?)
78 }
79 AccountEventKind::Trade(trade) => AccountEventKind::Trade(self.trade(trade)?),
80 AccountEventKind::StreamTerminated(reason) => {
82 AccountEventKind::StreamTerminated(reason)
83 }
84 AccountEventKind::FillRecoveryGaveUp(gap) => {
85 AccountEventKind::FillRecoveryGaveUp(self.fill_recovery_gap(gap)?)
86 }
87 AccountEventKind::TradeAmended(amendment) => {
88 AccountEventKind::TradeAmended(self.trade_amendment(amendment)?)
89 }
90 AccountEventKind::ReinitFailed(failure) => {
91 AccountEventKind::ReinitFailed(self.reinit_failure(failure))
92 }
93 AccountEventKind::CashFlow(flow) => AccountEventKind::CashFlow(self.cash_flow(flow)?),
94 AccountEventKind::Notice(notice) => {
95 AccountEventKind::Notice(self.notice(exchange, notice))
96 }
97 };
98
99 Ok(AccountEvent { exchange, kind })
100 }
101
102 pub fn snapshot(
103 &self,
104 snapshot: UnindexedAccountSnapshot,
105 ) -> Result<AccountSnapshot, IndexError> {
106 let UnindexedAccountSnapshot {
107 exchange,
108 balances,
109 instruments,
110 } = snapshot;
111
112 let exchange = self.map.find_exchange_index(exchange)?;
113
114 let balances = balances
115 .into_iter()
116 .map(|balance| self.asset_balance(balance))
117 .collect::<Result<Vec<_>, _>>()?;
118
119 let instruments = instruments
120 .into_iter()
121 .map(|snapshot| {
122 let InstrumentAccountSnapshot {
123 instrument,
124 orders,
125 orders_complete,
126 position,
127 isolated,
128 } = snapshot;
129
130 let instrument = self.map.find_instrument_index(&instrument)?;
131
132 let orders = orders
133 .into_iter()
134 .map(|order| self.order_snapshot(order))
135 .collect::<Result<Vec<_>, _>>()?;
136
137 let isolated = isolated
141 .map(|state| self.isolated_instrument_state(state))
142 .transpose()?;
143
144 Ok(InstrumentAccountSnapshot {
145 instrument,
146 orders,
147 orders_complete,
148 position,
149 isolated,
150 })
151 })
152 .collect::<Result<Vec<_>, _>>()?;
153
154 Ok(AccountSnapshot {
155 exchange,
156 balances,
157 instruments,
158 })
159 }
160
161 pub fn asset_balance(
162 &self,
163 balance: AssetBalance<AssetNameExchange>,
164 ) -> Result<AssetBalance<AssetIndex>, IndexError> {
165 let AssetBalance {
166 asset,
167 balance,
168 time_exchange,
169 } = balance;
170 let asset = self.map.find_asset_index(&asset)?;
171
172 Ok(AssetBalance {
173 asset,
174 balance,
175 time_exchange,
176 })
177 }
178
179 pub fn asset_balance_update(
180 &self,
181 update: AssetBalanceUpdate<AssetNameExchange>,
182 ) -> Result<AssetBalanceUpdate<AssetIndex>, IndexError> {
183 let AssetBalanceUpdate {
184 asset,
185 update,
186 time_exchange,
187 } = update;
188 let asset = self.map.find_asset_index(&asset)?;
189
190 Ok(AssetBalanceUpdate {
191 asset,
192 update,
193 time_exchange,
194 })
195 }
196
197 pub fn isolated_instrument_state(
203 &self,
204 state: IsolatedInstrumentState<AssetNameExchange>,
205 ) -> Result<IsolatedInstrumentState<AssetIndex>, IndexError> {
206 let IsolatedInstrumentState { base, quote, risk } = state;
207
208 Ok(IsolatedInstrumentState {
209 base: self.asset_balance(base)?,
210 quote: self.asset_balance(quote)?,
211 risk,
212 })
213 }
214
215 pub fn fill_recovery_gap(
223 &self,
224 gap: FillRecoveryGap<InstrumentNameExchange>,
225 ) -> Result<FillRecoveryGap<InstrumentIndex>, IndexError> {
226 let FillRecoveryGap {
227 scope,
228 start,
229 end,
230 attempts,
231 reason,
232 } = gap;
233
234 let scope = match scope {
235 FillRecoveryScope::Instruments(instruments) => FillRecoveryScope::Instruments(
236 instruments
237 .iter()
238 .map(|instrument| self.map.find_instrument_index(instrument))
239 .collect::<Result<_, _>>()?,
240 ),
241 FillRecoveryScope::AllInstruments => FillRecoveryScope::AllInstruments,
242 };
243
244 Ok(FillRecoveryGap {
245 scope,
246 start,
247 end,
248 attempts,
249 reason,
250 })
251 }
252
253 pub fn cash_flow(
258 &self,
259 flow: CashFlow<AssetNameExchange, InstrumentNameExchange>,
260 ) -> Result<CashFlow<AssetIndex, InstrumentIndex>, IndexError> {
261 let CashFlow {
262 kind,
263 asset,
264 amount,
265 instrument,
266 time_exchange,
267 id,
268 } = flow;
269
270 Ok(CashFlow {
271 kind,
272 asset: self.map.find_asset_index(&asset)?,
273 amount,
274 instrument: instrument
275 .map(|instrument| self.map.find_instrument_index(&instrument))
276 .transpose()?,
277 time_exchange,
278 id,
279 })
280 }
281
282 pub fn notice(
288 &self,
289 exchange: ExchangeIndex,
290 notice: AccountNotice<InstrumentNameExchange>,
291 ) -> AccountNotice<InstrumentIndex> {
292 let AccountNotice {
293 kind,
294 instrument,
295 time_exchange,
296 margin_level,
297 status,
298 } = notice;
299
300 let instrument = instrument.and_then(|instrument| {
301 self.map
302 .find_instrument_index(&instrument)
303 .inspect_err(|error| {
304 warn!(
305 ?exchange,
306 %instrument,
307 %kind,
308 %status,
309 %error,
310 "account notice names an instrument that is not in the map, \
311 delivering it unattributed",
312 );
313 })
314 .ok()
315 });
316
317 AccountNotice {
318 kind,
319 instrument,
320 time_exchange,
321 margin_level,
322 status,
323 }
324 }
325
326 pub fn instrument_balance_update(
331 &self,
332 update: InstrumentBalanceUpdate<AssetNameExchange, InstrumentNameExchange>,
333 ) -> Result<InstrumentBalanceUpdate, IndexError> {
334 let InstrumentBalanceUpdate {
335 instrument,
336 base,
337 quote,
338 } = update;
339
340 Ok(InstrumentBalanceUpdate {
341 instrument: self.map.find_instrument_index(&instrument)?,
342 base: self.asset_balance_update(base)?,
343 quote: self.asset_balance_update(quote)?,
344 })
345 }
346
347 pub fn order_snapshot(
348 &self,
349 order: UnindexedOrderSnapshot,
350 ) -> Result<OrderSnapshot, IndexError> {
351 let Order {
352 key,
353 side,
354 price,
355 quantity,
356 kind,
357 time_in_force,
358 state,
359 } = order;
360
361 let key = self.order_key(key)?;
362 let state = self.order_state(state);
363
364 Ok(Order {
365 key,
366 side,
367 price,
368 quantity,
369 kind,
370 time_in_force,
371 state,
372 })
373 }
374
375 pub fn order_response_cancel(
376 &self,
377 response: OrderResponseCancel<ExchangeId, AssetNameExchange, InstrumentNameExchange>,
378 ) -> Result<OrderResponseCancel, IndexError> {
379 let OrderResponseCancel { key, state } = response;
380
381 Ok(OrderResponseCancel {
382 key: self.order_key(key)?,
383 state: match state {
384 Ok(cancelled) => Ok(cancelled),
385 Err(error) => Err(self.order_error(error)),
386 },
387 })
388 }
389
390 pub fn order_key(&self, key: UnindexedOrderKey) -> Result<OrderKey, IndexError> {
391 let UnindexedOrderKey {
392 exchange,
393 instrument,
394 strategy,
395 cid,
396 } = key;
397
398 Ok(OrderKey {
399 exchange: self.map.find_exchange_index(exchange)?,
400 instrument: self.map.find_instrument_index(&instrument)?,
401 strategy,
402 cid,
403 })
404 }
405
406 pub fn order_state(&self, state: UnindexedOrderState) -> OrderState {
411 match state {
412 UnindexedOrderState::Active(active) => OrderState::Active(active),
413 UnindexedOrderState::Inactive(inactive) => match inactive {
414 InactiveOrderState::OpenFailed(failed) => {
415 OrderState::inactive(self.order_error(failed))
416 }
417 InactiveOrderState::Cancelled(cancelled) => OrderState::inactive(cancelled),
418 InactiveOrderState::FullyFilled(filled) => OrderState::fully_filled(filled),
419 InactiveOrderState::Expired(expired) => OrderState::expired(expired),
420 },
421 }
422 }
423
424 pub fn api_error(&self, error: UnindexedApiError) -> ApiError {
438 self.index_api_error(error, ApiError::OrderRejected)
439 }
440
441 fn index_api_error(
446 &self,
447 error: UnindexedApiError,
448 rejected: fn(String) -> ApiError,
449 ) -> ApiError {
450 match error {
451 UnindexedApiError::RateLimit { retry_after } => ApiError::rate_limited(retry_after),
452 UnindexedApiError::CapacityExceeded(msg) => ApiError::CapacityExceeded(msg),
453 UnindexedApiError::Unauthenticated(msg) => ApiError::Unauthenticated(msg),
454 UnindexedApiError::AssetInvalid(asset, value) => {
455 match self.map.find_asset_index(&asset) {
456 Ok(asset) => ApiError::AssetInvalid(asset, value),
457 Err(_) => self.degrade_unresolvable(
458 UnindexedApiError::AssetInvalid(asset, value),
459 rejected,
460 ),
461 }
462 }
463 UnindexedApiError::InstrumentInvalid(instrument, value) => {
464 match self.map.find_instrument_index(&instrument) {
465 Ok(instrument) => ApiError::InstrumentInvalid(instrument, value),
466 Err(_) => self.degrade_unresolvable(
467 UnindexedApiError::InstrumentInvalid(instrument, value),
468 rejected,
469 ),
470 }
471 }
472 UnindexedApiError::BalanceInsufficient(asset, value) => {
473 let asset = asset.and_then(|asset| {
474 self.map
475 .find_asset_index(&asset)
476 .inspect_err(|_| {
477 warn!(
478 exchange = %self.map.exchange.value,
479 %asset,
480 message = %value,
481 "AccountEventIndexer dropping the asset of a BalanceInsufficient \
482 error: the instrument map does not hold it"
483 )
484 })
485 .ok()
486 });
487 ApiError::BalanceInsufficient(asset, value)
488 }
489 UnindexedApiError::OrderRejected(reason) => ApiError::OrderRejected(reason),
490 UnindexedApiError::OrderAlreadyCancelled => ApiError::OrderAlreadyCancelled,
491 UnindexedApiError::OrderAlreadyFullyFilled => ApiError::OrderAlreadyFullyFilled,
492 UnindexedApiError::OrderAlreadyExpired => ApiError::OrderAlreadyExpired,
493 UnindexedApiError::DuplicateClientOrderId(message) => {
494 ApiError::DuplicateClientOrderId(message)
495 }
496 UnindexedApiError::RequestRejected(reason) => ApiError::RequestRejected(reason),
497 UnindexedApiError::BorrowRejected(reject) => ApiError::BorrowRejected(reject),
498 }
499 }
500
501 fn degrade_unresolvable(
505 &self,
506 error: UnindexedApiError,
507 rejected: fn(String) -> ApiError,
508 ) -> ApiError {
509 let error = error.to_string();
510 warn!(
511 exchange = %self.map.exchange.value,
512 %error,
513 "AccountEventIndexer degrading an API error to a rejection: the instrument map does \
514 not hold the asset or instrument it names"
515 );
516 rejected(error)
517 }
518
519 pub fn order_request<Kind>(
520 &self,
521 order: &OrderEvent<Kind, ExchangeIndex, InstrumentIndex>,
522 ) -> Result<OrderEvent<Kind, ExchangeId, &InstrumentNameExchange>, KeyError>
523 where
524 Kind: Clone,
525 {
526 let OrderEvent {
527 key:
528 OrderKey {
529 exchange,
530 instrument,
531 strategy,
532 cid,
533 },
534 state,
535 } = order;
536
537 let exchange = self.map.find_exchange_id(*exchange)?;
538 let instrument = self.map.find_instrument_name_exchange(*instrument)?;
539
540 Ok(OrderEvent {
541 key: OrderKey {
542 exchange,
543 instrument,
544 strategy: strategy.clone(),
545 cid: cid.clone(),
546 },
547 state: state.clone(),
548 })
549 }
550
551 pub fn order_error(&self, error: UnindexedOrderError) -> OrderError {
553 match error {
554 UnindexedOrderError::Connectivity(error) => OrderError::Connectivity(error),
555 UnindexedOrderError::Rejected(error) => OrderError::Rejected(self.api_error(error)),
556 UnindexedOrderError::UnsupportedOrderType(msg) => OrderError::UnsupportedOrderType(msg),
557 UnindexedOrderError::InvalidPrecision(violation) => {
558 OrderError::InvalidPrecision(violation)
559 }
560 }
561 }
562
563 pub fn client_error(&self, error: UnindexedClientError) -> ClientError {
567 match error {
568 UnindexedClientError::Connectivity(error) => ClientError::Connectivity(error),
569 UnindexedClientError::Api(error) => {
570 ClientError::Api(self.index_api_error(error, ApiError::RequestRejected))
571 }
572 UnindexedClientError::TaskFailed(value) => ClientError::TaskFailed(value),
573 UnindexedClientError::Internal(value) => ClientError::Internal(value),
574 UnindexedClientError::Truncated { fills_read } => ClientError::Truncated { fills_read },
575 UnindexedClientError::TruncatedSnapshot { limit } => {
576 ClientError::TruncatedSnapshot { limit }
577 }
578 }
579 }
580
581 pub fn account_stream_init_error(
583 &self,
584 error: UnindexedAccountStreamInitError,
585 ) -> AccountStreamInitError {
586 match error {
587 AccountStreamInitError::Client(error) => {
588 AccountStreamInitError::Client(self.client_error(error))
589 }
590 AccountStreamInitError::Index(error) => AccountStreamInitError::Index(error),
591 }
592 }
593
594 pub fn reinit_failure(
596 &self,
597 failure: AccountReinitFailure<AssetNameExchange, InstrumentNameExchange>,
598 ) -> AccountReinitFailure {
599 let AccountReinitFailure { attempt, error } = failure;
600 AccountReinitFailure {
601 attempt,
602 error: self.account_stream_init_error(error),
603 }
604 }
605
606 pub fn trade_amendment(
609 &self,
610 amendment: TradeAmendment<AssetNameExchange, InstrumentNameExchange>,
611 ) -> Result<TradeAmendment<AssetIndex, InstrumentIndex>, IndexError> {
612 let TradeAmendment {
613 instrument,
614 order_id,
615 time_exchange,
616 original,
617 kind,
618 } = amendment;
619
620 let instrument = self.map.find_instrument_index(&instrument)?;
621 let kind = match kind {
622 TradeAmendmentKind::Busted { quantity } => TradeAmendmentKind::Busted { quantity },
623 TradeAmendmentKind::Corrected { replacement } => TradeAmendmentKind::Corrected {
624 replacement: self.trade(replacement)?,
625 },
626 TradeAmendmentKind::CorrectedUnresolved {
627 id,
628 price,
629 quantity,
630 } => TradeAmendmentKind::CorrectedUnresolved {
631 id,
632 price,
633 quantity,
634 },
635 };
636
637 Ok(TradeAmendment {
638 instrument,
639 order_id,
640 time_exchange,
641 original,
642 kind,
643 })
644 }
645
646 pub fn trade(
659 &self,
660 trade: Trade<AssetNameExchange, InstrumentNameExchange>,
661 ) -> Result<Trade<AssetIndex, InstrumentIndex>, IndexError> {
662 let Trade {
663 id,
664 order_id,
665 instrument,
666 strategy,
667 time_exchange,
668 side,
669 price: trade_price,
670 quantity,
671 order_filled_quantity,
672 fees,
673 origin,
674 position_side,
675 client_order_id,
676 } = trade;
677
678 let instrument_index = self.map.find_instrument_index(&instrument)?;
679 let fee_asset_index = self.map.find_asset_index(&fees.asset)?;
680
681 let fees_quote = self
683 .map
684 .instruments
685 .get_index(instrument_index.index())
686 .and_then(|instr| {
687 if fee_asset_index == instr.underlying.quote {
688 Some(fees.fees)
690 } else if fee_asset_index == instr.underlying.base {
691 Some(fees.fees * trade_price)
693 } else {
694 None
696 }
697 });
698
699 Ok(Trade {
700 id,
701 order_id,
702 instrument: instrument_index,
703 strategy,
704 time_exchange,
705 side,
706 price: trade_price,
707 quantity,
708 order_filled_quantity,
709 fees: AssetFees {
710 asset: fee_asset_index,
711 fees: fees.fees,
712 fees_quote,
713 },
714 origin,
715 position_side,
716 client_order_id,
717 })
718 }
719}
720
721#[cfg(test)]
722#[allow(clippy::unwrap_used)] mod tests {
724 use super::*;
725 use crate::{
726 error::StreamTerminationReason, fill_recovery::FillRecoveryFailure,
727 map::generate_execution_instrument_map,
728 };
729 use chrono::{DateTime, Utc};
730 use rustrade_instrument::{index::IndexedInstruments, test_utils};
731
732 fn binance_indexer() -> AccountEventIndexer {
733 let instruments = IndexedInstruments::new(vec![test_utils::instrument(
734 ExchangeId::BinanceSpot,
735 "BTC",
736 "USDT",
737 )]);
738 let map = generate_execution_instrument_map(&instruments, ExchangeId::BinanceSpot).unwrap();
739 AccountEventIndexer::new(Arc::new(map))
740 }
741
742 #[test]
745 fn account_event_passes_stream_terminated_through_verbatim() {
746 let indexer = binance_indexer();
747 let reason = StreamTerminationReason::ReconnectBudgetExhausted {
748 attempts: 3,
749 last_error: "socket reset".to_string(),
750 };
751
752 let indexed = indexer
753 .account_event(UnindexedAccountEvent::new(
754 ExchangeId::BinanceSpot,
755 AccountEventKind::StreamTerminated(reason.clone()),
756 ))
757 .unwrap();
758
759 assert_eq!(indexed.exchange, indexer.map.exchange.key);
760 assert!(
761 matches!(indexed.kind, AccountEventKind::StreamTerminated(r) if r == reason),
762 "expected StreamTerminated to pass through unchanged",
763 );
764 }
765
766 #[test]
769 fn account_event_indexes_a_reinit_failure() {
770 let indexer = binance_indexer();
771 let btc = indexer
772 .map
773 .find_asset_index(&AssetNameExchange::new("BTC"))
774 .unwrap();
775 let unindexed_client = AccountStreamInitError::Client(ClientError::Api(
776 ApiError::BalanceInsufficient(Some(AssetNameExchange::new("BTC")), "low".to_owned()),
777 ));
778 let indexed_client = AccountStreamInitError::Client(ClientError::Api(
779 ApiError::BalanceInsufficient(Some(btc), "low".to_owned()),
780 ));
781 let index_error = IndexError::AssetIndex("ETH".to_owned());
782
783 for (error, expected) in [
784 (unindexed_client, indexed_client),
785 (
786 AccountStreamInitError::Index(index_error.clone()),
787 AccountStreamInitError::Index(index_error),
788 ),
789 ] {
790 let indexed = indexer
791 .account_event(UnindexedAccountEvent::new(
792 ExchangeId::BinanceSpot,
793 AccountEventKind::ReinitFailed(AccountReinitFailure::new(2, error)),
794 ))
795 .unwrap();
796
797 assert_eq!(indexed.exchange, indexer.map.exchange.key);
798 assert_eq!(
799 indexed.kind,
800 AccountEventKind::ReinitFailed(AccountReinitFailure::new(2, expected))
801 );
802 }
803 }
804
805 #[test]
808 fn account_event_indexes_a_fill_recovery_give_up_by_its_instruments() {
809 let indexer = binance_indexer();
810 let start = DateTime::<Utc>::MIN_UTC;
811 let end = DateTime::<Utc>::MAX_UTC;
812 let give_up = |scope| {
813 UnindexedAccountEvent::new(
814 ExchangeId::BinanceSpot,
815 AccountEventKind::FillRecoveryGaveUp(FillRecoveryGap::new(
816 scope,
817 start,
818 end,
819 6,
820 FillRecoveryFailure::TimedOut { timeout_secs: 30 },
821 )),
822 )
823 };
824 let index_of = |scope| match indexer.account_event(give_up(scope)) {
825 Ok(AccountEvent {
826 kind: AccountEventKind::FillRecoveryGaveUp(gap),
827 ..
828 }) => Ok(gap),
829 Ok(other) => panic!("expected FillRecoveryGaveUp, got {other:?}"),
830 Err(error) => Err(error),
831 };
832 let Ok(btc) = indexer
833 .map
834 .find_instrument_index(&InstrumentNameExchange::new("BTC_USDT"))
835 else {
836 panic!("BTC_USDT is mapped");
837 };
838
839 assert_eq!(
840 index_of(FillRecoveryScope::Instruments(vec![
841 InstrumentNameExchange::new("BTC_USDT")
842 ])),
843 Ok(FillRecoveryGap::new(
844 FillRecoveryScope::Instruments(vec![btc]),
845 start,
846 end,
847 6,
848 FillRecoveryFailure::TimedOut { timeout_secs: 30 },
849 ))
850 );
851 assert_eq!(
852 index_of(FillRecoveryScope::AllInstruments).map(|gap| gap.scope),
853 Ok(FillRecoveryScope::AllInstruments)
854 );
855 assert!(
856 index_of(FillRecoveryScope::Instruments(vec![
857 InstrumentNameExchange::new("BTC_USDT"),
858 InstrumentNameExchange::new("ETHUSDT"),
859 ]))
860 .is_err(),
861 "an unmapped instrument fails the whole give-up"
862 );
863 }
864
865 #[test]
868 fn account_event_indexes_a_trade_amendment() {
869 use crate::{
870 order::id::{OrderId, StrategyId},
871 trade::TradeId,
872 };
873 use rust_decimal::Decimal;
874 use rustrade_instrument::Side;
875
876 let indexer = binance_indexer();
877 let time = DateTime::<Utc>::MIN_UTC;
878 let amended = |instrument: &str, kind| {
879 UnindexedAccountEvent::new(
880 ExchangeId::BinanceSpot,
881 AccountEventKind::TradeAmended(TradeAmendment::new(
882 InstrumentNameExchange::new(instrument),
883 OrderId::new("ord-1"),
884 time,
885 Some(TradeId::new("t-1")),
886 kind,
887 )),
888 )
889 };
890 let index_of = |event| match indexer.account_event(event) {
891 Ok(AccountEvent {
892 kind: AccountEventKind::TradeAmended(amendment),
893 ..
894 }) => Ok(amendment),
895 Ok(other) => panic!("expected TradeAmended, got {other:?}"),
896 Err(error) => Err(error),
897 };
898 let Ok(btc) = indexer
899 .map
900 .find_instrument_index(&InstrumentNameExchange::new("BTC_USDT"))
901 else {
902 panic!("BTC_USDT is mapped");
903 };
904
905 assert_eq!(
906 index_of(amended(
907 "BTC_USDT",
908 TradeAmendmentKind::Busted {
909 quantity: Some(Decimal::ONE)
910 }
911 )),
912 Ok(TradeAmendment::new(
913 btc,
914 OrderId::new("ord-1"),
915 time,
916 Some(TradeId::new("t-1")),
917 TradeAmendmentKind::Busted {
918 quantity: Some(Decimal::ONE)
919 },
920 ))
921 );
922
923 let replacement = Trade::new(
924 TradeId::new("t-2"),
925 OrderId::new("ord-1"),
926 InstrumentNameExchange::new("BTC_USDT"),
927 StrategyId::unknown(),
928 time,
929 Side::Buy,
930 Decimal::TEN,
931 Decimal::ONE,
932 None,
933 AssetFees::new(AssetNameExchange::new("USDT"), Decimal::ONE, None),
934 );
935 let Ok(TradeAmendment {
936 kind: TradeAmendmentKind::Corrected { replacement },
937 ..
938 }) = index_of(amended(
939 "BTC_USDT",
940 TradeAmendmentKind::Corrected { replacement },
941 ))
942 else {
943 panic!("a correction indexes to a correction");
944 };
945 assert_eq!(replacement.instrument, btc);
946 assert_eq!(
947 replacement.fees.fees_quote,
948 Some(Decimal::ONE),
949 "indexed as a trade is, fees and all"
950 );
951
952 assert!(
953 index_of(amended(
954 "ETHUSDT",
955 TradeAmendmentKind::Busted { quantity: None }
956 ))
957 .is_err(),
958 "an unmapped instrument fails the amendment"
959 );
960 }
961
962 fn count_warnings<T>(index: impl FnOnce() -> T) -> (T, usize) {
966 use std::sync::atomic::{AtomicUsize, Ordering};
967 use tracing_subscriber::layer::SubscriberExt;
968
969 #[derive(Default)]
970 struct CountWarnings(Arc<AtomicUsize>);
971
972 impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for CountWarnings {
973 fn on_event(
974 &self,
975 event: &tracing::Event<'_>,
976 _: tracing_subscriber::layer::Context<'_, S>,
977 ) {
978 if *event.metadata().level() == tracing::Level::WARN {
979 self.0.fetch_add(1, Ordering::Relaxed);
980 }
981 }
982 }
983
984 let layer = CountWarnings::default();
985 let count = Arc::clone(&layer.0);
986 let value =
988 tracing::subscriber::with_default(tracing_subscriber::registry().with(layer), index);
989 (value, count.load(Ordering::Relaxed))
990 }
991
992 fn btc_usdt() -> InstrumentNameExchange {
993 binance_indexer()
994 .map
995 .exchange_instruments()
996 .next()
997 .cloned()
998 .unwrap_or_else(|| panic!("the map holds one instrument"))
999 }
1000
1001 #[test]
1002 fn balance_insufficient_keeps_a_held_asset_and_drops_an_unheld_one() {
1003 let indexer = binance_indexer();
1004 let btc = indexer
1005 .map
1006 .find_asset_index(&AssetNameExchange::new("BTC"))
1007 .unwrap();
1008
1009 for (asset, expected, warnings) in [
1010 (Some("BTC"), Some(btc), 0),
1011 (Some("ETH_USDT"), None, 1),
1013 (None, None, 0),
1014 ] {
1015 let (indexed, warned) = count_warnings(|| {
1016 indexer.api_error(ApiError::BalanceInsufficient(
1017 asset.map(AssetNameExchange::new),
1018 "low".to_string(),
1019 ))
1020 });
1021 assert_eq!(
1022 indexed,
1023 ApiError::BalanceInsufficient(expected, "low".to_string()),
1024 "{asset:?}"
1025 );
1026 assert_eq!(warned, warnings, "{asset:?}: a dropped asset is logged");
1027 }
1028 }
1029
1030 #[test]
1034 fn an_unresolvable_invalid_name_degrades_to_a_rejection_keeping_its_text() {
1035 let indexer = binance_indexer();
1036 for (error, text) in [
1037 (
1038 ApiError::AssetInvalid(AssetNameExchange::new("XYZ"), "bad".to_string()),
1039 "asset XYZ invalid: bad",
1040 ),
1041 (
1042 ApiError::InstrumentInvalid(
1043 InstrumentNameExchange::new("XYZUSDT"),
1044 "bad".to_string(),
1045 ),
1046 "instrument XYZUSDT invalid: bad",
1047 ),
1048 ] {
1049 let (indexed, warned) = count_warnings(|| indexer.api_error(error.clone()));
1050 assert_eq!(indexed, ApiError::OrderRejected(text.to_string()));
1051 assert_eq!(warned, 1, "{text}: a degrade is logged");
1052
1053 let (indexed, warned) =
1054 count_warnings(|| indexer.client_error(ClientError::Api(error)));
1055 assert_eq!(
1056 indexed,
1057 ClientError::Api(ApiError::RequestRejected(text.to_string()))
1058 );
1059 assert_eq!(warned, 1, "{text}: a degrade is logged");
1060 }
1061
1062 let (indexed, warned) = count_warnings(|| {
1064 indexer.api_error(ApiError::InstrumentInvalid(btc_usdt(), "bad".to_string()))
1065 });
1066 assert!(matches!(indexed, ApiError::InstrumentInvalid(_, _)));
1067 assert_eq!(warned, 0);
1068 }
1069
1070 #[test]
1073 fn a_response_whose_rejection_names_an_unresolvable_asset_still_indexes() {
1074 let indexer = binance_indexer();
1075 let rejection = || {
1076 OrderError::Rejected(ApiError::BalanceInsufficient(
1077 Some(AssetNameExchange::new("ETH_USDT")),
1078 "low".to_string(),
1079 ))
1080 };
1081 let indexed = OrderError::Rejected(ApiError::BalanceInsufficient(None, "low".to_string()));
1082
1083 assert_eq!(
1084 indexer.order_state(OrderState::inactive(rejection())),
1085 OrderState::inactive(indexed.clone())
1086 );
1087
1088 let key = OrderKey {
1089 exchange: ExchangeId::BinanceSpot,
1090 instrument: btc_usdt(),
1091 strategy: crate::order::id::StrategyId::new("test"),
1092 cid: crate::order::id::ClientOrderId::random(),
1093 };
1094 let cancel = indexer
1095 .order_response_cancel(OrderResponseCancel {
1096 key,
1097 state: Err(rejection()),
1098 })
1099 .unwrap();
1100 assert_eq!(cancel.state, Err(indexed));
1101 }
1102
1103 #[test]
1106 fn account_event_indexes_a_cash_flow() {
1107 use crate::cash_flow::{CashFlowId, CashFlowKind};
1108 use rust_decimal_macros::dec;
1109
1110 let indexer = binance_indexer();
1111 let time = DateTime::<Utc>::MIN_UTC;
1112 let kind = CashFlowKind::Funding {
1113 rate: Some(dec!(0.0001)),
1114 position_quantity: Some(dec!(-2)),
1115 };
1116 let flow = |asset: &str, instrument: Option<&str>| {
1117 UnindexedAccountEvent::new(
1118 ExchangeId::BinanceSpot,
1119 AccountEventKind::CashFlow(CashFlow::new(
1120 kind.clone(),
1121 AssetNameExchange::new(asset),
1122 dec!(1.5),
1123 instrument.map(InstrumentNameExchange::new),
1124 time,
1125 Some(CashFlowId::new("cf-1")),
1126 )),
1127 )
1128 };
1129 let index_of = |event| match indexer.account_event(event) {
1130 Ok(AccountEvent {
1131 kind: AccountEventKind::CashFlow(flow),
1132 ..
1133 }) => Ok(flow),
1134 Ok(other) => panic!("expected CashFlow, got {other:?}"),
1135 Err(error) => Err(error),
1136 };
1137 let (Ok(usdt), Ok(btc_usdt)) = (
1138 indexer
1139 .map
1140 .find_asset_index(&AssetNameExchange::new("USDT")),
1141 indexer
1142 .map
1143 .find_instrument_index(&InstrumentNameExchange::new("BTC_USDT")),
1144 ) else {
1145 panic!("USDT and BTC_USDT are mapped");
1146 };
1147
1148 assert_eq!(
1149 index_of(flow("USDT", Some("BTC_USDT"))),
1150 Ok(CashFlow::new(
1151 kind.clone(),
1152 usdt,
1153 dec!(1.5),
1154 Some(btc_usdt),
1155 time,
1156 Some(CashFlowId::new("cf-1")),
1157 ))
1158 );
1159 assert_eq!(
1160 index_of(flow("USDT", None)).map(|flow| flow.instrument),
1161 Ok(None)
1162 );
1163 assert!(
1164 index_of(flow("ETH", None)).is_err(),
1165 "an unmapped asset fails"
1166 );
1167 assert!(
1168 index_of(flow("USDT", Some("ETHUSDT"))).is_err(),
1169 "an unmapped instrument fails"
1170 );
1171 }
1172
1173 #[test]
1176 fn account_event_indexes_a_notice() {
1177 use crate::notice::{AccountNotice, NoticeKind};
1178 use rust_decimal_macros::dec;
1179 use smol_str::SmolStr;
1180
1181 let indexer = binance_indexer();
1182 let time = DateTime::<Utc>::MIN_UTC;
1183 let notice = |instrument: Option<&str>| {
1184 UnindexedAccountEvent::new(
1185 ExchangeId::BinanceSpot,
1186 AccountEventKind::Notice(AccountNotice::new(
1187 NoticeKind::MarginCall,
1188 instrument.map(InstrumentNameExchange::new),
1189 time,
1190 Some(dec!(1.1)),
1191 SmolStr::new("MARGIN_CALL"),
1192 )),
1193 )
1194 };
1195 let index_of = |event| match indexer.account_event(event) {
1196 Ok(AccountEvent {
1197 kind: AccountEventKind::Notice(notice),
1198 ..
1199 }) => notice,
1200 other => panic!("expected Notice, got {other:?}"),
1201 };
1202 let Ok(btc_usdt) = indexer
1203 .map
1204 .find_instrument_index(&InstrumentNameExchange::new("BTC_USDT"))
1205 else {
1206 panic!("BTC_USDT is mapped");
1207 };
1208
1209 assert_eq!(
1210 index_of(notice(Some("BTC_USDT"))),
1211 AccountNotice::new(
1212 NoticeKind::MarginCall,
1213 Some(btc_usdt),
1214 time,
1215 Some(dec!(1.1)),
1216 SmolStr::new("MARGIN_CALL"),
1217 )
1218 );
1219 assert_eq!(index_of(notice(None)).instrument, None);
1220 assert_eq!(
1221 index_of(notice(Some("ETHUSDT"))).instrument,
1222 None,
1223 "an unmapped instrument is dropped from the notice, not the notice itself"
1224 );
1225 }
1226}