use crate::{
UnindexedAccountEvent,
client::mock::MockExecutionConfig,
exchange::mock::request::{MockExchangeRequest, MockExchangeRequestKind},
order::{Order, state::UnindexedOrderState},
};
use chrono::{DateTime, TimeDelta, Utc};
use fnv::FnvHashMap;
use futures::stream::BoxStream;
use rustrade_instrument::{
asset::name::AssetNameExchange,
exchange::ExchangeId,
instrument::{Instrument, name::InstrumentNameExchange},
};
use std::fmt::Debug;
use tokio::sync::{broadcast, mpsc, oneshot};
use tokio_stream::{StreamExt, wrappers::BroadcastStream};
use tracing::{error, info};
pub mod account;
pub mod orders;
pub mod request;
pub mod venue;
pub use venue::{CancelOutcome, OpenOutcome, SimulatedVenue, VenueInstrumentMarket, VenueOutcome};
#[derive(Debug)]
pub struct MockExchange {
pub venue: SimulatedVenue,
pub latency_ms: u64,
pub request_rx: mpsc::UnboundedReceiver<MockExchangeRequest>,
pub event_tx: broadcast::Sender<UnindexedAccountEvent>,
}
impl MockExchange {
pub fn new(
config: MockExecutionConfig,
request_rx: mpsc::UnboundedReceiver<MockExchangeRequest>,
event_tx: broadcast::Sender<UnindexedAccountEvent>,
instruments: FnvHashMap<InstrumentNameExchange, Instrument<ExchangeId, AssetNameExchange>>,
) -> Self {
Self {
venue: SimulatedVenue::new(&config, instruments),
latency_ms: config.latency_ms,
request_rx,
event_tx,
}
}
pub async fn run(mut self) {
let (emit_tx, emit_rx) = mpsc::unbounded_channel();
let emitter = tokio::spawn(Self::emit_queued_output(
emit_rx,
self.event_tx.clone(),
self.venue.exchange,
));
while let Some(request) = self.request_rx.recv().await {
let expiries = self.advance_venue_time(request.time_request);
self.queue_emission(&emit_tx, expiries, None);
match request.kind {
MockExchangeRequestKind::FetchAccountSnapshot { response_tx } => {
let snapshot = self.venue.account_snapshot();
self.respond_with_latency(response_tx, snapshot);
}
MockExchangeRequestKind::FetchBalances {
response_tx,
assets,
} => {
let balances = self.venue.balances(&assets);
self.respond_with_latency(response_tx, balances);
}
MockExchangeRequestKind::FetchOrdersOpen {
response_tx,
instruments,
} => {
let orders_open = self.venue.orders_open(&instruments);
self.respond_with_latency(response_tx, orders_open);
}
MockExchangeRequestKind::FetchOrdersEnded {
response_tx,
orders,
} => {
let orders_ended = self.venue.orders_ended(&orders);
self.respond_with_latency(response_tx, orders_ended);
}
MockExchangeRequestKind::FetchTrades {
response_tx,
start,
end,
} => {
let mut trades = self.venue.trades(start);
trades.retain(|trade| trade.time_exchange <= end);
self.respond_with_latency(response_tx, trades);
}
MockExchangeRequestKind::CancelOrder {
response_tx,
request,
} => {
let outcome = self.venue.cancel_order(request);
self.queue_emission(&emit_tx, outcome.events, None);
let _ = response_tx.send(outcome.response);
}
MockExchangeRequestKind::OpenOrder {
response_tx,
request,
} => {
let outcome = self.venue.open_order(request);
self.respond_open_with_latency(&emit_tx, response_tx, outcome);
}
}
}
drop(emit_tx);
if let Err(error) = emitter.await {
error!(
exchange = %self.venue.exchange,
%error,
"MockExchange emitter task did not shut down cleanly; queued fills may be lost"
);
}
info!(exchange = %self.venue.exchange, "MockExchange shutting down");
}
#[must_use]
fn advance_venue_time(&mut self, time_request: DateTime<Utc>) -> Vec<UnindexedAccountEvent> {
let client_to_exchange_latency = self.latency_ms / 2;
let time_exchange = time_request
.checked_add_signed(TimeDelta::milliseconds(client_to_exchange_latency as i64))
.unwrap_or(time_request);
self.venue.advance_time(time_exchange)
}
fn respond_with_latency<Response>(
&self,
response_tx: oneshot::Sender<Response>,
response: Response,
) where
Response: Send + 'static,
{
let exchange = self.venue.exchange;
let latency = std::time::Duration::from_millis(self.latency_ms);
tokio::spawn(async move {
tokio::time::sleep(latency).await;
if response_tx.send(response).is_err() {
error!(
%exchange,
kind = std::any::type_name::<Response>(),
"MockExchange failed to send oneshot response to client"
);
}
});
}
fn respond_open_with_latency(
&self,
emit_tx: &mpsc::UnboundedSender<PendingEmission>,
response_tx: oneshot::Sender<
Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>,
>,
outcome: OpenOutcome,
) {
self.queue_emission(
emit_tx,
outcome.events,
Some(PendingOpenResponse {
response_tx,
response: outcome.response,
}),
);
}
fn queue_emission(
&self,
emit_tx: &mpsc::UnboundedSender<PendingEmission>,
events: Vec<UnindexedAccountEvent>,
response: Option<PendingOpenResponse>,
) {
if events.is_empty() && response.is_none() {
return;
}
let ready_at =
tokio::time::Instant::now() + std::time::Duration::from_millis(self.latency_ms);
if emit_tx
.send(PendingEmission {
ready_at,
events,
response,
})
.is_err()
{
error!(
exchange = %self.venue.exchange,
"MockExchange could not queue venue output: the emitter has stopped"
);
}
}
async fn emit_queued_output(
mut emit_rx: mpsc::UnboundedReceiver<PendingEmission>,
event_tx: broadcast::Sender<UnindexedAccountEvent>,
exchange: ExchangeId,
) {
while let Some(emission) = emit_rx.recv().await {
tokio::time::sleep_until(emission.ready_at).await;
for event in emission.events {
if event_tx.send(event).is_err() {
error!(
%exchange,
"MockExchange failed to send AccountEvent notification to client"
);
}
}
let Some(PendingOpenResponse {
response_tx,
response,
}) = emission.response
else {
continue;
};
if response_tx.send(response).is_err() {
error!(
%exchange,
kind = "OrderResponseOpen",
"MockExchange failed to send oneshot response to client"
);
}
}
}
pub fn account_stream(&self) -> BoxStream<'static, UnindexedAccountEvent> {
futures::StreamExt::boxed(BroadcastStream::new(self.event_tx.subscribe()).map_while(
|result| match result {
Ok(event) => Some(event),
Err(error) => {
error!(
?error,
"MockExchange Broadcast AccountStream lagged - terminating"
);
None
}
},
))
}
}
#[derive(Debug)]
struct PendingEmission {
ready_at: tokio::time::Instant,
events: Vec<UnindexedAccountEvent>,
response: Option<PendingOpenResponse>,
}
#[derive(Debug)]
struct PendingOpenResponse {
response_tx: oneshot::Sender<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>>,
response: Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>,
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)] pub(crate) mod fixtures {
use crate::{
InstrumentAccountSnapshot, UnindexedAccountSnapshot,
balance::{AssetBalance, Balance},
client::mock::MockExecutionConfig,
fee::{FeeModelConfig, SpotFeeAsset},
fill::SimFillConfig,
market::MarketSnapshot,
order::{
Order, OrderEvent, OrderKey, OrderKind, TimeInForce, UnindexedOrder,
id::{ClientOrderId, OrderId, StrategyId, VenueOrderId},
request::{OrderRequestOpen, RequestOpen},
state::{Open, OrderState},
},
position::PositionReport,
};
use chrono::{DateTime, Utc};
use fnv::FnvHashMap;
use rust_decimal::Decimal;
use rustrade_instrument::{
Side, Underlying,
asset::name::AssetNameExchange,
exchange::ExchangeId,
instrument::{
Instrument,
kind::InstrumentKind,
name::{InstrumentNameExchange, InstrumentNameInternal},
quote::InstrumentQuoteAsset,
},
};
pub(super) const EXCHANGE: ExchangeId = ExchangeId::BinanceSpot;
pub(super) fn d(s: &str) -> Decimal {
s.parse().unwrap()
}
pub(super) fn base() -> AssetNameExchange {
AssetNameExchange::new("BTC")
}
pub(super) fn quote() -> AssetNameExchange {
AssetNameExchange::new("USDT")
}
pub(super) fn instrument_name() -> InstrumentNameExchange {
InstrumentNameExchange::new("BTCUSDT")
}
pub(super) fn funded(
asset: AssetNameExchange,
amount: Decimal,
) -> AssetBalance<AssetNameExchange> {
AssetBalance {
asset,
balance: Balance::new(amount, amount),
time_exchange: Utc::now(),
}
}
pub(super) fn config_from_balances(
balances: Vec<AssetBalance<AssetNameExchange>>,
fee_model: FeeModelConfig,
) -> MockExecutionConfig {
config_from_balances_with_fill(balances, fee_model, SimFillConfig::default())
}
pub(super) fn config_from_balances_with_fill(
balances: Vec<AssetBalance<AssetNameExchange>>,
fee_model: FeeModelConfig,
fill_model: SimFillConfig,
) -> MockExecutionConfig {
MockExecutionConfig::new(
EXCHANGE,
UnindexedAccountSnapshot {
exchange: EXCHANGE,
balances,
instruments: vec![],
},
0, fee_model,
fill_model,
SpotFeeAsset::default(),
crate::slippage::SlippageConfig::default(),
)
}
pub(super) fn spot_config_holding(
btc: &str,
usdt: &str,
order: UnindexedOrder,
) -> MockExecutionConfig {
MockExecutionConfig::new(
EXCHANGE,
UnindexedAccountSnapshot {
exchange: EXCHANGE,
balances: vec![funded(base(), d(btc)), funded(quote(), d(usdt))],
instruments: vec![InstrumentAccountSnapshot {
instrument: instrument_name(),
orders: vec![order],
orders_complete: true,
position: PositionReport::Unreported,
isolated: None,
}],
},
0, FeeModelConfig::default(),
SimFillConfig::default(),
SpotFeeAsset::default(),
crate::slippage::SlippageConfig::default(),
)
}
pub(super) fn seeded_gtd(
cid: &str,
price: &str,
opened: DateTime<Utc>,
expiry: DateTime<Utc>,
) -> UnindexedOrder {
Order {
key: OrderKey {
exchange: EXCHANGE,
instrument: instrument_name(),
strategy: StrategyId::new("test"),
cid: ClientOrderId::new(cid),
},
side: Side::Buy,
price: Some(d(price)),
quantity: d("0.01"),
kind: OrderKind::Limit,
time_in_force: TimeInForce::GoodTillDate { expiry },
state: OrderState::active(Open::new(
VenueOrderId::Assigned(OrderId::new(cid)),
opened,
rust_decimal::Decimal::ZERO,
)),
}
}
pub(super) fn spot_config(
btc: &str,
usdt: &str,
fee_model: FeeModelConfig,
) -> MockExecutionConfig {
config_from_balances(
vec![funded(base(), d(btc)), funded(quote(), d(usdt))],
fee_model,
)
}
pub(super) fn spot_config_with_fill(
btc: &str,
usdt: &str,
fee_model: FeeModelConfig,
fill_model: SimFillConfig,
) -> MockExecutionConfig {
config_from_balances_with_fill(
vec![funded(base(), d(btc)), funded(quote(), d(usdt))],
fee_model,
fill_model,
)
}
pub(super) fn instruments_of(
instrument: Instrument<ExchangeId, AssetNameExchange>,
) -> FnvHashMap<InstrumentNameExchange, Instrument<ExchangeId, AssetNameExchange>> {
let mut instruments = FnvHashMap::default();
instruments.insert(instrument.name_exchange.clone(), instrument);
instruments
}
pub(super) fn spot_instruments()
-> FnvHashMap<InstrumentNameExchange, Instrument<ExchangeId, AssetNameExchange>> {
instruments_of(Instrument {
exchange: EXCHANGE,
name_internal: InstrumentNameInternal::new("btcusdt"),
name_exchange: instrument_name(),
underlying: Underlying {
base: base(),
quote: quote(),
},
quote: InstrumentQuoteAsset::UnderlyingQuote,
kind: InstrumentKind::Spot,
spec: None,
data_venue: None,
})
}
pub(super) fn request(
instrument: InstrumentNameExchange,
side: Side,
quantity: &str,
market: Option<MarketSnapshot>,
) -> OrderRequestOpen<ExchangeId, InstrumentNameExchange> {
OrderEvent {
key: OrderKey {
exchange: EXCHANGE,
instrument,
strategy: StrategyId::new("test"),
cid: ClientOrderId::new("test-cid"),
},
state: RequestOpen {
side,
price: None, quantity: d(quantity),
kind: OrderKind::Market,
time_in_force: TimeInForce::ImmediateOrCancel,
position_id: None,
reduce_only: false,
market,
},
}
}
pub(super) fn buy_request(
quantity: &str,
market: Option<MarketSnapshot>,
) -> OrderRequestOpen<ExchangeId, InstrumentNameExchange> {
request(instrument_name(), Side::Buy, quantity, market)
}
pub(super) fn sell_request(
quantity: &str,
market: Option<MarketSnapshot>,
) -> OrderRequestOpen<ExchangeId, InstrumentNameExchange> {
request(instrument_name(), Side::Sell, quantity, market)
}
pub(super) fn gtc() -> TimeInForce {
TimeInForce::GoodUntilCancelled { post_only: false }
}
pub(super) fn limit_request(
cid: &str,
side: Side,
quantity: &str,
price: &str,
time_in_force: TimeInForce,
) -> OrderRequestOpen<ExchangeId, InstrumentNameExchange> {
OrderEvent {
key: OrderKey {
exchange: EXCHANGE,
instrument: instrument_name(),
strategy: StrategyId::new("test"),
cid: ClientOrderId::new(cid),
},
state: RequestOpen {
side,
price: Some(d(price)),
quantity: d(quantity),
kind: OrderKind::Limit,
time_in_force,
position_id: None,
reduce_only: false,
market: None,
},
}
}
pub(super) fn book(best_bid: &str, best_ask: &str) -> MarketSnapshot {
MarketSnapshot {
best_bid: Some(d(best_bid)),
best_ask: Some(d(best_ask)),
last_price: None,
}
}
pub(super) fn traded(last_price: &str) -> MarketSnapshot {
MarketSnapshot {
best_bid: None,
best_ask: None,
last_price: Some(d(last_price)),
}
}
pub(super) fn market_prices(price: &str) -> Option<MarketSnapshot> {
let p = Some(d(price));
Some(MarketSnapshot {
best_bid: p,
best_ask: p,
last_price: p,
})
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests {
use super::{fixtures::*, *};
use crate::{
AccountEventKind,
fee::FeeModelConfig,
order::{
OrderEvent, OrderKey,
id::ClientOrderId,
request::RequestCancel,
state::{InactiveOrderState, OrderState},
},
};
use rust_decimal::Decimal;
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn open_order_emissions_are_ordered_across_fills() {
const FILLS: usize = 8;
const DEBIT_PER_FILL: &str = "500";
let usdt_start = "10000000";
let (request_tx, request_rx) = mpsc::unbounded_channel();
let (event_tx, mut events) = broadcast::channel(64);
let exchange = MockExchange::new(
spot_config("100", usdt_start, FeeModelConfig::default()),
request_rx,
event_tx,
spot_instruments(),
);
let driver = tokio::spawn(exchange.run());
let mut responses = Vec::with_capacity(FILLS);
for nth in 0..FILLS {
let (response_tx, response_rx) = oneshot::channel();
let mut request = buy_request("0.01", market_prices("50000"));
request.key.cid = ClientOrderId::new(format!("cid-{nth}"));
request_tx
.send(MockExchangeRequest::open_order(
Utc::now(),
response_tx,
request,
))
.unwrap();
responses.push(response_rx);
}
drop(request_tx);
driver.await.unwrap();
for response in responses {
let response = response.await.expect("every open is answered");
assert!(
matches!(
response.state,
OrderState::Active(_) | OrderState::Inactive(_)
),
"the account is funded and the instrument priced, so every open fills: {response:?}"
);
}
let balances = std::iter::from_fn(|| events.try_recv().ok())
.filter_map(|event| match event.kind {
AccountEventKind::BalanceSnapshot(snapshot) if snapshot.0.asset == quote() => {
Some(snapshot.0.balance.total)
}
_ => None,
})
.collect::<Vec<_>>();
let expected = (1..=FILLS)
.map(|nth| d(usdt_start) - d(DEBIT_PER_FILL) * Decimal::from(nth))
.collect::<Vec<_>>();
assert_eq!(
balances, expected,
"balance snapshots must reach the client in the order the venue booked them"
);
}
#[tokio::test]
async fn the_driver_fills_at_the_configured_slippage() {
let (request_tx, request_rx) = mpsc::unbounded_channel();
let (event_tx, mut events) = broadcast::channel(16);
let config = MockExecutionConfig {
slippage: crate::slippage::SlippageConfig::fixed(d("0.01")).unwrap(),
..spot_config("100", "10000000", FeeModelConfig::default())
};
let exchange = MockExchange::new(config, request_rx, event_tx, spot_instruments());
let driver = tokio::spawn(exchange.run());
let (response_tx, response_rx) = oneshot::channel();
request_tx
.send(MockExchangeRequest::open_order(
Utc::now(),
response_tx,
buy_request("0.01", market_prices("50000")),
))
.unwrap();
response_rx.await.expect("the open is answered");
drop(request_tx);
driver.await.unwrap();
let prices = std::iter::from_fn(|| events.try_recv().ok())
.filter_map(|event| match event.kind {
AccountEventKind::Trade(trade) => Some(trade.price),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(prices, vec![d("50500")], "50,000 bought 1% above");
}
#[tokio::test(start_paused = true)]
async fn a_swept_expiry_is_emitted_behind_the_fill_booked_before_it() {
let opened = Utc::now();
let expiry = opened + chrono::TimeDelta::seconds(30);
let (request_tx, request_rx) = mpsc::unbounded_channel();
let (event_tx, mut events) = broadcast::channel(64);
let mut config =
spot_config_holding("100", "10000000", seeded_gtd("gtd", "1", opened, expiry));
config.latency_ms = 50;
let exchange = MockExchange::new(config, request_rx, event_tx, spot_instruments());
let driver = tokio::spawn(exchange.run());
let (fill_tx, fill_rx) = oneshot::channel();
request_tx
.send(MockExchangeRequest::open_order(
opened,
fill_tx,
buy_request("0.01", market_prices("50000")),
))
.unwrap();
let (snapshot_tx, snapshot_rx) = oneshot::channel();
request_tx
.send(MockExchangeRequest::fetch_account_snapshot(
expiry + chrono::TimeDelta::seconds(1),
snapshot_tx,
))
.unwrap();
drop(request_tx);
driver.await.unwrap();
fill_rx.await.expect("the open is answered");
let _ = snapshot_rx.await;
let kinds = std::iter::from_fn(|| events.try_recv().ok())
.map(|event| match event.kind {
AccountEventKind::BalanceSnapshot(_) => "balance",
AccountEventKind::Trade(_) => "trade",
AccountEventKind::OrderSnapshot(_) => "order",
_ => "other",
})
.collect::<Vec<_>>();
assert_eq!(
kinds,
["balance", "balance", "trade", "order"],
"the fill's events must all precede the sweep, which for a seeded order — held with \
no reservation — owes only its terminal snapshot"
);
}
#[tokio::test]
async fn a_cancel_request_is_answered_rather_than_dropped() {
let (request_tx, request_rx) = mpsc::unbounded_channel();
let (event_tx, _events) = broadcast::channel(16);
let exchange = MockExchange::new(
spot_config("100", "10000", FeeModelConfig::default()),
request_rx,
event_tx,
spot_instruments(),
);
let driver = tokio::spawn(exchange.run());
let (response_tx, response_rx) = oneshot::channel();
let open = buy_request("1", market_prices("50000"));
request_tx
.send(MockExchangeRequest::cancel_order(
Utc::now(),
response_tx,
OrderEvent {
key: open.key,
state: RequestCancel { id: None },
},
))
.unwrap();
let response = response_rx.await.expect("a cancel must be answered");
assert!(
response.state.is_err(),
"this venue rests no orders, so a cancel must be rejected: {response:?}"
);
drop(request_tx);
driver.await.unwrap();
}
#[tokio::test]
async fn an_order_state_lookup_is_answered_from_the_ledger() {
use crate::client::{OrderStatusClient, mock::MockExecution};
let (request_tx, request_rx) = mpsc::unbounded_channel();
let (event_tx, event_rx) = broadcast::channel(16);
let exchange = MockExchange::new(
spot_config("100", "10000000", FeeModelConfig::default()),
request_rx,
event_tx,
spot_instruments(),
);
let driver = tokio::spawn(exchange.run());
let open = buy_request("1", market_prices("50000"));
let (response_tx, response_rx) = oneshot::channel();
request_tx
.send(MockExchangeRequest::open_order(
Utc::now(),
response_tx,
open.clone(),
))
.unwrap();
let opened = response_rx.await.expect("an open must be answered");
assert!(matches!(
opened.state,
OrderState::Inactive(InactiveOrderState::FullyFilled(_))
));
let client = MockExecution {
mocked_exchange: EXCHANGE,
clock: Utc::now,
request_tx: request_tx.clone(),
event_rx,
};
let unknown = OrderKey {
cid: ClientOrderId::new("never-opened"),
..open.key.clone()
};
let ended = client
.fetch_ended_orders(&[open.key.clone(), unknown])
.await
.expect("the venue is running");
let [order] = ended.as_slice() else {
panic!("only the filled order is reported: {ended:?}");
};
assert_eq!(order.key, open.key);
assert!(matches!(order.state, InactiveOrderState::FullyFilled(_)));
drop(client);
drop(request_tx);
driver.await.unwrap();
}
}