use crate::{
UnindexedAccountEvent,
client::mock::MockExecutionConfig,
error::StreamTerminationReason,
exchange::mock::{
request::{MockExchangeRequest, MockExchangeRequestKind},
venue::VenueRegime,
},
market::MarketObservation,
};
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::{collections::VecDeque, fmt::Debug, sync::Arc, time::Duration};
const MAX_DEADLINE_WAIT: Duration = Duration::from_secs(86_400);
use tokio::sync::{broadcast, mpsc, oneshot};
use tokio_stream::{StreamExt, wrappers::BroadcastStream};
use tracing::{error, info, warn};
pub mod account;
pub mod orders;
pub mod request;
pub mod venue;
pub use venue::{CancelOutcome, OpenOutcome, SimulatedVenue, VenueInstrumentMarket, VenueOutcome};
pub type MockExchangeClock = Arc<dyn Fn() -> DateTime<Utc> + Send + Sync>;
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum MarketFeedEvent {
Observation {
instrument: InstrumentNameExchange,
observation: MarketObservation,
},
Lagged { missed: u64 },
}
pub struct MockExchange {
pub venue: SimulatedVenue,
pub latency_ms: u64,
pub request_rx: mpsc::UnboundedReceiver<MockExchangeRequest>,
pub event_tx: broadcast::Sender<UnindexedAccountEvent>,
market: Option<MarketDrive>,
}
impl Debug for MockExchange {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MockExchange")
.field("venue", &self.venue)
.field("latency_ms", &self.latency_ms)
.field("request_rx", &self.request_rx)
.field("event_tx", &self.event_tx)
.field("market_driven", &self.market.is_some())
.finish()
}
}
struct MarketDrive {
feed: BoxStream<'static, MarketFeedEvent>,
clock: MockExchangeClock,
}
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,
market: None,
}
}
pub fn new_market_driven(
venue: SimulatedVenue,
latency_ms: u64,
request_rx: mpsc::UnboundedReceiver<MockExchangeRequest>,
event_tx: broadcast::Sender<UnindexedAccountEvent>,
feed: BoxStream<'static, MarketFeedEvent>,
clock: MockExchangeClock,
) -> Self {
assert_eq!(
venue.regime(),
VenueRegime::MarketDriven,
"MockExchange::new_market_driven given a request-priced venue: build it with \
SimulatedVenue::new_market_driven"
);
Self {
venue,
latency_ms,
request_rx,
event_tx,
market: Some(MarketDrive { feed, clock }),
}
}
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,
));
match self.market.take() {
None => {
let emit = Emit::new(emit_tx, self.venue.exchange, self.latency_ms);
self.serve_request_priced(&emit).await;
}
Some(drive) => {
let emit = Emit::new(emit_tx, self.venue.exchange, self.latency_ms / 2);
self.serve_market_driven(drive, &emit).await;
}
}
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");
}
async fn serve_request_priced(&mut self, emit: &Emit) {
while let Some(request) = self.request_rx.recv().await {
let expiries = self.advance_venue_time(request.time_request);
emit.queue(expiries, None);
self.book(request.kind, emit);
}
}
async fn serve_market_driven(&mut self, drive: MarketDrive, emit: &Emit) {
let MarketDrive { mut feed, clock } = drive;
let mut clock = MonotonicClock::new(clock);
let to_venue = Duration::from_millis(self.latency_ms / 2);
let exchange = self.venue.exchange;
let mut arriving = VecDeque::<(tokio::time::Instant, MockExchangeRequestKind)>::new();
let mut feed_open = true;
let mut requests_open = true;
let mut stuck_deadline = None;
let started = self.venue.advance_time(clock.now());
emit.queue(started, None);
while requests_open || !arriving.is_empty() {
let arrives_at = arriving.front().map(|(at, _)| *at);
let deadline = self
.venue
.next_deadline()
.filter(|deadline| Some(*deadline) != stuck_deadline);
let mut capped = false;
let deadline_at = deadline.map(|deadline| {
let wait = (deadline - clock.peek()).to_std().unwrap_or(Duration::ZERO);
capped = wait > MAX_DEADLINE_WAIT;
tokio::time::Instant::now() + wait.min(MAX_DEADLINE_WAIT)
});
tokio::select! {
biased;
event = futures::StreamExt::next(&mut feed), if feed_open => match event {
Some(MarketFeedEvent::Observation { instrument, observation }) => {
let events = self.venue.apply_market(&instrument, observation, clock.now());
emit.queue(events, None);
}
Some(MarketFeedEvent::Lagged { missed }) => {
error!(
%exchange,
missed,
"MockExchange market feed lagged: the venue missed market \
observations, so resting orders may have missed fills; ending the \
account stream"
);
emit.queue(
vec![UnindexedAccountEvent::stream_terminated(
exchange,
StreamTerminationReason::Error(format!(
"mock exchange market feed lagged: {missed} observations missed"
)),
)],
None,
);
}
None => {
feed_open = false;
warn!(
%exchange,
"MockExchange market feed ended: serving requests on the last market seen"
);
}
},
() = sleep_until(arrives_at), if arrives_at.is_some() => {
if let Some((_, kind)) = arriving.pop_front() {
let expiries = self.venue.advance_time(clock.now());
emit.queue(expiries, None);
self.book(kind, emit);
}
},
request = self.request_rx.recv(), if requests_open => match request {
Some(request) => {
arriving.push_back((tokio::time::Instant::now() + to_venue, request.kind));
}
None => requests_open = false,
},
() = sleep_until(deadline_at), if deadline_at.is_some() => {
if let Some(deadline) = deadline {
if capped {
continue;
}
let now = clock.raise_to(deadline);
let events = self.venue.advance_time(now);
emit.queue(events, None);
if self.venue.next_deadline() == Some(deadline) {
error!(
%exchange,
%deadline,
"MockExchange venue still reports a deadline it was moved to; \
no longer waking for it"
);
stuck_deadline = Some(deadline);
}
}
},
}
}
}
fn book(&mut self, kind: MockExchangeRequestKind, emit: &Emit) {
match kind {
MockExchangeRequestKind::FetchAccountSnapshot { response_tx } => {
emit.respond(Vec::new(), response_tx, self.venue.account_snapshot());
}
MockExchangeRequestKind::FetchBalances {
response_tx,
assets,
} => {
emit.respond(Vec::new(), response_tx, self.venue.balances(&assets));
}
MockExchangeRequestKind::FetchOrdersOpen {
response_tx,
instruments,
} => {
emit.respond(
Vec::new(),
response_tx,
self.venue.orders_open(&instruments),
);
}
MockExchangeRequestKind::FetchOrdersEnded {
response_tx,
orders,
} => {
emit.respond(Vec::new(), response_tx, self.venue.orders_ended(&orders));
}
MockExchangeRequestKind::FetchTrades {
response_tx,
start,
end,
} => {
let mut trades = self.venue.trades(start);
trades.retain(|trade| trade.time_exchange <= end);
emit.respond(Vec::new(), response_tx, trades);
}
MockExchangeRequestKind::CancelOrder {
response_tx,
request,
} => {
let outcome = self.venue.cancel_order(request);
emit.respond(outcome.events, response_tx, outcome.response);
}
MockExchangeRequestKind::OpenOrder {
response_tx,
request,
} => {
let outcome = self.venue.open_order(request);
emit.respond(outcome.events, response_tx, outcome.response);
}
}
}
#[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)
}
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"
);
}
}
if let Some(respond) = emission.respond {
respond(exchange);
}
}
}
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
}
},
))
}
}
async fn sleep_until(at: Option<tokio::time::Instant>) {
match at {
Some(at) => tokio::time::sleep_until(at).await,
None => std::future::pending().await,
}
}
struct MonotonicClock {
clock: MockExchangeClock,
latest: Option<DateTime<Utc>>,
}
impl MonotonicClock {
fn new(clock: MockExchangeClock) -> Self {
Self {
clock,
latest: None,
}
}
fn now(&mut self) -> DateTime<Utc> {
let now = (self.clock)();
self.raise_to(now)
}
fn peek(&self) -> DateTime<Utc> {
let now = (self.clock)();
self.latest.map_or(now, |latest| latest.max(now))
}
fn raise_to(&mut self, time: DateTime<Utc>) -> DateTime<Utc> {
let latest = self.latest.map_or(time, |latest| latest.max(time));
self.latest = Some(latest);
latest
}
}
struct Emit {
tx: mpsc::UnboundedSender<PendingEmission>,
exchange: ExchangeId,
delay: Duration,
}
impl Emit {
fn new(
tx: mpsc::UnboundedSender<PendingEmission>,
exchange: ExchangeId,
delay_ms: u64,
) -> Self {
Self {
tx,
exchange,
delay: Duration::from_millis(delay_ms),
}
}
fn respond<Response>(
&self,
events: Vec<UnindexedAccountEvent>,
response_tx: oneshot::Sender<Response>,
response: Response,
) where
Response: Send + 'static,
{
let respond: Respond = Box::new(move |exchange| {
if response_tx.send(response).is_err() {
error!(
%exchange,
kind = std::any::type_name::<Response>(),
"MockExchange failed to send oneshot response to client"
);
}
});
self.queue(events, Some(respond));
}
fn queue(&self, events: Vec<UnindexedAccountEvent>, respond: Option<Respond>) {
if events.is_empty() && respond.is_none() {
return;
}
let ready_at = tokio::time::Instant::now() + self.delay;
if self
.tx
.send(PendingEmission {
ready_at,
events,
respond,
})
.is_err()
{
error!(
exchange = %self.exchange,
"MockExchange could not queue venue output: the emitter has stopped"
);
}
}
}
type Respond = Box<dyn FnOnce(ExchangeId) + Send>;
struct PendingEmission {
ready_at: tokio::time::Instant,
events: Vec<UnindexedAccountEvent>,
respond: Option<Respond>,
}
#[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(),
crate::fill::MarketOrderTiming::default(),
crate::fill::StaleMarketPolicy::default(),
None,
None,
)
}
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(),
crate::fill::MarketOrderTiming::default(),
crate::fill::StaleMarketPolicy::default(),
None,
None,
)
}
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 {
market,
..RequestOpen::new(
side,
None, d(quantity),
OrderKind::Market,
TimeInForce::ImmediateOrCancel,
)
},
}
}
pub(super) fn with_effect(
mut request: OrderRequestOpen<ExchangeId, InstrumentNameExchange>,
effect: crate::margin::MarginEffect,
) -> OrderRequestOpen<ExchangeId, InstrumentNameExchange> {
request.state = request.state.with_margin_effect(effect);
request
}
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::new(
side,
Some(d(price)),
d(quantity),
OrderKind::Limit,
time_in_force,
),
}
}
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::market::MarketSnapshot;
use crate::{
AccountEventKind,
fee::FeeModelConfig,
order::{
OrderEvent, OrderKey,
id::ClientOrderId,
request::RequestCancel,
state::{InactiveOrderState, OrderState},
},
};
use rust_decimal::Decimal;
use rustrade_instrument::Side;
#[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();
}
#[tokio::test(start_paused = true)]
async fn a_fetch_is_answered_after_the_fill_booked_before_it() {
let (request_tx, request_rx) = mpsc::unbounded_channel();
let (event_tx, mut events) = broadcast::channel(64);
let mut config = spot_config("0", "10000000", FeeModelConfig::default());
config.latency_ms = 50;
let exchange = MockExchange::new(config, request_rx, event_tx, spot_instruments());
let driver = tokio::spawn(exchange.run());
let (open_tx, open_rx) = oneshot::channel();
request_tx
.send(MockExchangeRequest::open_order(
Utc::now(),
open_tx,
buy_request("0.01", market_prices("50000")),
))
.unwrap();
let (balances_tx, balances_rx) = oneshot::channel();
request_tx
.send(MockExchangeRequest::fetch_balances(
Utc::now(),
vec![quote()],
balances_tx,
))
.unwrap();
balances_rx.await.expect("the fetch is answered");
let kinds = std::iter::from_fn(|| events.try_recv().ok())
.map(|event| event_name(&event))
.collect::<Vec<_>>();
assert!(
kinds.contains(&"trade"),
"the fill booked before the fetch is emitted before the fetch is answered: {kinds:?}"
);
open_rx.await.expect("the open is answered");
drop(request_tx);
driver.await.unwrap();
}
#[test]
#[should_panic(expected = "request-priced venue")]
fn a_market_driven_driver_refuses_a_request_priced_venue() {
let config = spot_config("0", "10000", FeeModelConfig::default());
let (_request_tx, request_rx) = mpsc::unbounded_channel();
let (event_tx, _events) = broadcast::channel(16);
let _ = MockExchange::new_market_driven(
SimulatedVenue::new(&config, spot_instruments()),
0,
request_rx,
event_tx,
futures::StreamExt::boxed(futures::stream::empty()),
Arc::new(Utc::now),
);
}
fn event_name(event: &UnindexedAccountEvent) -> &'static str {
match &event.kind {
AccountEventKind::BalanceSnapshot(_) => "balance",
AccountEventKind::Trade(_) => "trade",
AccountEventKind::OrderSnapshot(_) => "order",
AccountEventKind::StreamTerminated(_) => "terminated",
_ => "other",
}
}
struct Paper {
request_tx: mpsc::UnboundedSender<MockExchangeRequest>,
market_tx: mpsc::UnboundedSender<MarketFeedEvent>,
events: broadcast::Receiver<UnindexedAccountEvent>,
driver: tokio::task::JoinHandle<()>,
start: DateTime<Utc>,
}
impl Paper {
fn start(config: MockExecutionConfig) -> Self {
let start = DateTime::<Utc>::from_timestamp(1_700_000_000, 0).unwrap();
let anchor = tokio::time::Instant::now();
let clock: MockExchangeClock = Arc::new(move || {
start + TimeDelta::from_std(anchor.elapsed()).expect("a test runs for less")
});
Self::start_on(config, clock, start)
}
fn start_on(
config: MockExecutionConfig,
clock: MockExchangeClock,
start: DateTime<Utc>,
) -> Self {
let (request_tx, request_rx) = mpsc::unbounded_channel();
let (event_tx, events) = broadcast::channel(256);
let (market_tx, market_rx) = mpsc::unbounded_channel();
let exchange = MockExchange::new_market_driven(
SimulatedVenue::new_market_driven(&config, spot_instruments()),
config.latency_ms,
request_rx,
event_tx,
futures::StreamExt::boxed(tokio_stream::wrappers::UnboundedReceiverStream::new(
market_rx,
)),
clock,
);
Self {
request_tx,
market_tx,
events,
driver: tokio::spawn(exchange.run()),
start,
}
}
fn observe(&self, snapshot: MarketSnapshot) {
self.market_tx
.send(MarketFeedEvent::Observation {
instrument: instrument_name(),
observation: MarketObservation::new(snapshot),
})
.unwrap();
}
async fn open(
&self,
request: crate::order::request::OrderRequestOpen<ExchangeId, InstrumentNameExchange>,
) -> crate::order::Order<
ExchangeId,
InstrumentNameExchange,
crate::order::state::UnindexedOrderState,
> {
let (response_tx, response_rx) = oneshot::channel();
self.request_tx
.send(MockExchangeRequest::open_order(
Utc::now(),
response_tx,
request,
))
.unwrap();
response_rx.await.expect("an open is answered")
}
async fn next_event(&mut self) -> UnindexedAccountEvent {
tokio::time::timeout(std::time::Duration::from_secs(3600), self.events.recv())
.await
.expect("an account event arrives")
.expect("the account channel is open")
}
async fn until_trade(
&mut self,
) -> crate::trade::Trade<AssetNameExchange, InstrumentNameExchange> {
loop {
if let AccountEventKind::Trade(trade) = self.next_event().await.kind {
return trade;
}
}
}
async fn stop(self) {
drop(self.request_tx);
self.driver.await.unwrap();
}
}
fn priced(price: &str) -> MarketSnapshot {
market_prices(price).unwrap()
}
#[tokio::test(start_paused = true)]
async fn a_paper_limit_rests_then_fills_when_the_market_crosses_it() {
let mut config = spot_config("0", "100000", FeeModelConfig::default());
config.latency_ms = 100;
let mut paper = Paper::start(config);
paper.observe(priced("50000"));
let sent = tokio::time::Instant::now();
let response = paper
.open(limit_request("rest", Side::Buy, "1", "49000", gtc()))
.await;
assert!(
matches!(response.state, OrderState::Active(_)),
"a limit below the market rests: {response:?}"
);
assert_eq!(
sent.elapsed(),
std::time::Duration::from_millis(100),
"half the latency to the venue, half back"
);
paper.observe(priced("48000"));
let trade = paper.until_trade().await;
assert_eq!(trade.quantity, d("1"));
assert!(
trade.price <= d("49000"),
"a buy limit never pays more: {trade:?}"
);
paper.stop().await;
}
#[tokio::test(start_paused = true)]
async fn a_paper_request_is_booked_against_the_market_when_it_arrives() {
let mut config = spot_config("0", "10000000", FeeModelConfig::default());
config.latency_ms = 100;
let mut paper = Paper::start(config);
paper.observe(priced("50000"));
let (response_tx, response_rx) = oneshot::channel();
paper
.request_tx
.send(MockExchangeRequest::open_order(
Utc::now(),
response_tx,
buy_request("0.01", market_prices("1")),
))
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
paper.observe(priced("51000"));
let trade = paper.until_trade().await;
assert_eq!(
trade.price,
d("51000"),
"booked against the market as it arrived"
);
assert_eq!(
trade.time_exchange,
paper.start + TimeDelta::milliseconds(50),
"stamped when it reached the venue"
);
response_rx.await.expect("the open is answered");
paper.stop().await;
}
#[tokio::test(start_paused = true)]
async fn a_paper_order_expires_at_its_deadline_in_a_quiet_market() {
let config = spot_config("0", "100000", FeeModelConfig::default());
let mut paper = Paper::start(config);
paper.observe(priced("50000"));
let expiry = paper.start + TimeDelta::seconds(10);
let started = tokio::time::Instant::now();
let response = paper
.open(limit_request(
"gtd",
Side::Buy,
"1",
"49000",
crate::order::TimeInForce::GoodTillDate { expiry },
))
.await;
assert!(matches!(response.state, OrderState::Active(_)));
let expired = loop {
let event = paper.next_event().await;
if let AccountEventKind::OrderSnapshot(order) = event.kind
&& matches!(
order.0.state,
OrderState::Inactive(InactiveOrderState::Expired(_))
)
{
break order.0;
}
};
let OrderState::Inactive(InactiveOrderState::Expired(expired)) = expired.state else {
unreachable!("matched above")
};
assert_eq!(expired.time_exchange, expiry, "retired at its own deadline");
assert!(
started.elapsed() >= std::time::Duration::from_secs(10),
"and not before that time has passed: {:?}",
started.elapsed()
);
paper.stop().await;
}
#[tokio::test(start_paused = true)]
async fn a_lagged_paper_feed_ends_the_account_stream_after_the_fills_before_it() {
let config = spot_config("0", "10000000", FeeModelConfig::default());
let mut paper = Paper::start(config);
paper.observe(priced("50000"));
paper.open(buy_request("0.01", None)).await;
paper
.market_tx
.send(MarketFeedEvent::Lagged { missed: 3 })
.unwrap();
let mut kinds = Vec::new();
while kinds.last() != Some(&"terminated") {
kinds.push(event_name(&paper.next_event().await));
}
assert!(
kinds.contains(&"trade"),
"the fill booked before the lag precedes the termination: {kinds:?}"
);
let response = paper.open(buy_request("0.01", None)).await;
assert!(
matches!(
response.state,
OrderState::Inactive(InactiveOrderState::FullyFilled(_))
),
"the venue keeps trading after the lag: {response:?}"
);
paper.stop().await;
}
#[tokio::test(start_paused = true)]
async fn a_paper_driver_keeps_trading_once_its_feed_ends() {
let config = spot_config("0", "10000000", FeeModelConfig::default());
let mut paper = Paper::start(config);
paper.observe(priced("50000"));
let (replacement, _) = mpsc::unbounded_channel();
drop(std::mem::replace(&mut paper.market_tx, replacement));
paper.open(buy_request("0.01", None)).await;
assert_eq!(paper.until_trade().await.price, d("50000"));
paper.stop().await;
}
#[tokio::test(start_paused = true)]
async fn a_paper_clock_that_steps_back_does_not_move_the_venue_back() {
let later = DateTime::<Utc>::from_timestamp(1_700_000_000, 0).unwrap();
let reading = Arc::new(std::sync::Mutex::new(later));
let clock: MockExchangeClock = {
let reading = Arc::clone(&reading);
Arc::new(move || *reading.lock().unwrap())
};
let config = spot_config("0", "10000000", FeeModelConfig::default());
let mut paper = Paper::start_on(config, clock, later);
paper.observe(priced("50000"));
tokio::time::sleep(std::time::Duration::from_millis(1)).await;
*reading.lock().unwrap() = later - TimeDelta::seconds(100);
paper.open(buy_request("0.01", None)).await;
assert_eq!(paper.until_trade().await.time_exchange, later);
paper.stop().await;
}
#[tokio::test]
async fn a_request_priced_request_is_booked_half_the_latency_after_it_was_made() {
let (request_tx, request_rx) = mpsc::unbounded_channel();
let (event_tx, _events) = broadcast::channel(16);
let mut config = spot_config("0", "10000000", FeeModelConfig::default());
config.latency_ms = 100;
let exchange = MockExchange::new(config, request_rx, event_tx, spot_instruments());
let driver = tokio::spawn(exchange.run());
let made = DateTime::<Utc>::from_timestamp(1_700_000_000, 0).unwrap();
let (open_tx, open_rx) = oneshot::channel();
request_tx
.send(MockExchangeRequest::open_order(
made,
open_tx,
buy_request("0.01", market_prices("50000")),
))
.unwrap();
open_rx.await.expect("the open is answered");
let booked = made + TimeDelta::milliseconds(50);
let (trades_tx, trades_rx) = oneshot::channel();
request_tx
.send(MockExchangeRequest::fetch_trades(
made, trades_tx, made, booked,
))
.unwrap();
let trades = trades_rx.await.expect("the fetch is answered");
assert_eq!(
trades
.iter()
.map(|trade| trade.time_exchange)
.collect::<Vec<_>>(),
vec![booked],
"booked at half the latency, and a bound at that instant includes it"
);
drop(request_tx);
driver.await.unwrap();
}
#[tokio::test(start_paused = true)]
async fn a_paper_request_in_flight_is_answered_after_the_request_channel_closes() {
let mut config = spot_config("0", "10000000", FeeModelConfig::default());
config.latency_ms = 100;
let paper = Paper::start(config);
paper.observe(priced("50000"));
let (response_tx, response_rx) = oneshot::channel();
paper
.request_tx
.send(MockExchangeRequest::open_order(
Utc::now(),
response_tx,
buy_request("0.01", None),
))
.unwrap();
drop(paper.request_tx);
let response = response_rx.await.expect("the open is still answered");
assert!(matches!(response.state, OrderState::Inactive(_)));
paper.driver.await.unwrap();
}
#[tokio::test]
async fn a_driver_debug_names_the_driver() {
let (_request_tx, request_rx) = mpsc::unbounded_channel();
let (event_tx, _events) = broadcast::channel(16);
let config = spot_config("0", "10000000", FeeModelConfig::default());
let exchange = MockExchange::new(config, request_rx, event_tx, spot_instruments());
assert!(format!("{exchange:?}").contains("MockExchange"));
}
#[tokio::test(start_paused = true)]
async fn a_paper_venue_refuses_a_market_order_before_the_first_observation_and_cancels_a_rest()
{
let mut config = spot_config("0", "10000000", FeeModelConfig::default());
config.latency_ms = 100;
let paper = Paper::start(config);
let refused = paper.open(buy_request("0.01", None)).await;
assert!(
matches!(refused.state, OrderState::Inactive(_)),
"nothing to price a market order from: {refused:?}"
);
let rest = limit_request("rest", Side::Buy, "1", "49000", gtc());
let key = rest.key.clone();
let resting = paper.open(rest).await;
assert!(matches!(resting.state, OrderState::Active(_)));
let (response_tx, response_rx) = oneshot::channel();
paper
.request_tx
.send(MockExchangeRequest::cancel_order(
Utc::now(),
response_tx,
OrderEvent {
key,
state: RequestCancel { id: None },
},
))
.unwrap();
let cancelled = response_rx.await.expect("the cancel is answered");
assert!(cancelled.state.is_ok(), "{cancelled:?}");
paper.stop().await;
}
#[tokio::test(start_paused = true)]
async fn a_paper_deadline_beyond_the_longest_sleep_is_retired_on_time() {
let config = spot_config("0", "100000", FeeModelConfig::default());
let mut paper = Paper::start(config);
paper.observe(priced("50000"));
let expiry = paper.start + TimeDelta::days(3 * 365);
let started = tokio::time::Instant::now();
paper
.open(limit_request(
"far",
Side::Buy,
"1",
"49000",
crate::order::TimeInForce::GoodTillDate { expiry },
))
.await;
let expired = loop {
let event = paper
.events
.recv()
.await
.expect("the account channel is open");
if let AccountEventKind::OrderSnapshot(order) = event.kind
&& let OrderState::Inactive(InactiveOrderState::Expired(expired)) = order.0.state
{
break expired;
}
};
assert_eq!(expired.time_exchange, expiry);
assert!(started.elapsed() >= std::time::Duration::from_secs(3 * 365 * 24 * 60 * 60));
paper.stop().await;
}
}