use crate::{
AccountEventKind, InstrumentAccountSnapshot, UnindexedAccountEvent, UnindexedAccountSnapshot,
balance::AssetBalance,
client::mock::MockExecutionConfig,
error::{ApiError, UnindexedApiError, UnindexedOrderError},
exchange::mock::{
account::{AccountState, Debit},
orders::{AlreadyResting, OpenOrder, Reservation, RestingOrder},
},
fee::{FeeModel, FeeModelConfig, Liquidity},
fill::{FillContext, FillModel, SimFillConfig},
market::{MarketDepth, MarketSnapshot},
order::{
Order, OrderKind, TimeInForce, UnindexedInactiveOrder, UnindexedOrder, UnindexedOrderKey,
id::{ClientOrderId, OrderId, VenueOrderId},
request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
state::{Cancelled, Expired, Filled, Open, OrderState, UnindexedOrderState},
},
position::{Position, PositionReport},
trade::{AssetFees, Trade, TradeId},
};
use chrono::{DateTime, Utc};
use fnv::{FnvHashMap, FnvHashSet};
use itertools::Itertools;
use rust_decimal::Decimal;
use rustrade_instrument::{
Side, Underlying,
asset::name::AssetNameExchange,
exchange::ExchangeId,
instrument::{Instrument, kind::InstrumentKind, name::InstrumentNameExchange},
};
use rustrade_integration::collection::snapshot::Snapshot;
use smol_str::ToSmolStr;
#[derive(Debug)]
pub struct VenueOutcome<Response> {
pub events: Vec<UnindexedAccountEvent>,
pub response: Response,
}
pub type OpenOutcome = VenueOutcome<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>>;
pub type CancelOutcome = VenueOutcome<UnindexedOrderResponseCancel>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct VenueInstrumentMarket {
pub snapshot: MarketSnapshot,
pub depth: MarketDepth,
pub time_exchange: DateTime<Utc>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VenueRegime {
RequestPriced,
MarketDriven,
}
#[derive(Debug)]
pub struct SimulatedVenue {
pub exchange: ExchangeId,
pub fee_model: FeeModelConfig,
pub fill_model: SimFillConfig,
pub instruments: FnvHashMap<InstrumentNameExchange, Instrument<ExchangeId, AssetNameExchange>>,
pub account: AccountState,
market: FnvHashMap<InstrumentNameExchange, VenueInstrumentMarket>,
positions: FnvHashMap<InstrumentNameExchange, NetPosition>,
order_sequence: u64,
order_id_start: u128,
trade_sequence: u64,
time_exchange_latest: DateTime<Utc>,
regime: VenueRegime,
}
impl SimulatedVenue {
pub fn new(
config: &MockExecutionConfig,
instruments: FnvHashMap<InstrumentNameExchange, Instrument<ExchangeId, AssetNameExchange>>,
) -> Self {
Self::with_regime(config, instruments, VenueRegime::RequestPriced)
}
pub fn new_market_driven(
config: &MockExecutionConfig,
instruments: FnvHashMap<InstrumentNameExchange, Instrument<ExchangeId, AssetNameExchange>>,
) -> Self {
Self::with_regime(config, instruments, VenueRegime::MarketDriven)
}
fn with_regime(
config: &MockExecutionConfig,
instruments: FnvHashMap<InstrumentNameExchange, Instrument<ExchangeId, AssetNameExchange>>,
regime: VenueRegime,
) -> Self {
let positions = seeded_positions(&config.initial_state, &instruments);
let account = match AccountState::try_from(config.initial_state.clone()) {
Ok(account) => account,
Err(error) => panic!("invalid MockExecutionConfig::initial_state: {error}"),
};
let order_id_start = first_unseeded_order_id(&account);
Self {
exchange: config.mocked_exchange,
fee_model: config.fee_model,
fill_model: config.fill_model,
instruments,
account,
market: FnvHashMap::default(),
positions,
order_sequence: 0,
order_id_start,
trade_sequence: 0,
time_exchange_latest: Default::default(),
regime,
}
}
pub fn regime(&self) -> VenueRegime {
self.regime
}
#[must_use]
pub fn advance_time(&mut self, time_exchange: DateTime<Utc>) -> Vec<UnindexedAccountEvent> {
self.time_exchange_latest = time_exchange;
self.account.update_time_exchange(time_exchange);
self.sweep_expired(time_exchange)
}
fn sweep_expired(&mut self, time_exchange: DateTime<Utc>) -> Vec<UnindexedAccountEvent> {
let expired = self.account.orders().expired_as_of(time_exchange);
if expired.is_empty() {
return Vec::new();
}
let mut events = Vec::with_capacity(expired.len() * 2);
for cid in expired {
let Some(RestingOrder { order, reservation }) = self.account.remove_order(&cid) else {
continue;
};
if let Some(Reservation { asset, amount }) = reservation {
let balance = self.account.release(&asset, amount, time_exchange);
events.push(self.build_account_event(Snapshot(balance)));
}
let expired_id = order.state.id.or_client_id(&order.key.cid);
let expired_order = Order {
key: order.key,
side: order.side,
price: order.price,
quantity: order.quantity,
kind: order.kind,
time_in_force: order.time_in_force,
state: Expired {
id: expired_id,
time_exchange,
filled_quantity: Some(order.state.filled_quantity),
},
};
self.account.ack_expired(expired_order.clone());
events.push(self.build_account_event(Snapshot(UnindexedOrder::from(expired_order))));
}
events
}
pub fn time_exchange(&self) -> DateTime<Utc> {
self.time_exchange_latest
}
#[must_use]
pub fn apply_market(
&mut self,
instrument: &InstrumentNameExchange,
snapshot: MarketSnapshot,
depth: MarketDepth,
time_exchange: DateTime<Utc>,
) -> Vec<UnindexedAccountEvent> {
let entry = self.market.entry(instrument.clone()).or_default();
entry.snapshot = snapshot;
entry.depth = depth;
entry.time_exchange = time_exchange;
let mut events = self.sweep_expired(time_exchange);
events.append(&mut self.match_resting(instrument, snapshot, time_exchange));
events
}
fn match_resting(
&mut self,
instrument: &InstrumentNameExchange,
snapshot: MarketSnapshot,
time_exchange: DateTime<Utc>,
) -> Vec<UnindexedAccountEvent> {
let mut crossing: Vec<(ClientOrderId, Decimal)> = Vec::new();
for side in [Side::Buy, Side::Sell] {
crossing.extend(
self.account
.orders()
.resting(instrument, side)
.take_while(|order| {
order
.price
.is_some_and(|limit| crosses(side, limit, &snapshot))
})
.filter_map(|order| Some((order.key.cid.clone(), order.price?))),
);
}
if crossing.is_empty() {
return Vec::new();
}
let terms = match self.instrument_terms(instrument) {
Ok(terms) => terms,
Err(error) => panic!(
"SimulatedVenue holds resting orders on an instrument it cannot price: {error}"
),
};
let mut events = Vec::with_capacity(crossing.len() * 3);
for (cid, limit) in crossing {
let Some(RestingOrder { order, reservation }) = self.account.remove_order(&cid) else {
continue;
};
let remaining = order.state.quantity_remaining(order.quantity);
debug_assert!(
remaining > Decimal::ZERO,
"resting order {cid} arrived with {} of {} already filled, so crossing it trades \
nothing: an order with no quantity left to trade does not belong on the book",
order.state.filled_quantity,
order.quantity
);
let settlement = self.settlement(
&terms,
self.net_position(&terms, instrument),
order.side,
remaining,
limit,
Liquidity::Maker,
);
let balance = match self.settle_resting(
&order,
reservation,
&settlement,
&terms,
time_exchange,
) {
RestingSettlement::Settled(balance) => balance,
RestingSettlement::Unaffordable { released } => {
events.extend(self.cancel_unaffordable(order, released, time_exchange));
continue;
}
};
self.record_fill(
&terms,
instrument,
order.side,
Fill {
quantity: remaining,
price: limit,
},
);
let order_id = order.state.id.or_client_id(&order.key.cid);
let trade = Trade {
id: self.trade_id_sequence_fetch_add(),
order_id: order_id.clone(),
instrument: order.key.instrument.clone(),
strategy: order.key.strategy.clone(),
time_exchange,
side: order.side,
price: limit,
quantity: remaining,
order_filled_quantity: Some(order.state.filled_quantity + remaining),
fees: settlement.fees,
};
self.account.ack_trade(trade.clone());
let filled = Order {
key: order.key,
side: order.side,
price: order.price,
quantity: order.quantity,
kind: order.kind,
time_in_force: order.time_in_force,
state: Filled::new(
order_id,
time_exchange,
order.quantity,
order.state.filled_quantity.is_zero().then_some(limit),
),
};
self.account.ack_filled(filled.clone());
let filled = filled.map_state(OrderState::fully_filled);
events.push(self.build_account_event(Snapshot(balance)));
events.push(self.build_account_event(trade));
events.push(self.build_account_event(Snapshot(filled)));
}
events
}
fn settle_resting(
&mut self,
order: &OpenOrder,
reservation: Option<Reservation>,
settlement: &Settlement,
terms: &InstrumentTerms,
time_exchange: DateTime<Utc>,
) -> RestingSettlement {
if terms.cash_settled {
let released = reservation.map(|Reservation { asset, amount }| {
self.account.release(&asset, amount, time_exchange)
});
return match self.account.commit(
&settlement.settled(),
settlement.credit,
time_exchange,
) {
Ok(balance) => RestingSettlement::Settled(balance),
Err(_) => RestingSettlement::Unaffordable { released },
};
}
let Some(Reservation { asset, amount }) = reservation else {
return RestingSettlement::Settled(
self.account
.commit(&settlement.settled(), Decimal::ZERO, time_exchange)
.unwrap_or_else(|insufficient| {
panic!(
"SimulatedVenue cannot afford the fill of resting order {}, which was \
seeded by `initial_state` and so reserved nothing: {} of {} free, {} \
required",
order.key.cid,
insufficient.free,
settlement.asset,
insufficient.required
)
}),
);
};
debug_assert!(
asset == settlement.asset && amount == settlement.amount,
"resting order {} reserved {amount} of {asset} but its fill settles {} of {}: the \
reservation and the fill disagree, so a balance the account never held would be \
reported",
order.key.cid,
settlement.amount,
settlement.asset
);
RestingSettlement::Settled(self.account.settle(&asset, amount, time_exchange))
}
fn cancel_unaffordable(
&mut self,
order: OpenOrder,
released: Option<AssetBalance<AssetNameExchange>>,
time_exchange: DateTime<Utc>,
) -> Vec<UnindexedAccountEvent> {
let mut events = Vec::with_capacity(2);
if let Some(balance) = released {
events.push(self.build_account_event(Snapshot(balance)));
}
let id = order.state.id.or_client_id(&order.key.cid);
let cancelled = Order {
key: order.key,
side: order.side,
price: order.price,
quantity: order.quantity,
kind: order.kind,
time_in_force: order.time_in_force,
state: Cancelled {
id,
time_exchange,
filled_quantity: Some(order.state.filled_quantity),
},
};
self.account.ack_cancelled(cancelled.clone());
events.push(self.build_account_event(Snapshot(UnindexedOrder::from(cancelled))));
events
}
pub fn market(&self, instrument: &InstrumentNameExchange) -> Option<&VenueInstrumentMarket> {
self.market.get(instrument)
}
pub fn order_sequence(&self) -> u64 {
self.order_sequence
}
pub fn balances(&self, assets: &[AssetNameExchange]) -> Vec<AssetBalance<AssetNameExchange>> {
self.account
.balances()
.filter(|balance| assets.is_empty() || assets.contains(&balance.asset))
.cloned()
.collect()
}
pub fn orders_open(
&self,
instruments: &[InstrumentNameExchange],
) -> Vec<Order<ExchangeId, InstrumentNameExchange, Open>> {
self.account
.orders_open()
.filter(|order| instruments.is_empty() || instruments.contains(&order.key.instrument))
.cloned()
.collect()
}
pub fn orders_ended(&self, orders: &[UnindexedOrderKey]) -> Vec<UnindexedInactiveOrder> {
let mut reported = FnvHashSet::default();
orders
.iter()
.filter_map(|key| {
let order = self
.account
.order_ended(&key.cid)
.filter(|order| order.key.instrument == key.instrument)?;
reported.insert(&key.cid).then(|| Order {
key: key.clone(),
..order
})
})
.collect()
}
pub fn trades(
&self,
time_since: DateTime<Utc>,
) -> Vec<Trade<AssetNameExchange, InstrumentNameExchange>> {
self.account.trades(time_since).cloned().collect()
}
pub fn open_order(
&mut self,
request: OrderRequestOpen<ExchangeId, InstrumentNameExchange>,
) -> OpenOutcome {
let market = self.pricing_market(&request);
let (response, notifications) = self.open_order_inner(request, market);
let events = match notifications {
Some(OpenOrderNotifications::Filled { balance, trade }) => vec![
self.build_account_event(balance),
self.build_account_event(trade),
],
Some(OpenOrderNotifications::Rested { balance }) => {
vec![self.build_account_event(balance)]
}
None => Vec::new(),
};
VenueOutcome { events, response }
}
pub fn cancel_order(
&mut self,
request: OrderRequestCancel<ExchangeId, InstrumentNameExchange>,
) -> CancelOutcome {
let time_exchange = self.time_exchange();
let Some(RestingOrder { order, reservation }) = self.account.remove_order(&request.key.cid)
else {
return VenueOutcome {
events: Vec::new(),
response: UnindexedOrderResponseCancel {
state: Err(self.cancel_missing_reason(&request.key.cid)),
key: request.key,
},
};
};
let events = match reservation {
Some(Reservation { asset, amount }) => {
let balance = self.account.release(&asset, amount, time_exchange);
vec![self.build_account_event(Snapshot(balance))]
}
None => Vec::new(),
};
let cancelled = Cancelled {
id: order.state.id.or_client_id(&order.key.cid),
time_exchange,
filled_quantity: Some(order.state.filled_quantity),
};
self.account.ack_cancelled(Order {
key: order.key,
side: order.side,
price: order.price,
quantity: order.quantity,
kind: order.kind,
time_in_force: order.time_in_force,
state: cancelled.clone(),
});
VenueOutcome {
events,
response: UnindexedOrderResponseCancel {
key: request.key,
state: Ok(cancelled),
},
}
}
fn cancel_missing_reason(&self, cid: &ClientOrderId) -> UnindexedOrderError {
if self.account.is_filled(cid) {
ApiError::OrderAlreadyFullyFilled.into()
} else if self.account.is_cancelled(cid) {
ApiError::OrderAlreadyCancelled.into()
} else if self.account.is_expired(cid) {
ApiError::OrderAlreadyExpired.into()
} else {
UnindexedOrderError::Rejected(ApiError::OrderRejected(format!(
"SimulatedVenue is not holding an open order with {cid}"
)))
}
}
pub fn account_snapshot(&self) -> UnindexedAccountSnapshot {
let balances = self.account.balances().cloned().collect();
let orders_open = self
.account
.orders_open()
.cloned()
.map(UnindexedOrder::from);
let orders_cancelled = self
.account
.orders_cancelled()
.cloned()
.map(UnindexedOrder::from);
let orders_expired = self
.account
.orders_expired()
.cloned()
.map(UnindexedOrder::from);
let orders_all = orders_open.chain(orders_cancelled).chain(orders_expired);
let orders_all = orders_all
.sorted_unstable_by_key(|order| (order.key.instrument.clone(), order.key.cid.clone()));
let orders_by_instrument = orders_all.chunk_by(|order| order.key.instrument.clone());
let mut orders_by_instrument: FnvHashMap<_, Vec<_>> = orders_by_instrument
.into_iter()
.map(|(instrument, orders)| (instrument, orders.collect()))
.collect();
let mut instruments: Vec<_> = self
.instruments
.iter()
.filter_map(|(name, instrument)| {
let orders = orders_by_instrument.remove(name);
let position = self.position_report(name, instrument);
if orders.is_none() && position == PositionReport::Unreported {
return None;
}
Some(InstrumentAccountSnapshot {
instrument: name.clone(),
orders: orders.unwrap_or_default(),
orders_complete: true,
position,
isolated: None,
})
})
.collect();
instruments.extend(
orders_by_instrument
.into_iter()
.map(|(instrument, orders)| InstrumentAccountSnapshot {
instrument,
orders,
orders_complete: true,
position: PositionReport::Unreported,
isolated: None,
}),
);
instruments.sort_unstable_by(|a, b| a.instrument.cmp(&b.instrument));
UnindexedAccountSnapshot {
exchange: self.exchange,
balances,
instruments,
}
}
fn position_report(
&self,
name: &InstrumentNameExchange,
instrument: &Instrument<ExchangeId, AssetNameExchange>,
) -> PositionReport {
if !matches!(instrument.kind, InstrumentKind::Cfd(_)) {
return PositionReport::Unreported;
}
let position = self.positions.get(name).copied().unwrap_or_default();
PositionReport::from_position(Position::new(
position.quantity,
(!position.quantity.is_zero()).then_some(position.entry),
None,
None,
None,
None,
self.time_exchange(),
))
}
fn pricing_market(
&self,
request: &OrderRequestOpen<ExchangeId, InstrumentNameExchange>,
) -> Option<MarketSnapshot> {
match self.regime {
VenueRegime::RequestPriced => request.state.market,
VenueRegime::MarketDriven => self
.market(&request.key.instrument)
.map(|market| market.snapshot),
}
}
fn open_order_inner(
&mut self,
request: OrderRequestOpen<ExchangeId, InstrumentNameExchange>,
market: Option<MarketSnapshot>,
) -> (
Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>,
Option<OpenOrderNotifications>,
) {
if let Err(error) = self.validate_order_kind_supported(request.state.kind) {
return (build_open_order_err_response(request, error), None);
}
if let Err(error) =
validate_time_in_force_supported(request.state.kind, request.state.time_in_force)
{
return (build_open_order_err_response(request, error), None);
}
let terms = match self.instrument_terms(&request.key.instrument) {
Ok(terms) => terms,
Err(error) => return (build_open_order_err_response(request, error), None),
};
if self.account.orders().contains(&request.key.cid) {
let reason = format!(
"{} already names an order resting on {}",
request.key.cid, request.key.exchange
);
return (
build_open_order_err_response(request, ApiError::DuplicateClientOrderId(reason)),
None,
);
}
let now = self.time_exchange();
if request.state.kind != OrderKind::Limit {
return match disposition(request.state.kind, request.state.time_in_force, true, now) {
Disposition::FillAndCancel => {
self.fill_on_arrival(request, market, &terms, Remainder::Cancel)
}
Disposition::FillOrNothing => {
self.fill_on_arrival(request, market, &terms, Remainder::Kill)
}
Disposition::Expire => self.expire_on_arrival(request, now),
Disposition::FillAndRest | Disposition::CancelUnfilled | Disposition::Rest => {
self.cancel_on_arrival(request, now)
}
};
}
let Some(limit) = request.state.price else {
let reason = format!(
"cannot open {} for {}: OrderKind::Limit carries no limit price",
request.key.instrument, request.key.exchange
);
return (
build_open_order_err_response(
request,
UnindexedOrderError::Rejected(ApiError::OrderRejected(reason)),
),
None,
);
};
let marketable = market.is_some_and(|market| crosses(request.state.side, limit, &market));
match disposition(
request.state.kind,
request.state.time_in_force,
marketable,
now,
) {
Disposition::FillAndRest => {
self.fill_on_arrival(request, market, &terms, Remainder::Rest { limit })
}
Disposition::FillAndCancel => {
self.fill_on_arrival(request, market, &terms, Remainder::Cancel)
}
Disposition::FillOrNothing => {
self.fill_on_arrival(request, market, &terms, Remainder::Kill)
}
Disposition::Rest => self.rest_order(request, limit, &terms, Decimal::ZERO),
Disposition::CancelUnfilled => self.cancel_on_arrival(request, now),
Disposition::Expire => self.expire_on_arrival(request, now),
}
}
fn cancel_on_arrival(
&mut self,
request: OrderRequestOpen<ExchangeId, InstrumentNameExchange>,
now: DateTime<Utc>,
) -> (
Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>,
Option<OpenOrderNotifications>,
) {
let cancelled = Cancelled {
id: self.order_id_sequence_fetch_add(),
time_exchange: now,
filled_quantity: Some(Decimal::ZERO),
};
self.account.ack_cancelled(Order {
key: request.key.clone(),
side: request.state.side,
price: request.state.price,
quantity: request.state.quantity,
kind: request.state.kind,
time_in_force: request.state.time_in_force,
state: cancelled.clone(),
});
(
Order {
key: request.key,
side: request.state.side,
price: request.state.price,
quantity: request.state.quantity,
kind: request.state.kind,
time_in_force: request.state.time_in_force,
state: OrderState::inactive(cancelled),
},
None,
)
}
fn expire_on_arrival(
&mut self,
request: OrderRequestOpen<ExchangeId, InstrumentNameExchange>,
now: DateTime<Utc>,
) -> (
Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>,
Option<OpenOrderNotifications>,
) {
let expired = Expired {
id: self.order_id_sequence_fetch_add(),
time_exchange: now,
filled_quantity: Some(Decimal::ZERO),
};
self.account.ack_expired(Order {
key: request.key.clone(),
side: request.state.side,
price: request.state.price,
quantity: request.state.quantity,
kind: request.state.kind,
time_in_force: request.state.time_in_force,
state: expired.clone(),
});
(
Order {
key: request.key,
side: request.state.side,
price: request.state.price,
quantity: request.state.quantity,
kind: request.state.kind,
time_in_force: request.state.time_in_force,
state: OrderState::expired(expired),
},
None,
)
}
fn fill_on_arrival(
&mut self,
request: OrderRequestOpen<ExchangeId, InstrumentNameExchange>,
market: Option<MarketSnapshot>,
terms: &InstrumentTerms,
remainder: Remainder,
) -> (
Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>,
Option<OpenOrderNotifications>,
) {
let market_supplied = market.is_some();
let market = market.unwrap_or_default();
let maybe_fill_price = self
.fill_model
.fill_price(&FillContext::new(request.state.side, &market))
.or(request.state.price);
let Some(fill_price) = maybe_fill_price else {
let cause = if market_supplied {
"no market price available yet"
} else {
"the request carried no market snapshot (RequestOpen::market was None)"
};
let reason = format!(
"cannot price {} for {}: {cause} and OrderKind::{:?} carries no limit price",
request.key.instrument, request.key.exchange, request.state.kind
);
return (
build_open_order_err_response(
request,
UnindexedOrderError::Rejected(ApiError::OrderRejected(reason)),
),
None,
);
};
let fill_price = match request.state.price {
Some(limit) if request.state.kind == OrderKind::Limit => match request.state.side {
Side::Buy => fill_price.min(limit),
Side::Sell => fill_price.max(limit),
},
_ => fill_price,
};
let time_exchange = self.time_exchange();
let side = request.state.side;
let filled_quantity = match self.depth_available(&request.key.instrument, side) {
Some(available) => request.state.quantity.min(available),
None => request.state.quantity,
};
let unfilled = request.state.quantity - filled_quantity;
if filled_quantity <= Decimal::ZERO || (unfilled > Decimal::ZERO && remainder.is_kill()) {
return match remainder {
Remainder::Rest { limit } => self.rest_order(request, limit, terms, Decimal::ZERO),
Remainder::Cancel | Remainder::Kill => {
self.cancel_on_arrival(request, time_exchange)
}
};
}
let position = self.net_position(terms, &request.key.instrument);
let fill = self.settlement(
terms,
position,
side,
filled_quantity,
fill_price,
Liquidity::Taker,
);
let held = match remainder {
Remainder::Rest { limit } if unfilled > Decimal::ZERO => Some(self.settlement(
terms,
position.after(side, filled_quantity, fill_price),
side,
unfilled,
limit,
Liquidity::Maker,
)),
_ => None,
};
let debit = match &held {
Some(held) => fill.settled_holding(held),
None => fill.settled(),
};
let balance = match self.account.commit(&debit, fill.credit, time_exchange) {
Ok(balance) => balance,
Err(insufficient) => {
return (
build_open_order_err_response(
request,
ApiError::BalanceInsufficient(
Some(debit.asset),
format!(
"Available Balance: {}, Required Balance inc. fees: {}",
insufficient.free, insufficient.required
),
),
),
None,
);
}
};
self.consume_depth(&request.key.instrument, side, filled_quantity);
self.record_fill(
terms,
&request.key.instrument,
side,
Fill {
quantity: filled_quantity,
price: fill_price,
},
);
let order_id = self.order_id_sequence_fetch_add();
let trade = Trade {
id: self.trade_id_sequence_fetch_add(),
order_id: order_id.clone(),
instrument: request.key.instrument.clone(),
strategy: request.key.strategy.clone(),
time_exchange,
side,
price: fill_price,
quantity: filled_quantity,
order_filled_quantity: Some(filled_quantity),
fees: fill.fees,
};
self.account.ack_trade(trade.clone());
let state = self.retire_filled(
&request,
order_id,
Fill {
quantity: filled_quantity,
price: fill_price,
},
held,
time_exchange,
);
(
Order {
key: request.key,
side,
price: request.state.price,
quantity: request.state.quantity,
kind: request.state.kind,
time_in_force: request.state.time_in_force,
state,
},
Some(OpenOrderNotifications::Filled {
balance: Snapshot(balance),
trade,
}),
)
}
fn retire_filled(
&mut self,
request: &OrderRequestOpen<ExchangeId, InstrumentNameExchange>,
order_id: OrderId,
fill: Fill,
held: Option<Settlement>,
time_exchange: DateTime<Utc>,
) -> UnindexedOrderState {
match held {
Some(held) => {
let open = Open::new(
VenueOrderId::Assigned(order_id),
time_exchange,
fill.quantity,
);
self.book_rested(
Order {
key: request.key.clone(),
side: request.state.side,
price: request.state.price,
quantity: request.state.quantity,
kind: request.state.kind,
time_in_force: request.state.time_in_force,
state: open.clone(),
},
Reservation {
amount: held.requirement(),
asset: held.asset,
},
);
OrderState::active(open)
}
None if fill.quantity == request.state.quantity => {
let filled = Filled::new(
order_id,
time_exchange,
request.state.quantity,
Some(fill.price),
);
self.account.ack_filled(Order {
key: request.key.clone(),
side: request.state.side,
price: request.state.price,
quantity: request.state.quantity,
kind: request.state.kind,
time_in_force: request.state.time_in_force,
state: filled.clone(),
});
OrderState::fully_filled(filled)
}
None => {
let cancelled = Cancelled {
id: order_id,
time_exchange,
filled_quantity: Some(fill.quantity),
};
self.account.ack_cancelled(Order {
key: request.key.clone(),
side: request.state.side,
price: request.state.price,
quantity: request.state.quantity,
kind: request.state.kind,
time_in_force: request.state.time_in_force,
state: cancelled.clone(),
});
OrderState::inactive(cancelled)
}
}
}
fn depth_available(&self, instrument: &InstrumentNameExchange, side: Side) -> Option<Decimal> {
self.market
.get(instrument)
.and_then(|market| market.depth.available_to(side))
}
fn consume_depth(
&mut self,
instrument: &InstrumentNameExchange,
side: Side,
quantity: Decimal,
) {
if let Some(market) = self.market.get_mut(instrument) {
market.depth.consume(side, quantity);
}
}
fn book_rested(&mut self, order: OpenOrder, reservation: Reservation) {
if let Err(AlreadyResting(refused)) = self.account.book(order, Some(reservation)) {
unreachable!(
"SimulatedVenue booked {} over a resting order: open_order_inner checks first",
refused.order.key.cid
);
}
}
fn rest_order(
&mut self,
request: OrderRequestOpen<ExchangeId, InstrumentNameExchange>,
limit: Decimal,
terms: &InstrumentTerms,
filled_quantity: Decimal,
) -> (
Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>,
Option<OpenOrderNotifications>,
) {
let time_exchange = self.time_exchange();
let settlement = self.settlement(
terms,
self.net_position(terms, &request.key.instrument),
request.state.side,
request.state.quantity - filled_quantity,
limit,
Liquidity::Maker,
);
let balance_snapshot =
match self
.account
.commit(&settlement.reserved(), Decimal::ZERO, time_exchange)
{
Ok(balance_snapshot) => balance_snapshot,
Err(insufficient) => {
return (
build_open_order_err_response(
request,
ApiError::BalanceInsufficient(
Some(settlement.asset),
format!(
"Available Balance: {}, Required Balance inc. fees: {}",
insufficient.free, insufficient.required
),
),
),
None,
);
}
};
let open = Open::new(
VenueOrderId::Assigned(self.order_id_sequence_fetch_add()),
time_exchange,
filled_quantity,
);
self.book_rested(
Order {
key: request.key.clone(),
side: request.state.side,
price: request.state.price,
quantity: request.state.quantity,
kind: request.state.kind,
time_in_force: request.state.time_in_force,
state: open.clone(),
},
Reservation {
amount: settlement.requirement(),
asset: settlement.asset,
},
);
let order_response = Order {
key: request.key,
side: request.state.side,
price: request.state.price,
quantity: request.state.quantity,
kind: request.state.kind,
time_in_force: request.state.time_in_force,
state: OrderState::active(open),
};
(
order_response,
Some(OpenOrderNotifications::Rested {
balance: Snapshot(balance_snapshot),
}),
)
}
fn instrument_terms(
&self,
instrument: &InstrumentNameExchange,
) -> Result<InstrumentTerms, UnindexedApiError> {
let instrument = self.find_instrument_data(instrument)?;
let cash_settled = Self::settlement_of_supported_kind(instrument)?;
Ok(InstrumentTerms {
underlying: instrument.underlying.clone(),
contract_size: instrument.kind.contract_size(),
cash_settled,
})
}
fn net_position(
&self,
terms: &InstrumentTerms,
instrument: &InstrumentNameExchange,
) -> NetPosition {
if !terms.cash_settled {
return NetPosition::default();
}
self.positions.get(instrument).copied().unwrap_or_default()
}
fn record_fill(
&mut self,
terms: &InstrumentTerms,
instrument: &InstrumentNameExchange,
side: Side,
fill: Fill,
) {
if !terms.cash_settled {
return;
}
match self.positions.get_mut(instrument) {
Some(position) => {
*position = position.after(side, fill.quantity, fill.price);
if position.quantity.is_zero() {
self.positions.remove(instrument);
}
}
None => {
let position = NetPosition::default().after(side, fill.quantity, fill.price);
if !position.quantity.is_zero() {
self.positions.insert(instrument.clone(), position);
}
}
}
}
fn settlement(
&self,
terms: &InstrumentTerms,
position: NetPosition,
side: Side,
quantity: Decimal,
price: Decimal,
liquidity: Liquidity,
) -> Settlement {
let quantity = quantity.abs();
let fees_quote =
self.fee_model
.compute_fee(price, quantity, terms.contract_size, liquidity);
let fees = AssetFees::new(terms.underlying.quote.clone(), fees_quote, Some(fees_quote));
if terms.cash_settled {
let closing = position.closing(side, quantity);
let opening = quantity - closing;
let open_margin = price * opening * terms.contract_size;
let close_proceeds = closing
* terms.contract_size
* match position.is_long() {
true => price,
false => position.entry + position.entry - price,
};
let (credit, shortfall) = match close_proceeds.is_sign_negative() {
true => (Decimal::ZERO, -close_proceeds),
false => (close_proceeds, Decimal::ZERO),
};
return Settlement {
asset: terms.underlying.quote.clone(),
amount: open_margin + fees_quote + shortfall,
credit,
fees,
};
}
match side {
Side::Buy => Settlement {
asset: terms.underlying.quote.clone(),
amount: price * quantity * terms.contract_size + fees_quote,
credit: Decimal::ZERO,
fees,
},
Side::Sell => {
debug_assert!(
!matches!(self.fee_model, FeeModelConfig::PerContract(_)),
"PerContractFeeModel produces nonsensical base-denominated fees on the spot \
sell path"
);
let fees_base = if price.is_zero() {
Decimal::ZERO
} else {
fees_quote / price
};
Settlement {
asset: terms.underlying.base.clone(),
amount: quantity + fees_base,
credit: Decimal::ZERO,
fees,
}
}
}
}
pub fn validate_order_kind_supported(
&self,
order_kind: OrderKind,
) -> Result<(), UnindexedOrderError> {
let reason = match (order_kind, self.regime) {
(OrderKind::Market, _) | (OrderKind::Limit, VenueRegime::MarketDriven) => return Ok(()),
(OrderKind::Limit, VenueRegime::RequestPriced) => format!(
"SimulatedVenue does not support OrderKind::{order_kind:?} in \
VenueRegime::RequestPriced: it has no market feed, so an order that rested could \
never be matched. Construct it with SimulatedVenue::new_market_driven, and feed \
it with apply_market"
),
_ => format!("SimulatedVenue does not support OrderKind::{order_kind:?}"),
};
Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
reason,
)))
}
fn settlement_of_supported_kind(
instrument: &Instrument<ExchangeId, AssetNameExchange>,
) -> Result<bool, UnindexedApiError> {
match &instrument.kind {
InstrumentKind::Spot => Ok(false),
InstrumentKind::Cfd(_) => Ok(true),
unsupported => Err(ApiError::InstrumentInvalid(
instrument.name_exchange.clone(),
format!(
"SimulatedVenue does not support {}; only Spot and Cfd are modelled",
match unsupported {
InstrumentKind::Perpetual(_) => "InstrumentKind::Perpetual",
InstrumentKind::Future(_) => "InstrumentKind::Future",
InstrumentKind::Option(_) => "InstrumentKind::Option",
InstrumentKind::Spot | InstrumentKind::Cfd(_) => "InstrumentKind",
}
),
)),
}
}
pub fn find_instrument_data(
&self,
instrument: &InstrumentNameExchange,
) -> Result<&Instrument<ExchangeId, AssetNameExchange>, UnindexedApiError> {
self.instruments.get(instrument).ok_or_else(|| {
ApiError::InstrumentInvalid(
instrument.clone(),
format!("SimulatedVenue is not set-up for managing: {instrument}"),
)
})
}
fn order_id_sequence_fetch_add(&mut self) -> OrderId {
let sequence = self.order_sequence;
self.order_sequence += 1;
OrderId((self.order_id_start + u128::from(sequence)).to_smolstr())
}
fn trade_id_sequence_fetch_add(&mut self) -> TradeId {
let sequence = self.trade_sequence;
self.trade_sequence += 1;
TradeId(sequence.to_smolstr())
}
fn build_account_event<Kind>(&self, kind: Kind) -> UnindexedAccountEvent
where
Kind: Into<AccountEventKind<ExchangeId, AssetNameExchange, InstrumentNameExchange>>,
{
UnindexedAccountEvent {
exchange: self.exchange,
kind: kind.into(),
}
}
}
fn seeded_positions(
initial_state: &UnindexedAccountSnapshot,
instruments: &FnvHashMap<InstrumentNameExchange, Instrument<ExchangeId, AssetNameExchange>>,
) -> FnvHashMap<InstrumentNameExchange, NetPosition> {
initial_state
.instruments
.iter()
.filter(|snapshot| {
instruments
.get(&snapshot.instrument)
.is_some_and(|instrument| matches!(instrument.kind, InstrumentKind::Cfd(_)))
})
.filter_map(|snapshot| {
let position = snapshot.position.open()?;
let Some(entry) = position.entry_price else {
panic!(
"SimulatedVenue's initial_state reports an open position in {} with no entry \
price, so the margin and PnL its close would credit cannot be computed",
snapshot.instrument
);
};
Some((
snapshot.instrument.clone(),
NetPosition {
quantity: position.quantity,
entry,
},
))
})
.collect()
}
fn crosses(side: Side, limit: Decimal, market: &MarketSnapshot) -> bool {
match side {
Side::Buy => match market.best_ask {
Some(best_ask) => best_ask <= limit,
None => market.last_price.is_some_and(|last| last <= limit),
},
Side::Sell => match market.best_bid {
Some(best_bid) => best_bid >= limit,
None => market.last_price.is_some_and(|last| last >= limit),
},
}
}
fn validate_time_in_force_supported(
order_kind: OrderKind,
time_in_force: TimeInForce,
) -> Result<(), UnindexedOrderError> {
const NO_CALENDAR: &str = "a session calendar, which this venue does not have";
let missing = match (order_kind, time_in_force) {
(_, TimeInForce::AtOpen | TimeInForce::AtClose) => NO_CALENDAR,
(OrderKind::Market, TimeInForce::GoodUntilCancelled { post_only: true }) => {
"an order that both takes liquidity and refuses to"
}
(OrderKind::Market, _) => return Ok(()),
(OrderKind::Limit, TimeInForce::GoodUntilEndOfDay) => NO_CALENDAR,
(
OrderKind::Limit,
TimeInForce::GoodUntilCancelled { .. }
| TimeInForce::ImmediateOrCancel
| TimeInForce::FillOrKill
| TimeInForce::GoodTillDate { .. },
) => return Ok(()),
(_, _) => return Ok(()),
};
Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
format!(
"SimulatedVenue does not support TimeInForce::{time_in_force} on \
OrderKind::{order_kind:?}: it does not model {missing}"
),
)))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Disposition {
FillAndRest,
FillAndCancel,
FillOrNothing,
Rest,
CancelUnfilled,
Expire,
}
fn disposition(
order_kind: OrderKind,
time_in_force: TimeInForce,
marketable: bool,
now: DateTime<Utc>,
) -> Disposition {
match time_in_force {
TimeInForce::GoodUntilCancelled { post_only: true } if marketable => {
Disposition::CancelUnfilled
}
TimeInForce::GoodTillDate { expiry } if now >= expiry => Disposition::Expire,
TimeInForce::FillOrKill if marketable => Disposition::FillOrNothing,
_ if marketable => match (order_kind, time_in_force) {
(
OrderKind::Limit,
TimeInForce::GoodUntilCancelled { .. } | TimeInForce::GoodTillDate { .. },
) => Disposition::FillAndRest,
_ => Disposition::FillAndCancel,
},
TimeInForce::ImmediateOrCancel | TimeInForce::FillOrKill => Disposition::CancelUnfilled,
_ => Disposition::Rest,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Remainder {
Rest { limit: Decimal },
Cancel,
Kill,
}
impl Remainder {
fn is_kill(self) -> bool {
matches!(self, Self::Kill)
}
}
fn build_open_order_err_response<E>(
request: OrderRequestOpen<ExchangeId, InstrumentNameExchange>,
error: E,
) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>
where
E: Into<UnindexedOrderError>,
{
Order {
key: request.key,
side: request.state.side,
price: request.state.price,
quantity: request.state.quantity,
kind: request.state.kind,
time_in_force: request.state.time_in_force,
state: OrderState::inactive(error.into()),
}
}
#[allow(clippy::large_enum_variant)]
#[derive(Debug)]
enum OpenOrderNotifications {
Filled {
balance: Snapshot<AssetBalance<AssetNameExchange>>,
trade: Trade<AssetNameExchange, InstrumentNameExchange>,
},
Rested {
balance: Snapshot<AssetBalance<AssetNameExchange>>,
},
}
#[derive(Debug, Clone)]
struct InstrumentTerms {
underlying: Underlying<AssetNameExchange>,
contract_size: Decimal,
cash_settled: bool,
}
#[derive(Debug)]
enum RestingSettlement {
Settled(AssetBalance<AssetNameExchange>),
Unaffordable {
released: Option<AssetBalance<AssetNameExchange>>,
},
}
#[derive(Debug, Clone, Copy)]
struct Fill {
quantity: Decimal,
price: Decimal,
}
#[derive(Debug, Clone)]
struct Settlement {
asset: AssetNameExchange,
amount: Decimal,
credit: Decimal,
fees: AssetFees<AssetNameExchange>,
}
impl Settlement {
fn requirement(&self) -> Decimal {
(self.amount - self.credit).max(Decimal::ZERO)
}
fn settled(&self) -> Debit {
Debit {
asset: self.asset.clone(),
settled: self.amount,
reserved: Decimal::ZERO,
}
}
fn reserved(&self) -> Debit {
Debit {
asset: self.asset.clone(),
settled: Decimal::ZERO,
reserved: self.requirement(),
}
}
fn settled_holding(&self, remainder: &Settlement) -> Debit {
debug_assert!(
self.asset == remainder.asset,
"one order settled {} of {} and reserved {} of {}: an order pays with one asset, so \
two settlements of it cannot name two",
self.amount,
self.asset,
remainder.amount,
remainder.asset
);
Debit {
asset: self.asset.clone(),
settled: self.amount,
reserved: remainder.requirement(),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
struct NetPosition {
quantity: Decimal,
entry: Decimal,
}
impl NetPosition {
fn is_long(&self) -> bool {
self.quantity.is_sign_positive() && !self.quantity.is_zero()
}
fn closing(&self, side: Side, quantity: Decimal) -> Decimal {
let opposes = match side {
Side::Buy => self.quantity.is_sign_negative() && !self.quantity.is_zero(),
Side::Sell => self.is_long(),
};
match opposes {
true => quantity.min(self.quantity.abs()),
false => Decimal::ZERO,
}
}
fn after(self, side: Side, quantity: Decimal, price: Decimal) -> Self {
let signed = match side {
Side::Buy => quantity,
Side::Sell => -quantity,
};
let next = self.quantity + signed;
let entry = if next.is_zero() {
Decimal::ZERO
} else if self.quantity.is_zero()
|| next.is_sign_negative() != self.quantity.is_sign_negative()
{
price
} else if signed.is_sign_negative() == self.quantity.is_sign_negative() {
(self.entry * self.quantity.abs() + price * quantity) / next.abs()
} else {
self.entry
};
Self {
quantity: next,
entry,
}
}
}
fn first_unseeded_order_id(account: &AccountState) -> u128 {
let open = account
.orders_open()
.filter_map(|order| match &order.state.id {
VenueOrderId::Assigned(id) => Some(id),
VenueOrderId::ClientAssigned => None,
});
let cancelled = account.orders_cancelled().map(|order| &order.state.id);
open.chain(cancelled)
.filter_map(|id| id.0.parse::<u64>().ok())
.max()
.map_or(0, |highest| u128::from(highest) + 1)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests {
use super::*;
use crate::{
error::OrderError,
exchange::mock::{
fixtures::*,
orders::{OpenOrders, as_open},
},
fee::PercentageFeeModel,
fill::BidAskFillModel,
order::{
OrderEvent, OrderKey, TimeInForce,
id::{ClientOrderId, StrategyId},
request::RequestCancel,
state::{ActiveOrderState, InactiveOrderState},
},
};
use rustrade_instrument::{
Underlying,
instrument::{
kind::cfd::CfdContract, name::InstrumentNameInternal, quote::InstrumentQuoteAsset,
},
};
fn make_venue(btc: &str, usdt: &str) -> SimulatedVenue {
make_venue_with_fee(btc, usdt, FeeModelConfig::default())
}
fn make_venue_with_fee(btc: &str, usdt: &str, fee_model: FeeModelConfig) -> SimulatedVenue {
SimulatedVenue::new(&spot_config(btc, usdt, fee_model), spot_instruments())
}
fn maker_taker_fee() -> FeeModelConfig {
FeeModelConfig::Percentage(PercentageFeeModel::maker_taker(d("0.0002"), d("0.001")))
}
fn make_market_venue(btc: &str, usdt: &str) -> SimulatedVenue {
make_market_venue_with(btc, usdt, maker_taker_fee(), SimFillConfig::default())
}
fn make_market_venue_with(
btc: &str,
usdt: &str,
fee_model: FeeModelConfig,
fill_model: SimFillConfig,
) -> SimulatedVenue {
SimulatedVenue::new_market_driven(
&spot_config_with_fill(btc, usdt, fee_model, fill_model),
spot_instruments(),
)
}
fn balance_of(event: &UnindexedAccountEvent) -> &AssetBalance<AssetNameExchange> {
match &event.kind {
AccountEventKind::BalanceSnapshot(balance) => &balance.0,
other => panic!("expected a balance restatement, got: {other:?}"),
}
}
fn trade_of(
event: &UnindexedAccountEvent,
) -> &Trade<AssetNameExchange, InstrumentNameExchange> {
match &event.kind {
AccountEventKind::Trade(trade) => trade,
other => panic!("expected a trade, got: {other:?}"),
}
}
fn order_of(
event: &UnindexedAccountEvent,
) -> &Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
match &event.kind {
AccountEventKind::OrderSnapshot(order) => &order.0,
other => panic!("expected an order snapshot, got: {other:?}"),
}
}
fn rested_open(outcome: &OpenOutcome) -> &Open {
match &outcome.response.state {
OrderState::Active(ActiveOrderState::Open(open)) => open,
other => panic!("expected a resting open order, got: {other:?}"),
}
}
fn filled_price(
order: &Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>,
) -> Decimal {
match &order.state {
OrderState::Inactive(InactiveOrderState::FullyFilled(filled)) => filled
.avg_price
.expect("a filled order reports the price it filled at"),
other => panic!("expected a fully filled order, got: {other:?}"),
}
}
fn venue_resting_one_buy(limit: &str) -> (SimulatedVenue, OpenOutcome) {
let mut venue = make_market_venue("10", "1000000");
advance(&mut venue, time(1));
assert!(
venue
.apply_market(
&instrument_name(),
book("49000", "49100"),
MarketDepth::UNKNOWN,
time(1)
)
.is_empty(),
"an empty book fills nothing"
);
let outcome = venue.open_order(limit_request("resting", Side::Buy, "1", limit, gtc()));
assert_eq!(
outcome.events.len(),
1,
"a rested order restates one balance and books no trade"
);
(venue, outcome)
}
fn advance(venue: &mut SimulatedVenue, time_exchange: DateTime<Utc>) {
assert!(
venue.advance_time(time_exchange).is_empty(),
"advancing to {time_exchange} retired an order this test never gave a deadline"
);
}
fn time(seconds: i64) -> DateTime<Utc> {
DateTime::<Utc>::from_timestamp(1_700_000_000 + seconds, 0).expect("representable instant")
}
fn filled_balance(outcome: &OpenOutcome) -> &AssetBalance<AssetNameExchange> {
match outcome.events.first().map(|event| &event.kind) {
Some(AccountEventKind::BalanceSnapshot(balance)) => &balance.0,
other => panic!("a filled open must emit its balance first, got: {other:?}"),
}
}
fn filled_trade(outcome: &OpenOutcome) -> &Trade<AssetNameExchange, InstrumentNameExchange> {
match outcome.events.get(1).map(|event| &event.kind) {
Some(AccountEventKind::Trade(trade)) => trade,
other => panic!("a filled open must emit its trade after its balance, got: {other:?}"),
}
}
#[test]
fn a_filled_open_reports_its_balance_before_its_trade_and_debits_monotonically() {
const FILLS: usize = 4;
let mut venue = make_venue("100", "10000000");
let mut balances = Vec::with_capacity(FILLS);
for _ in 0..FILLS {
let outcome = venue.open_order(buy_request("0.01", market_prices("50000")));
assert_eq!(
outcome.events.len(),
2,
"a fill owes exactly one balance and one trade"
);
let balance = filled_balance(&outcome);
let trade = filled_trade(&outcome);
assert_eq!(balance.asset, quote(), "a spot buy debits quote");
assert_eq!(trade.price, d("50000"));
balances.push(balance.balance.total);
}
let expected = (1..=FILLS)
.map(|nth| d("10000000") - d("500") * Decimal::from(nth))
.collect::<Vec<_>>();
assert_eq!(
balances, expected,
"successive fills must restate a strictly decreasing balance"
);
}
#[test]
fn a_filled_open_is_in_the_ledger_before_its_events_are_delivered() {
let mut venue = make_venue("100", "10000000");
let before = venue.time_exchange();
let outcome = venue.open_order(buy_request("0.01", market_prices("50000")));
assert_eq!(outcome.events.len(), 2, "the fill must owe events");
let trades = venue.trades(before);
assert_eq!(trades.len(), 1, "the venue must acknowledge its own trade");
assert_eq!(trades[0].price, d("50000"));
}
#[test]
fn a_fill_is_stamped_with_the_instant_the_venue_was_last_advanced() {
let mut venue = make_venue("100", "10000000");
let time_exchange = "2025-01-01T00:00:00Z".parse::<DateTime<Utc>>().unwrap();
advance(&mut venue, time_exchange);
assert_eq!(venue.time_exchange(), time_exchange);
let outcome = venue.open_order(buy_request("0.01", market_prices("50000")));
assert_eq!(filled_trade(&outcome).time_exchange, time_exchange);
assert_eq!(filled_balance(&outcome).time_exchange, time_exchange);
}
#[test]
fn advancing_the_clock_does_not_restamp_a_resting_order() {
let arrived = "2025-01-01T00:00:00Z".parse::<DateTime<Utc>>().unwrap();
let much_later = "2025-06-01T00:00:00Z".parse::<DateTime<Utc>>().unwrap();
let mut venue = make_venue("100", "10000000");
venue.account = AccountState::new(
venue
.account
.balances()
.cloned()
.map(|balance| (balance.asset.clone(), balance))
.collect(),
{
let mut book = OpenOrders::default();
book.insert(
Order {
key: OrderKey {
exchange: EXCHANGE,
instrument: instrument_name(),
strategy: StrategyId::new("test"),
cid: ClientOrderId::new("resting"),
},
side: Side::Buy,
price: Some(d("50000")),
quantity: d("1"),
kind: OrderKind::Limit,
time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
state: Open {
id: VenueOrderId::Assigned(OrderId::new("resting")),
time_exchange: arrived,
filled_quantity: Decimal::ZERO,
},
},
None,
)
.unwrap();
book
},
Default::default(),
Vec::new(),
);
advance(&mut venue, much_later);
let resting = venue.orders_open(&[]);
assert_eq!(resting.len(), 1, "the seeded order must still be open");
assert_eq!(
resting[0].state.time_exchange, arrived,
"an order does not become a different order because time passed"
);
let usdt = venue.balances(&[quote()]);
assert_eq!(usdt[0].time_exchange, much_later);
}
#[test]
fn a_cancel_for_an_unknown_order_is_rejected_as_unknown() {
let mut venue = make_venue("100", "10000000");
let key = buy_request("1", market_prices("50000")).key;
let outcome = venue.cancel_order(OrderEvent {
key: key.clone(),
state: RequestCancel { id: None },
});
assert!(
outcome.events.is_empty(),
"a rejected cancel moves nothing, so it owes no events"
);
assert_eq!(
outcome.response.key, key,
"the response must name its request"
);
match outcome.response.state {
Err(OrderError::Rejected(ApiError::OrderRejected(ref reason))) => {
assert!(
reason.contains("test-cid"),
"the rejection must name the order it could not find, got: {reason}"
);
}
ref other => panic!("expected a rejected cancel, got: {other:?}"),
}
assert!(
venue.account_snapshot().instruments.is_empty(),
"a rejected cancel must not enter the ledger"
);
}
const CFD_CONTRACT_SIZE: &str = "25";
fn cfd_instrument_name() -> InstrumentNameExchange {
InstrumentNameExchange::new("spx500_usd")
}
fn make_cfd_venue(usd: &str, fee_model: FeeModelConfig) -> SimulatedVenue {
make_cfd_venue_with_balances(
vec![funded(AssetNameExchange::new("usd"), d(usd))],
fee_model,
)
}
fn make_cfd_venue_with_balances(
balances: Vec<AssetBalance<AssetNameExchange>>,
fee_model: FeeModelConfig,
) -> SimulatedVenue {
SimulatedVenue::new(
&config_from_balances(balances, fee_model),
instruments_of(cfd_instrument()),
)
}
fn cfd_instrument() -> Instrument<ExchangeId, AssetNameExchange> {
Instrument {
exchange: EXCHANGE,
name_internal: InstrumentNameInternal::new("spx500_usd"),
name_exchange: cfd_instrument_name(),
underlying: Underlying {
base: AssetNameExchange::new("spx500"),
quote: AssetNameExchange::new("usd"),
},
quote: InstrumentQuoteAsset::UnderlyingQuote,
kind: InstrumentKind::Cfd(CfdContract {
contract_size: d(CFD_CONTRACT_SIZE),
settlement_asset: AssetNameExchange::new("gbp"),
}),
spec: None,
data_venue: None,
}
}
fn cfd_request(
side: Side,
quantity: &str,
market: Option<MarketSnapshot>,
) -> OrderRequestOpen<ExchangeId, InstrumentNameExchange> {
request(cfd_instrument_name(), side, quantity, market)
}
#[test]
fn cfd_buy_debits_the_contract_size_scaled_notional() {
let mut venue = make_cfd_venue("200000", FeeModelConfig::default());
let outcome = venue.open_order(cfd_request(Side::Buy, "1", market_prices("5000")));
assert!(
outcome.response.state.is_accepted(),
"cfd buy should fill: {:?}",
outcome.response.state
);
assert_eq!(outcome.events.len(), 2);
let usd = venue
.account
.balance_mut(&AssetNameExchange::new("usd"))
.unwrap();
assert_eq!(usd.balance.free, d("200000") - d("125000"));
assert_eq!(usd.balance.total, usd.balance.free);
}
#[test]
fn cfd_sell_needs_no_base_inventory_and_debits_quote() {
let mut venue = make_cfd_venue("200000", FeeModelConfig::default());
let outcome = venue.open_order(cfd_request(Side::Sell, "1", market_prices("5000")));
assert!(
outcome.response.state.is_accepted(),
"cfd short should fill without base inventory: {:?}",
outcome.response.state
);
let balance = filled_balance(&outcome);
assert_eq!(
balance.asset,
AssetNameExchange::new("usd"),
"a cash-settled short debits quote, not base"
);
assert_eq!(balance.balance.free, d("75000"));
}
#[test]
fn cfd_buy_is_rejected_when_only_the_unscaled_notional_is_funded() {
let mut venue = make_cfd_venue("10000", FeeModelConfig::default());
let outcome = venue.open_order(cfd_request(Side::Buy, "1", market_prices("5000")));
assert!(outcome.events.is_empty());
match outcome.response.state {
OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Rejected(
ApiError::BalanceInsufficient(Some(ref asset), _),
))) => {
assert_eq!(*asset, AssetNameExchange::new("usd"));
}
other => panic!("expected BalanceInsufficient, got: {other:?}"),
}
}
#[test]
fn cfd_fee_is_quote_denominated_on_both_sides() {
for side in [Side::Buy, Side::Sell] {
let mut venue = make_cfd_venue(
"200000",
FeeModelConfig::Percentage(PercentageFeeModel::new(d("0.001"))),
);
let outcome = venue.open_order(cfd_request(side, "1", market_prices("5000")));
assert!(
outcome.response.state.is_accepted(),
"{side:?} should fill: {:?}",
outcome.response.state
);
assert_eq!(
filled_trade(&outcome).fees.asset,
AssetNameExchange::new("usd"),
"{side:?} fee asset"
);
assert_eq!(
filled_trade(&outcome).fees.fees,
d("125"),
"{side:?} fee must be 0.1% of the contract_size-scaled notional"
);
assert_eq!(
filled_balance(&outcome).balance.free,
d("200000") - d("125000") - d("125"),
"{side:?} debit must be notional + fee"
);
}
}
#[test]
#[should_panic(expected = "SimulatedVenue has Balance for all configured Instrument assets")]
fn cfd_panics_when_the_quote_asset_is_unfunded() {
let mut venue = make_cfd_venue_with_balances(
vec![funded(AssetNameExchange::new("gbp"), d("200000"))],
FeeModelConfig::default(),
);
let _ = venue.open_order(cfd_request(Side::Buy, "1", market_prices("5000")));
}
fn usd() -> AssetNameExchange {
AssetNameExchange::new("usd")
}
fn free_usd(venue: &SimulatedVenue) -> Decimal {
venue.balances(&[usd()]).remove(0).balance.free
}
fn total_usd(venue: &SimulatedVenue) -> Decimal {
venue.balances(&[usd()]).remove(0).balance.total
}
fn cfd_fill(
venue: &mut SimulatedVenue,
side: Side,
quantity: &str,
price: &str,
) -> OpenOutcome {
let outcome = venue.open_order(cfd_request(side, quantity, market_prices(price)));
assert!(
outcome.response.state.is_accepted(),
"{side:?} {quantity} @ {price} should fill: {:?}",
outcome.response.state
);
outcome
}
fn cfd_position(venue: &SimulatedVenue) -> Option<(Decimal, Option<Decimal>)> {
let snapshot = venue.account_snapshot();
let Some(instrument) = snapshot
.instruments
.iter()
.find(|instrument| instrument.instrument == cfd_instrument_name())
else {
panic!("a CFD instrument is always in the snapshot, flat or not");
};
match &instrument.position {
PositionReport::Open(position) => Some((position.quantity, position.entry_price)),
PositionReport::Flat => None,
PositionReport::Unreported => panic!("a CFD position is reported, not Unreported"),
}
}
#[test]
fn a_cfd_round_trip_returns_the_margin_and_settles_the_pnl() {
for (side, open, close, end) in [
(Side::Buy, "5000", "5100", "152500"), (Side::Sell, "5000", "4900", "152500"), (Side::Sell, "5000", "5200", "145000"), ] {
let mut venue = make_cfd_venue("150000", FeeModelConfig::default());
cfd_fill(&mut venue, side, "1", open);
assert_eq!(
free_usd(&venue),
d("25000"),
"{side:?}: opening posts 125,000"
);
let closing = match side {
Side::Buy => Side::Sell,
Side::Sell => Side::Buy,
};
let outcome = cfd_fill(&mut venue, closing, "1", close);
assert_eq!(
filled_balance(&outcome).balance.free,
d(end),
"{side:?} @ {close}"
);
assert_eq!(total_usd(&venue), d(end), "{side:?}: nothing is left held");
assert_eq!(
cfd_position(&venue),
None,
"{side:?}: the round trip is flat"
);
}
}
#[test]
fn a_cfd_flip_closes_the_position_and_opens_the_rest_in_one_fill() {
let mut venue = make_cfd_venue("300000", FeeModelConfig::default());
cfd_fill(&mut venue, Side::Buy, "1", "5000");
assert_eq!(free_usd(&venue), d("175000"));
cfd_fill(&mut venue, Side::Sell, "3", "5100");
assert_eq!(free_usd(&venue), d("175000") + d("127500") - d("255000"));
assert_eq!(cfd_position(&venue), Some((d("-2"), Some(d("5100")))));
}
#[test]
fn a_partial_cfd_reduce_keeps_the_entry_and_pays_back_its_share() {
let mut venue = make_cfd_venue("300000", FeeModelConfig::default());
cfd_fill(&mut venue, Side::Buy, "2", "5000");
assert_eq!(free_usd(&venue), d("50000"));
cfd_fill(&mut venue, Side::Sell, "1", "5200");
assert_eq!(free_usd(&venue), d("180000"));
assert_eq!(
cfd_position(&venue),
Some((d("2") - d("1"), Some(d("5000"))))
);
}
#[test]
fn increasing_a_cfd_position_averages_its_entry() {
let mut venue = make_cfd_venue("300000", FeeModelConfig::default());
cfd_fill(&mut venue, Side::Buy, "1", "5000");
cfd_fill(&mut venue, Side::Buy, "1", "5200");
assert_eq!(free_usd(&venue), d("300000") - d("125000") - d("130000"));
assert_eq!(cfd_position(&venue), Some((d("2"), Some(d("5100")))));
}
#[test]
fn a_cfd_close_is_charged_its_fee_on_top_of_the_proceeds() {
let mut venue = make_cfd_venue(
"200000",
FeeModelConfig::Percentage(PercentageFeeModel::new(d("0.001"))),
);
cfd_fill(&mut venue, Side::Buy, "1", "5000"); let outcome = cfd_fill(&mut venue, Side::Sell, "1", "5100");
assert_eq!(filled_trade(&outcome).fees.fees, d("127.5"));
assert_eq!(
free_usd(&venue),
d("200000") + d("2500") - d("125") - d("127.5")
);
}
#[test]
fn a_cfd_short_that_loses_more_than_its_margin_pays_the_shortfall() {
let mut venue = make_cfd_venue("200000", FeeModelConfig::default());
cfd_fill(&mut venue, Side::Sell, "1", "1000"); cfd_fill(&mut venue, Side::Buy, "1", "2500");
assert_eq!(free_usd(&venue), d("200000") - d("37500"));
assert_eq!(cfd_position(&venue), None);
}
#[test]
fn a_spot_instrument_still_reports_no_position() {
let mut venue = make_venue("10", "1000000");
let _ = venue.open_order(request(
instrument_name(),
Side::Buy,
"1",
market_prices("50000"),
));
let snapshot = venue.account_snapshot();
assert!(
snapshot
.instruments
.iter()
.all(|instrument| instrument.position == PositionReport::Unreported),
"a spot holding is a balance, not a position"
);
}
#[test]
fn a_cfd_position_in_the_initial_state_is_where_the_venue_starts() {
let config = MockExecutionConfig::new(
EXCHANGE,
UnindexedAccountSnapshot {
exchange: EXCHANGE,
balances: vec![funded(usd(), d("10000"))],
instruments: vec![InstrumentAccountSnapshot {
instrument: cfd_instrument_name(),
orders: vec![],
orders_complete: true,
position: PositionReport::from_position(Position::new(
d("-1"),
Some(d("5000")),
None,
None,
None,
None,
time(0),
)),
isolated: None,
}],
},
0,
FeeModelConfig::default(),
SimFillConfig::default(),
);
let mut venue = SimulatedVenue::new(&config, instruments_of(cfd_instrument()));
assert_eq!(cfd_position(&venue), Some((d("-1"), Some(d("5000")))));
cfd_fill(&mut venue, Side::Buy, "1", "4800");
assert_eq!(free_usd(&venue), d("10000") + d("130000"));
}
#[test]
#[should_panic(expected = "no entry price")]
fn a_cfd_position_in_the_initial_state_without_an_entry_price_panics() {
let config = MockExecutionConfig::new(
EXCHANGE,
UnindexedAccountSnapshot {
exchange: EXCHANGE,
balances: vec![funded(usd(), d("10000"))],
instruments: vec![InstrumentAccountSnapshot {
instrument: cfd_instrument_name(),
orders: vec![],
orders_complete: true,
position: PositionReport::from_position(Position::new(
d("-1"),
None,
None,
None,
None,
None,
time(0),
)),
isolated: None,
}],
},
0,
FeeModelConfig::default(),
SimFillConfig::default(),
);
let _ = SimulatedVenue::new(&config, instruments_of(cfd_instrument()));
}
fn make_cfd_market_venue(usd: &str) -> SimulatedVenue {
let mut venue = SimulatedVenue::new_market_driven(
&config_from_balances(vec![funded(self::usd(), d(usd))], FeeModelConfig::default()),
instruments_of(cfd_instrument()),
);
advance(&mut venue, time(1));
venue
}
fn cfd_limit(
cid: &str,
side: Side,
quantity: &str,
price: &str,
) -> OrderRequestOpen<ExchangeId, InstrumentNameExchange> {
let mut request = limit_request(cid, side, quantity, price, gtc());
request.key.instrument = cfd_instrument_name();
request
}
fn cfd_market(
venue: &mut SimulatedVenue,
bid: &str,
ask: &str,
last: &str,
) -> Vec<UnindexedAccountEvent> {
let time_exchange = venue.time_exchange();
venue.apply_market(
&cfd_instrument_name(),
MarketSnapshot {
best_bid: Some(d(bid)),
best_ask: Some(d(ask)),
last_price: Some(d(last)),
},
MarketDepth::UNKNOWN,
time_exchange,
)
}
fn cfd_market_fill(venue: &mut SimulatedVenue, side: Side, quantity: &str) {
let outcome = venue.open_order(cfd_request(side, quantity, None));
assert!(
outcome.response.state.is_accepted(),
"{side:?} {quantity} should fill: {:?}",
outcome.response.state
);
}
#[test]
fn a_resting_order_that_only_reduces_a_cfd_position_holds_nothing() {
let mut venue = make_cfd_market_venue("125000");
assert!(cfd_market(&mut venue, "4990", "5000", "5000").is_empty());
cfd_market_fill(&mut venue, Side::Buy, "1");
assert_eq!(
free_usd(&venue),
Decimal::ZERO,
"the long took the whole balance"
);
let outcome = venue.open_order(cfd_limit("close", Side::Sell, "1", "5200"));
assert!(rested_open(&outcome).filled_quantity.is_zero());
assert_eq!(
total_usd(&venue) - free_usd(&venue),
Decimal::ZERO,
"nothing is held"
);
let events = cfd_market(&mut venue, "5200", "5210", "5200");
assert_eq!(balance_of(&events[0]).balance.free, d("130000"));
assert_eq!(filled_price(order_of(&events[2])), d("5200"));
assert_eq!(cfd_position(&venue), None);
}
#[test]
fn a_resting_order_that_opens_a_cfd_position_holds_its_margin_until_cancelled() {
let mut venue = make_cfd_market_venue("200000");
assert!(cfd_market(&mut venue, "4990", "5000", "5000").is_empty());
let _ = venue.open_order(cfd_limit("open", Side::Buy, "1", "4800"));
assert_eq!(free_usd(&venue), d("200000") - d("120000"));
assert_eq!(total_usd(&venue), d("200000"));
let cancelled = venue.cancel_order(OrderRequestCancel {
key: cfd_limit("open", Side::Buy, "1", "4800").key,
state: RequestCancel { id: None },
});
assert!(cancelled.response.state.is_ok());
assert_eq!(free_usd(&venue), d("200000"));
}
#[test]
fn a_resting_cfd_order_is_costed_against_the_position_when_it_fills() {
let mut venue = make_cfd_market_venue("300000");
assert!(cfd_market(&mut venue, "5000", "5000", "5000").is_empty());
cfd_market_fill(&mut venue, Side::Buy, "1");
let _ = venue.open_order(cfd_limit("close", Side::Sell, "1", "5200"));
assert_eq!(
free_usd(&venue),
d("175000"),
"a reducing order holds nothing"
);
cfd_market_fill(&mut venue, Side::Sell, "1"); assert_eq!(free_usd(&venue), d("300000"));
assert_eq!(cfd_position(&venue), None);
let events = cfd_market(&mut venue, "5200", "5210", "5200");
assert_eq!(
balance_of(&events[0]).balance.free,
d("300000") - d("130000"),
"the resting sell now opens a short of 1 at 5,200"
);
assert_eq!(cfd_position(&venue), Some((d("-1"), Some(d("5200")))));
}
#[test]
fn a_resting_cfd_order_the_account_can_no_longer_afford_is_cancelled() {
let mut venue = make_cfd_market_venue("126000");
assert!(cfd_market(&mut venue, "5000", "5000", "5000").is_empty());
cfd_market_fill(&mut venue, Side::Buy, "1");
let _ = venue.open_order(cfd_limit("close", Side::Sell, "1", "5200"));
cfd_market_fill(&mut venue, Side::Sell, "1");
assert_eq!(free_usd(&venue), d("126000"));
let events = cfd_market(&mut venue, "5200", "5210", "5200");
let Some((last, released)) = events.split_last() else {
panic!("an unaffordable fill reports its cancelled order");
};
assert!(
matches!(
order_of(last).state,
OrderState::Inactive(InactiveOrderState::Cancelled(_))
),
"{:?}",
order_of(last).state
);
assert!(
released
.iter()
.all(|event| balance_of(event).balance.free == d("126000")),
"no balance moved"
);
assert_eq!(free_usd(&venue), d("126000"), "nothing moved");
assert_eq!(cfd_position(&venue), None);
assert!(venue.account.is_cancelled(&ClientOrderId::new("close")));
}
#[test]
fn a_resting_cfd_order_cancelled_as_unaffordable_releases_its_hold() {
let mut venue = make_cfd_market_venue("260000");
assert!(cfd_market(&mut venue, "5000", "5000", "5000").is_empty());
cfd_market_fill(&mut venue, Side::Buy, "1");
let _ = venue.open_order(cfd_limit("flip", Side::Sell, "3", "5200"));
assert_eq!(
free_usd(&venue),
d("260000") - d("125000") - (d("260000") - d("130000")),
"holds the short's 260,000 net of the long's 130,000"
);
cfd_market_fill(&mut venue, Side::Sell, "1"); assert_eq!(free_usd(&venue), d("130000"));
assert_eq!(total_usd(&venue), d("260000"));
let events = cfd_market(&mut venue, "5200", "5210", "5200");
let Some((last, released)) = events.split_last() else {
panic!("an unaffordable fill reports its cancelled order");
};
assert!(
matches!(
order_of(last).state,
OrderState::Inactive(InactiveOrderState::Cancelled(_))
),
"{:?}",
order_of(last).state
);
let [released] = released else {
panic!("exactly the released hold restates: {released:?}");
};
assert_eq!(
balance_of(released).balance.free,
d("260000"),
"the hold is given back"
);
assert_eq!(free_usd(&venue), d("260000"));
assert_eq!(total_usd(&venue), d("260000"), "nothing settled");
assert_eq!(cfd_position(&venue), None);
assert!(venue.account.is_cancelled(&ClientOrderId::new("flip")));
}
#[test]
fn sell_order_decrements_base_balance_not_quote() {
let mut venue = make_venue("1.0", "10000");
let initial_usdt = d("10000");
let outcome = venue.open_order(sell_request("0.5", market_prices("50000")));
assert!(
outcome.response.state.is_accepted(),
"sell should succeed: {:?}",
outcome.response.state
);
assert_eq!(outcome.events.len(), 2, "a successful sell must notify");
let btc = venue.account.balance_mut(&base()).unwrap();
assert_eq!(
btc.balance.free,
d("0.5"),
"base balance should decrease by quantity sold"
);
let usdt = venue.account.balance_mut("e()).unwrap();
assert_eq!(
usdt.balance.free, initial_usdt,
"quote balance should be unchanged on sell"
);
}
#[test]
fn unsupported_instrument_kinds_are_rejected_rather_than_filled_as_spot() {
use rustrade_instrument::instrument::kind::{
future::FutureContract,
option::{OptionContract, OptionExercise, OptionKind},
perpetual::PerpetualContract,
};
let settlement = AssetNameExchange::new("USDT");
let expiry = Utc::now();
let unsupported = [
InstrumentKind::Perpetual(PerpetualContract {
contract_size: d("10"),
settlement_asset: settlement.clone(),
}),
InstrumentKind::Future(FutureContract {
contract_size: d("10"),
settlement_asset: settlement.clone(),
expiry,
}),
InstrumentKind::Option(OptionContract {
contract_size: d("10"),
settlement_asset: settlement.clone(),
kind: OptionKind::Call,
exercise: OptionExercise::European,
expiry,
strike: d("50000"),
}),
];
for kind in unsupported {
let mut venue = make_venue("10", "10000000");
venue.instruments.get_mut(&instrument_name()).unwrap().kind = kind.clone();
let outcome = venue.open_order(buy_request("1.0", market_prices("50000")));
assert!(
outcome.events.is_empty(),
"{kind:?} must produce no account events"
);
assert!(
matches!(
outcome.response.state,
OrderState::Inactive(InactiveOrderState::OpenFailed(
UnindexedOrderError::Connectivity(_) | UnindexedOrderError::Rejected(_)
))
),
"{kind:?} must be rejected, got {:?}",
outcome.response.state
);
let usdt = venue.account.balance_mut("e()).unwrap();
assert_eq!(
usdt.balance.free,
d("10000000"),
"{kind:?} must not move the quote balance"
);
}
}
#[test]
fn sell_order_insufficient_balance_names_base_asset() {
let mut venue = make_venue("0.1", "10000");
let outcome = venue.open_order(sell_request("1.0", market_prices("50000")));
assert!(
outcome.events.is_empty(),
"failed order must produce no account events"
);
match outcome.response.state {
OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Rejected(
ApiError::BalanceInsufficient(Some(ref asset), _),
))) => {
assert_eq!(
*asset,
base(),
"BalanceInsufficient must name the base asset (BTC), not the quote (USDT)"
);
}
other => panic!("expected BalanceInsufficient, got: {other:?}"),
}
}
#[test]
fn bid_ask_fill_model_fills_at_ask_price_and_deducts_correct_balance() {
let mut venue = make_venue("0", "10000"); venue.fill_model = SimFillConfig::BidAsk(BidAskFillModel);
let market = Some(MarketSnapshot {
best_bid: Some(d("99.5")),
best_ask: Some(d("100.5")),
last_price: Some(d("100.0")),
});
let outcome = venue.open_order(buy_request("1", market));
assert!(
outcome.response.state.is_accepted(),
"buy should succeed: {:?}",
outcome.response.state
);
assert_eq!(
filled_trade(&outcome).price,
d("100.5"),
"fill price must be best_ask"
);
let usdt = venue.account.balance_mut("e()).unwrap();
assert_eq!(
usdt.balance.free,
d("9899.5"),
"quote balance must decrease by fill_price * qty"
);
}
#[test]
fn percentage_fee_model_deducts_correct_fee_on_buy() {
let fee_model = FeeModelConfig::Percentage(PercentageFeeModel::new(d("0.001")));
let mut venue = make_venue_with_fee("0", "10000", fee_model);
let outcome = venue.open_order(buy_request("10", market_prices("100")));
assert!(
outcome.response.state.is_accepted(),
"buy should succeed: {:?}",
outcome.response.state
);
assert_eq!(
filled_trade(&outcome).fees.fees,
d("1"),
"trade fee must be 1 USDT"
);
let usdt = venue.account.balance_mut("e()).unwrap();
assert_eq!(
usdt.balance.free,
d("8999"),
"quote balance must decrease by notional + fee"
);
}
#[test]
fn percentage_fee_model_deducts_correct_fee_on_sell() {
let fee_model = FeeModelConfig::Percentage(PercentageFeeModel::new(d("0.001")));
let mut venue = make_venue_with_fee("10", "0", fee_model);
let outcome = venue.open_order(sell_request("1", market_prices("100")));
assert!(
outcome.response.state.is_accepted(),
"sell should succeed: {:?}",
outcome.response.state
);
assert_eq!(
filled_trade(&outcome).fees.fees,
d("0.1"),
"trade fee must be 0.1 USDT"
);
let btc = venue.account.balance_mut(&base()).unwrap();
assert_eq!(
btc.balance.free,
d("8.999"),
"base balance must decrease by quantity + fee_in_base"
);
}
#[test]
fn percentage_fee_with_zero_price_returns_zero_fee() {
let fee_model = FeeModelConfig::Percentage(PercentageFeeModel::new(d("0.001")));
let mut venue = make_venue_with_fee("10", "0", fee_model);
let outcome = venue.open_order(sell_request("1", market_prices("0")));
assert!(
outcome.response.state.is_accepted(),
"sell at zero price should succeed: {:?}",
outcome.response.state
);
assert_eq!(
filled_trade(&outcome).fees.fees,
Decimal::ZERO,
"fee must be zero when price is zero"
);
let btc = venue.account.balance_mut(&base()).unwrap();
assert_eq!(
btc.balance.free,
d("9"),
"base balance must decrease by quantity only"
);
}
#[test]
fn a_market_order_that_cannot_be_priced_is_rejected_rather_than_panicking() {
for market in [None, Some(MarketSnapshot::default())] {
let mut venue = make_venue("10", "10000000");
let outcome = venue.open_order(buy_request("1.0", market));
assert!(
outcome.events.is_empty(),
"a rejected order must not notify a fill (market={market:?})"
);
assert!(
matches!(
outcome.response.state,
OrderState::Inactive(InactiveOrderState::OpenFailed(
UnindexedOrderError::Rejected(_)
))
),
"expected a rejection, got {:?} (market={market:?})",
outcome.response.state
);
let usd = venue.account.balance_mut("e()).unwrap();
assert_eq!(
usd.balance.free,
d("10000000"),
"a rejected order must not move the balance (market={market:?})"
);
}
}
#[test]
fn an_unpriceable_rejection_distinguishes_an_absent_snapshot_from_an_empty_one() {
let reason_of = |market| {
let mut venue = make_venue("10", "10000000");
match venue.open_order(buy_request("1.0", market)).response.state {
OrderState::Inactive(InactiveOrderState::OpenFailed(
UnindexedOrderError::Rejected(ApiError::OrderRejected(reason)),
)) => reason,
other => panic!("expected an OrderRejected, got {other:?}"),
}
};
assert!(
reason_of(None).contains("no market snapshot"),
"an absent snapshot must be named as such, got: {}",
reason_of(None)
);
assert!(
reason_of(Some(MarketSnapshot::default())).contains("no market price available yet"),
"an empty snapshot must read as a cold start, got: {}",
reason_of(Some(MarketSnapshot::default()))
);
}
#[test]
fn a_market_order_fills_at_the_price_its_snapshot_carried() {
let mut venue = make_venue("0", "10000");
let outcome = venue.open_order(buy_request(
"1.0",
Some(MarketSnapshot::from_last_price(Some(d("100")))),
));
let OrderState::Inactive(InactiveOrderState::FullyFilled(ref filled)) =
outcome.response.state
else {
panic!("expected a filled order, got {:?}", outcome.response.state)
};
assert_eq!(
filled.avg_price,
Some(d("100")),
"a market order must fill at its snapshot's last price"
);
assert_eq!(filled.filled_quantity, d("1.0"));
assert_eq!(filled_trade(&outcome).price, d("100"));
let usd = venue.account.balance_mut("e()).unwrap();
assert_eq!(
usd.balance.free,
d("9900"),
"a 1 BTC buy at 100 must debit 100 quote"
);
}
#[test]
fn a_configured_quote_reservation_survives_a_fill() {
let mut venue = make_venue("10", "10000000");
venue.account.balance_mut("e()).unwrap().balance.total = d("20000000");
let outcome = venue.open_order(buy_request("1.0", market_prices("50000")));
assert!(
matches!(outcome.response.state, OrderState::Inactive(_)),
"a market order fills on arrival, got {:?}",
outcome.response.state
);
let usdt = venue.account.balance_mut("e()).unwrap().balance;
assert_eq!(
usdt.free,
d("10000000") - d("50000"),
"the fill draws the notional from the spendable side"
);
assert_eq!(
usdt.total - usdt.free,
d("10000000"),
"the configured reservation must be untouched by an unrelated fill"
);
}
#[test]
fn a_configured_base_reservation_survives_a_fill() {
let mut venue = make_venue("10", "10000000");
venue.account.balance_mut(&base()).unwrap().balance.total = d("20");
let outcome = venue.open_order(sell_request("1.0", market_prices("50000")));
assert!(
matches!(outcome.response.state, OrderState::Inactive(_)),
"a market order fills on arrival, got {:?}",
outcome.response.state
);
let btc = venue.account.balance_mut(&base()).unwrap().balance;
assert_eq!(
btc.free,
d("10") - d("1"),
"the fill delivers one base unit from the spendable side"
);
assert_eq!(
btc.total - btc.free,
d("10"),
"the configured reservation must be untouched by an unrelated fill"
);
}
#[test]
fn a_reservation_is_not_spendable() {
let mut venue = make_venue("10", "100");
venue.account.balance_mut("e()).unwrap().balance.total = d("10000000");
let outcome = venue.open_order(buy_request("1.0", market_prices("50000")));
assert!(
matches!(
outcome.response.state,
OrderState::Inactive(InactiveOrderState::OpenFailed(
UnindexedOrderError::Rejected(ApiError::BalanceInsufficient(_, _))
))
),
"expected an insufficient-balance rejection, got {:?}",
outcome.response.state
);
assert!(
outcome.events.is_empty(),
"a refused order moves nothing, so it restates nothing"
);
let usdt = venue.account.balance_mut("e()).unwrap().balance;
assert_eq!(usdt.free, d("100"), "a refused reserve must not move free");
assert_eq!(
usdt.total,
d("10000000"),
"a refused reserve must not move total"
);
}
#[test]
fn an_account_snapshot_orders_one_instruments_orders_deterministically() {
const CIDS: [&str; 8] = ["h", "d", "a", "g", "c", "f", "b", "e"];
let resting = |cid: &str| UnindexedOrder {
key: OrderKey {
exchange: EXCHANGE,
instrument: instrument_name(),
strategy: StrategyId::new("test"),
cid: ClientOrderId::new(cid),
},
side: Side::Buy,
price: Some(d("50000")),
quantity: d("1"),
kind: OrderKind::Limit,
time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
state: OrderState::active(Open {
id: VenueOrderId::Assigned(OrderId::new(cid)),
time_exchange: Default::default(),
filled_quantity: Decimal::ZERO,
}),
};
let mut config = spot_config("10", "10000000", FeeModelConfig::default());
config.initial_state.instruments = vec![InstrumentAccountSnapshot {
instrument: instrument_name(),
orders: CIDS.into_iter().map(resting).collect(),
orders_complete: true,
position: PositionReport::Unreported,
isolated: None,
}];
let venue = SimulatedVenue::new(&config, spot_instruments());
let cids = venue
.account_snapshot()
.instruments
.into_iter()
.flat_map(|instrument| instrument.orders)
.map(|order| order.key.cid)
.collect::<Vec<_>>();
let mut expected = CIDS.map(ClientOrderId::new).to_vec();
expected.sort();
assert_eq!(
cids, expected,
"orders on one instrument must be ordered by a key no two of them share"
);
}
#[test]
fn minted_order_ids_start_past_the_highest_decimal_id_seeded() {
let resting = |cid: &str, id: &str| UnindexedOrder {
key: OrderKey {
exchange: EXCHANGE,
instrument: instrument_name(),
strategy: StrategyId::new("test"),
cid: ClientOrderId::new(cid),
},
side: Side::Buy,
price: Some(d("50000")),
quantity: d("1"),
kind: OrderKind::Limit,
time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
state: OrderState::active(Open {
id: VenueOrderId::Assigned(OrderId::new(id)),
time_exchange: Default::default(),
filled_quantity: Decimal::ZERO,
}),
};
let seeded_venue = |orders| {
let mut config = spot_config("10", "10000000", FeeModelConfig::default());
config.initial_state.instruments = vec![InstrumentAccountSnapshot {
instrument: instrument_name(),
orders,
orders_complete: true,
position: PositionReport::Unreported,
isolated: None,
}];
SimulatedVenue::new(&config, spot_instruments())
};
let mut venue = seeded_venue(vec![
resting("a", "3"),
resting("b", "1"),
resting("c", "zzz"),
]);
assert_eq!(venue.order_id_sequence_fetch_add(), OrderId::new("4"));
assert_eq!(venue.order_id_sequence_fetch_add(), OrderId::new("5"));
assert_eq!(venue.order_sequence(), 2);
let mut cancelled = resting("d", "9");
cancelled.state = OrderState::inactive(Cancelled::new(
OrderId::new("9"),
Default::default(),
Some(Decimal::ZERO),
));
let mut venue = seeded_venue(vec![resting("a", "3"), cancelled]);
assert_eq!(venue.order_id_sequence_fetch_add(), OrderId::new("10"));
let mut venue = seeded_venue(vec![resting("c", "zzz")]);
assert_eq!(venue.order_id_sequence_fetch_add(), OrderId::new("0"));
}
#[test]
fn minted_order_ids_start_at_zero_when_nothing_is_seeded() {
let config = spot_config("10", "10000000", FeeModelConfig::default());
let mut venue = SimulatedVenue::new(&config, spot_instruments());
assert_eq!(venue.order_id_sequence_fetch_add(), OrderId::new("0"));
}
#[test]
fn a_seeded_u64_max_order_id_does_not_overflow_the_next() {
let mut account = AccountState::try_from(
spot_config("10", "10000000", FeeModelConfig::default()).initial_state,
)
.unwrap();
let mut seeded = seeded_part_filled("48000", "1", "0");
seeded.state.id = VenueOrderId::Assigned(OrderId::new(u64::MAX.to_string()));
assert!(
account.book(seeded, None).is_ok(),
"the book is vacant under this id"
);
assert_eq!(first_unseeded_order_id(&account), u128::from(u64::MAX) + 1);
}
#[test]
fn a_limit_order_is_rejected_before_a_fill_model_is_consulted() {
let mut venue = make_venue("100", "10000000");
venue.fill_model = SimFillConfig::Midpoint(crate::fill::MidpointFillModel);
let mut request = buy_request("1", market_prices("50000"));
request.state.kind = OrderKind::Limit;
request.state.price = Some(d("40000"));
let outcome = venue.open_order(request);
assert!(
outcome.events.is_empty(),
"a rejected order moves no balance and books no trade"
);
match outcome.response.state {
OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Rejected(
ApiError::OrderRejected(ref reason),
))) => assert!(
reason.contains("does not support OrderKind::Limit"),
"the rejection must name the unsupported kind, got: {reason}"
),
other => panic!("a limit order must be rejected by kind, got: {other:?}"),
}
}
#[test]
fn a_request_priced_venue_rejects_a_limit_order_and_says_what_would_accept_one() {
let mut venue = make_venue("10", "1000000");
assert_eq!(venue.regime(), VenueRegime::RequestPriced);
let outcome = venue.open_order(limit_request("a", Side::Buy, "1", "48000", gtc()));
assert!(
outcome.events.is_empty(),
"a rejected order moves no balance"
);
match outcome.response.state {
OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Rejected(
ApiError::OrderRejected(ref reason),
))) => {
assert!(
reason.contains("new_market_driven"),
"the rejection must name the constructor that would accept it, got: {reason}"
);
}
ref other => panic!("a limit order must be rejected by the regime, got: {other:?}"),
}
}
#[test]
fn a_limit_order_that_does_not_cross_rests_and_reserves_what_its_fill_will_cost() {
let (venue, rested) = venue_resting_one_buy("48000");
let reserved = d("48009.6");
let balance = venue.balances(&[quote()]).remove(0);
assert_eq!(
balance.balance.total,
d("1000000"),
"resting an order spends nothing: `total` moves only on a fill"
);
assert_eq!(
balance.balance.free,
d("1000000") - reserved,
"`free` falls by the notional plus the MAKER fee the fill will charge"
);
assert_eq!(
balance.balance.used(),
reserved,
"the held portion is what a client sees as committed to the order"
);
assert_eq!(
rested_open(&rested).filled_quantity,
Decimal::ZERO,
"the client is told an order is working with none of its quantity done"
);
let open = venue
.orders_open(&[instrument_name()])
.pop()
.expect("the order is on the book");
assert_eq!(open.price, Some(d("48000")));
assert_eq!(
open.state,
*rested_open(&rested),
"the book and the response describe the same order"
);
}
#[test]
fn a_resting_order_fills_at_its_own_limit_and_is_never_improved_by_the_book() {
let (mut venue, _) = venue_resting_one_buy("48000");
advance(&mut venue, time(2));
let events = venue.apply_market(
&instrument_name(),
book("47800", "47900"),
MarketDepth::UNKNOWN,
time(2),
);
assert_eq!(
events.len(),
3,
"one filled order owes a balance, a trade and a terminal order snapshot"
);
let trade = trade_of(&events[1]);
assert_eq!(
trade.price,
d("48000"),
"a resting order fills at its own limit; 47900 would credit it improvement no maker \
can obtain"
);
assert_eq!(
trade.fees.fees,
d("9.6"),
"2bp MAKER on 48000, not 10bp taker: the resting side supplied liquidity"
);
assert_eq!(
filled_price(order_of(&events[2])),
d("48000"),
"the terminal snapshot reports the same price as the trade"
);
}
#[test]
fn a_resting_fill_reports_its_trade_before_the_order_that_produced_it() {
let (mut venue, _) = venue_resting_one_buy("48000");
advance(&mut venue, time(2));
let events = venue.apply_market(
&instrument_name(),
book("47800", "47900"),
MarketDepth::UNKNOWN,
time(2),
);
assert!(
matches!(events[0].kind, AccountEventKind::BalanceSnapshot(_)),
"the balance the fill paid with comes first, got: {:?}",
events[0].kind
);
assert!(
matches!(events[1].kind, AccountEventKind::Trade(_)),
"then the trade, got: {:?}",
events[1].kind
);
assert!(
matches!(events[2].kind, AccountEventKind::OrderSnapshot(_)),
"and only then the order it terminated, got: {:?}",
events[2].kind
);
}
#[test]
fn a_resting_fill_reports_its_order_as_terminal_rather_than_as_a_complete_open() {
let (mut venue, _) = venue_resting_one_buy("48000");
advance(&mut venue, time(2));
let events = venue.apply_market(
&instrument_name(),
book("47800", "47900"),
MarketDepth::UNKNOWN,
time(2),
);
assert!(
matches!(
order_of(&events[2]).state,
OrderState::Inactive(InactiveOrderState::FullyFilled(_))
),
"got: {:?}",
order_of(&events[2]).state
);
assert!(
venue.orders_open(&[]).is_empty(),
"a filled order leaves the book"
);
}
#[test]
fn a_resting_order_settles_exactly_what_it_reserved() {
let (mut venue, _) = venue_resting_one_buy("48000");
advance(&mut venue, time(2));
let reserved = d("48009.6");
let free_while_resting = venue.balances(&[quote()]).remove(0).balance.free;
let events = venue.apply_market(
&instrument_name(),
book("47800", "47900"),
MarketDepth::UNKNOWN,
time(2),
);
let balance = balance_of(&events[0]);
assert_eq!(
balance.balance.total,
d("1000000") - reserved,
"`total` falls by exactly what was held"
);
assert_eq!(
balance.balance.free, free_while_resting,
"`free` was already reduced when the order rested, so the fill does not move it again"
);
assert_eq!(
balance.balance.used(),
Decimal::ZERO,
"nothing is held any more: the order that held it is gone"
);
}
fn seeded_part_filled(price: &str, quantity: &str, filled: &str) -> OpenOrder {
as_open(Order {
key: OrderKey {
exchange: EXCHANGE,
instrument: instrument_name(),
strategy: StrategyId::new("test"),
cid: ClientOrderId::new("part_filled"),
},
side: Side::Buy,
price: Some(d(price)),
quantity: d(quantity),
kind: OrderKind::Limit,
time_in_force: gtc(),
state: OrderState::active(Open::new(
VenueOrderId::Assigned(OrderId::new("part_filled")),
time(1),
d(filled),
)),
})
.expect("the seeded order is Open")
}
#[test]
fn a_part_filled_order_on_the_book_settles_and_prints_only_its_remainder() {
let mut venue = make_market_venue("10", "1000000");
advance(&mut venue, time(1));
assert!(
venue
.account
.book(
seeded_part_filled("48000", "1", "0.4"),
None,
)
.is_ok(),
"the book is vacant under this id"
);
advance(&mut venue, time(2));
let events = venue.apply_market(
&instrument_name(),
book("47800", "47900"),
MarketDepth::UNKNOWN,
time(2),
);
assert_eq!(
events.len(),
3,
"one filled order owes a balance, a trade and a terminal order snapshot"
);
let trade = trade_of(&events[1]);
assert_eq!(
trade.quantity,
d("0.6"),
"0.6 is what was left to trade; printing 1 would report a fill that did not happen"
);
assert_eq!(
trade.fees.fees,
d("5.76"),
"2bp maker on 0.6 at 48000, not on the whole quantity"
);
assert_eq!(
balance_of(&events[0]).balance.total,
d("1000000") - d("28805.76"),
"the fill costs what the remainder costs; 48009.6 would pay a second time for the 0.4 \
that traded elsewhere"
);
}
#[test]
fn a_fill_completing_a_part_filled_order_reports_no_average_price() {
let mut venue = make_market_venue("10", "1000000");
advance(&mut venue, time(1));
assert!(
venue
.account
.book(seeded_part_filled("48000", "1", "0.4"), None)
.is_ok(),
"the book is vacant under this id"
);
advance(&mut venue, time(2));
let events = venue.apply_market(
&instrument_name(),
book("47800", "47900"),
MarketDepth::UNKNOWN,
time(2),
);
let filled = match &order_of(&events[2]).state {
OrderState::Inactive(InactiveOrderState::FullyFilled(filled)) => filled,
other => panic!("expected a fully filled order, got: {other:?}"),
};
assert_eq!(
filled.filled_quantity,
d("1"),
"the order has now done its whole quantity, which is what this field reports"
);
assert_eq!(
filled.avg_price, None,
"48000 is one of the two prices behind that quantity, not their mean"
);
}
#[test]
fn resting_a_part_filled_order_reserves_and_settles_exactly_its_remainder() {
let mut venue = make_market_venue("10", "1000000");
advance(&mut venue, time(1));
let terms = venue
.instrument_terms(&instrument_name())
.expect("the spot fixture is priceable");
let (response, notifications) = venue.rest_order(
limit_request("part_filled", Side::Buy, "1", "48000", gtc()),
d("48000"),
&terms,
d("0.4"),
);
match &response.state {
OrderState::Active(ActiveOrderState::Open(open)) => assert_eq!(
open.filled_quantity,
d("0.4"),
"the order rests carrying what it has already done"
),
other => panic!("expected a resting open order, got: {other:?}"),
}
match notifications {
Some(OpenOrderNotifications::Rested { balance }) => assert_eq!(
balance.0.balance.free,
d("1000000") - d("28805.76"),
"0.6 at 48000 plus 2bp maker is held; 48009.6 would hold against the 0.4 that has \
already settled"
),
other => panic!("a rested order restates one balance, got: {other:?}"),
}
advance(&mut venue, time(2));
let events = venue.apply_market(
&instrument_name(),
book("47800", "47900"),
MarketDepth::UNKNOWN,
time(2),
);
assert_eq!(events.len(), 3);
assert_eq!(
trade_of(&events[1]).quantity,
d("0.6"),
"the cross prints the remainder that was held for"
);
assert_eq!(
balance_of(&events[0]).balance.total,
d("1000000") - d("28805.76"),
"and settles exactly that: one restatement, with nothing left over to release"
);
assert_eq!(
balance_of(&events[0]).balance.used(),
Decimal::ZERO,
"nothing is held any more: the order that held it is gone"
);
}
#[test]
fn a_marketable_order_aggresses_the_way_its_own_terms_asked_for() {
let now = time(1);
let gtd = TimeInForce::GoodTillDate { expiry: time(9) };
assert_eq!(
disposition(OrderKind::Limit, gtc(), true, now),
Disposition::FillAndRest,
"a limit order with somewhere to wait keeps whatever it could not fill"
);
assert_eq!(
disposition(OrderKind::Limit, gtd, true, now),
Disposition::FillAndRest,
"a deadline it has not reached is still somewhere to wait"
);
assert_eq!(
disposition(OrderKind::Limit, TimeInForce::ImmediateOrCancel, true, now),
Disposition::FillAndCancel,
"immediate-or-cancel asked not to wait"
);
assert_eq!(
disposition(OrderKind::Limit, TimeInForce::FillOrKill, true, now),
Disposition::FillOrNothing,
"fill-or-kill would rather trade nothing than trade in part"
);
assert_eq!(
disposition(OrderKind::Market, gtc(), true, now),
Disposition::FillAndCancel,
"an order carrying no limit price has nothing to wait at, whatever its time in force"
);
assert_eq!(
disposition(OrderKind::Market, gtd, true, now),
Disposition::FillAndCancel,
"including one whose deadline is still ahead of it"
);
assert_eq!(
disposition(OrderKind::Market, TimeInForce::FillOrKill, true, now),
Disposition::FillOrNothing,
"and all-or-nothing stays all-or-nothing whatever the kind"
);
}
#[test]
fn a_marketable_limit_order_fills_on_arrival_at_the_book_bounded_by_its_limit() {
let mut venue = make_market_venue_with(
"10",
"1000000",
maker_taker_fee(),
SimFillConfig::BidAsk(BidAskFillModel),
);
advance(&mut venue, time(1));
assert!(
venue
.apply_market(
&instrument_name(),
book("47800", "47900"),
MarketDepth::UNKNOWN,
time(1)
)
.is_empty()
);
let outcome = venue.open_order(limit_request("taker", Side::Buy, "1", "48000", gtc()));
assert_eq!(outcome.events.len(), 2, "a filled open owes balance, trade");
let trade = trade_of(&outcome.events[1]);
assert_eq!(
trade.price,
d("47900"),
"the aggressor takes the offer, which is better than its limit"
);
assert_eq!(
trade.fees.fees,
d("47.9"),
"10bp TAKER on 47900: it crossed the spread rather than resting"
);
assert!(
venue.orders_open(&[]).is_empty(),
"it filled on arrival, so it never rested"
);
}
#[test]
fn a_marketable_limit_order_never_fills_worse_than_its_limit() {
let mut venue = make_market_venue_with(
"10",
"1000000",
FeeModelConfig::default(),
SimFillConfig::default(),
);
advance(&mut venue, time(1));
let stale = MarketSnapshot {
best_bid: Some(d("46900")),
best_ask: Some(d("47000")),
last_price: Some(d("48000")),
};
assert!(
venue
.apply_market(&instrument_name(), stale, MarketDepth::UNKNOWN, time(1))
.is_empty()
);
let outcome = venue.open_order(limit_request("clamped", Side::Buy, "1", "47100", gtc()));
assert_eq!(
trade_of(&outcome.events[1]).price,
d("47100"),
"the model priced it at 48000, which the order never agreed to pay"
);
}
#[test]
fn the_two_pricing_rules_agree_where_the_book_meets_the_limit() {
let limit = "48000";
let touching = || book("47900", "48000");
let mut aggressor = make_market_venue_with(
"10",
"1000000",
maker_taker_fee(),
SimFillConfig::BidAsk(BidAskFillModel),
);
advance(&mut aggressor, time(1));
assert!(
aggressor
.apply_market(
&instrument_name(),
touching(),
MarketDepth::UNKNOWN,
time(1)
)
.is_empty()
);
let on_arrival = aggressor.open_order(limit_request("a", Side::Buy, "1", limit, gtc()));
let arrival_price = trade_of(&on_arrival.events[1]).price;
let mut maker = make_market_venue_with(
"10",
"1000000",
maker_taker_fee(),
SimFillConfig::BidAsk(BidAskFillModel),
);
advance(&mut maker, time(1));
assert!(
maker
.apply_market(
&instrument_name(),
book("49000", "49100"),
MarketDepth::UNKNOWN,
time(1)
)
.is_empty()
);
let rested = maker.open_order(limit_request("b", Side::Buy, "1", limit, gtc()));
assert_eq!(rested.events.len(), 1, "it rested rather than filling");
advance(&mut maker, time(2));
let resting_events = maker.apply_market(
&instrument_name(),
touching(),
MarketDepth::UNKNOWN,
time(2),
);
let resting_price = trade_of(&resting_events[1]).price;
assert_eq!(
arrival_price,
d(limit),
"an order arriving when the book is at its limit fills at the limit"
);
assert_eq!(
resting_price, arrival_price,
"the two pricing rules must agree exactly at the crossing point; they may differ only \
where the order genuinely is, or is not, marketable"
);
}
#[test]
fn matching_fills_in_price_time_order_and_stops_at_the_first_order_that_does_not_cross() {
let mut venue = make_market_venue("10", "1000000");
advance(&mut venue, time(1));
assert!(
venue
.apply_market(
&instrument_name(),
book("49000", "49100"),
MarketDepth::UNKNOWN,
time(1)
)
.is_empty()
);
for (cid, price) in [("worst", "47000"), ("mid", "48000"), ("best", "48500")] {
let outcome = venue.open_order(limit_request(cid, Side::Buy, "1", price, gtc()));
assert_eq!(outcome.events.len(), 1, "{cid} must rest");
}
advance(&mut venue, time(2));
let events = venue.apply_market(
&instrument_name(),
book("47900", "48000"),
MarketDepth::UNKNOWN,
time(2),
);
let filled: Vec<_> = events
.iter()
.filter_map(|event| match &event.kind {
AccountEventKind::Trade(trade) => Some(trade.price),
_ => None,
})
.collect();
assert_eq!(
filled,
vec![d("48500"), d("48000")],
"best bid first, and each at its own limit"
);
assert_eq!(
venue
.orders_open(&[])
.into_iter()
.map(|order| order.key.cid)
.collect::<Vec<_>>(),
vec![ClientOrderId::new("worst")],
"the order the market did not reach is untouched"
);
}
#[test]
fn a_feed_with_no_book_matches_on_the_trade_price() {
let mut venue = make_market_venue("10", "1000000");
advance(&mut venue, time(1));
assert!(
venue
.apply_market(
&instrument_name(),
traded("49000"),
MarketDepth::UNKNOWN,
time(1)
)
.is_empty()
);
let outcome = venue.open_order(limit_request("t", Side::Buy, "1", "48000", gtc()));
assert_eq!(outcome.events.len(), 1, "49000 traded does not reach 48000");
advance(&mut venue, time(2));
let events = venue.apply_market(
&instrument_name(),
traded("47500"),
MarketDepth::UNKNOWN,
time(2),
);
assert_eq!(events.len(), 3, "a print through the limit fills it");
assert_eq!(trade_of(&events[1]).price, d("48000"));
}
#[test]
fn a_price_inside_the_spread_does_not_cross_a_resting_order() {
let (mut venue, _) = venue_resting_one_buy("48000");
advance(&mut venue, time(2));
let inside = MarketSnapshot {
best_bid: Some(d("47900")),
best_ask: Some(d("48100")),
last_price: Some(d("47950")),
};
assert!(
venue
.apply_market(&instrument_name(), inside, MarketDepth::UNKNOWN, time(2))
.is_empty(),
"the offer is 48100, so a 48000 bid is not crossed however the trade price reads"
);
assert_eq!(
venue.orders_open(&[]).len(),
1,
"the order is still resting"
);
}
#[test]
fn a_resting_sell_reserves_base_and_settles_it() {
let mut venue = make_market_venue("10", "1000000");
advance(&mut venue, time(1));
assert!(
venue
.apply_market(
&instrument_name(),
book("47000", "47100"),
MarketDepth::UNKNOWN,
time(1)
)
.is_empty()
);
let outcome = venue.open_order(limit_request("s", Side::Sell, "1", "48000", gtc()));
assert_eq!(outcome.events.len(), 1, "47000 bid does not reach 48000");
assert_eq!(
balance_of(&outcome.events[0]).asset,
base(),
"a spot sell delivers the base asset, so that is what is held"
);
advance(&mut venue, time(2));
let events = venue.apply_market(
&instrument_name(),
book("48100", "48200"),
MarketDepth::UNKNOWN,
time(2),
);
assert_eq!(trade_of(&events[1]).price, d("48000"), "at its own limit");
let balance = balance_of(&events[0]);
assert_eq!(balance.asset, base());
assert_eq!(balance.balance.total, d("10") - d("1") - d("0.0002"));
}
#[test]
fn a_maker_rebate_is_held_and_settled_as_a_smaller_amount_than_the_notional() {
let rebate =
FeeModelConfig::Percentage(PercentageFeeModel::maker_taker(d("-0.0001"), d("0.001")));
let mut venue = make_market_venue_with("10", "1000000", rebate, SimFillConfig::default());
advance(&mut venue, time(1));
assert!(
venue
.apply_market(
&instrument_name(),
book("49000", "49100"),
MarketDepth::UNKNOWN,
time(1)
)
.is_empty()
);
let outcome = venue.open_order(limit_request("r", Side::Buy, "1", "48000", gtc()));
let held = d("48000") - d("4.8");
assert_eq!(
balance_of(&outcome.events[0]).balance.used(),
held,
"a rebate means less is held than the notional"
);
advance(&mut venue, time(2));
let events = venue.apply_market(
&instrument_name(),
book("47800", "47900"),
MarketDepth::UNKNOWN,
time(2),
);
assert_eq!(
balance_of(&events[0]).balance.total,
d("1000000") - held,
"and exactly that less is settled, so the rebate is kept"
);
assert_eq!(trade_of(&events[1]).fees.fees, d("-4.8"));
}
#[test]
fn a_limit_order_that_cannot_be_afforded_is_rejected_without_resting() {
let mut venue = make_market_venue("10", "1000");
advance(&mut venue, time(1));
assert!(
venue
.apply_market(
&instrument_name(),
book("49000", "49100"),
MarketDepth::UNKNOWN,
time(1)
)
.is_empty()
);
let outcome = venue.open_order(limit_request("poor", Side::Buy, "1", "48000", gtc()));
assert!(
outcome.events.is_empty(),
"a refused reservation moves nothing"
);
assert!(matches!(
outcome.response.state,
OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Rejected(
ApiError::BalanceInsufficient(..)
)))
));
assert!(venue.orders_open(&[]).is_empty(), "it never rested");
assert_eq!(
venue.balances(&[quote()]).remove(0).balance.free,
d("1000"),
"the ledger is left exactly as it was"
);
}
#[test]
fn a_cancel_releases_the_reservation_and_restates_the_balance() {
let (mut venue, _) = venue_resting_one_buy("48000");
advance(&mut venue, time(2));
let outcome = venue.cancel_order(OrderEvent {
key: OrderKey {
exchange: EXCHANGE,
instrument: instrument_name(),
strategy: StrategyId::new("test"),
cid: ClientOrderId::new("resting"),
},
state: RequestCancel { id: None },
});
assert_eq!(outcome.events.len(), 1, "a cancel restates one balance");
let balance = balance_of(&outcome.events[0]);
assert_eq!(
balance.balance.free,
d("1000000"),
"everything held against the order is spendable again"
);
assert_eq!(
balance.balance.total,
d("1000000"),
"nothing ever left the account, so `total` never moved"
);
let cancelled = outcome.response.state.expect("the order was on the book");
assert_eq!(cancelled.filled_quantity, Some(Decimal::ZERO));
assert_eq!(cancelled.time_exchange, time(2));
assert!(venue.orders_open(&[]).is_empty());
}
#[test]
fn a_cancel_that_loses_the_race_to_its_own_fill_reports_the_fill() {
let (mut venue, _) = venue_resting_one_buy("48000");
advance(&mut venue, time(2));
let events = venue.apply_market(
&instrument_name(),
book("47800", "47900"),
MarketDepth::UNKNOWN,
time(2),
);
assert_eq!(events.len(), 3, "the order filled first");
let outcome = venue.cancel_order(OrderEvent {
key: OrderKey {
exchange: EXCHANGE,
instrument: instrument_name(),
strategy: StrategyId::new("test"),
cid: ClientOrderId::new("resting"),
},
state: RequestCancel { id: None },
});
assert!(outcome.events.is_empty(), "a failed cancel moves nothing");
assert!(
matches!(
outcome.response.state,
Err(OrderError::Rejected(ApiError::OrderAlreadyFullyFilled))
),
"got: {:?}",
outcome.response.state
);
}
#[test]
fn a_second_cancel_reports_the_order_as_already_cancelled() {
let (mut venue, _) = venue_resting_one_buy("48000");
let cancel = || OrderEvent {
key: OrderKey {
exchange: EXCHANGE,
instrument: instrument_name(),
strategy: StrategyId::new("test"),
cid: ClientOrderId::new("resting"),
},
state: RequestCancel { id: None },
};
assert!(venue.cancel_order(cancel()).response.state.is_ok());
let second = venue.cancel_order(cancel());
assert!(
matches!(
second.response.state,
Err(OrderError::Rejected(ApiError::OrderAlreadyCancelled))
),
"got: {:?}",
second.response.state
);
}
#[test]
fn a_cancelled_order_enters_the_account_snapshot() {
let (mut venue, _) = venue_resting_one_buy("48000");
venue.cancel_order(OrderEvent {
key: OrderKey {
exchange: EXCHANGE,
instrument: instrument_name(),
strategy: StrategyId::new("test"),
cid: ClientOrderId::new("resting"),
},
state: RequestCancel { id: None },
});
let snapshot = venue.account_snapshot();
let orders = &snapshot.instruments[0].orders;
assert_eq!(orders.len(), 1);
assert!(matches!(
orders[0].state,
OrderState::Inactive(InactiveOrderState::Cancelled(_))
));
}
#[test]
fn a_limit_order_is_rejected_for_every_time_in_force_this_venue_cannot_honour() {
let unsupported = [
TimeInForce::GoodUntilEndOfDay,
TimeInForce::AtOpen,
TimeInForce::AtClose,
];
for time_in_force in unsupported {
let mut venue = make_market_venue("10", "1000000");
advance(&mut venue, time(1));
assert!(
venue
.apply_market(
&instrument_name(),
book("49000", "49100"),
MarketDepth::UNKNOWN,
time(1)
)
.is_empty()
);
let outcome =
venue.open_order(limit_request("tif", Side::Buy, "1", "48000", time_in_force));
assert!(
outcome.events.is_empty(),
"{time_in_force} must move no balance"
);
assert!(
venue.orders_open(&[]).is_empty(),
"{time_in_force} must not rest"
);
match outcome.response.state {
OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Rejected(
ApiError::OrderRejected(ref reason),
))) => assert!(
reason.contains("does not model"),
"the rejection must say what is missing, got: {reason}"
),
ref other => panic!("{time_in_force} must be rejected, got: {other:?}"),
}
}
}
#[test]
fn a_market_order_is_accepted_for_every_time_in_force_that_only_bounds_how_long_it_works() {
for time_in_force in [
TimeInForce::GoodUntilCancelled { post_only: false },
TimeInForce::ImmediateOrCancel,
TimeInForce::FillOrKill,
TimeInForce::GoodTillDate { expiry: time(9) },
TimeInForce::GoodUntilEndOfDay,
] {
let mut venue = make_venue("10", "1000000");
let mut request = buy_request("1", market_prices("48000"));
request.state.time_in_force = time_in_force;
let outcome = venue.open_order(request);
assert!(
matches!(
outcome.response.state,
OrderState::Inactive(InactiveOrderState::FullyFilled(_))
),
"{time_in_force} on a market order must still fill, got: {:?}",
outcome.response.state
);
}
}
#[test]
fn a_market_order_is_rejected_for_a_time_in_force_it_cannot_keep() {
for time_in_force in [
TimeInForce::GoodUntilCancelled { post_only: true },
TimeInForce::AtOpen,
TimeInForce::AtClose,
] {
let mut venue = make_venue("10", "1000000");
let mut request = buy_request("1", market_prices("48000"));
request.state.time_in_force = time_in_force;
let outcome = venue.open_order(request);
assert!(
outcome.events.is_empty(),
"{time_in_force} must move no balance"
);
match outcome.response.state {
OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Rejected(
ApiError::OrderRejected(ref reason),
))) => assert!(
reason.contains("does not model"),
"the rejection must say what is missing, got: {reason}"
),
ref other => panic!("{time_in_force} must be rejected, got: {other:?}"),
}
}
}
#[test]
fn a_post_only_order_that_would_take_liquidity_is_cancelled_having_traded_nothing() {
let mut venue = make_market_venue("10", "1000000");
advance(&mut venue, time(1));
assert!(
venue
.apply_market(
&instrument_name(),
book("49000", "49100"),
MarketDepth::UNKNOWN,
time(1)
)
.is_empty()
);
let outcome = venue.open_order(limit_request(
"post",
Side::Buy,
"1",
"50000",
TimeInForce::GoodUntilCancelled { post_only: true },
));
assert!(outcome.events.is_empty(), "nothing was reserved or traded");
assert!(venue.orders_open(&[]).is_empty(), "it must not rest");
assert!(venue.trades(time(0)).is_empty(), "it must not trade");
match outcome.response.state {
OrderState::Inactive(InactiveOrderState::Cancelled(ref cancelled)) => {
assert_eq!(cancelled.filled_quantity, Some(Decimal::ZERO))
}
ref other => panic!("a marketable post-only order must be cancelled, got: {other:?}"),
}
}
#[test]
fn a_post_only_order_that_would_rest_is_accepted_and_reserves() {
let mut venue = make_market_venue("10", "1000000");
advance(&mut venue, time(1));
assert!(
venue
.apply_market(
&instrument_name(),
book("49000", "49100"),
MarketDepth::UNKNOWN,
time(1)
)
.is_empty()
);
let outcome = venue.open_order(limit_request(
"post",
Side::Buy,
"1",
"48000",
TimeInForce::GoodUntilCancelled { post_only: true },
));
assert_eq!(rested_open(&outcome).filled_quantity, Decimal::ZERO);
assert_eq!(venue.orders_open(&[]).len(), 1);
assert_eq!(
balance_of(&outcome.events[0]).balance.free,
d("1000000") - d("48000") - d("9.6"),
"resting post-only reserves the notional plus the 2bp maker fee its fill will charge"
);
}
#[test]
fn a_non_marketable_immediate_order_is_cancelled_having_traded_nothing() {
for time_in_force in [TimeInForce::ImmediateOrCancel, TimeInForce::FillOrKill] {
let mut venue = make_market_venue("10", "1000000");
advance(&mut venue, time(1));
assert!(
venue
.apply_market(
&instrument_name(),
book("49000", "49100"),
MarketDepth::UNKNOWN,
time(1)
)
.is_empty()
);
let outcome =
venue.open_order(limit_request("ioc", Side::Buy, "1", "48000", time_in_force));
assert!(
outcome.events.is_empty(),
"{time_in_force} must move no balance"
);
assert!(
venue.orders_open(&[]).is_empty(),
"{time_in_force} must not rest"
);
match outcome.response.state {
OrderState::Inactive(InactiveOrderState::Cancelled(ref cancelled)) => assert_eq!(
cancelled.filled_quantity,
Some(Decimal::ZERO),
"{time_in_force} traded nothing"
),
ref other => panic!("{time_in_force} must be cancelled, got: {other:?}"),
}
}
}
#[test]
fn a_marketable_immediate_order_fills_in_full() {
for time_in_force in [TimeInForce::ImmediateOrCancel, TimeInForce::FillOrKill] {
let mut venue = make_market_venue("10", "1000000");
advance(&mut venue, time(1));
assert!(
venue
.apply_market(
&instrument_name(),
traded("49000"),
MarketDepth::UNKNOWN,
time(1)
)
.is_empty()
);
let outcome =
venue.open_order(limit_request("ioc", Side::Buy, "1", "50000", time_in_force));
assert!(
matches!(
outcome.response.state,
OrderState::Inactive(InactiveOrderState::FullyFilled(_))
),
"{time_in_force} must fill, got: {:?}",
outcome.response.state
);
}
}
#[test]
fn a_good_till_date_order_rests_until_its_deadline() {
let mut venue = make_market_venue("10", "1000000");
advance(&mut venue, time(1));
let outcome = venue.open_order(limit_request(
"gtd",
Side::Buy,
"1",
"48000",
TimeInForce::GoodTillDate { expiry: time(9) },
));
assert_eq!(rested_open(&outcome).filled_quantity, Decimal::ZERO);
advance(&mut venue, time(8));
assert_eq!(venue.orders_open(&[]).len(), 1, "still working at time(8)");
}
#[test]
fn advancing_to_a_deadline_releases_the_reservation_then_reports_the_order() {
let mut venue = make_market_venue("10", "1000000");
advance(&mut venue, time(1));
let outcome = venue.open_order(limit_request(
"gtd",
Side::Buy,
"1",
"48000",
TimeInForce::GoodTillDate { expiry: time(9) },
));
let reserved = balance_of(&outcome.events[0]).balance.free;
let events = venue.advance_time(time(9));
assert_eq!(events.len(), 2, "one release and one terminal snapshot");
assert_eq!(
balance_of(&events[0]).balance.free,
reserved + d("48000") + d("9.6"),
"the release restates exactly what resting took"
);
let order = order_of(&events[1]);
match order.state {
OrderState::Inactive(InactiveOrderState::Expired(ref expired)) => {
assert_eq!(expired.time_exchange, time(9));
assert_eq!(expired.filled_quantity, Some(Decimal::ZERO));
}
ref other => panic!("the order must be reported expired, got: {other:?}"),
}
assert!(venue.orders_open(&[]).is_empty(), "it must leave the book");
}
#[test]
fn a_deadline_is_reached_at_its_instant_not_after_it() {
let mut venue = make_market_venue("10", "1000000");
advance(&mut venue, time(1));
let _ = venue.open_order(limit_request(
"gtd",
Side::Buy,
"1",
"48000",
TimeInForce::GoodTillDate { expiry: time(5) },
));
advance(&mut venue, time(4));
assert_eq!(venue.orders_open(&[]).len(), 1);
assert_eq!(venue.advance_time(time(5)).len(), 2);
assert!(venue.orders_open(&[]).is_empty());
}
#[test]
fn a_deadline_is_swept_before_the_tick_that_would_have_filled_it() {
let mut venue = make_market_venue("10", "1000000");
advance(&mut venue, time(1));
let _ = venue.open_order(limit_request(
"gtd",
Side::Buy,
"1",
"48000",
TimeInForce::GoodTillDate { expiry: time(5) },
));
let events = venue.apply_market(
&instrument_name(),
book("46900", "47000"),
MarketDepth::UNKNOWN,
time(5),
);
assert_eq!(events.len(), 2, "a release and an expiry, not a fill");
assert!(
venue.trades(time(0)).is_empty(),
"an expired order must not trade on the tick that retired it"
);
assert!(matches!(
order_of(&events[1]).state,
OrderState::Inactive(InactiveOrderState::Expired(_))
));
}
#[test]
fn a_good_till_date_order_that_has_already_expired_is_never_rested() {
let mut venue = make_market_venue("10", "1000000");
advance(&mut venue, time(9));
let outcome = venue.open_order(limit_request(
"stale",
Side::Buy,
"1",
"48000",
TimeInForce::GoodTillDate { expiry: time(5) },
));
assert!(outcome.events.is_empty(), "nothing was reserved");
assert!(venue.orders_open(&[]).is_empty(), "it must not rest");
match outcome.response.state {
OrderState::Inactive(InactiveOrderState::Expired(ref expired)) => {
assert_eq!(expired.time_exchange, time(9), "stamped when it was found");
assert_eq!(expired.filled_quantity, Some(Decimal::ZERO));
}
ref other => panic!("a stale deadline must expire, not {other:?}"),
}
}
#[test]
fn a_stale_deadline_retires_an_order_the_book_would_have_filled() {
let mut venue = make_market_venue("10", "1000000");
advance(&mut venue, time(9));
assert!(
venue
.apply_market(
&instrument_name(),
traded("47000"),
MarketDepth::UNKNOWN,
time(9)
)
.is_empty()
);
let outcome = venue.open_order(limit_request(
"stale",
Side::Buy,
"1",
"48000",
TimeInForce::GoodTillDate { expiry: time(5) },
));
assert!(
matches!(
outcome.response.state,
OrderState::Inactive(InactiveOrderState::Expired(_))
),
"a marketable order past its deadline must expire, not fill: {:?}",
outcome.response.state
);
assert!(
venue.trades(time(0)).is_empty(),
"it must not trade on its way out"
);
}
#[test]
fn a_cancel_after_a_deadline_is_answered_already_expired() {
let mut venue = make_market_venue("10", "1000000");
advance(&mut venue, time(1));
let _ = venue.open_order(limit_request(
"gtd",
Side::Buy,
"1",
"48000",
TimeInForce::GoodTillDate { expiry: time(5) },
));
assert_eq!(venue.advance_time(time(6)).len(), 2);
let outcome = venue.cancel_order(OrderEvent {
key: OrderKey {
exchange: EXCHANGE,
instrument: instrument_name(),
strategy: StrategyId::new("test"),
cid: ClientOrderId::new("gtd"),
},
state: RequestCancel { id: None },
});
assert_eq!(
outcome.response.state,
Err(OrderError::Rejected(ApiError::OrderAlreadyExpired)),
"a cancel that lost to a deadline must not read as an unknown order"
);
}
#[test]
fn an_expired_order_enters_the_account_snapshot() {
let mut venue = make_market_venue("10", "1000000");
advance(&mut venue, time(1));
let _ = venue.open_order(limit_request(
"gtd",
Side::Buy,
"1",
"48000",
TimeInForce::GoodTillDate { expiry: time(5) },
));
assert_eq!(venue.advance_time(time(5)).len(), 2);
let snapshot = venue.account_snapshot();
let orders = &snapshot.instruments[0].orders;
assert_eq!(orders.len(), 1);
assert!(matches!(
orders[0].state,
OrderState::Inactive(InactiveOrderState::Expired(_))
));
}
#[test]
fn deadlines_are_swept_earliest_first() {
let mut venue = make_market_venue("10", "1000000");
advance(&mut venue, time(1));
for (cid, expiry) in [("late", time(7)), ("early", time(5)), ("middle", time(6))] {
let _ = venue.open_order(limit_request(
cid,
Side::Buy,
"1",
"48000",
TimeInForce::GoodTillDate { expiry },
));
}
let events = venue.advance_time(time(9));
let swept: Vec<_> = events
.iter()
.skip(1)
.step_by(2)
.map(|event| order_of(event).key.cid.0.to_string())
.collect();
assert_eq!(swept, ["early", "middle", "late"]);
}
#[test]
fn a_deadline_on_a_quiet_instrument_is_swept_by_a_tick_on_another() {
let mut venue = make_market_venue("10", "1000000");
advance(&mut venue, time(1));
let _ = venue.open_order(limit_request(
"gtd",
Side::Buy,
"1",
"48000",
TimeInForce::GoodTillDate { expiry: time(5) },
));
let elsewhere = InstrumentNameExchange::new("eth_usdt");
let events = venue.apply_market(
&elsewhere,
book("2000", "2001"),
MarketDepth::UNKNOWN,
time(6),
);
assert_eq!(
events.len(),
2,
"the quiet instrument's order still retires"
);
assert!(venue.orders_open(&[]).is_empty());
}
#[test]
fn expiring_an_order_seeded_with_no_reservation_restates_no_balance() {
let expiry = time(5);
let seeded = Order {
key: OrderKey {
exchange: EXCHANGE,
instrument: instrument_name(),
strategy: StrategyId::new("test"),
cid: ClientOrderId::new("seeded"),
},
side: Side::Buy,
price: Some(d("48000")),
quantity: d("1"),
kind: OrderKind::Limit,
time_in_force: TimeInForce::GoodTillDate { expiry },
state: OrderState::active(Open::new(
VenueOrderId::Assigned(OrderId::new("seeded")),
time(1),
Decimal::ZERO,
)),
};
let mut venue = make_market_venue("10", "1000000");
assert!(
venue
.account
.book(
as_open(seeded).expect("the seeded order is Open"),
None,
)
.is_ok(),
"the book is vacant under this id"
);
let events = venue.advance_time(expiry);
assert_eq!(events.len(), 1, "the terminal snapshot alone, no release");
assert!(matches!(
order_of(&events[0]).state,
OrderState::Inactive(InactiveOrderState::Expired(_))
));
}
#[test]
fn a_market_driven_venue_prices_a_market_order_from_its_own_book() {
let mut venue = make_market_venue("10", "1000000");
advance(&mut venue, time(1));
assert!(
venue
.apply_market(
&instrument_name(),
traded("49000"),
MarketDepth::UNKNOWN,
time(1)
)
.is_empty()
);
let outcome = venue.open_order(buy_request("1.0", Some(traded("40000"))));
assert_eq!(
filled_price(&outcome.response),
d("49000"),
"the fill is struck against the venue's book, not the snapshot the request carried"
);
}
#[test]
fn a_request_priced_venue_still_prices_a_market_order_from_the_request() {
let mut venue = make_venue("10", "1000000");
let outcome = venue.open_order(buy_request("1.0", Some(traded("40000"))));
assert_eq!(
filled_price(&outcome.response),
d("40000"),
"with no book of its own, the request's snapshot is still the only price source"
);
}
#[test]
fn a_market_driven_venue_with_no_book_rejects_a_market_order_rather_than_using_the_request() {
let mut venue = make_market_venue("10", "1000000");
advance(&mut venue, time(1));
let outcome = venue.open_order(buy_request("1.0", Some(traded("49000"))));
assert!(outcome.events.is_empty(), "nothing was debited or traded");
match outcome.response.state {
OrderState::Inactive(InactiveOrderState::OpenFailed(
UnindexedOrderError::Rejected(ApiError::OrderRejected(ref reason)),
)) => assert!(
reason.contains("no market snapshot"),
"the rejection must name the venue's absent market, got: {reason}"
),
ref other => panic!("an unpriceable market order must be rejected, got: {other:?}"),
}
}
fn sized(amount: &str) -> MarketDepth {
MarketDepth::new(Some(d(amount)), Some(d(amount)))
}
fn venue_offering(depth: MarketDepth) -> SimulatedVenue {
venue_offering_funded("1000000", depth)
}
fn venue_offering_funded(usdt: &str, depth: MarketDepth) -> SimulatedVenue {
let mut venue = make_market_venue_with(
"10",
usdt,
maker_taker_fee(),
SimFillConfig::BidAsk(BidAskFillModel),
);
advance(&mut venue, time(1));
assert!(
venue
.apply_market(&instrument_name(), book("47800", "47900"), depth, time(1))
.is_empty(),
"an empty book fills nothing"
);
venue
}
fn free_quote(venue: &SimulatedVenue) -> Decimal {
venue.balances(&[quote()])[0].balance.free
}
fn total_quote(venue: &SimulatedVenue) -> Decimal {
venue.balances(&[quote()])[0].balance.total
}
#[test]
fn a_market_order_fills_what_the_book_has_and_cancels_what_it_has_not() {
let mut venue = venue_offering(sized("0.4"));
let outcome = venue.open_order(buy_request("1", None));
assert_eq!(outcome.events.len(), 2, "a fill owes balance, then trade");
let trade = trade_of(&outcome.events[1]);
assert_eq!(
trade.quantity,
d("0.4"),
"it took the whole offer and no more"
);
assert_eq!(trade.price, d("47900"));
match &outcome.response.state {
OrderState::Inactive(InactiveOrderState::Cancelled(cancelled)) => assert_eq!(
cancelled.filled_quantity,
Some(d("0.4")),
"the remainder retires carrying what did trade"
),
other => panic!("a market order's unfillable remainder is cancelled, got: {other:?}"),
}
assert!(
venue.orders_open(&[]).is_empty(),
"a market order has no price to rest the remainder at"
);
}
#[test]
fn fill_or_kill_refuses_the_partial_fill_immediate_or_cancel_accepts() {
let mut immediate = venue_offering(sized("0.4"));
let mut all_or_nothing = venue_offering(sized("0.4"));
let ioc = immediate.open_order(limit_request(
"ioc",
Side::Buy,
"1",
"48000",
TimeInForce::ImmediateOrCancel,
));
let fok = all_or_nothing.open_order(limit_request(
"fok",
Side::Buy,
"1",
"48000",
TimeInForce::FillOrKill,
));
assert_eq!(
trade_of(&ioc.events[1]).quantity,
d("0.4"),
"immediate-or-cancel keeps what the book could give it"
);
assert!(
fok.events.is_empty(),
"fill-or-kill would rather trade nothing than trade in part"
);
assert!(
all_or_nothing.trades(time(0)).is_empty(),
"and nothing reached the ledger either"
);
assert_eq!(
free_quote(&all_or_nothing),
d("1000000"),
"a killed order moves no balance"
);
}
#[test]
fn a_capped_limit_order_fills_what_it_can_and_rests_the_remainder() {
let mut venue = venue_offering(sized("0.4"));
let outcome = venue.open_order(limit_request("split", Side::Buy, "1", "48000", gtc()));
assert_eq!(
outcome.events.len(),
2,
"one arrival restates one balance and prints one trade, even settling and holding at \
once"
);
let trade = trade_of(&outcome.events[1]);
assert_eq!(trade.quantity, d("0.4"));
assert_eq!(
trade.price,
d("47900"),
"the part that traded took the offer"
);
let open = rested_open(&outcome);
assert_eq!(
open.filled_quantity,
d("0.4"),
"the remainder rests carrying what has already traded"
);
assert_eq!(open.quantity_remaining(d("1")), d("0.6"));
assert_eq!(venue.orders_open(&[]).len(), 1);
assert_eq!(total_quote(&venue), d("1000000") - d("19179.16"));
assert_eq!(
free_quote(&venue),
d("1000000") - d("19179.16") - d("28805.76")
);
}
#[test]
fn an_order_filled_in_part_then_crossed_settles_the_remainder_and_no_more() {
let mut venue = venue_offering(sized("0.4"));
let arrival = venue.open_order(limit_request("split", Side::Buy, "1", "48000", gtc()));
let taker = trade_of(&arrival.events[1]).clone();
let fills = venue.apply_market(
&instrument_name(),
book("48000", "48000"),
MarketDepth::UNKNOWN,
time(2),
);
assert_eq!(fills.len(), 3, "a resting fill owes balance, trade, order");
let maker = trade_of(&fills[1]);
assert_eq!(maker.quantity, d("0.6"), "only the remainder crosses");
assert_eq!(
maker.price,
d("48000"),
"a maker is paid the price it quoted, not the one the book moved to"
);
assert_eq!(
taker.quantity + maker.quantity,
d("1"),
"the two fills are the whole order, and no more than it"
);
assert_ne!(
taker.id, maker.id,
"one order printing twice must print two distinguishable trades"
);
assert_eq!(
taker.order_id, maker.order_id,
"and both must still say which order they belong to"
);
let spent = d("19179.16") + d("28805.76");
assert_eq!(total_quote(&venue), d("1000000") - spent);
assert_eq!(
free_quote(&venue),
d("1000000") - spent,
"the order is done, so nothing is still held against it"
);
}
#[test]
fn a_second_order_on_one_observation_sees_only_what_the_first_left() {
let mut venue = venue_offering(sized("1"));
let first = venue.open_order(limit_request(
"first",
Side::Buy,
"0.6",
"48000",
TimeInForce::ImmediateOrCancel,
));
assert_eq!(trade_of(&first.events[1]).quantity, d("0.6"));
let second = venue.open_order(limit_request(
"second",
Side::Buy,
"0.6",
"48000",
TimeInForce::ImmediateOrCancel,
));
assert_eq!(
trade_of(&second.events[1]).quantity,
d("0.4"),
"the offer was displayed once, so the two of them share it"
);
let third = venue.open_order(limit_request(
"third",
Side::Buy,
"0.6",
"48000",
TimeInForce::ImmediateOrCancel,
));
assert!(
third.events.is_empty(),
"the offer is exhausted until the next observation replaces it"
);
}
#[test]
fn a_new_observation_replaces_the_remaining_size_rather_than_adding_to_it() {
let mut venue = venue_offering(sized("1"));
let taken = venue.open_order(limit_request(
"first",
Side::Buy,
"0.6",
"48000",
TimeInForce::ImmediateOrCancel,
));
assert_eq!(trade_of(&taken.events[1]).quantity, d("0.6"));
assert!(
venue
.apply_market(
&instrument_name(),
book("47800", "47900"),
sized("1"),
time(2)
)
.is_empty()
);
let after = venue.open_order(limit_request(
"second",
Side::Buy,
"1",
"48000",
TimeInForce::ImmediateOrCancel,
));
assert_eq!(
trade_of(&after.events[1]).quantity,
d("1"),
"the fresh observation offers a whole unit again"
);
}
#[test]
fn a_feed_supplying_no_size_caps_nothing() {
let mut venue = venue_offering(MarketDepth::UNKNOWN);
let outcome = venue.open_order(limit_request(
"uncapped",
Side::Buy,
"5",
"48000",
TimeInForce::ImmediateOrCancel,
));
assert_eq!(
trade_of(&outcome.events[1]).quantity,
d("5"),
"an absent size caps nothing, however large the order"
);
}
#[test]
fn a_marketable_order_with_nothing_on_offer_rests_rather_than_filling() {
let mut venue = venue_offering(MarketDepth::new(Some(d("1")), Some(Decimal::ZERO)));
let outcome = venue.open_order(limit_request("nothing", Side::Buy, "1", "48000", gtc()));
assert_eq!(
outcome.events.len(),
1,
"nothing traded, so it owes only the hold it took"
);
assert_eq!(rested_open(&outcome).filled_quantity, Decimal::ZERO);
}
#[test]
fn an_order_that_cannot_fund_both_legs_is_refused_with_nothing_moved() {
const FUNDED: &str = "47950";
let mut uncapped = venue_offering_funded(FUNDED, MarketDepth::UNKNOWN);
let affordable =
uncapped.open_order(limit_request("whole", Side::Buy, "1", "48000", gtc()));
assert_eq!(
trade_of(&affordable.events[1]).quantity,
d("1"),
"uncapped, this very order is affordable and fills outright"
);
let mut venue = venue_offering_funded(FUNDED, sized("0.4"));
let outcome = venue.open_order(limit_request("split", Side::Buy, "1", "48000", gtc()));
assert!(outcome.events.is_empty(), "a refused order moves nothing");
assert!(
matches!(
outcome.response.state,
OrderState::Inactive(InactiveOrderState::OpenFailed(_))
),
"and says so, rather than filling the part it could afford: {:?}",
outcome.response.state
);
assert_eq!(free_quote(&venue), d(FUNDED), "the ledger is as it was");
assert_eq!(total_quote(&venue), d(FUNDED));
assert!(venue.trades(time(0)).is_empty(), "and nothing traded");
assert!(venue.orders_open(&[]).is_empty());
}
fn is_duplicate_cid(state: &UnindexedOrderState) -> bool {
matches!(
state,
OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Rejected(
ApiError::DuplicateClientOrderId(_)
)))
)
}
#[test]
fn an_open_under_a_resting_id_is_rejected_and_the_resting_order_is_untouched() {
let (mut venue, _) = venue_resting_one_buy("48000");
let free = free_quote(&venue);
let second = venue.open_order(limit_request("resting", Side::Buy, "0.5", "47000", gtc()));
assert!(
is_duplicate_cid(&second.response.state),
"rejected as a duplicate: {:?}",
second.response.state
);
assert!(second.events.is_empty(), "a rejection moves nothing");
let [resting] = &venue.orders_open(&[])[..] else {
panic!("one cid, one resting order");
};
assert_eq!(
(resting.quantity, resting.price),
(d("1"), Some(d("48000"))),
"the resting order is the first one"
);
assert_eq!(
free_quote(&venue),
free,
"its hold is still held, and only its"
);
assert_eq!(total_quote(&venue), d("1000000"));
}
#[test]
fn a_market_order_under_a_resting_id_is_rejected_before_it_trades() {
let (mut venue, _) = venue_resting_one_buy("48000");
let mut market = buy_request("0.1", None);
market.key.cid = ClientOrderId::new("resting");
let outcome = venue.open_order(market);
assert!(
is_duplicate_cid(&outcome.response.state),
"rejected as a duplicate: {:?}",
outcome.response.state
);
assert!(venue.trades(time(0)).is_empty(), "nothing traded");
assert_eq!(venue.orders_open(&[]).len(), 1);
}
#[test]
fn a_malformed_open_under_a_resting_id_is_rejected_as_malformed() {
let (mut venue, _) = venue_resting_one_buy("48000");
let outcome = venue.open_order(limit_request(
"resting",
Side::Buy,
"1",
"47000",
TimeInForce::AtOpen,
));
assert!(
matches!(
outcome.response.state,
OrderState::Inactive(InactiveOrderState::OpenFailed(_))
) && !is_duplicate_cid(&outcome.response.state),
"rejected on its time in force: {:?}",
outcome.response.state
);
}
#[test]
fn a_cancelled_orders_id_is_free_again_and_the_latest_order_is_reported() {
let (mut venue, _) = venue_resting_one_buy("48000");
let cancel = |venue: &mut SimulatedVenue| {
venue.cancel_order(OrderRequestCancel {
key: limit_request("resting", Side::Buy, "1", "48000", gtc()).key,
state: RequestCancel { id: None },
})
};
assert!(cancel(&mut venue).response.state.is_ok());
let second = venue.open_order(limit_request("resting", Side::Buy, "0.5", "47000", gtc()));
assert!(
matches!(
second.response.state,
OrderState::Active(ActiveOrderState::Open(_))
),
"the id is free again: {:?}",
second.response.state
);
assert!(
venue.orders_ended(&[key_of("resting")]).is_empty(),
"the order under the id is working, whatever became of the one before it"
);
let snapshot = venue.account_snapshot();
let listed = snapshot
.instruments
.iter()
.flat_map(|instrument| &instrument.orders)
.filter(|order| order.key.cid == ClientOrderId::new("resting"))
.count();
assert_eq!(
listed, 1,
"a snapshot lists the id once, as the working order"
);
assert!(
cancel(&mut venue).response.state.is_ok(),
"and the cancel reaches it"
);
let [ended] = &venue.orders_ended(&[key_of("resting")])[..] else {
panic!("the second order has ended");
};
assert_eq!(ended.quantity, d("0.5"), "the latest order under the id");
}
#[test]
fn a_filled_orders_id_is_free_again() {
let (mut venue, _) = venue_resting_one_buy("48000");
let filled = venue.open_order(limit_request("reused", Side::Buy, "1", "49100", gtc()));
assert!(
matches!(
filled.response.state,
OrderState::Inactive(InactiveOrderState::FullyFilled(_))
),
"crosses the ask, so fills on arrival"
);
let second = venue.open_order(limit_request("reused", Side::Buy, "1", "47000", gtc()));
assert!(
matches!(
second.response.state,
OrderState::Active(ActiveOrderState::Open(_))
),
"the id is free again: {:?}",
second.response.state
);
let cancelled = venue.cancel_order(OrderRequestCancel {
key: limit_request("reused", Side::Buy, "1", "47000", gtc()).key,
state: RequestCancel { id: None },
});
assert!(
cancelled.response.state.is_ok(),
"the cancel reaches the working order: {:?}",
cancelled.response.state
);
}
fn key_of(cid: &str) -> UnindexedOrderKey {
OrderKey {
exchange: EXCHANGE,
instrument: instrument_name(),
strategy: StrategyId::new("asker"),
cid: ClientOrderId::new(cid),
}
}
#[test]
fn orders_ended_reports_each_ended_order_and_omits_the_rest() {
let mut venue = make_market_venue("10", "1000000");
advance(&mut venue, time(1));
assert!(
venue
.apply_market(
&instrument_name(),
book("49000", "49100"),
MarketDepth::UNKNOWN,
time(1)
)
.is_empty()
);
let filled = venue.open_order(limit_request("filled", Side::Buy, "1", "49100", gtc()));
assert!(
matches!(
filled.response.state,
OrderState::Inactive(InactiveOrderState::FullyFilled(_))
),
"crosses the ask, so fills on arrival"
);
let _ = venue.open_order(limit_request("working", Side::Buy, "1", "48000", gtc()));
let _ = venue.open_order(limit_request("cancelled", Side::Buy, "1", "47500", gtc()));
let _ = venue.cancel_order(OrderEvent {
key: key_of("cancelled"),
state: RequestCancel { id: None },
});
let _ = venue.open_order(limit_request(
"expired",
Side::Buy,
"1",
"47000",
TimeInForce::GoodTillDate { expiry: time(9) },
));
assert_eq!(
venue.advance_time(time(9)).len(),
2,
"the deadline retires it"
);
let asked = ["filled", "working", "cancelled", "expired", "unknown"].map(key_of);
let ended = venue.orders_ended(&asked);
assert_eq!(ended.len(), 3, "exactly the three ended orders: {ended:?}");
assert!(
ended
.iter()
.all(|order| order.key.strategy == StrategyId::new("asker")),
"each comes back under the key it was asked for"
);
let ended_as = |cid: &str| {
let Some(order) = ended
.iter()
.find(|order| order.key.cid == ClientOrderId::new(cid))
else {
panic!("{cid} is reported: {ended:?}");
};
order
};
let (filled, cancelled, expired) = (
ended_as("filled"),
ended_as("cancelled"),
ended_as("expired"),
);
match &filled.state {
InactiveOrderState::FullyFilled(state) => {
assert_eq!(state.filled_quantity, d("1"));
assert_eq!(state.avg_price, Some(d("49100")));
}
other => panic!("filled, got {other:?}"),
}
match &cancelled.state {
InactiveOrderState::Cancelled(state) => {
assert_eq!(state.filled_quantity, Some(Decimal::ZERO))
}
other => panic!("cancelled, got {other:?}"),
}
match &expired.state {
InactiveOrderState::Expired(state) => assert_eq!(state.time_exchange, time(9)),
other => panic!("expired, got {other:?}"),
}
assert!(
venue.orders_ended(&[]).is_empty(),
"an empty request asks about nothing, not about everything"
);
}
#[test]
fn orders_ended_reports_a_market_remainder_as_cancelled_with_its_fill() {
let mut venue = venue_offering(sized("0.4"));
let outcome = venue.open_order(buy_request("1", None));
let ended = venue.orders_ended(std::slice::from_ref(&outcome.response.key));
let [order] = ended.as_slice() else {
panic!("the remainder's order is reported: {ended:?}");
};
match &order.state {
InactiveOrderState::Cancelled(state) => {
assert_eq!(state.filled_quantity, Some(d("0.4")))
}
other => panic!("cancelled carrying its fill, got {other:?}"),
}
}
#[test]
fn orders_ended_reports_a_resting_fill_and_its_average_price_as_the_fill_did() {
let (mut venue, _) = venue_resting_one_buy("48000");
assert!(
venue
.account
.book(seeded_part_filled("48000", "1", "0.4"), None)
.is_ok(),
"the book is vacant under this id"
);
advance(&mut venue, time(2));
let events = venue.apply_market(
&instrument_name(),
book("47800", "47900"),
MarketDepth::UNKNOWN,
time(2),
);
assert_eq!(
events.len(),
6,
"two fills, each a balance, a trade and an order"
);
let ended = venue.orders_ended(&[key_of("resting"), key_of("part_filled")]);
let avg_price_of = |cid: &str| {
let Some(order) = ended
.iter()
.find(|order| order.key.cid == ClientOrderId::new(cid))
else {
panic!("{cid} is reported: {ended:?}");
};
match &order.state {
InactiveOrderState::FullyFilled(filled) => {
assert_eq!(filled.filled_quantity, d("1"));
filled.avg_price
}
other => panic!("{cid} filled, got {other:?}"),
}
};
assert_eq!(avg_price_of("resting"), Some(d("48000")));
assert_eq!(
avg_price_of("part_filled"),
None,
"this venue saw only one of the two fills behind it"
);
}
#[test]
fn orders_ended_reports_an_order_the_initial_state_seeded_as_cancelled() {
let seeded = Order {
key: key_of("seeded"),
side: Side::Buy,
price: Some(d("48000")),
quantity: d("1"),
kind: OrderKind::Limit,
time_in_force: gtc(),
state: OrderState::inactive(Cancelled {
id: OrderId::new("seeded"),
time_exchange: time(0),
filled_quantity: Some(d("0.25")),
}),
};
let venue = SimulatedVenue::new(
&spot_config_holding("10", "1000000", seeded),
spot_instruments(),
);
let ended = venue.orders_ended(&[key_of("seeded")]);
let [order] = ended.as_slice() else {
panic!("the seeded cancel is reported: {ended:?}");
};
match &order.state {
InactiveOrderState::Cancelled(cancelled) => {
assert_eq!(cancelled.filled_quantity, Some(d("0.25")))
}
other => panic!("cancelled, got {other:?}"),
}
}
#[test]
fn orders_ended_reports_an_id_once_and_only_for_its_own_instrument() {
let mut venue = venue_offering(sized("0.4"));
let outcome = venue.open_order(buy_request("1", None));
let key = outcome.response.key.clone();
let other_instrument = OrderKey {
instrument: InstrumentNameExchange::new("ETHUSDT"),
..key.clone()
};
let second_ask = OrderKey {
strategy: StrategyId::new("second"),
..key.clone()
};
let ended = venue.orders_ended(&[key.clone(), second_ask]);
let [order] = ended.as_slice() else {
panic!("one entry for one id: {ended:?}");
};
assert_eq!(order.key, key, "the first key that finds it");
let ended = venue.orders_ended(&[other_instrument.clone(), key.clone()]);
let [order] = ended.as_slice() else {
panic!("a key that finds nothing does not stop a later one: {ended:?}");
};
assert_eq!(order.key, key);
assert!(
venue.orders_ended(&[other_instrument]).is_empty(),
"a key for another instrument does not find it"
);
}
}