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(
670 &self,
671 trade: Trade<AssetNameExchange, InstrumentNameExchange>,
672 ) -> Result<Trade<AssetIndex, InstrumentIndex>, IndexError> {
673 let Trade {
674 id,
675 order_id,
676 instrument,
677 strategy,
678 time_exchange,
679 side,
680 price: trade_price,
681 quantity,
682 order_filled_quantity,
683 fees,
684 origin,
685 position_side,
686 client_order_id,
687 } = trade;
688
689 let instrument_index = self.map.find_instrument_index(&instrument)?;
690 let underlying = &self
693 .map
694 .instruments
695 .get_index(instrument_index.index())
696 .ok_or_else(|| {
697 IndexError::InstrumentIndex(format!(
698 "ExecutionInstrumentMap has no instrument at {instrument_index} for {instrument}"
699 ))
700 })?
701 .underlying;
702
703 let fees = match self.map.find_asset_index(&fees.asset) {
704 Ok(fee_asset) => {
705 let fees_quote = if fee_asset == underlying.quote {
706 Some(fees.fees)
708 } else if fee_asset == underlying.base {
709 Some(fees.fees * trade_price)
711 } else {
712 None
714 };
715 AssetFees::new(fee_asset, fees.fees, fees_quote)
716 }
717 Err(error) => {
718 warn!(
719 exchange = %self.map.exchange.value,
720 %instrument,
721 trade_id = %id,
722 fee_asset = %fees.asset,
723 fee = %fees.fees,
724 %error,
725 "trade's fee asset is not in the map, keeping the trade with its fee zeroed; \
726 register the asset with IndexedInstrumentsBuilder::add_asset to keep its fee",
727 );
728 AssetFees::new(underlying.quote, Decimal::ZERO, None)
729 }
730 };
731
732 Ok(Trade {
733 id,
734 order_id,
735 instrument: instrument_index,
736 strategy,
737 time_exchange,
738 side,
739 price: trade_price,
740 quantity,
741 order_filled_quantity,
742 fees,
743 origin,
744 position_side,
745 client_order_id,
746 })
747 }
748}
749
750#[cfg(test)]
751#[allow(clippy::unwrap_used)] mod tests {
753 use super::*;
754 use crate::{
755 error::StreamTerminationReason, fill_recovery::FillRecoveryFailure,
756 map::generate_execution_instrument_map,
757 };
758 use chrono::{DateTime, Utc};
759 use rustrade_instrument::{index::IndexedInstruments, test_utils};
760
761 fn binance_indexer() -> AccountEventIndexer {
762 let instruments = IndexedInstruments::new(vec![test_utils::instrument(
763 ExchangeId::BinanceSpot,
764 "BTC",
765 "USDT",
766 )]);
767 let map = generate_execution_instrument_map(&instruments, ExchangeId::BinanceSpot).unwrap();
768 AccountEventIndexer::new(Arc::new(map))
769 }
770
771 #[test]
774 fn account_event_passes_stream_terminated_through_verbatim() {
775 let indexer = binance_indexer();
776 let reason = StreamTerminationReason::ReconnectBudgetExhausted {
777 attempts: 3,
778 last_error: "socket reset".to_string(),
779 };
780
781 let indexed = indexer
782 .account_event(UnindexedAccountEvent::new(
783 ExchangeId::BinanceSpot,
784 AccountEventKind::StreamTerminated(reason.clone()),
785 ))
786 .unwrap();
787
788 assert_eq!(indexed.exchange, indexer.map.exchange.key);
789 assert!(
790 matches!(indexed.kind, AccountEventKind::StreamTerminated(r) if r == reason),
791 "expected StreamTerminated to pass through unchanged",
792 );
793 }
794
795 #[test]
798 fn account_event_indexes_a_reinit_failure() {
799 let indexer = binance_indexer();
800 let btc = indexer
801 .map
802 .find_asset_index(&AssetNameExchange::new("BTC"))
803 .unwrap();
804 let unindexed_client = AccountStreamInitError::Client(ClientError::Api(
805 ApiError::BalanceInsufficient(Some(AssetNameExchange::new("BTC")), "low".to_owned()),
806 ));
807 let indexed_client = AccountStreamInitError::Client(ClientError::Api(
808 ApiError::BalanceInsufficient(Some(btc), "low".to_owned()),
809 ));
810 let index_error = IndexError::AssetIndex("ETH".to_owned());
811
812 for (error, expected) in [
813 (unindexed_client, indexed_client),
814 (
815 AccountStreamInitError::Index(index_error.clone()),
816 AccountStreamInitError::Index(index_error),
817 ),
818 ] {
819 let indexed = indexer
820 .account_event(UnindexedAccountEvent::new(
821 ExchangeId::BinanceSpot,
822 AccountEventKind::ReinitFailed(AccountReinitFailure::new(2, error)),
823 ))
824 .unwrap();
825
826 assert_eq!(indexed.exchange, indexer.map.exchange.key);
827 assert_eq!(
828 indexed.kind,
829 AccountEventKind::ReinitFailed(AccountReinitFailure::new(2, expected))
830 );
831 }
832 }
833
834 #[test]
837 fn account_event_indexes_a_fill_recovery_give_up_by_its_instruments() {
838 let indexer = binance_indexer();
839 let start = DateTime::<Utc>::MIN_UTC;
840 let end = DateTime::<Utc>::MAX_UTC;
841 let give_up = |scope| {
842 UnindexedAccountEvent::new(
843 ExchangeId::BinanceSpot,
844 AccountEventKind::FillRecoveryGaveUp(FillRecoveryGap::new(
845 scope,
846 start,
847 end,
848 6,
849 FillRecoveryFailure::TimedOut { timeout_secs: 30 },
850 )),
851 )
852 };
853 let index_of = |scope| match indexer.account_event(give_up(scope)) {
854 Ok(AccountEvent {
855 kind: AccountEventKind::FillRecoveryGaveUp(gap),
856 ..
857 }) => Ok(gap),
858 Ok(other) => panic!("expected FillRecoveryGaveUp, got {other:?}"),
859 Err(error) => Err(error),
860 };
861 let Ok(btc) = indexer
862 .map
863 .find_instrument_index(&InstrumentNameExchange::new("BTC_USDT"))
864 else {
865 panic!("BTC_USDT is mapped");
866 };
867
868 assert_eq!(
869 index_of(FillRecoveryScope::Instruments(vec![
870 InstrumentNameExchange::new("BTC_USDT")
871 ])),
872 Ok(FillRecoveryGap::new(
873 FillRecoveryScope::Instruments(vec![btc]),
874 start,
875 end,
876 6,
877 FillRecoveryFailure::TimedOut { timeout_secs: 30 },
878 ))
879 );
880 assert_eq!(
881 index_of(FillRecoveryScope::AllInstruments).map(|gap| gap.scope),
882 Ok(FillRecoveryScope::AllInstruments)
883 );
884 assert!(
885 index_of(FillRecoveryScope::Instruments(vec![
886 InstrumentNameExchange::new("BTC_USDT"),
887 InstrumentNameExchange::new("ETHUSDT"),
888 ]))
889 .is_err(),
890 "an unmapped instrument fails the whole give-up"
891 );
892 }
893
894 #[test]
897 fn account_event_indexes_a_trade_amendment() {
898 use crate::{
899 order::id::{OrderId, StrategyId},
900 trade::TradeId,
901 };
902 use rust_decimal::Decimal;
903 use rustrade_instrument::Side;
904
905 let indexer = binance_indexer();
906 let time = DateTime::<Utc>::MIN_UTC;
907 let amended = |instrument: &str, kind| {
908 UnindexedAccountEvent::new(
909 ExchangeId::BinanceSpot,
910 AccountEventKind::TradeAmended(TradeAmendment::new(
911 InstrumentNameExchange::new(instrument),
912 OrderId::new("ord-1"),
913 time,
914 Some(TradeId::new("t-1")),
915 kind,
916 )),
917 )
918 };
919 let index_of = |event| match indexer.account_event(event) {
920 Ok(AccountEvent {
921 kind: AccountEventKind::TradeAmended(amendment),
922 ..
923 }) => Ok(amendment),
924 Ok(other) => panic!("expected TradeAmended, got {other:?}"),
925 Err(error) => Err(error),
926 };
927 let Ok(btc) = indexer
928 .map
929 .find_instrument_index(&InstrumentNameExchange::new("BTC_USDT"))
930 else {
931 panic!("BTC_USDT is mapped");
932 };
933
934 assert_eq!(
935 index_of(amended(
936 "BTC_USDT",
937 TradeAmendmentKind::Busted {
938 quantity: Some(Decimal::ONE)
939 }
940 )),
941 Ok(TradeAmendment::new(
942 btc,
943 OrderId::new("ord-1"),
944 time,
945 Some(TradeId::new("t-1")),
946 TradeAmendmentKind::Busted {
947 quantity: Some(Decimal::ONE)
948 },
949 ))
950 );
951
952 let replacement = Trade::new(
953 TradeId::new("t-2"),
954 OrderId::new("ord-1"),
955 InstrumentNameExchange::new("BTC_USDT"),
956 StrategyId::unknown(),
957 time,
958 Side::Buy,
959 Decimal::TEN,
960 Decimal::ONE,
961 None,
962 AssetFees::new(AssetNameExchange::new("USDT"), Decimal::ONE, None),
963 );
964 let Ok(TradeAmendment {
965 kind: TradeAmendmentKind::Corrected { replacement },
966 ..
967 }) = index_of(amended(
968 "BTC_USDT",
969 TradeAmendmentKind::Corrected { replacement },
970 ))
971 else {
972 panic!("a correction indexes to a correction");
973 };
974 assert_eq!(replacement.instrument, btc);
975 assert_eq!(
976 replacement.fees.fees_quote,
977 Some(Decimal::ONE),
978 "indexed as a trade is, fees and all"
979 );
980
981 assert!(
982 index_of(amended(
983 "ETHUSDT",
984 TradeAmendmentKind::Busted { quantity: None }
985 ))
986 .is_err(),
987 "an unmapped instrument fails the amendment"
988 );
989 }
990
991 #[test]
995 fn trade_keeps_a_fill_whose_fee_asset_is_unmapped() {
996 use crate::{
997 order::id::{OrderId, StrategyId},
998 trade::TradeId,
999 };
1000 use rust_decimal_macros::dec;
1001 use rustrade_instrument::{Side, index::builder::IndexedInstrumentsBuilder};
1002
1003 let fill = |fee_asset: &str, fee| {
1004 Trade::new(
1005 TradeId::new("t-1"),
1006 OrderId::new("ord-1"),
1007 InstrumentNameExchange::new("BTC_USDT"),
1008 StrategyId::unknown(),
1009 DateTime::<Utc>::MIN_UTC,
1010 Side::Buy,
1011 dec!(50000),
1012 dec!(0.01),
1013 None,
1014 AssetFees::new(AssetNameExchange::new(fee_asset), fee, None),
1015 )
1016 };
1017 let asset = |indexer: &AccountEventIndexer, name: &str| {
1018 indexer
1019 .map
1020 .find_asset_index(&AssetNameExchange::new(name))
1021 .unwrap()
1022 };
1023
1024 let indexer = binance_indexer();
1025 let usdt = asset(&indexer, "USDT");
1026 let btc = asset(&indexer, "BTC");
1027
1028 let (quote, warnings) = count_warnings(|| indexer.trade(fill("USDT", dec!(0.5))));
1029 assert_eq!(
1030 quote.unwrap().fees,
1031 AssetFees::new(usdt, dec!(0.5), Some(dec!(0.5)))
1032 );
1033 assert_eq!(warnings, 0);
1034
1035 let (base, warnings) = count_warnings(|| indexer.trade(fill("BTC", dec!(0.00001))));
1036 assert_eq!(
1037 base.unwrap().fees,
1038 AssetFees::new(btc, dec!(0.00001), Some(dec!(0.5)))
1039 );
1040 assert_eq!(warnings, 0);
1041
1042 for unmapped in ["BNB", "UNKNOWN"] {
1043 let (kept, warnings) = count_warnings(|| indexer.trade(fill(unmapped, dec!(0.001))));
1044 let kept = kept.unwrap_or_else(|error| panic!("{unmapped}: dropped: {error}"));
1045 assert_eq!(
1046 kept.quantity,
1047 dec!(0.01),
1048 "{unmapped}: the fill itself is intact"
1049 );
1050 assert_eq!(
1051 kept.fees,
1052 AssetFees::new(usdt, Decimal::ZERO, None),
1053 "{unmapped}: fee zeroed in quote, with no quote equivalent"
1054 );
1055 assert_eq!(warnings, 1, "{unmapped}: the zeroed fee is reported");
1056 }
1057
1058 let instruments = IndexedInstrumentsBuilder::new()
1059 .add_instrument(test_utils::instrument(
1060 ExchangeId::BinanceSpot,
1061 "BTC",
1062 "USDT",
1063 ))
1064 .add_asset(test_utils::exchange_asset(ExchangeId::BinanceSpot, "BNB"))
1065 .build();
1066 let with_bnb = AccountEventIndexer::new(Arc::new(
1067 generate_execution_instrument_map(&instruments, ExchangeId::BinanceSpot).unwrap(),
1068 ));
1069 let bnb = asset(&with_bnb, "BNB");
1070 let (third_party, warnings) = count_warnings(|| with_bnb.trade(fill("BNB", dec!(0.001))));
1071 assert_eq!(
1072 third_party.unwrap().fees,
1073 AssetFees::new(bnb, dec!(0.001), None),
1074 "a registered fee asset keeps its fee, with no quote equivalent"
1075 );
1076 assert_eq!(warnings, 0);
1077
1078 let mut unmapped_instrument = fill("USDT", dec!(0.5));
1079 unmapped_instrument.instrument = InstrumentNameExchange::new("ETH_USDT");
1080 assert!(
1081 indexer.trade(unmapped_instrument).is_err(),
1082 "an unmapped instrument still fails"
1083 );
1084 }
1085
1086 fn count_warnings<T>(index: impl FnOnce() -> T) -> (T, usize) {
1090 use std::sync::atomic::{AtomicUsize, Ordering};
1091 use tracing_subscriber::layer::SubscriberExt;
1092
1093 #[derive(Default)]
1094 struct CountWarnings(Arc<AtomicUsize>);
1095
1096 impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for CountWarnings {
1097 fn on_event(
1098 &self,
1099 event: &tracing::Event<'_>,
1100 _: tracing_subscriber::layer::Context<'_, S>,
1101 ) {
1102 if *event.metadata().level() == tracing::Level::WARN {
1103 self.0.fetch_add(1, Ordering::Relaxed);
1104 }
1105 }
1106 }
1107
1108 let layer = CountWarnings::default();
1109 let count = Arc::clone(&layer.0);
1110 let value =
1112 tracing::subscriber::with_default(tracing_subscriber::registry().with(layer), index);
1113 (value, count.load(Ordering::Relaxed))
1114 }
1115
1116 fn btc_usdt() -> InstrumentNameExchange {
1117 binance_indexer()
1118 .map
1119 .exchange_instruments()
1120 .next()
1121 .cloned()
1122 .unwrap_or_else(|| panic!("the map holds one instrument"))
1123 }
1124
1125 #[test]
1126 fn balance_insufficient_keeps_a_held_asset_and_drops_an_unheld_one() {
1127 let indexer = binance_indexer();
1128 let btc = indexer
1129 .map
1130 .find_asset_index(&AssetNameExchange::new("BTC"))
1131 .unwrap();
1132
1133 for (asset, expected, warnings) in [
1134 (Some("BTC"), Some(btc), 0),
1135 (Some("ETH_USDT"), None, 1),
1137 (None, None, 0),
1138 ] {
1139 let (indexed, warned) = count_warnings(|| {
1140 indexer.api_error(ApiError::BalanceInsufficient(
1141 asset.map(AssetNameExchange::new),
1142 "low".to_string(),
1143 ))
1144 });
1145 assert_eq!(
1146 indexed,
1147 ApiError::BalanceInsufficient(expected, "low".to_string()),
1148 "{asset:?}"
1149 );
1150 assert_eq!(warned, warnings, "{asset:?}: a dropped asset is logged");
1151 }
1152 }
1153
1154 #[test]
1158 fn an_unresolvable_invalid_name_degrades_to_a_rejection_keeping_its_text() {
1159 let indexer = binance_indexer();
1160 for (error, text) in [
1161 (
1162 ApiError::AssetInvalid(AssetNameExchange::new("XYZ"), "bad".to_string()),
1163 "asset XYZ invalid: bad",
1164 ),
1165 (
1166 ApiError::InstrumentInvalid(
1167 InstrumentNameExchange::new("XYZUSDT"),
1168 "bad".to_string(),
1169 ),
1170 "instrument XYZUSDT invalid: bad",
1171 ),
1172 ] {
1173 let (indexed, warned) = count_warnings(|| indexer.api_error(error.clone()));
1174 assert_eq!(indexed, ApiError::OrderRejected(text.to_string()));
1175 assert_eq!(warned, 1, "{text}: a degrade is logged");
1176
1177 let (indexed, warned) =
1178 count_warnings(|| indexer.client_error(ClientError::Api(error)));
1179 assert_eq!(
1180 indexed,
1181 ClientError::Api(ApiError::RequestRejected(text.to_string()))
1182 );
1183 assert_eq!(warned, 1, "{text}: a degrade is logged");
1184 }
1185
1186 let (indexed, warned) = count_warnings(|| {
1188 indexer.api_error(ApiError::InstrumentInvalid(btc_usdt(), "bad".to_string()))
1189 });
1190 assert!(matches!(indexed, ApiError::InstrumentInvalid(_, _)));
1191 assert_eq!(warned, 0);
1192 }
1193
1194 #[test]
1197 fn a_response_whose_rejection_names_an_unresolvable_asset_still_indexes() {
1198 let indexer = binance_indexer();
1199 let rejection = || {
1200 OrderError::Rejected(ApiError::BalanceInsufficient(
1201 Some(AssetNameExchange::new("ETH_USDT")),
1202 "low".to_string(),
1203 ))
1204 };
1205 let indexed = OrderError::Rejected(ApiError::BalanceInsufficient(None, "low".to_string()));
1206
1207 assert_eq!(
1208 indexer.order_state(OrderState::inactive(rejection())),
1209 OrderState::inactive(indexed.clone())
1210 );
1211
1212 let key = OrderKey {
1213 exchange: ExchangeId::BinanceSpot,
1214 instrument: btc_usdt(),
1215 strategy: crate::order::id::StrategyId::new("test"),
1216 cid: crate::order::id::ClientOrderId::random(),
1217 };
1218 let cancel = indexer
1219 .order_response_cancel(OrderResponseCancel {
1220 key,
1221 state: Err(rejection()),
1222 })
1223 .unwrap();
1224 assert_eq!(cancel.state, Err(indexed));
1225 }
1226
1227 #[test]
1230 fn account_event_indexes_a_cash_flow() {
1231 use crate::cash_flow::{CashFlowId, CashFlowKind};
1232 use rust_decimal_macros::dec;
1233
1234 let indexer = binance_indexer();
1235 let time = DateTime::<Utc>::MIN_UTC;
1236 let kind = CashFlowKind::Funding {
1237 rate: Some(dec!(0.0001)),
1238 position_quantity: Some(dec!(-2)),
1239 };
1240 let flow = |asset: &str, instrument: Option<&str>| {
1241 UnindexedAccountEvent::new(
1242 ExchangeId::BinanceSpot,
1243 AccountEventKind::CashFlow(CashFlow::new(
1244 kind.clone(),
1245 AssetNameExchange::new(asset),
1246 dec!(1.5),
1247 instrument.map(InstrumentNameExchange::new),
1248 time,
1249 Some(CashFlowId::new("cf-1")),
1250 )),
1251 )
1252 };
1253 let index_of = |event| match indexer.account_event(event) {
1254 Ok(AccountEvent {
1255 kind: AccountEventKind::CashFlow(flow),
1256 ..
1257 }) => Ok(flow),
1258 Ok(other) => panic!("expected CashFlow, got {other:?}"),
1259 Err(error) => Err(error),
1260 };
1261 let (Ok(usdt), Ok(btc_usdt)) = (
1262 indexer
1263 .map
1264 .find_asset_index(&AssetNameExchange::new("USDT")),
1265 indexer
1266 .map
1267 .find_instrument_index(&InstrumentNameExchange::new("BTC_USDT")),
1268 ) else {
1269 panic!("USDT and BTC_USDT are mapped");
1270 };
1271
1272 assert_eq!(
1273 index_of(flow("USDT", Some("BTC_USDT"))),
1274 Ok(CashFlow::new(
1275 kind.clone(),
1276 usdt,
1277 dec!(1.5),
1278 Some(btc_usdt),
1279 time,
1280 Some(CashFlowId::new("cf-1")),
1281 ))
1282 );
1283 assert_eq!(
1284 index_of(flow("USDT", None)).map(|flow| flow.instrument),
1285 Ok(None)
1286 );
1287 assert!(
1288 index_of(flow("ETH", None)).is_err(),
1289 "an unmapped asset fails"
1290 );
1291 assert!(
1292 index_of(flow("USDT", Some("ETHUSDT"))).is_err(),
1293 "an unmapped instrument fails"
1294 );
1295 }
1296
1297 #[test]
1300 fn account_event_indexes_a_notice() {
1301 use crate::notice::{AccountNotice, NoticeKind};
1302 use rust_decimal_macros::dec;
1303 use smol_str::SmolStr;
1304
1305 let indexer = binance_indexer();
1306 let time = DateTime::<Utc>::MIN_UTC;
1307 let notice = |instrument: Option<&str>| {
1308 UnindexedAccountEvent::new(
1309 ExchangeId::BinanceSpot,
1310 AccountEventKind::Notice(AccountNotice::new(
1311 NoticeKind::MarginCall,
1312 instrument.map(InstrumentNameExchange::new),
1313 time,
1314 Some(dec!(1.1)),
1315 SmolStr::new("MARGIN_CALL"),
1316 )),
1317 )
1318 };
1319 let index_of = |event| match indexer.account_event(event) {
1320 Ok(AccountEvent {
1321 kind: AccountEventKind::Notice(notice),
1322 ..
1323 }) => notice,
1324 other => panic!("expected Notice, got {other:?}"),
1325 };
1326 let Ok(btc_usdt) = indexer
1327 .map
1328 .find_instrument_index(&InstrumentNameExchange::new("BTC_USDT"))
1329 else {
1330 panic!("BTC_USDT is mapped");
1331 };
1332
1333 assert_eq!(
1334 index_of(notice(Some("BTC_USDT"))),
1335 AccountNotice::new(
1336 NoticeKind::MarginCall,
1337 Some(btc_usdt),
1338 time,
1339 Some(dec!(1.1)),
1340 SmolStr::new("MARGIN_CALL"),
1341 )
1342 );
1343 assert_eq!(index_of(notice(None)).instrument, None);
1344 assert_eq!(
1345 index_of(notice(Some("ETHUSDT"))).instrument,
1346 None,
1347 "an unmapped instrument is dropped from the notice, not the notice itself"
1348 );
1349 }
1350}