Skip to main content

rustrade_execution/exchange/mock/
mod.rs

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                    // Empty slice means "return all" (consistent with account_snapshot behavior).
90                    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                    // Empty slice means "return all" (consistent with account_snapshot behavior).
103                    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                    // MockExchange only supports Market orders which fill immediately,
125                    // so there are never any open orders to cancel. Send a rejection
126                    // response so the caller doesn't hang waiting on the oneshot.
127                    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    /// Sends the provided `Response` via the [`oneshot::Sender`] after waiting for the latency
214    /// [`Duration`].
215    ///
216    /// Used to simulate network latency between the exchange and client.
217    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    /// Sends the provided `OpenOrderNotifications` via the `MockExchanges`
240    /// `broadcast::Sender<UnindexedAccountEvent>` after waiting for the latency
241    /// [`Duration`].
242    ///
243    /// Used to simulate network latency between the exchange and client.
244    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)] // Mock exchange: panic if test data is incomplete
295    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        // Compute fill price via the configured FillModel.
313        //
314        // For limit orders, pass the limit price as `order_price`; for market orders pass `None`
315        // so the model can select the best available market price (bid/ask/last).  When no market
316        // data is present (standard MockExecution passes all-None MarketPrices), `request.state.price`
317        // is used as the `last_price` fallback so behaviour is identical to the pre-FillModel path.
318        //
319        // Invariant: `fill_price` is only called for marketable orders. `validate_order_kind_supported`
320        // (called above) currently rejects Limit orders, ensuring FillModel::fill_price never receives
321        // a non-marketable limit order. If Limit support is added later, the fill model must enforce
322        // limit-price semantics (e.g. a limit buy must not fill above the limit price).
323        let fill_price = self
324            .fill_model
325            .fill_price(
326                request.state.side,
327                match request.state.kind {
328                    // unreachable: validate_order_kind_supported (called above) already
329                    // rejects non-Market orders with Err, so these arms are never reached.
330                    // Kept for exhaustiveness; passes the limit/trigger price so fill models
331                    // that gain support in future behave correctly without a separate change.
332                    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        // Compute fee using the configured FeeModel. For spot, contract_size = 1.
354        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                // Buying Instrument requires sufficient QuoteAsset Balance
361                #[allow(clippy::expect_used)]
362                // Invariant: MockExchange - balances exist for all configured instruments
363                let current = self
364                    .account
365                    .balance_mut(&underlying.quote)
366                    .expect("MockExchange has Balance for all configured Instrument assets");
367
368                // Currently we only supported MarketKind orders, so they should be identical
369                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                // Selling Instrument requires sufficient BaseAsset Balance
401                #[allow(clippy::expect_used)]
402                // Invariant: MockExchange - balances exist for all configured instruments
403                let current = self
404                    .account
405                    .balance_mut(&underlying.base)
406                    .expect("MockExchange has Balance for all configured Instrument assets");
407
408                // Currently we only supported MarketKind orders, so they should be identical
409                assert_eq!(current.balance.total, current.balance.free);
410
411                let order_value_base = request.state.quantity.abs();
412                // Fee is quote-denominated; convert to base for deduction.
413                // Note: For PerContractFeeModel this conversion is nonsensical (flat USD / price),
414                // but MockExchange is spot-only so PerContract isn't used in practice.
415                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)] // Test code: panics on bad input are acceptable
562mod 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, // latency_ms
645            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, // Market orders don't have a limit price
684                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, // Market orders don't have a limit price
705                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        // Base (BTC) must be decremented by the quantity sold.
742        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        // Quote (USDT) must be unchanged (fees = 0 in this test).
750        let usdt = exchange.account.balance_mut(&quote()).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        // Regression guard for the sell-side balance bug fixed in this branch:
760        // previously `balance_mut(&underlying.quote)` was called for sells, so
761        // BalanceInsufficient would name the quote asset (USDT) instead of the base (BTC).
762        let mut exchange = make_exchange("0.1", "10000");
763
764        let (response, notifications) = exchange.open_order(
765            sell_request("1.0"), // selling 1 BTC but only 0.1 available
766            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"); // 0 BTC, 10 000 USDT
790        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        // Market buy of 1 BTC; reference price 100 is only used as a fallback
799        // when fill_model returns None — BidAsk returns best_ask so it is not used.
800        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        // BidAskFillModel: market buy fills at best_ask = 100.5, not last_price 100.0.
810        assert_eq!(
811            notifs.trade.price,
812            d("100.5"),
813            "fill price must be best_ask"
814        );
815
816        // Balance deduction: 1 * 100.5 = 100.5 USDT; fee_model = Zero.
817        let usdt = exchange.account.balance_mut(&quote()).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        // 0.1% fee rate
828        let fee_model = FeeModelConfig::Percentage(PercentageFeeModel { rate: d("0.001") });
829        let mut exchange = make_exchange_with_fee("0", "10000", fee_model);
830
831        // Buy 10 BTC at price 100 USDT each
832        // Notional = 10 * 100 = 1000 USDT
833        // Fee = 1000 * 0.001 = 1 USDT
834        // Total deducted = 1000 + 1 = 1001 USDT
835        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        // Trade must report fee in quote denomination
846        assert_eq!(notifs.trade.fees.fees, d("1"), "trade fee must be 1 USDT");
847
848        // Quote balance: 10000 - 1001 = 8999
849        let usdt = exchange.account.balance_mut(&quote()).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        // 0.1% fee rate
860        let fee_model = FeeModelConfig::Percentage(PercentageFeeModel { rate: d("0.001") });
861        let mut exchange = make_exchange_with_fee("10", "0", fee_model);
862
863        // Sell 1 BTC at price 100 USDT
864        // Notional = 1 * 100 = 100 USDT
865        // Fee (quote) = 100 * 0.001 = 0.1 USDT
866        // Fee (base) = 0.1 / 100 = 0.001 BTC
867        // Total base deducted = 1 + 0.001 = 1.001 BTC
868        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        // Trade must report fee in quote denomination
879        assert_eq!(
880            notifs.trade.fees.fees,
881            d("0.1"),
882            "trade fee must be 0.1 USDT"
883        );
884
885        // Base balance: 10 - 1.001 = 8.999
886        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        // Edge case: if fill_price is zero, fee computation must not divide by zero
897        let fee_model = FeeModelConfig::Percentage(PercentageFeeModel { rate: d("0.001") });
898        let mut exchange = make_exchange_with_fee("10", "0", fee_model);
899
900        // Sell 1 BTC at price 0 (degenerate case)
901        // Fee (quote) = 0 * 0.001 * 1 = 0
902        // Fee (base) = guarded by is_zero() check, returns 0
903        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        // Fee must be zero (not NaN or panic from division by zero)
913        assert_eq!(
914            notifs.trade.fees.fees,
915            Decimal::ZERO,
916            "fee must be zero when price is zero"
917        );
918
919        // Base balance: 10 - 1 = 9 (no fee deducted)
920        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}