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