1use crate::{
2 AccountEvent, AccountEventKind, AccountSnapshot, InstrumentAccountSnapshot,
3 InstrumentBalanceUpdate, IsolatedInstrumentState, UnindexedAccountEvent,
4 UnindexedAccountSnapshot,
5 balance::{AssetBalance, AssetBalanceUpdate},
6 error::{
7 AccountReinitFailure, AccountStreamInitError, ApiError, ClientError, KeyError, OrderError,
8 UnindexedAccountStreamInitError, UnindexedApiError, UnindexedClientError,
9 UnindexedOrderError,
10 },
11 fill_recovery::{FillRecoveryGap, FillRecoveryScope},
12 map::ExecutionInstrumentMap,
13 order::{
14 Order, OrderEvent, OrderKey, OrderSnapshot, UnindexedOrderKey, UnindexedOrderSnapshot,
15 request::OrderResponseCancel,
16 state::{InactiveOrderState, OrderState, UnindexedOrderState},
17 },
18 trade::{AssetFees, Trade, TradeAmendment, TradeAmendmentKind},
19};
20use derive_more::Constructor;
21use rustrade_instrument::{
22 asset::{AssetIndex, name::AssetNameExchange},
23 exchange::{ExchangeId, ExchangeIndex},
24 index::error::IndexError,
25 instrument::{InstrumentIndex, name::InstrumentNameExchange},
26};
27use rustrade_integration::{
28 collection::snapshot::Snapshot,
29 stream::ext::indexed::{IndexedStream, Indexer},
30};
31use std::sync::Arc;
32use tracing::warn;
33
34pub type IndexedAccountStream<St> = IndexedStream<St, AccountEventIndexer>;
35
36#[derive(Debug, Clone, Constructor)]
37pub struct AccountEventIndexer {
38 pub map: Arc<ExecutionInstrumentMap>,
39}
40
41impl Indexer for AccountEventIndexer {
42 type Unindexed = UnindexedAccountEvent;
43 type Indexed = AccountEvent;
44
45 fn index(&self, item: Self::Unindexed) -> Result<Self::Indexed, IndexError> {
46 self.account_event(item)
47 }
48}
49
50impl AccountEventIndexer {
51 pub fn account_event(&self, event: UnindexedAccountEvent) -> Result<AccountEvent, IndexError> {
52 let UnindexedAccountEvent { exchange, kind } = event;
53
54 let exchange = self.map.find_exchange_index(exchange)?;
55
56 let kind = match kind {
57 AccountEventKind::Snapshot(snapshot) => {
58 AccountEventKind::Snapshot(self.snapshot(snapshot)?)
59 }
60 AccountEventKind::BalanceSnapshot(snapshot) => {
61 AccountEventKind::BalanceSnapshot(self.asset_balance(snapshot.0).map(Snapshot)?)
62 }
63 AccountEventKind::BalanceStreamUpdate(snapshot) => {
64 AccountEventKind::BalanceStreamUpdate(
65 self.asset_balance_update(snapshot.0).map(Snapshot)?,
66 )
67 }
68 AccountEventKind::InstrumentBalanceUpdate(update) => {
69 AccountEventKind::InstrumentBalanceUpdate(self.instrument_balance_update(update)?)
70 }
71 AccountEventKind::OrderSnapshot(snapshot) => {
72 AccountEventKind::OrderSnapshot(self.order_snapshot(snapshot.0).map(Snapshot)?)
73 }
74 AccountEventKind::OrderCancelled(response) => {
75 AccountEventKind::OrderCancelled(self.order_response_cancel(response)?)
76 }
77 AccountEventKind::Trade(trade) => AccountEventKind::Trade(self.trade(trade)?),
78 AccountEventKind::StreamTerminated(reason) => {
80 AccountEventKind::StreamTerminated(reason)
81 }
82 AccountEventKind::FillRecoveryGaveUp(gap) => {
83 AccountEventKind::FillRecoveryGaveUp(self.fill_recovery_gap(gap)?)
84 }
85 AccountEventKind::TradeAmended(amendment) => {
86 AccountEventKind::TradeAmended(self.trade_amendment(amendment)?)
87 }
88 AccountEventKind::ReinitFailed(failure) => {
89 AccountEventKind::ReinitFailed(self.reinit_failure(failure))
90 }
91 };
92
93 Ok(AccountEvent { exchange, kind })
94 }
95
96 pub fn snapshot(
97 &self,
98 snapshot: UnindexedAccountSnapshot,
99 ) -> Result<AccountSnapshot, IndexError> {
100 let UnindexedAccountSnapshot {
101 exchange,
102 balances,
103 instruments,
104 } = snapshot;
105
106 let exchange = self.map.find_exchange_index(exchange)?;
107
108 let balances = balances
109 .into_iter()
110 .map(|balance| self.asset_balance(balance))
111 .collect::<Result<Vec<_>, _>>()?;
112
113 let instruments = instruments
114 .into_iter()
115 .map(|snapshot| {
116 let InstrumentAccountSnapshot {
117 instrument,
118 orders,
119 orders_complete,
120 position,
121 isolated,
122 } = snapshot;
123
124 let instrument = self.map.find_instrument_index(&instrument)?;
125
126 let orders = orders
127 .into_iter()
128 .map(|order| self.order_snapshot(order))
129 .collect::<Result<Vec<_>, _>>()?;
130
131 let isolated = isolated
135 .map(|state| self.isolated_instrument_state(state))
136 .transpose()?;
137
138 Ok(InstrumentAccountSnapshot {
139 instrument,
140 orders,
141 orders_complete,
142 position,
143 isolated,
144 })
145 })
146 .collect::<Result<Vec<_>, _>>()?;
147
148 Ok(AccountSnapshot {
149 exchange,
150 balances,
151 instruments,
152 })
153 }
154
155 pub fn asset_balance(
156 &self,
157 balance: AssetBalance<AssetNameExchange>,
158 ) -> Result<AssetBalance<AssetIndex>, IndexError> {
159 let AssetBalance {
160 asset,
161 balance,
162 time_exchange,
163 } = balance;
164 let asset = self.map.find_asset_index(&asset)?;
165
166 Ok(AssetBalance {
167 asset,
168 balance,
169 time_exchange,
170 })
171 }
172
173 pub fn asset_balance_update(
174 &self,
175 update: AssetBalanceUpdate<AssetNameExchange>,
176 ) -> Result<AssetBalanceUpdate<AssetIndex>, IndexError> {
177 let AssetBalanceUpdate {
178 asset,
179 update,
180 time_exchange,
181 } = update;
182 let asset = self.map.find_asset_index(&asset)?;
183
184 Ok(AssetBalanceUpdate {
185 asset,
186 update,
187 time_exchange,
188 })
189 }
190
191 pub fn isolated_instrument_state(
197 &self,
198 state: IsolatedInstrumentState<AssetNameExchange>,
199 ) -> Result<IsolatedInstrumentState<AssetIndex>, IndexError> {
200 let IsolatedInstrumentState { base, quote, risk } = state;
201
202 Ok(IsolatedInstrumentState {
203 base: self.asset_balance(base)?,
204 quote: self.asset_balance(quote)?,
205 risk,
206 })
207 }
208
209 pub fn fill_recovery_gap(
217 &self,
218 gap: FillRecoveryGap<InstrumentNameExchange>,
219 ) -> Result<FillRecoveryGap<InstrumentIndex>, IndexError> {
220 let FillRecoveryGap {
221 scope,
222 start,
223 end,
224 attempts,
225 reason,
226 } = gap;
227
228 let scope = match scope {
229 FillRecoveryScope::Instruments(instruments) => FillRecoveryScope::Instruments(
230 instruments
231 .iter()
232 .map(|instrument| self.map.find_instrument_index(instrument))
233 .collect::<Result<_, _>>()?,
234 ),
235 FillRecoveryScope::AllInstruments => FillRecoveryScope::AllInstruments,
236 };
237
238 Ok(FillRecoveryGap {
239 scope,
240 start,
241 end,
242 attempts,
243 reason,
244 })
245 }
246
247 pub fn instrument_balance_update(
252 &self,
253 update: InstrumentBalanceUpdate<AssetNameExchange, InstrumentNameExchange>,
254 ) -> Result<InstrumentBalanceUpdate, IndexError> {
255 let InstrumentBalanceUpdate {
256 instrument,
257 base,
258 quote,
259 } = update;
260
261 Ok(InstrumentBalanceUpdate {
262 instrument: self.map.find_instrument_index(&instrument)?,
263 base: self.asset_balance_update(base)?,
264 quote: self.asset_balance_update(quote)?,
265 })
266 }
267
268 pub fn order_snapshot(
269 &self,
270 order: UnindexedOrderSnapshot,
271 ) -> Result<OrderSnapshot, IndexError> {
272 let Order {
273 key,
274 side,
275 price,
276 quantity,
277 kind,
278 time_in_force,
279 state,
280 } = order;
281
282 let key = self.order_key(key)?;
283 let state = self.order_state(state);
284
285 Ok(Order {
286 key,
287 side,
288 price,
289 quantity,
290 kind,
291 time_in_force,
292 state,
293 })
294 }
295
296 pub fn order_response_cancel(
297 &self,
298 response: OrderResponseCancel<ExchangeId, AssetNameExchange, InstrumentNameExchange>,
299 ) -> Result<OrderResponseCancel, IndexError> {
300 let OrderResponseCancel { key, state } = response;
301
302 Ok(OrderResponseCancel {
303 key: self.order_key(key)?,
304 state: match state {
305 Ok(cancelled) => Ok(cancelled),
306 Err(error) => Err(self.order_error(error)),
307 },
308 })
309 }
310
311 pub fn order_key(&self, key: UnindexedOrderKey) -> Result<OrderKey, IndexError> {
312 let UnindexedOrderKey {
313 exchange,
314 instrument,
315 strategy,
316 cid,
317 } = key;
318
319 Ok(OrderKey {
320 exchange: self.map.find_exchange_index(exchange)?,
321 instrument: self.map.find_instrument_index(&instrument)?,
322 strategy,
323 cid,
324 })
325 }
326
327 pub fn order_state(&self, state: UnindexedOrderState) -> OrderState {
332 match state {
333 UnindexedOrderState::Active(active) => OrderState::Active(active),
334 UnindexedOrderState::Inactive(inactive) => match inactive {
335 InactiveOrderState::OpenFailed(failed) => {
336 OrderState::inactive(self.order_error(failed))
337 }
338 InactiveOrderState::Cancelled(cancelled) => OrderState::inactive(cancelled),
339 InactiveOrderState::FullyFilled(filled) => OrderState::fully_filled(filled),
340 InactiveOrderState::Expired(expired) => OrderState::expired(expired),
341 },
342 }
343 }
344
345 pub fn api_error(&self, error: UnindexedApiError) -> ApiError {
359 self.index_api_error(error, ApiError::OrderRejected)
360 }
361
362 fn index_api_error(
367 &self,
368 error: UnindexedApiError,
369 rejected: fn(String) -> ApiError,
370 ) -> ApiError {
371 match error {
372 UnindexedApiError::RateLimit => ApiError::RateLimit,
373 UnindexedApiError::Unauthenticated(msg) => ApiError::Unauthenticated(msg),
374 UnindexedApiError::AssetInvalid(asset, value) => {
375 match self.map.find_asset_index(&asset) {
376 Ok(asset) => ApiError::AssetInvalid(asset, value),
377 Err(_) => self.degrade_unresolvable(
378 UnindexedApiError::AssetInvalid(asset, value),
379 rejected,
380 ),
381 }
382 }
383 UnindexedApiError::InstrumentInvalid(instrument, value) => {
384 match self.map.find_instrument_index(&instrument) {
385 Ok(instrument) => ApiError::InstrumentInvalid(instrument, value),
386 Err(_) => self.degrade_unresolvable(
387 UnindexedApiError::InstrumentInvalid(instrument, value),
388 rejected,
389 ),
390 }
391 }
392 UnindexedApiError::BalanceInsufficient(asset, value) => {
393 let asset = asset.and_then(|asset| {
394 self.map
395 .find_asset_index(&asset)
396 .inspect_err(|_| {
397 warn!(
398 exchange = %self.map.exchange.value,
399 %asset,
400 message = %value,
401 "AccountEventIndexer dropping the asset of a BalanceInsufficient \
402 error: the instrument map does not hold it"
403 )
404 })
405 .ok()
406 });
407 ApiError::BalanceInsufficient(asset, value)
408 }
409 UnindexedApiError::OrderRejected(reason) => ApiError::OrderRejected(reason),
410 UnindexedApiError::OrderAlreadyCancelled => ApiError::OrderAlreadyCancelled,
411 UnindexedApiError::OrderAlreadyFullyFilled => ApiError::OrderAlreadyFullyFilled,
412 UnindexedApiError::OrderAlreadyExpired => ApiError::OrderAlreadyExpired,
413 UnindexedApiError::DuplicateClientOrderId(message) => {
414 ApiError::DuplicateClientOrderId(message)
415 }
416 UnindexedApiError::RequestRejected(reason) => ApiError::RequestRejected(reason),
417 }
418 }
419
420 fn degrade_unresolvable(
424 &self,
425 error: UnindexedApiError,
426 rejected: fn(String) -> ApiError,
427 ) -> ApiError {
428 let error = error.to_string();
429 warn!(
430 exchange = %self.map.exchange.value,
431 %error,
432 "AccountEventIndexer degrading an API error to a rejection: the instrument map does \
433 not hold the asset or instrument it names"
434 );
435 rejected(error)
436 }
437
438 pub fn order_request<Kind>(
439 &self,
440 order: &OrderEvent<Kind, ExchangeIndex, InstrumentIndex>,
441 ) -> Result<OrderEvent<Kind, ExchangeId, &InstrumentNameExchange>, KeyError>
442 where
443 Kind: Clone,
444 {
445 let OrderEvent {
446 key:
447 OrderKey {
448 exchange,
449 instrument,
450 strategy,
451 cid,
452 },
453 state,
454 } = order;
455
456 let exchange = self.map.find_exchange_id(*exchange)?;
457 let instrument = self.map.find_instrument_name_exchange(*instrument)?;
458
459 Ok(OrderEvent {
460 key: OrderKey {
461 exchange,
462 instrument,
463 strategy: strategy.clone(),
464 cid: cid.clone(),
465 },
466 state: state.clone(),
467 })
468 }
469
470 pub fn order_error(&self, error: UnindexedOrderError) -> OrderError {
472 match error {
473 UnindexedOrderError::Connectivity(error) => OrderError::Connectivity(error),
474 UnindexedOrderError::Rejected(error) => OrderError::Rejected(self.api_error(error)),
475 UnindexedOrderError::UnsupportedOrderType(msg) => OrderError::UnsupportedOrderType(msg),
476 UnindexedOrderError::InvalidPrecision(violation) => {
477 OrderError::InvalidPrecision(violation)
478 }
479 }
480 }
481
482 pub fn client_error(&self, error: UnindexedClientError) -> ClientError {
486 match error {
487 UnindexedClientError::Connectivity(error) => ClientError::Connectivity(error),
488 UnindexedClientError::Api(error) => {
489 ClientError::Api(self.index_api_error(error, ApiError::RequestRejected))
490 }
491 UnindexedClientError::TaskFailed(value) => ClientError::TaskFailed(value),
492 UnindexedClientError::Internal(value) => ClientError::Internal(value),
493 UnindexedClientError::Truncated { fills_read } => ClientError::Truncated { fills_read },
494 UnindexedClientError::TruncatedSnapshot { limit } => {
495 ClientError::TruncatedSnapshot { limit }
496 }
497 }
498 }
499
500 pub fn account_stream_init_error(
502 &self,
503 error: UnindexedAccountStreamInitError,
504 ) -> AccountStreamInitError {
505 match error {
506 AccountStreamInitError::Client(error) => {
507 AccountStreamInitError::Client(self.client_error(error))
508 }
509 AccountStreamInitError::Index(error) => AccountStreamInitError::Index(error),
510 }
511 }
512
513 pub fn reinit_failure(
515 &self,
516 failure: AccountReinitFailure<AssetNameExchange, InstrumentNameExchange>,
517 ) -> AccountReinitFailure {
518 let AccountReinitFailure { attempt, error } = failure;
519 AccountReinitFailure {
520 attempt,
521 error: self.account_stream_init_error(error),
522 }
523 }
524
525 pub fn trade_amendment(
528 &self,
529 amendment: TradeAmendment<AssetNameExchange, InstrumentNameExchange>,
530 ) -> Result<TradeAmendment<AssetIndex, InstrumentIndex>, IndexError> {
531 let TradeAmendment {
532 instrument,
533 order_id,
534 time_exchange,
535 original,
536 kind,
537 } = amendment;
538
539 let instrument = self.map.find_instrument_index(&instrument)?;
540 let kind = match kind {
541 TradeAmendmentKind::Busted { quantity } => TradeAmendmentKind::Busted { quantity },
542 TradeAmendmentKind::Corrected { replacement } => TradeAmendmentKind::Corrected {
543 replacement: self.trade(replacement)?,
544 },
545 TradeAmendmentKind::CorrectedUnresolved {
546 id,
547 price,
548 quantity,
549 } => TradeAmendmentKind::CorrectedUnresolved {
550 id,
551 price,
552 quantity,
553 },
554 };
555
556 Ok(TradeAmendment {
557 instrument,
558 order_id,
559 time_exchange,
560 original,
561 kind,
562 })
563 }
564
565 pub fn trade(
578 &self,
579 trade: Trade<AssetNameExchange, InstrumentNameExchange>,
580 ) -> Result<Trade<AssetIndex, InstrumentIndex>, IndexError> {
581 let Trade {
582 id,
583 order_id,
584 instrument,
585 strategy,
586 time_exchange,
587 side,
588 price: trade_price,
589 quantity,
590 order_filled_quantity,
591 fees,
592 } = trade;
593
594 let instrument_index = self.map.find_instrument_index(&instrument)?;
595 let fee_asset_index = self.map.find_asset_index(&fees.asset)?;
596
597 let fees_quote = self
599 .map
600 .instruments
601 .get_index(instrument_index.index())
602 .and_then(|instr| {
603 if fee_asset_index == instr.underlying.quote {
604 Some(fees.fees)
606 } else if fee_asset_index == instr.underlying.base {
607 Some(fees.fees * trade_price)
609 } else {
610 None
612 }
613 });
614
615 Ok(Trade {
616 id,
617 order_id,
618 instrument: instrument_index,
619 strategy,
620 time_exchange,
621 side,
622 price: trade_price,
623 quantity,
624 order_filled_quantity,
625 fees: AssetFees {
626 asset: fee_asset_index,
627 fees: fees.fees,
628 fees_quote,
629 },
630 })
631 }
632}
633
634#[cfg(test)]
635#[allow(clippy::unwrap_used)] mod tests {
637 use super::*;
638 use crate::{
639 error::StreamTerminationReason, fill_recovery::FillRecoveryFailure,
640 map::generate_execution_instrument_map,
641 };
642 use chrono::{DateTime, Utc};
643 use rustrade_instrument::{index::IndexedInstruments, test_utils};
644
645 fn binance_indexer() -> AccountEventIndexer {
646 let instruments = IndexedInstruments::new(vec![test_utils::instrument(
647 ExchangeId::BinanceSpot,
648 "BTC",
649 "USDT",
650 )]);
651 let map = generate_execution_instrument_map(&instruments, ExchangeId::BinanceSpot).unwrap();
652 AccountEventIndexer::new(Arc::new(map))
653 }
654
655 #[test]
658 fn account_event_passes_stream_terminated_through_verbatim() {
659 let indexer = binance_indexer();
660 let reason = StreamTerminationReason::ReconnectBudgetExhausted {
661 attempts: 3,
662 last_error: "socket reset".to_string(),
663 };
664
665 let indexed = indexer
666 .account_event(UnindexedAccountEvent::new(
667 ExchangeId::BinanceSpot,
668 AccountEventKind::StreamTerminated(reason.clone()),
669 ))
670 .unwrap();
671
672 assert_eq!(indexed.exchange, indexer.map.exchange.key);
673 assert!(
674 matches!(indexed.kind, AccountEventKind::StreamTerminated(r) if r == reason),
675 "expected StreamTerminated to pass through unchanged",
676 );
677 }
678
679 #[test]
682 fn account_event_indexes_a_reinit_failure() {
683 let indexer = binance_indexer();
684 let btc = indexer
685 .map
686 .find_asset_index(&AssetNameExchange::new("BTC"))
687 .unwrap();
688 let unindexed_client = AccountStreamInitError::Client(ClientError::Api(
689 ApiError::BalanceInsufficient(Some(AssetNameExchange::new("BTC")), "low".to_owned()),
690 ));
691 let indexed_client = AccountStreamInitError::Client(ClientError::Api(
692 ApiError::BalanceInsufficient(Some(btc), "low".to_owned()),
693 ));
694 let index_error = IndexError::AssetIndex("ETH".to_owned());
695
696 for (error, expected) in [
697 (unindexed_client, indexed_client),
698 (
699 AccountStreamInitError::Index(index_error.clone()),
700 AccountStreamInitError::Index(index_error),
701 ),
702 ] {
703 let indexed = indexer
704 .account_event(UnindexedAccountEvent::new(
705 ExchangeId::BinanceSpot,
706 AccountEventKind::ReinitFailed(AccountReinitFailure::new(2, error)),
707 ))
708 .unwrap();
709
710 assert_eq!(indexed.exchange, indexer.map.exchange.key);
711 assert_eq!(
712 indexed.kind,
713 AccountEventKind::ReinitFailed(AccountReinitFailure::new(2, expected))
714 );
715 }
716 }
717
718 #[test]
721 fn account_event_indexes_a_fill_recovery_give_up_by_its_instruments() {
722 let indexer = binance_indexer();
723 let start = DateTime::<Utc>::MIN_UTC;
724 let end = DateTime::<Utc>::MAX_UTC;
725 let give_up = |scope| {
726 UnindexedAccountEvent::new(
727 ExchangeId::BinanceSpot,
728 AccountEventKind::FillRecoveryGaveUp(FillRecoveryGap::new(
729 scope,
730 start,
731 end,
732 6,
733 FillRecoveryFailure::TimedOut { timeout_secs: 30 },
734 )),
735 )
736 };
737 let index_of = |scope| match indexer.account_event(give_up(scope)) {
738 Ok(AccountEvent {
739 kind: AccountEventKind::FillRecoveryGaveUp(gap),
740 ..
741 }) => Ok(gap),
742 Ok(other) => panic!("expected FillRecoveryGaveUp, got {other:?}"),
743 Err(error) => Err(error),
744 };
745 let Ok(btc) = indexer
746 .map
747 .find_instrument_index(&InstrumentNameExchange::new("BTC_USDT"))
748 else {
749 panic!("BTC_USDT is mapped");
750 };
751
752 assert_eq!(
753 index_of(FillRecoveryScope::Instruments(vec![
754 InstrumentNameExchange::new("BTC_USDT")
755 ])),
756 Ok(FillRecoveryGap::new(
757 FillRecoveryScope::Instruments(vec![btc]),
758 start,
759 end,
760 6,
761 FillRecoveryFailure::TimedOut { timeout_secs: 30 },
762 ))
763 );
764 assert_eq!(
765 index_of(FillRecoveryScope::AllInstruments).map(|gap| gap.scope),
766 Ok(FillRecoveryScope::AllInstruments)
767 );
768 assert!(
769 index_of(FillRecoveryScope::Instruments(vec![
770 InstrumentNameExchange::new("BTC_USDT"),
771 InstrumentNameExchange::new("ETHUSDT"),
772 ]))
773 .is_err(),
774 "an unmapped instrument fails the whole give-up"
775 );
776 }
777
778 #[test]
781 fn account_event_indexes_a_trade_amendment() {
782 use crate::{
783 order::id::{OrderId, StrategyId},
784 trade::TradeId,
785 };
786 use rust_decimal::Decimal;
787 use rustrade_instrument::Side;
788
789 let indexer = binance_indexer();
790 let time = DateTime::<Utc>::MIN_UTC;
791 let amended = |instrument: &str, kind| {
792 UnindexedAccountEvent::new(
793 ExchangeId::BinanceSpot,
794 AccountEventKind::TradeAmended(TradeAmendment::new(
795 InstrumentNameExchange::new(instrument),
796 OrderId::new("ord-1"),
797 time,
798 Some(TradeId::new("t-1")),
799 kind,
800 )),
801 )
802 };
803 let index_of = |event| match indexer.account_event(event) {
804 Ok(AccountEvent {
805 kind: AccountEventKind::TradeAmended(amendment),
806 ..
807 }) => Ok(amendment),
808 Ok(other) => panic!("expected TradeAmended, got {other:?}"),
809 Err(error) => Err(error),
810 };
811 let Ok(btc) = indexer
812 .map
813 .find_instrument_index(&InstrumentNameExchange::new("BTC_USDT"))
814 else {
815 panic!("BTC_USDT is mapped");
816 };
817
818 assert_eq!(
819 index_of(amended(
820 "BTC_USDT",
821 TradeAmendmentKind::Busted {
822 quantity: Some(Decimal::ONE)
823 }
824 )),
825 Ok(TradeAmendment::new(
826 btc,
827 OrderId::new("ord-1"),
828 time,
829 Some(TradeId::new("t-1")),
830 TradeAmendmentKind::Busted {
831 quantity: Some(Decimal::ONE)
832 },
833 ))
834 );
835
836 let replacement = Trade::new(
837 TradeId::new("t-2"),
838 OrderId::new("ord-1"),
839 InstrumentNameExchange::new("BTC_USDT"),
840 StrategyId::unknown(),
841 time,
842 Side::Buy,
843 Decimal::TEN,
844 Decimal::ONE,
845 None,
846 AssetFees::new(AssetNameExchange::new("USDT"), Decimal::ONE, None),
847 );
848 let Ok(TradeAmendment {
849 kind: TradeAmendmentKind::Corrected { replacement },
850 ..
851 }) = index_of(amended(
852 "BTC_USDT",
853 TradeAmendmentKind::Corrected { replacement },
854 ))
855 else {
856 panic!("a correction indexes to a correction");
857 };
858 assert_eq!(replacement.instrument, btc);
859 assert_eq!(
860 replacement.fees.fees_quote,
861 Some(Decimal::ONE),
862 "indexed as a trade is, fees and all"
863 );
864
865 assert!(
866 index_of(amended(
867 "ETHUSDT",
868 TradeAmendmentKind::Busted { quantity: None }
869 ))
870 .is_err(),
871 "an unmapped instrument fails the amendment"
872 );
873 }
874
875 fn count_warnings<T>(index: impl FnOnce() -> T) -> (T, usize) {
879 use std::sync::atomic::{AtomicUsize, Ordering};
880 use tracing_subscriber::layer::SubscriberExt;
881
882 #[derive(Default)]
883 struct CountWarnings(Arc<AtomicUsize>);
884
885 impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for CountWarnings {
886 fn on_event(
887 &self,
888 event: &tracing::Event<'_>,
889 _: tracing_subscriber::layer::Context<'_, S>,
890 ) {
891 if *event.metadata().level() == tracing::Level::WARN {
892 self.0.fetch_add(1, Ordering::Relaxed);
893 }
894 }
895 }
896
897 let layer = CountWarnings::default();
898 let count = Arc::clone(&layer.0);
899 let value =
901 tracing::subscriber::with_default(tracing_subscriber::registry().with(layer), index);
902 (value, count.load(Ordering::Relaxed))
903 }
904
905 fn btc_usdt() -> InstrumentNameExchange {
906 binance_indexer()
907 .map
908 .exchange_instruments()
909 .next()
910 .cloned()
911 .unwrap_or_else(|| panic!("the map holds one instrument"))
912 }
913
914 #[test]
915 fn balance_insufficient_keeps_a_held_asset_and_drops_an_unheld_one() {
916 let indexer = binance_indexer();
917 let btc = indexer
918 .map
919 .find_asset_index(&AssetNameExchange::new("BTC"))
920 .unwrap();
921
922 for (asset, expected, warnings) in [
923 (Some("BTC"), Some(btc), 0),
924 (Some("ETH_USDT"), None, 1),
926 (None, None, 0),
927 ] {
928 let (indexed, warned) = count_warnings(|| {
929 indexer.api_error(ApiError::BalanceInsufficient(
930 asset.map(AssetNameExchange::new),
931 "low".to_string(),
932 ))
933 });
934 assert_eq!(
935 indexed,
936 ApiError::BalanceInsufficient(expected, "low".to_string()),
937 "{asset:?}"
938 );
939 assert_eq!(warned, warnings, "{asset:?}: a dropped asset is logged");
940 }
941 }
942
943 #[test]
947 fn an_unresolvable_invalid_name_degrades_to_a_rejection_keeping_its_text() {
948 let indexer = binance_indexer();
949 for (error, text) in [
950 (
951 ApiError::AssetInvalid(AssetNameExchange::new("XYZ"), "bad".to_string()),
952 "asset XYZ invalid: bad",
953 ),
954 (
955 ApiError::InstrumentInvalid(
956 InstrumentNameExchange::new("XYZUSDT"),
957 "bad".to_string(),
958 ),
959 "instrument XYZUSDT invalid: bad",
960 ),
961 ] {
962 let (indexed, warned) = count_warnings(|| indexer.api_error(error.clone()));
963 assert_eq!(indexed, ApiError::OrderRejected(text.to_string()));
964 assert_eq!(warned, 1, "{text}: a degrade is logged");
965
966 let (indexed, warned) =
967 count_warnings(|| indexer.client_error(ClientError::Api(error)));
968 assert_eq!(
969 indexed,
970 ClientError::Api(ApiError::RequestRejected(text.to_string()))
971 );
972 assert_eq!(warned, 1, "{text}: a degrade is logged");
973 }
974
975 let (indexed, warned) = count_warnings(|| {
977 indexer.api_error(ApiError::InstrumentInvalid(btc_usdt(), "bad".to_string()))
978 });
979 assert!(matches!(indexed, ApiError::InstrumentInvalid(_, _)));
980 assert_eq!(warned, 0);
981 }
982
983 #[test]
986 fn a_response_whose_rejection_names_an_unresolvable_asset_still_indexes() {
987 let indexer = binance_indexer();
988 let rejection = || {
989 OrderError::Rejected(ApiError::BalanceInsufficient(
990 Some(AssetNameExchange::new("ETH_USDT")),
991 "low".to_string(),
992 ))
993 };
994 let indexed = OrderError::Rejected(ApiError::BalanceInsufficient(None, "low".to_string()));
995
996 assert_eq!(
997 indexer.order_state(OrderState::inactive(rejection())),
998 OrderState::inactive(indexed.clone())
999 );
1000
1001 let key = OrderKey {
1002 exchange: ExchangeId::BinanceSpot,
1003 instrument: btc_usdt(),
1004 strategy: crate::order::id::StrategyId::new("test"),
1005 cid: crate::order::id::ClientOrderId::random(),
1006 };
1007 let cancel = indexer
1008 .order_response_cancel(OrderResponseCancel {
1009 key,
1010 state: Err(rejection()),
1011 })
1012 .unwrap();
1013 assert_eq!(cancel.state, Err(indexed));
1014 }
1015}