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 })
204 .collect();
205
206 UnindexedAccountSnapshot {
207 exchange: self.exchange,
208 balances,
209 instruments,
210 }
211 }
212
213 fn respond_with_latency<Response>(
218 &self,
219 response_tx: oneshot::Sender<Response>,
220 response: Response,
221 ) where
222 Response: Send + 'static,
223 {
224 let exchange = self.exchange;
225 let latency = std::time::Duration::from_millis(self.latency_ms);
226
227 tokio::spawn(async move {
228 tokio::time::sleep(latency).await;
229 if response_tx.send(response).is_err() {
230 error!(
231 %exchange,
232 kind = std::any::type_name::<Response>(),
233 "MockExchange failed to send oneshot response to client"
234 );
235 }
236 });
237 }
238
239 fn send_notifications_with_latency(&self, notifications: OpenOrderNotifications) {
245 let balance = self.build_account_event(notifications.balance);
246 let trade = self.build_account_event(notifications.trade);
247
248 let exchange = self.exchange;
249 let latency = std::time::Duration::from_millis(self.latency_ms);
250 let tx = self.event_tx.clone();
251 tokio::spawn(async move {
252 tokio::time::sleep(latency).await;
253
254 if tx.send(balance).is_err() {
255 error!(
256 %exchange,
257 kind = "Snapshot<AssetBalance<AssetNameExchange>",
258 "MockExchange failed to send AccountEvent notification to client"
259 );
260 }
261
262 if tx.send(trade).is_err() {
263 error!(
264 %exchange,
265 kind = "Trade<AssetNameExchange, InstrumentNameExchange>",
266 "MockExchange failed to send AccountEvent notification to client"
267 );
268 }
269 });
270 }
271
272 pub fn account_stream(&self) -> BoxStream<'static, UnindexedAccountEvent> {
273 futures::StreamExt::boxed(BroadcastStream::new(self.event_tx.subscribe()).map_while(
274 |result| match result {
275 Ok(event) => Some(event),
276 Err(error) => {
277 error!(
278 ?error,
279 "MockExchange Broadcast AccountStream lagged - terminating"
280 );
281 None
282 }
283 },
284 ))
285 }
286
287 pub fn cancel_order(
288 &mut self,
289 _: OrderRequestCancel<ExchangeId, InstrumentNameExchange>,
290 ) -> Order<ExchangeId, InstrumentNameExchange, Result<Cancelled, UnindexedOrderError>> {
291 unimplemented!()
292 }
293
294 #[allow(clippy::expect_used)] pub fn open_order(
296 &mut self,
297 request: OrderRequestOpen<ExchangeId, InstrumentNameExchange>,
298 market_prices: MarketPrices,
299 ) -> (
300 Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>,
301 Option<OpenOrderNotifications>,
302 ) {
303 if let Err(error) = self.validate_order_kind_supported(request.state.kind) {
304 return (build_open_order_err_response(request, error), None);
305 }
306
307 let underlying = match self.find_instrument_data(&request.key.instrument) {
308 Ok(instrument) => instrument.underlying.clone(),
309 Err(error) => return (build_open_order_err_response(request, error), None),
310 };
311
312 let fill_price = self
324 .fill_model
325 .fill_price(
326 request.state.side,
327 match request.state.kind {
328 OrderKind::Market => None,
333 OrderKind::Limit
334 | OrderKind::StopLimit { .. }
335 | OrderKind::TakeProfitLimit { .. }
336 | OrderKind::TrailingStopLimit { .. } => request.state.price,
337 OrderKind::Stop { trigger_price }
338 | OrderKind::TakeProfit { trigger_price }
339 | OrderKind::TrailingStop {
340 offset: trigger_price,
341 ..
342 } => Some(trigger_price),
343 },
344 market_prices.best_bid,
345 market_prices.best_ask,
346 market_prices.last_price.or(request.state.price),
347 )
348 .or(request.state.price)
349 .expect("fill_price must be available from market data or request price");
350
351 let time_exchange = self.time_exchange();
352
353 let order_fees_quote =
355 self.fee_model
356 .compute_fee(fill_price, request.state.quantity, Decimal::ONE);
357
358 let balance_change_result = match request.state.side {
359 Side::Buy => {
360 #[allow(clippy::expect_used)]
362 let current = self
364 .account
365 .balance_mut(&underlying.quote)
366 .expect("MockExchange has Balance for all configured Instrument assets");
367
368 assert_eq!(current.balance.total, current.balance.free);
370
371 let order_value_quote = fill_price * request.state.quantity.abs();
372 let quote_required = order_value_quote + order_fees_quote;
373
374 let maybe_new_balance = current.balance.free - quote_required;
375
376 if maybe_new_balance >= Decimal::ZERO {
377 current.balance.free = maybe_new_balance;
378 current.balance.total = maybe_new_balance;
379 current.time_exchange = time_exchange;
380
381 Ok((
382 current.clone(),
383 AssetFees::new(
384 underlying.quote.clone(),
385 order_fees_quote,
386 Some(order_fees_quote),
387 ),
388 ))
389 } else {
390 Err(ApiError::BalanceInsufficient(
391 underlying.quote.clone(),
392 format!(
393 "Available Balance: {}, Required Balance inc. fees: {}",
394 current.balance.free, quote_required
395 ),
396 ))
397 }
398 }
399 Side::Sell => {
400 #[allow(clippy::expect_used)]
402 let current = self
404 .account
405 .balance_mut(&underlying.base)
406 .expect("MockExchange has Balance for all configured Instrument assets");
407
408 assert_eq!(current.balance.total, current.balance.free);
410
411 let order_value_base = request.state.quantity.abs();
412 debug_assert!(
416 !matches!(self.fee_model, FeeModelConfig::PerContract(_)),
417 "PerContractFeeModel produces nonsensical base-denominated fees on sell path"
418 );
419 let order_fees_base = if fill_price.is_zero() {
420 Decimal::ZERO
421 } else {
422 order_fees_quote / fill_price
423 };
424 let base_required = order_value_base + order_fees_base;
425
426 let maybe_new_balance = current.balance.free - base_required;
427
428 if maybe_new_balance >= Decimal::ZERO {
429 current.balance.free = maybe_new_balance;
430 current.balance.total = maybe_new_balance;
431 current.time_exchange = time_exchange;
432
433 Ok((
434 current.clone(),
435 AssetFees::new(
436 underlying.quote.clone(),
437 order_fees_quote,
438 Some(order_fees_quote),
439 ),
440 ))
441 } else {
442 Err(ApiError::BalanceInsufficient(
443 underlying.base,
444 format!(
445 "Available Balance: {}, Required Balance inc. fees: {}",
446 current.balance.free, base_required
447 ),
448 ))
449 }
450 }
451 };
452
453 let (balance_snapshot, fees) = match balance_change_result {
454 Ok((balance_snapshot, fees)) => (Snapshot(balance_snapshot), fees),
455 Err(error) => return (build_open_order_err_response(request, error), None),
456 };
457
458 let order_id = self.order_id_sequence_fetch_add();
459 let trade_id = TradeId(order_id.0.clone());
460
461 let order_response = Order {
462 key: request.key.clone(),
463 side: request.state.side,
464 price: request.state.price,
465 quantity: request.state.quantity,
466 kind: request.state.kind,
467 time_in_force: request.state.time_in_force,
468 state: OrderState::fully_filled(Filled::new(
469 order_id.clone(),
470 self.time_exchange(),
471 request.state.quantity,
472 Some(fill_price),
473 )),
474 };
475
476 let notifications = OpenOrderNotifications {
477 balance: balance_snapshot,
478 trade: Trade {
479 id: trade_id,
480 order_id: order_id.clone(),
481 instrument: request.key.instrument,
482 strategy: request.key.strategy,
483 time_exchange: self.time_exchange(),
484 side: request.state.side,
485 price: fill_price,
486 quantity: request.state.quantity,
487 fees,
488 },
489 };
490
491 (order_response, Some(notifications))
492 }
493
494 pub fn validate_order_kind_supported(
495 &self,
496 order_kind: OrderKind,
497 ) -> Result<(), UnindexedOrderError> {
498 if order_kind == OrderKind::Market {
499 Ok(())
500 } else {
501 Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
502 format!("MockExchange does not support OrderKind::{order_kind:?}"),
503 )))
504 }
505 }
506
507 pub fn find_instrument_data(
508 &self,
509 instrument: &InstrumentNameExchange,
510 ) -> Result<&Instrument<ExchangeId, AssetNameExchange>, UnindexedApiError> {
511 self.instruments.get(instrument).ok_or_else(|| {
512 ApiError::InstrumentInvalid(
513 instrument.clone(),
514 format!("MockExchange is not set-up for managing: {instrument}"),
515 )
516 })
517 }
518
519 fn order_id_sequence_fetch_add(&mut self) -> OrderId {
520 let sequence = self.order_sequence;
521 self.order_sequence += 1;
522 OrderId::new(sequence.to_smolstr())
523 }
524
525 fn build_account_event<Kind>(&self, kind: Kind) -> UnindexedAccountEvent
526 where
527 Kind: Into<AccountEventKind<ExchangeId, AssetNameExchange, InstrumentNameExchange>>,
528 {
529 UnindexedAccountEvent {
530 exchange: self.exchange,
531 kind: kind.into(),
532 }
533 }
534}
535
536fn build_open_order_err_response<E>(
537 request: OrderRequestOpen<ExchangeId, InstrumentNameExchange>,
538 error: E,
539) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>
540where
541 E: Into<UnindexedOrderError>,
542{
543 Order {
544 key: request.key,
545 side: request.state.side,
546 price: request.state.price,
547 quantity: request.state.quantity,
548 kind: request.state.kind,
549 time_in_force: request.state.time_in_force,
550 state: OrderState::inactive(error.into()),
551 }
552}
553
554#[derive(Debug)]
555pub struct OpenOrderNotifications {
556 pub balance: Snapshot<AssetBalance<AssetNameExchange>>,
557 pub trade: Trade<AssetNameExchange, InstrumentNameExchange>,
558}
559
560#[cfg(test)]
561#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests {
563 use super::*;
564 use crate::{
565 UnindexedAccountSnapshot,
566 balance::{AssetBalance, Balance},
567 error::ApiError,
568 exchange::mock::request::MarketPrices,
569 fee::{FeeModelConfig, PercentageFeeModel},
570 fill::{BidAskFillModel, SimFillConfig},
571 order::{
572 OrderEvent, OrderKey, OrderKind, TimeInForce,
573 id::{ClientOrderId, StrategyId},
574 request::RequestOpen,
575 state::InactiveOrderState,
576 },
577 };
578 use chrono::Utc;
579 use rust_decimal::Decimal;
580 use rustrade_instrument::{
581 Side, Underlying,
582 asset::name::AssetNameExchange,
583 exchange::ExchangeId,
584 instrument::{
585 Instrument,
586 kind::InstrumentKind,
587 name::{InstrumentNameExchange, InstrumentNameInternal},
588 quote::InstrumentQuoteAsset,
589 },
590 };
591 use tokio::sync::{broadcast, mpsc};
592
593 fn d(s: &str) -> Decimal {
594 s.parse().unwrap()
595 }
596
597 const EXCHANGE: ExchangeId = ExchangeId::BinanceSpot;
598
599 fn base() -> AssetNameExchange {
600 AssetNameExchange::new("BTC")
601 }
602
603 fn quote() -> AssetNameExchange {
604 AssetNameExchange::new("USDT")
605 }
606
607 fn instrument_name() -> InstrumentNameExchange {
608 InstrumentNameExchange::new("BTCUSDT")
609 }
610
611 fn make_exchange(btc: &str, usdt: &str) -> MockExchange {
612 make_exchange_with_fee(btc, usdt, FeeModelConfig::default())
613 }
614
615 fn make_exchange_with_fee(btc: &str, usdt: &str, fee_model: FeeModelConfig) -> MockExchange {
616 let btc = d(btc);
617 let usdt = d(usdt);
618 let initial_state = UnindexedAccountSnapshot {
619 exchange: EXCHANGE,
620 balances: vec![
621 AssetBalance {
622 asset: base(),
623 balance: Balance {
624 total: btc,
625 free: btc,
626 },
627 time_exchange: Utc::now(),
628 },
629 AssetBalance {
630 asset: quote(),
631 balance: Balance {
632 total: usdt,
633 free: usdt,
634 },
635 time_exchange: Utc::now(),
636 },
637 ],
638 instruments: vec![],
639 };
640
641 let config = MockExecutionConfig::new(
642 EXCHANGE,
643 initial_state,
644 0, fee_model,
646 SimFillConfig::default(),
647 );
648
649 let (_tx, request_rx) = mpsc::unbounded_channel();
650 let (event_tx, _) = broadcast::channel(1);
651
652 let mut instruments = FnvHashMap::default();
653 instruments.insert(
654 instrument_name(),
655 Instrument {
656 exchange: EXCHANGE,
657 name_internal: InstrumentNameInternal::new("btcusdt"),
658 name_exchange: instrument_name(),
659 underlying: Underlying {
660 base: base(),
661 quote: quote(),
662 },
663 quote: InstrumentQuoteAsset::UnderlyingQuote,
664 kind: InstrumentKind::Spot,
665 spec: None,
666 },
667 );
668
669 MockExchange::new(config, request_rx, event_tx, instruments)
670 }
671
672 fn buy_request(quantity: &str) -> OrderRequestOpen<ExchangeId, InstrumentNameExchange> {
673 let quantity = d(quantity);
674 OrderEvent {
675 key: OrderKey {
676 exchange: EXCHANGE,
677 instrument: instrument_name(),
678 strategy: StrategyId::new("test"),
679 cid: ClientOrderId::new("test-cid"),
680 },
681 state: RequestOpen {
682 side: Side::Buy,
683 price: None, quantity,
685 kind: OrderKind::Market,
686 time_in_force: TimeInForce::ImmediateOrCancel,
687 position_id: None,
688 reduce_only: false,
689 },
690 }
691 }
692
693 fn sell_request(quantity: &str) -> OrderRequestOpen<ExchangeId, InstrumentNameExchange> {
694 let quantity = d(quantity);
695 OrderEvent {
696 key: OrderKey {
697 exchange: EXCHANGE,
698 instrument: instrument_name(),
699 strategy: StrategyId::new("test"),
700 cid: ClientOrderId::new("test-cid"),
701 },
702 state: RequestOpen {
703 side: Side::Sell,
704 price: None, quantity,
706 kind: OrderKind::Market,
707 time_in_force: TimeInForce::ImmediateOrCancel,
708 position_id: None,
709 reduce_only: false,
710 },
711 }
712 }
713
714 fn market_prices(price: &str) -> MarketPrices {
715 let p = Some(d(price));
716 MarketPrices {
717 best_bid: p,
718 best_ask: p,
719 last_price: p,
720 }
721 }
722
723 #[test]
724 fn sell_order_decrements_base_balance_not_quote() {
725 let mut exchange = make_exchange("1.0", "10000");
726 let initial_usdt = d("10000");
727
728 let (response, notifications) =
729 exchange.open_order(sell_request("0.5"), market_prices("50000"));
730
731 assert!(
732 response.state.is_accepted(),
733 "sell should succeed: {:?}",
734 response.state
735 );
736 assert!(
737 notifications.is_some(),
738 "successful sell must produce notifications"
739 );
740
741 let btc = exchange.account.balance_mut(&base()).unwrap();
743 assert_eq!(
744 btc.balance.free,
745 d("0.5"),
746 "base balance should decrease by quantity sold"
747 );
748
749 let usdt = exchange.account.balance_mut("e()).unwrap();
751 assert_eq!(
752 usdt.balance.free, initial_usdt,
753 "quote balance should be unchanged on sell"
754 );
755 }
756
757 #[test]
758 fn sell_order_insufficient_balance_names_base_asset() {
759 let mut exchange = make_exchange("0.1", "10000");
763
764 let (response, notifications) = exchange.open_order(
765 sell_request("1.0"), market_prices("50000"),
767 );
768
769 assert!(
770 notifications.is_none(),
771 "failed order must produce no notifications"
772 );
773 match response.state {
774 OrderState::Inactive(InactiveOrderState::OpenFailed(
775 crate::error::OrderError::Rejected(ApiError::BalanceInsufficient(ref asset, _)),
776 )) => {
777 assert_eq!(
778 *asset,
779 base(),
780 "BalanceInsufficient must name the base asset (BTC), not the quote (USDT)"
781 );
782 }
783 other => panic!("expected BalanceInsufficient, got: {other:?}"),
784 }
785 }
786
787 #[test]
788 fn bid_ask_fill_model_fills_at_ask_price_and_deducts_correct_balance() {
789 let mut exchange = make_exchange("0", "10000"); exchange.fill_model = SimFillConfig::BidAsk(BidAskFillModel);
791
792 let market_prices = MarketPrices {
793 best_bid: Some(d("99.5")),
794 best_ask: Some(d("100.5")),
795 last_price: Some(d("100.0")),
796 };
797
798 let (response, notifications) = exchange.open_order(buy_request("1"), market_prices);
801
802 assert!(
803 response.state.is_accepted(),
804 "buy should succeed: {:?}",
805 response.state
806 );
807 let notifs = notifications.expect("successful buy must produce notifications");
808
809 assert_eq!(
811 notifs.trade.price,
812 d("100.5"),
813 "fill price must be best_ask"
814 );
815
816 let usdt = exchange.account.balance_mut("e()).unwrap();
818 assert_eq!(
819 usdt.balance.free,
820 d("9899.5"),
821 "quote balance must decrease by fill_price * qty"
822 );
823 }
824
825 #[test]
826 fn percentage_fee_model_deducts_correct_fee_on_buy() {
827 let fee_model = FeeModelConfig::Percentage(PercentageFeeModel { rate: d("0.001") });
829 let mut exchange = make_exchange_with_fee("0", "10000", fee_model);
830
831 let (response, notifications) =
836 exchange.open_order(buy_request("10"), market_prices("100"));
837
838 assert!(
839 response.state.is_accepted(),
840 "buy should succeed: {:?}",
841 response.state
842 );
843 let notifs = notifications.expect("successful buy must produce notifications");
844
845 assert_eq!(notifs.trade.fees.fees, d("1"), "trade fee must be 1 USDT");
847
848 let usdt = exchange.account.balance_mut("e()).unwrap();
850 assert_eq!(
851 usdt.balance.free,
852 d("8999"),
853 "quote balance must decrease by notional + fee"
854 );
855 }
856
857 #[test]
858 fn percentage_fee_model_deducts_correct_fee_on_sell() {
859 let fee_model = FeeModelConfig::Percentage(PercentageFeeModel { rate: d("0.001") });
861 let mut exchange = make_exchange_with_fee("10", "0", fee_model);
862
863 let (response, notifications) =
869 exchange.open_order(sell_request("1"), market_prices("100"));
870
871 assert!(
872 response.state.is_accepted(),
873 "sell should succeed: {:?}",
874 response.state
875 );
876 let notifs = notifications.expect("successful sell must produce notifications");
877
878 assert_eq!(
880 notifs.trade.fees.fees,
881 d("0.1"),
882 "trade fee must be 0.1 USDT"
883 );
884
885 let btc = exchange.account.balance_mut(&base()).unwrap();
887 assert_eq!(
888 btc.balance.free,
889 d("8.999"),
890 "base balance must decrease by quantity + fee_in_base"
891 );
892 }
893
894 #[test]
895 fn percentage_fee_with_zero_price_returns_zero_fee() {
896 let fee_model = FeeModelConfig::Percentage(PercentageFeeModel { rate: d("0.001") });
898 let mut exchange = make_exchange_with_fee("10", "0", fee_model);
899
900 let (response, notifications) = exchange.open_order(sell_request("1"), market_prices("0"));
904
905 assert!(
906 response.state.is_accepted(),
907 "sell at zero price should succeed: {:?}",
908 response.state
909 );
910 let notifs = notifications.expect("successful sell must produce notifications");
911
912 assert_eq!(
914 notifs.trade.fees.fees,
915 Decimal::ZERO,
916 "fee must be zero when price is zero"
917 );
918
919 let btc = exchange.account.balance_mut(&base()).unwrap();
921 assert_eq!(
922 btc.balance.free,
923 d("9"),
924 "base balance must decrease by quantity only"
925 );
926 }
927}