1use crate::{
2 AccountEventKind, InstrumentAccountSnapshot, UnindexedAccountEvent, UnindexedAccountSnapshot,
3 balance::AssetBalance,
4 client::mock::MockExecutionConfig,
5 error::{ApiError, UnindexedApiError, UnindexedOrderError},
6 exchange::mock::{
7 account::AccountState,
8 request::{MarketPrices, MockExchangeRequest, MockExchangeRequestKind},
9 },
10 fee::{FeeModel, FeeModelConfig},
11 fill::{FillModel, SimFillConfig},
12 order::{
13 Order, OrderKey, OrderKind, UnindexedOrder,
14 id::OrderId,
15 request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
16 state::{Cancelled, Filled, OrderState, UnindexedOrderState},
17 },
18 trade::{AssetFees, Trade, TradeId},
19};
20use chrono::{DateTime, TimeDelta, Utc};
21use fnv::FnvHashMap;
22use futures::stream::BoxStream;
23use itertools::Itertools;
24use rust_decimal::Decimal;
25use rustrade_instrument::{
26 Side,
27 asset::name::AssetNameExchange,
28 exchange::ExchangeId,
29 instrument::{Instrument, name::InstrumentNameExchange},
30};
31use rustrade_integration::collection::snapshot::Snapshot;
32use smol_str::ToSmolStr;
33use std::fmt::Debug;
34use tokio::sync::{broadcast, mpsc, oneshot};
35use tokio_stream::{StreamExt, wrappers::BroadcastStream};
36use tracing::{error, info};
37
38pub mod account;
39pub mod request;
40
41#[derive(Debug)]
42pub struct MockExchange {
43 pub exchange: ExchangeId,
44 pub latency_ms: u64,
45 pub fee_model: FeeModelConfig,
46 pub fill_model: SimFillConfig,
47 pub request_rx: mpsc::UnboundedReceiver<MockExchangeRequest>,
48 pub event_tx: broadcast::Sender<UnindexedAccountEvent>,
49 pub instruments: FnvHashMap<InstrumentNameExchange, Instrument<ExchangeId, AssetNameExchange>>,
50 pub account: AccountState,
51 pub order_sequence: u64,
52 pub time_exchange_latest: DateTime<Utc>,
53}
54
55impl MockExchange {
56 pub fn new(
57 config: MockExecutionConfig,
58 request_rx: mpsc::UnboundedReceiver<MockExchangeRequest>,
59 event_tx: broadcast::Sender<UnindexedAccountEvent>,
60 instruments: FnvHashMap<InstrumentNameExchange, Instrument<ExchangeId, AssetNameExchange>>,
61 ) -> Self {
62 Self {
63 exchange: config.mocked_exchange,
64 latency_ms: config.latency_ms,
65 fee_model: config.fee_model,
66 fill_model: config.fill_model,
67 request_rx,
68 event_tx,
69 instruments,
70 account: AccountState::from(config.initial_state),
71 order_sequence: 0,
72 time_exchange_latest: Default::default(),
73 }
74 }
75
76 pub async fn run(mut self) {
77 while let Some(request) = self.request_rx.recv().await {
78 self.update_time_exchange(request.time_request);
79
80 match request.kind {
81 MockExchangeRequestKind::FetchAccountSnapshot { response_tx } => {
82 let snapshot = self.account_snapshot();
83 self.respond_with_latency(response_tx, snapshot);
84 }
85 MockExchangeRequestKind::FetchBalances {
86 response_tx,
87 assets,
88 } => {
89 let balances = self
91 .account
92 .balances()
93 .filter(|balance| assets.is_empty() || assets.contains(&balance.asset))
94 .cloned()
95 .collect();
96 self.respond_with_latency(response_tx, balances);
97 }
98 MockExchangeRequestKind::FetchOrdersOpen {
99 response_tx,
100 instruments,
101 } => {
102 let orders_open = self
104 .account
105 .orders_open()
106 .filter(|order| {
107 instruments.is_empty() || instruments.contains(&order.key.instrument)
108 })
109 .cloned()
110 .collect();
111 self.respond_with_latency(response_tx, orders_open);
112 }
113 MockExchangeRequestKind::FetchTrades {
114 response_tx,
115 time_since,
116 } => {
117 let trades = self.account.trades(time_since).cloned().collect();
118 self.respond_with_latency(response_tx, trades);
119 }
120 MockExchangeRequestKind::CancelOrder {
121 response_tx,
122 request,
123 } => {
124 error!(
128 exchange = %self.exchange,
129 ?request,
130 "MockExchange received cancel request but only Market orders are supported"
131 );
132 let key = OrderKey {
133 exchange: request.key.exchange,
134 instrument: request.key.instrument,
135 strategy: request.key.strategy,
136 cid: request.key.cid,
137 };
138 let _ = response_tx.send(UnindexedOrderResponseCancel {
139 key,
140 state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
141 "MockExchange does not support CancelOrder (only Market orders which fill immediately)".into(),
142 ))),
143 });
144 }
145 MockExchangeRequestKind::OpenOrder {
146 response_tx,
147 request,
148 market_prices,
149 } => {
150 let (response, notifications) = self.open_order(request, market_prices);
151 self.respond_with_latency(response_tx, response);
152
153 if let Some(notifications) = notifications {
154 self.account.ack_trade(notifications.trade.clone());
155 self.send_notifications_with_latency(notifications);
156 }
157 }
158 }
159 }
160
161 info!(exchange = %self.exchange, "MockExchange shutting down");
162 }
163
164 fn update_time_exchange(&mut self, time_request: DateTime<Utc>) {
165 let client_to_exchange_latency = self.latency_ms / 2;
166
167 self.time_exchange_latest = time_request
168 .checked_add_signed(TimeDelta::milliseconds(client_to_exchange_latency as i64))
169 .unwrap_or(time_request);
170
171 self.account.update_time_exchange(self.time_exchange_latest)
172 }
173
174 pub fn time_exchange(&self) -> DateTime<Utc> {
175 self.time_exchange_latest
176 }
177
178 pub fn account_snapshot(&self) -> UnindexedAccountSnapshot {
179 let balances = self.account.balances().cloned().collect();
180
181 let orders_open = self
182 .account
183 .orders_open()
184 .cloned()
185 .map(UnindexedOrder::from);
186
187 let orders_cancelled = self
188 .account
189 .orders_cancelled()
190 .cloned()
191 .map(UnindexedOrder::from);
192
193 let orders_all = orders_open.chain(orders_cancelled);
194 let orders_all = orders_all.sorted_unstable_by_key(|order| order.key.instrument.clone());
195 let orders_by_instrument = orders_all.chunk_by(|order| order.key.instrument.clone());
196
197 let instruments = orders_by_instrument
198 .into_iter()
199 .map(|(instrument, orders)| InstrumentAccountSnapshot {
200 instrument,
201 orders: orders.into_iter().collect(),
202 position: None,
203 isolated: None,
204 })
205 .collect();
206
207 UnindexedAccountSnapshot {
208 exchange: self.exchange,
209 balances,
210 instruments,
211 }
212 }
213
214 fn respond_with_latency<Response>(
219 &self,
220 response_tx: oneshot::Sender<Response>,
221 response: Response,
222 ) where
223 Response: Send + 'static,
224 {
225 let exchange = self.exchange;
226 let latency = std::time::Duration::from_millis(self.latency_ms);
227
228 tokio::spawn(async move {
229 tokio::time::sleep(latency).await;
230 if response_tx.send(response).is_err() {
231 error!(
232 %exchange,
233 kind = std::any::type_name::<Response>(),
234 "MockExchange failed to send oneshot response to client"
235 );
236 }
237 });
238 }
239
240 fn send_notifications_with_latency(&self, notifications: OpenOrderNotifications) {
246 let balance = self.build_account_event(notifications.balance);
247 let trade = self.build_account_event(notifications.trade);
248
249 let exchange = self.exchange;
250 let latency = std::time::Duration::from_millis(self.latency_ms);
251 let tx = self.event_tx.clone();
252 tokio::spawn(async move {
253 tokio::time::sleep(latency).await;
254
255 if tx.send(balance).is_err() {
256 error!(
257 %exchange,
258 kind = "Snapshot<AssetBalance<AssetNameExchange>",
259 "MockExchange failed to send AccountEvent notification to client"
260 );
261 }
262
263 if tx.send(trade).is_err() {
264 error!(
265 %exchange,
266 kind = "Trade<AssetNameExchange, InstrumentNameExchange>",
267 "MockExchange failed to send AccountEvent notification to client"
268 );
269 }
270 });
271 }
272
273 pub fn account_stream(&self) -> BoxStream<'static, UnindexedAccountEvent> {
274 futures::StreamExt::boxed(BroadcastStream::new(self.event_tx.subscribe()).map_while(
275 |result| match result {
276 Ok(event) => Some(event),
277 Err(error) => {
278 error!(
279 ?error,
280 "MockExchange Broadcast AccountStream lagged - terminating"
281 );
282 None
283 }
284 },
285 ))
286 }
287
288 pub fn cancel_order(
289 &mut self,
290 _: OrderRequestCancel<ExchangeId, InstrumentNameExchange>,
291 ) -> Order<ExchangeId, InstrumentNameExchange, Result<Cancelled, UnindexedOrderError>> {
292 unimplemented!()
293 }
294
295 #[allow(clippy::expect_used)] pub fn open_order(
297 &mut self,
298 request: OrderRequestOpen<ExchangeId, InstrumentNameExchange>,
299 market_prices: MarketPrices,
300 ) -> (
301 Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>,
302 Option<OpenOrderNotifications>,
303 ) {
304 if let Err(error) = self.validate_order_kind_supported(request.state.kind) {
305 return (build_open_order_err_response(request, error), None);
306 }
307
308 let underlying = match self.find_instrument_data(&request.key.instrument) {
309 Ok(instrument) => instrument.underlying.clone(),
310 Err(error) => return (build_open_order_err_response(request, error), None),
311 };
312
313 let fill_price = self
325 .fill_model
326 .fill_price(
327 request.state.side,
328 match request.state.kind {
329 OrderKind::Market => None,
334 OrderKind::Limit
335 | OrderKind::StopLimit { .. }
336 | OrderKind::TakeProfitLimit { .. }
337 | OrderKind::TrailingStopLimit { .. } => request.state.price,
338 OrderKind::Stop { trigger_price }
339 | OrderKind::TakeProfit { trigger_price }
340 | OrderKind::TrailingStop {
341 offset: trigger_price,
342 ..
343 } => Some(trigger_price),
344 },
345 market_prices.best_bid,
346 market_prices.best_ask,
347 market_prices.last_price.or(request.state.price),
348 )
349 .or(request.state.price)
350 .expect("fill_price must be available from market data or request price");
351
352 let time_exchange = self.time_exchange();
353
354 let order_fees_quote =
356 self.fee_model
357 .compute_fee(fill_price, request.state.quantity, Decimal::ONE);
358
359 let balance_change_result = match request.state.side {
360 Side::Buy => {
361 #[allow(clippy::expect_used)]
363 let current = self
365 .account
366 .balance_mut(&underlying.quote)
367 .expect("MockExchange has Balance for all configured Instrument assets");
368
369 assert_eq!(current.balance.total, current.balance.free);
371
372 let order_value_quote = fill_price * request.state.quantity.abs();
373 let quote_required = order_value_quote + order_fees_quote;
374
375 let maybe_new_balance = current.balance.free - quote_required;
376
377 if maybe_new_balance >= Decimal::ZERO {
378 current.balance.free = maybe_new_balance;
379 current.balance.total = maybe_new_balance;
380 current.time_exchange = time_exchange;
381
382 Ok((
383 current.clone(),
384 AssetFees::new(
385 underlying.quote.clone(),
386 order_fees_quote,
387 Some(order_fees_quote),
388 ),
389 ))
390 } else {
391 Err(ApiError::BalanceInsufficient(
392 underlying.quote.clone(),
393 format!(
394 "Available Balance: {}, Required Balance inc. fees: {}",
395 current.balance.free, quote_required
396 ),
397 ))
398 }
399 }
400 Side::Sell => {
401 #[allow(clippy::expect_used)]
403 let current = self
405 .account
406 .balance_mut(&underlying.base)
407 .expect("MockExchange has Balance for all configured Instrument assets");
408
409 assert_eq!(current.balance.total, current.balance.free);
411
412 let order_value_base = request.state.quantity.abs();
413 debug_assert!(
417 !matches!(self.fee_model, FeeModelConfig::PerContract(_)),
418 "PerContractFeeModel produces nonsensical base-denominated fees on sell path"
419 );
420 let order_fees_base = if fill_price.is_zero() {
421 Decimal::ZERO
422 } else {
423 order_fees_quote / fill_price
424 };
425 let base_required = order_value_base + order_fees_base;
426
427 let maybe_new_balance = current.balance.free - base_required;
428
429 if maybe_new_balance >= Decimal::ZERO {
430 current.balance.free = maybe_new_balance;
431 current.balance.total = maybe_new_balance;
432 current.time_exchange = time_exchange;
433
434 Ok((
435 current.clone(),
436 AssetFees::new(
437 underlying.quote.clone(),
438 order_fees_quote,
439 Some(order_fees_quote),
440 ),
441 ))
442 } else {
443 Err(ApiError::BalanceInsufficient(
444 underlying.base,
445 format!(
446 "Available Balance: {}, Required Balance inc. fees: {}",
447 current.balance.free, base_required
448 ),
449 ))
450 }
451 }
452 };
453
454 let (balance_snapshot, fees) = match balance_change_result {
455 Ok((balance_snapshot, fees)) => (Snapshot(balance_snapshot), fees),
456 Err(error) => return (build_open_order_err_response(request, error), None),
457 };
458
459 let order_id = self.order_id_sequence_fetch_add();
460 let trade_id = TradeId(order_id.0.clone());
461
462 let order_response = Order {
463 key: request.key.clone(),
464 side: request.state.side,
465 price: request.state.price,
466 quantity: request.state.quantity,
467 kind: request.state.kind,
468 time_in_force: request.state.time_in_force,
469 state: OrderState::fully_filled(Filled::new(
470 order_id.clone(),
471 self.time_exchange(),
472 request.state.quantity,
473 Some(fill_price),
474 )),
475 };
476
477 let notifications = OpenOrderNotifications {
478 balance: balance_snapshot,
479 trade: Trade {
480 id: trade_id,
481 order_id: order_id.clone(),
482 instrument: request.key.instrument,
483 strategy: request.key.strategy,
484 time_exchange: self.time_exchange(),
485 side: request.state.side,
486 price: fill_price,
487 quantity: request.state.quantity,
488 fees,
489 },
490 };
491
492 (order_response, Some(notifications))
493 }
494
495 pub fn validate_order_kind_supported(
496 &self,
497 order_kind: OrderKind,
498 ) -> Result<(), UnindexedOrderError> {
499 if order_kind == OrderKind::Market {
500 Ok(())
501 } else {
502 Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
503 format!("MockExchange does not support OrderKind::{order_kind:?}"),
504 )))
505 }
506 }
507
508 pub fn find_instrument_data(
509 &self,
510 instrument: &InstrumentNameExchange,
511 ) -> Result<&Instrument<ExchangeId, AssetNameExchange>, UnindexedApiError> {
512 self.instruments.get(instrument).ok_or_else(|| {
513 ApiError::InstrumentInvalid(
514 instrument.clone(),
515 format!("MockExchange is not set-up for managing: {instrument}"),
516 )
517 })
518 }
519
520 fn order_id_sequence_fetch_add(&mut self) -> OrderId {
521 let sequence = self.order_sequence;
522 self.order_sequence += 1;
523 OrderId::new(sequence.to_smolstr())
524 }
525
526 fn build_account_event<Kind>(&self, kind: Kind) -> UnindexedAccountEvent
527 where
528 Kind: Into<AccountEventKind<ExchangeId, AssetNameExchange, InstrumentNameExchange>>,
529 {
530 UnindexedAccountEvent {
531 exchange: self.exchange,
532 kind: kind.into(),
533 }
534 }
535}
536
537fn build_open_order_err_response<E>(
538 request: OrderRequestOpen<ExchangeId, InstrumentNameExchange>,
539 error: E,
540) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>
541where
542 E: Into<UnindexedOrderError>,
543{
544 Order {
545 key: request.key,
546 side: request.state.side,
547 price: request.state.price,
548 quantity: request.state.quantity,
549 kind: request.state.kind,
550 time_in_force: request.state.time_in_force,
551 state: OrderState::inactive(error.into()),
552 }
553}
554
555#[derive(Debug)]
556pub struct OpenOrderNotifications {
557 pub balance: Snapshot<AssetBalance<AssetNameExchange>>,
558 pub trade: Trade<AssetNameExchange, InstrumentNameExchange>,
559}
560
561#[cfg(test)]
562#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests {
564 use super::*;
565 use crate::{
566 UnindexedAccountSnapshot,
567 balance::{AssetBalance, Balance},
568 error::ApiError,
569 exchange::mock::request::MarketPrices,
570 fee::{FeeModelConfig, PercentageFeeModel},
571 fill::{BidAskFillModel, SimFillConfig},
572 order::{
573 OrderEvent, OrderKey, OrderKind, TimeInForce,
574 id::{ClientOrderId, StrategyId},
575 request::RequestOpen,
576 state::InactiveOrderState,
577 },
578 };
579 use chrono::Utc;
580 use rust_decimal::Decimal;
581 use rustrade_instrument::{
582 Side, Underlying,
583 asset::name::AssetNameExchange,
584 exchange::ExchangeId,
585 instrument::{
586 Instrument,
587 kind::InstrumentKind,
588 name::{InstrumentNameExchange, InstrumentNameInternal},
589 quote::InstrumentQuoteAsset,
590 },
591 };
592 use tokio::sync::{broadcast, mpsc};
593
594 fn d(s: &str) -> Decimal {
595 s.parse().unwrap()
596 }
597
598 const EXCHANGE: ExchangeId = ExchangeId::BinanceSpot;
599
600 fn base() -> AssetNameExchange {
601 AssetNameExchange::new("BTC")
602 }
603
604 fn quote() -> AssetNameExchange {
605 AssetNameExchange::new("USDT")
606 }
607
608 fn instrument_name() -> InstrumentNameExchange {
609 InstrumentNameExchange::new("BTCUSDT")
610 }
611
612 fn make_exchange(btc: &str, usdt: &str) -> MockExchange {
613 make_exchange_with_fee(btc, usdt, FeeModelConfig::default())
614 }
615
616 fn make_exchange_with_fee(btc: &str, usdt: &str, fee_model: FeeModelConfig) -> MockExchange {
617 let btc = d(btc);
618 let usdt = d(usdt);
619 let initial_state = UnindexedAccountSnapshot {
620 exchange: EXCHANGE,
621 balances: vec![
622 AssetBalance {
623 asset: base(),
624 balance: Balance::new(btc, btc),
625 time_exchange: Utc::now(),
626 },
627 AssetBalance {
628 asset: quote(),
629 balance: Balance::new(usdt, usdt),
630 time_exchange: Utc::now(),
631 },
632 ],
633 instruments: vec![],
634 };
635
636 let config = MockExecutionConfig::new(
637 EXCHANGE,
638 initial_state,
639 0, fee_model,
641 SimFillConfig::default(),
642 );
643
644 let (_tx, request_rx) = mpsc::unbounded_channel();
645 let (event_tx, _) = broadcast::channel(1);
646
647 let mut instruments = FnvHashMap::default();
648 instruments.insert(
649 instrument_name(),
650 Instrument {
651 exchange: EXCHANGE,
652 name_internal: InstrumentNameInternal::new("btcusdt"),
653 name_exchange: instrument_name(),
654 underlying: Underlying {
655 base: base(),
656 quote: quote(),
657 },
658 quote: InstrumentQuoteAsset::UnderlyingQuote,
659 kind: InstrumentKind::Spot,
660 spec: None,
661 },
662 );
663
664 MockExchange::new(config, request_rx, event_tx, instruments)
665 }
666
667 fn buy_request(quantity: &str) -> OrderRequestOpen<ExchangeId, InstrumentNameExchange> {
668 let quantity = d(quantity);
669 OrderEvent {
670 key: OrderKey {
671 exchange: EXCHANGE,
672 instrument: instrument_name(),
673 strategy: StrategyId::new("test"),
674 cid: ClientOrderId::new("test-cid"),
675 },
676 state: RequestOpen {
677 side: Side::Buy,
678 price: None, quantity,
680 kind: OrderKind::Market,
681 time_in_force: TimeInForce::ImmediateOrCancel,
682 position_id: None,
683 reduce_only: false,
684 },
685 }
686 }
687
688 fn sell_request(quantity: &str) -> OrderRequestOpen<ExchangeId, InstrumentNameExchange> {
689 let quantity = d(quantity);
690 OrderEvent {
691 key: OrderKey {
692 exchange: EXCHANGE,
693 instrument: instrument_name(),
694 strategy: StrategyId::new("test"),
695 cid: ClientOrderId::new("test-cid"),
696 },
697 state: RequestOpen {
698 side: Side::Sell,
699 price: None, quantity,
701 kind: OrderKind::Market,
702 time_in_force: TimeInForce::ImmediateOrCancel,
703 position_id: None,
704 reduce_only: false,
705 },
706 }
707 }
708
709 fn market_prices(price: &str) -> MarketPrices {
710 let p = Some(d(price));
711 MarketPrices {
712 best_bid: p,
713 best_ask: p,
714 last_price: p,
715 }
716 }
717
718 #[test]
719 fn sell_order_decrements_base_balance_not_quote() {
720 let mut exchange = make_exchange("1.0", "10000");
721 let initial_usdt = d("10000");
722
723 let (response, notifications) =
724 exchange.open_order(sell_request("0.5"), market_prices("50000"));
725
726 assert!(
727 response.state.is_accepted(),
728 "sell should succeed: {:?}",
729 response.state
730 );
731 assert!(
732 notifications.is_some(),
733 "successful sell must produce notifications"
734 );
735
736 let btc = exchange.account.balance_mut(&base()).unwrap();
738 assert_eq!(
739 btc.balance.free,
740 d("0.5"),
741 "base balance should decrease by quantity sold"
742 );
743
744 let usdt = exchange.account.balance_mut("e()).unwrap();
746 assert_eq!(
747 usdt.balance.free, initial_usdt,
748 "quote balance should be unchanged on sell"
749 );
750 }
751
752 #[test]
753 fn sell_order_insufficient_balance_names_base_asset() {
754 let mut exchange = make_exchange("0.1", "10000");
758
759 let (response, notifications) = exchange.open_order(
760 sell_request("1.0"), market_prices("50000"),
762 );
763
764 assert!(
765 notifications.is_none(),
766 "failed order must produce no notifications"
767 );
768 match response.state {
769 OrderState::Inactive(InactiveOrderState::OpenFailed(
770 crate::error::OrderError::Rejected(ApiError::BalanceInsufficient(ref asset, _)),
771 )) => {
772 assert_eq!(
773 *asset,
774 base(),
775 "BalanceInsufficient must name the base asset (BTC), not the quote (USDT)"
776 );
777 }
778 other => panic!("expected BalanceInsufficient, got: {other:?}"),
779 }
780 }
781
782 #[test]
783 fn bid_ask_fill_model_fills_at_ask_price_and_deducts_correct_balance() {
784 let mut exchange = make_exchange("0", "10000"); exchange.fill_model = SimFillConfig::BidAsk(BidAskFillModel);
786
787 let market_prices = MarketPrices {
788 best_bid: Some(d("99.5")),
789 best_ask: Some(d("100.5")),
790 last_price: Some(d("100.0")),
791 };
792
793 let (response, notifications) = exchange.open_order(buy_request("1"), market_prices);
796
797 assert!(
798 response.state.is_accepted(),
799 "buy should succeed: {:?}",
800 response.state
801 );
802 let notifs = notifications.expect("successful buy must produce notifications");
803
804 assert_eq!(
806 notifs.trade.price,
807 d("100.5"),
808 "fill price must be best_ask"
809 );
810
811 let usdt = exchange.account.balance_mut("e()).unwrap();
813 assert_eq!(
814 usdt.balance.free,
815 d("9899.5"),
816 "quote balance must decrease by fill_price * qty"
817 );
818 }
819
820 #[test]
821 fn percentage_fee_model_deducts_correct_fee_on_buy() {
822 let fee_model = FeeModelConfig::Percentage(PercentageFeeModel { rate: d("0.001") });
824 let mut exchange = make_exchange_with_fee("0", "10000", fee_model);
825
826 let (response, notifications) =
831 exchange.open_order(buy_request("10"), market_prices("100"));
832
833 assert!(
834 response.state.is_accepted(),
835 "buy should succeed: {:?}",
836 response.state
837 );
838 let notifs = notifications.expect("successful buy must produce notifications");
839
840 assert_eq!(notifs.trade.fees.fees, d("1"), "trade fee must be 1 USDT");
842
843 let usdt = exchange.account.balance_mut("e()).unwrap();
845 assert_eq!(
846 usdt.balance.free,
847 d("8999"),
848 "quote balance must decrease by notional + fee"
849 );
850 }
851
852 #[test]
853 fn percentage_fee_model_deducts_correct_fee_on_sell() {
854 let fee_model = FeeModelConfig::Percentage(PercentageFeeModel { rate: d("0.001") });
856 let mut exchange = make_exchange_with_fee("10", "0", fee_model);
857
858 let (response, notifications) =
864 exchange.open_order(sell_request("1"), market_prices("100"));
865
866 assert!(
867 response.state.is_accepted(),
868 "sell should succeed: {:?}",
869 response.state
870 );
871 let notifs = notifications.expect("successful sell must produce notifications");
872
873 assert_eq!(
875 notifs.trade.fees.fees,
876 d("0.1"),
877 "trade fee must be 0.1 USDT"
878 );
879
880 let btc = exchange.account.balance_mut(&base()).unwrap();
882 assert_eq!(
883 btc.balance.free,
884 d("8.999"),
885 "base balance must decrease by quantity + fee_in_base"
886 );
887 }
888
889 #[test]
890 fn percentage_fee_with_zero_price_returns_zero_fee() {
891 let fee_model = FeeModelConfig::Percentage(PercentageFeeModel { rate: d("0.001") });
893 let mut exchange = make_exchange_with_fee("10", "0", fee_model);
894
895 let (response, notifications) = exchange.open_order(sell_request("1"), market_prices("0"));
899
900 assert!(
901 response.state.is_accepted(),
902 "sell at zero price should succeed: {:?}",
903 response.state
904 );
905 let notifs = notifications.expect("successful sell must produce notifications");
906
907 assert_eq!(
909 notifs.trade.fees.fees,
910 Decimal::ZERO,
911 "fee must be zero when price is zero"
912 );
913
914 let btc = exchange.account.balance_mut(&base()).unwrap();
916 assert_eq!(
917 btc.balance.free,
918 d("9"),
919 "base balance must decrease by quantity only"
920 );
921 }
922}