use bot_core::{
AssetId, ClientOrderId, ExchangeOrderId, Fee, Fill, InstrumentId, InstrumentKind,
InstrumentMeta, OrderSide, Price, Qty, Quote, TradeId,
};
use rust_decimal::Decimal;
use std::collections::{HashMap, VecDeque};
#[derive(Debug, Clone)]
pub struct PendingOrder {
pub client_id: ClientOrderId,
pub exchange_order_id: ExchangeOrderId,
pub instrument: InstrumentId,
pub side: OrderSide,
pub price: Price,
pub qty: Qty,
pub remaining_qty: Qty,
pub created_at: i64,
}
#[derive(Debug, Clone)]
pub struct SimulatedFill {
pub fill: Fill,
pub order_fully_filled: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct OrderGroupKey {
instrument: InstrumentId,
side: OrderSide,
}
#[derive(Debug, Clone)]
struct SimInstrumentAssets {
base_asset: AssetId,
quote_asset: AssetId,
kind: InstrumentKind,
}
pub struct FillSimulator {
order_groups: HashMap<OrderGroupKey, VecDeque<PendingOrder>>,
next_oid: u64,
balances: HashMap<AssetId, Decimal>,
instrument_assets: HashMap<InstrumentId, SimInstrumentAssets>,
fee_rate: Decimal,
}
impl FillSimulator {
pub fn new(initial_balances: HashMap<AssetId, Decimal>) -> Self {
Self {
order_groups: HashMap::new(),
next_oid: 1000,
balances: initial_balances,
instrument_assets: HashMap::new(),
fee_rate: Decimal::ZERO, }
}
pub fn new_with_fee(initial_balances: HashMap<AssetId, Decimal>, fee_rate: Decimal) -> Self {
Self {
order_groups: HashMap::new(),
next_oid: 1000,
balances: initial_balances,
instrument_assets: HashMap::new(),
fee_rate,
}
}
pub fn set_fee_rate(&mut self, fee_rate: Decimal) {
self.fee_rate = fee_rate;
}
pub fn register_instrument_meta(&mut self, meta: &InstrumentMeta) {
self.instrument_assets.insert(
meta.instrument_id.clone(),
SimInstrumentAssets {
base_asset: meta.base_asset.clone(),
quote_asset: meta.quote_asset.clone(),
kind: meta.kind,
},
);
}
pub fn instrument_is_perp(&self, instrument: &InstrumentId) -> bool {
self.assets_for(instrument).kind == InstrumentKind::Perp
}
fn assets_for(&self, instrument: &InstrumentId) -> SimInstrumentAssets {
Self::assets_for_from(&self.instrument_assets, instrument)
}
fn assets_for_from(
instrument_assets: &HashMap<InstrumentId, SimInstrumentAssets>,
instrument: &InstrumentId,
) -> SimInstrumentAssets {
if let Some(assets) = instrument_assets.get(instrument) {
return assets.clone();
}
let instrument_str = instrument.to_string();
let kind = if instrument_str.ends_with("-PERP") {
InstrumentKind::Perp
} else if instrument_str.ends_with("-OUTCOME") {
InstrumentKind::Outcome
} else {
InstrumentKind::Spot
};
let quote_asset = if kind == InstrumentKind::Outcome {
AssetId::new("USDH")
} else {
AssetId::new("USDC")
};
let base_asset = Self::fallback_base_asset(&instrument_str);
SimInstrumentAssets {
base_asset,
quote_asset,
kind,
}
}
fn fallback_base_asset(instrument: &str) -> AssetId {
if let Some(pos) = instrument.rfind('-') {
AssetId::new(&instrument[..pos])
} else {
AssetId::new(instrument)
}
}
fn calculate_fee_static(
fee_rate: Decimal,
assets: &SimInstrumentAssets,
side: OrderSide,
qty: Qty,
price: Price,
) -> Fee {
if fee_rate == Decimal::ZERO {
return Fee::new(Decimal::ZERO, assets.quote_asset.clone());
}
if assets.kind == InstrumentKind::Outcome {
let notional = qty.0 * price.0;
let fee_amount = notional * fee_rate;
return Fee::new(fee_amount, assets.quote_asset.clone());
}
if assets.kind.is_spot_like() {
match side {
OrderSide::Buy => {
let fee_amount = qty.0 * fee_rate;
Fee::new(fee_amount, assets.base_asset.clone())
}
OrderSide::Sell => {
let notional = qty.0 * price.0;
let fee_amount = notional * fee_rate;
Fee::new(fee_amount, assets.quote_asset.clone())
}
}
} else {
let notional = qty.0 * price.0;
let fee_amount = notional * fee_rate;
Fee::new(fee_amount, assets.quote_asset.clone())
}
}
pub fn next_exchange_order_id(&mut self, prefix: &str) -> ExchangeOrderId {
let oid = self.next_oid;
self.next_oid += 1;
ExchangeOrderId::new(format!("{}_{}", prefix, oid))
}
pub fn add_pending_order(&mut self, order: PendingOrder) {
let key = OrderGroupKey {
instrument: order.instrument.clone(),
side: order.side,
};
let orders = self.order_groups.entry(key).or_default();
let insert_pos = {
let slice = orders.make_contiguous();
match order.side {
OrderSide::Buy => {
slice.partition_point(|o| o.price.0 > order.price.0)
}
OrderSide::Sell => {
slice.partition_point(|o| o.price.0 < order.price.0)
}
}
};
orders.insert(insert_pos, order);
}
pub fn remove_order(&mut self, client_id: &ClientOrderId) -> Option<PendingOrder> {
for orders in self.order_groups.values_mut() {
if let Some(pos) = orders.iter().position(|o| &o.client_id == client_id) {
return orders.remove(pos); }
}
None
}
pub fn remove_orders_for_instrument(&mut self, instrument: &InstrumentId) -> Vec<PendingOrder> {
let mut removed = Vec::new();
for side in [OrderSide::Buy, OrderSide::Sell] {
let key = OrderGroupKey {
instrument: instrument.clone(),
side,
};
if let Some(orders) = self.order_groups.remove(&key) {
removed.extend(orders);
}
}
removed
}
pub fn balance(&self, asset: &AssetId) -> Decimal {
self.balances.get(asset).copied().unwrap_or_default()
}
pub fn set_balance(&mut self, asset: AssetId, amount: Decimal) {
self.balances.insert(asset, amount);
}
pub fn check_fills(
&mut self,
quotes: &HashMap<InstrumentId, Quote>,
time_ms: i64,
) -> Vec<SimulatedFill> {
let mut fills = Vec::new();
let fee_rate = self.fee_rate;
let instrument_assets = self.instrument_assets.clone();
for (instrument, quote) in quotes {
let buy_key = OrderGroupKey {
instrument: instrument.clone(),
side: OrderSide::Buy,
};
if let Some(orders) = self.order_groups.get_mut(&buy_key) {
let ask = quote.ask.0;
while orders.front().map(|o| o.price.0 >= ask).unwrap_or(false) {
let order = orders.pop_front().unwrap();
let assets = Self::assets_for_from(&instrument_assets, &order.instrument);
let fee = Self::calculate_fee_static(
fee_rate,
&assets,
order.side,
order.remaining_qty.clone(),
order.price.clone(),
);
let fill = Fill {
trade_id: TradeId::new(format!("sim_{}", order.exchange_order_id.0)),
client_id: Some(order.client_id.clone()),
exchange_order_id: Some(order.exchange_order_id.clone()),
instrument: order.instrument.clone(),
side: order.side,
price: order.price.clone(),
qty: order.remaining_qty.clone(),
fee,
ts: time_ms,
};
fills.push(SimulatedFill {
fill,
order_fully_filled: true,
});
}
}
let sell_key = OrderGroupKey {
instrument: instrument.clone(),
side: OrderSide::Sell,
};
if let Some(orders) = self.order_groups.get_mut(&sell_key) {
let bid = quote.bid.0;
while orders.front().map(|o| o.price.0 <= bid).unwrap_or(false) {
let order = orders.pop_front().unwrap();
let assets = Self::assets_for_from(&instrument_assets, &order.instrument);
let fee = Self::calculate_fee_static(
fee_rate,
&assets,
order.side,
order.remaining_qty.clone(),
order.price.clone(),
);
let fill = Fill {
trade_id: TradeId::new(format!("sim_{}", order.exchange_order_id.0)),
client_id: Some(order.client_id.clone()),
exchange_order_id: Some(order.exchange_order_id.clone()),
instrument: order.instrument.clone(),
side: order.side,
price: order.price.clone(),
qty: order.remaining_qty.clone(),
fee,
ts: time_ms,
};
fills.push(SimulatedFill {
fill,
order_fully_filled: true,
});
}
}
}
for sim_fill in &fills {
self.apply_fill_to_balances(&sim_fill.fill);
}
fills
}
fn apply_fill_to_balances(&mut self, fill: &Fill) {
let assets = self.assets_for(&fill.instrument);
if assets.kind == InstrumentKind::Perp {
return;
}
let quote_asset = assets.quote_asset;
let base_asset = assets.base_asset;
let notional = fill.price.0 * fill.qty.0;
match fill.side {
OrderSide::Buy => {
*self.balances.entry(quote_asset.clone()).or_default() -= notional;
let received = if fill.fee.asset == base_asset {
fill.qty.0 - fill.fee.amount } else {
fill.qty.0
};
*self.balances.entry(base_asset).or_default() += received;
if fill.fee.asset == quote_asset {
*self.balances.entry(quote_asset).or_default() -= fill.fee.amount;
}
}
OrderSide::Sell => {
let received = if fill.fee.asset == quote_asset {
notional - fill.fee.amount
} else {
notional
};
*self.balances.entry(quote_asset).or_default() += received;
*self.balances.entry(base_asset.clone()).or_default() -= fill.qty.0;
if fill.fee.asset == base_asset {
*self.balances.entry(base_asset).or_default() -= fill.fee.amount;
}
}
}
}
pub fn check_balance(
&self,
instrument: &InstrumentId,
side: OrderSide,
price: Decimal,
qty: Decimal,
) -> Result<(), String> {
let assets = self.assets_for(instrument);
if assets.kind == InstrumentKind::Perp {
return Ok(());
}
if side == OrderSide::Buy {
let required = price * qty;
let available = self.balance(&assets.quote_asset);
if required > available {
return Err(format!(
"Insufficient balance: need {} {}, have {}",
required, assets.quote_asset, available
));
}
} else {
let available = self.balance(&assets.base_asset);
if qty > available {
return Err(format!(
"Insufficient balance: need {} {}, have {}",
qty, assets.base_asset, available
));
}
}
Ok(())
}
pub fn apply_fill(&mut self, fill: &Fill) {
self.apply_fill_to_balances(fill);
}
pub fn pending_orders_count(&self) -> usize {
self.order_groups.values().map(|v| v.len()).sum()
}
pub fn pending_orders(&self) -> Vec<&PendingOrder> {
self.order_groups.values().flat_map(|v| v.iter()).collect()
}
}
impl Default for FillSimulator {
fn default() -> Self {
Self::new(HashMap::new())
}
}
#[cfg(test)]
mod tests {
use super::*;
use bot_core::MarketIndex;
use rust_decimal_macros::dec;
fn make_quote(instrument: &str, bid: i64, ask: i64) -> Quote {
Quote {
instrument: InstrumentId::new(instrument),
bid: Price::new(Decimal::new(bid, 0)),
ask: Price::new(Decimal::new(ask, 0)),
bid_size: Qty::new(Decimal::new(10, 0)),
ask_size: Qty::new(Decimal::new(10, 0)),
ts: 0,
}
}
fn outcome_meta() -> InstrumentMeta {
InstrumentMeta {
instrument_id: InstrumentId::new("#20-OUTCOME"),
market_index: MarketIndex::new(100_000_020),
base_asset: AssetId::new("BTC > 79980"),
quote_asset: AssetId::new("USDH"),
tick_size: dec!(0.001),
lot_size: dec!(1),
min_qty: Some(dec!(1)),
min_notional: Some(dec!(10)),
fee_asset_default: Some(AssetId::new("USDH")),
kind: InstrumentKind::Outcome,
}
}
fn spot_meta() -> InstrumentMeta {
InstrumentMeta {
instrument_id: InstrumentId::new("HYPE-SPOT"),
market_index: MarketIndex::new(200),
base_asset: AssetId::new("HYPE"),
quote_asset: AssetId::new("USDC"),
tick_size: dec!(0.001),
lot_size: dec!(0.01),
min_qty: Some(dec!(0.01)),
min_notional: Some(dec!(10)),
fee_asset_default: Some(AssetId::new("HYPE")),
kind: InstrumentKind::Spot,
}
}
#[test]
fn test_buy_order_fills_when_ask_crosses() {
let mut balances = HashMap::new();
balances.insert(AssetId::new("USDC"), Decimal::new(100000, 0));
let mut sim = FillSimulator::new(balances);
sim.add_pending_order(PendingOrder {
client_id: ClientOrderId::new("order1"),
exchange_order_id: ExchangeOrderId::new("ex1"),
instrument: InstrumentId::new("BTC-PERP"),
side: OrderSide::Buy,
price: Price::new(Decimal::new(50000, 0)),
qty: Qty::new(Decimal::new(1, 0)),
remaining_qty: Qty::new(Decimal::new(1, 0)),
created_at: 0,
});
let mut quotes = HashMap::new();
quotes.insert(
InstrumentId::new("BTC-PERP"),
make_quote("BTC-PERP", 49998, 49999),
);
let fills = sim.check_fills("es, 1000);
assert_eq!(fills.len(), 1);
assert_eq!(fills[0].fill.price.0, Decimal::new(50000, 0));
assert_eq!(sim.pending_orders_count(), 0);
}
#[test]
fn test_sell_order_fills_when_bid_crosses() {
let mut balances = HashMap::new();
balances.insert(AssetId::new("BTC"), Decimal::new(10, 0));
let mut sim = FillSimulator::new(balances);
sim.add_pending_order(PendingOrder {
client_id: ClientOrderId::new("order1"),
exchange_order_id: ExchangeOrderId::new("ex1"),
instrument: InstrumentId::new("BTC-PERP"),
side: OrderSide::Sell,
price: Price::new(Decimal::new(50000, 0)),
qty: Qty::new(Decimal::new(1, 0)),
remaining_qty: Qty::new(Decimal::new(1, 0)),
created_at: 0,
});
let mut quotes = HashMap::new();
quotes.insert(
InstrumentId::new("BTC-PERP"),
make_quote("BTC-PERP", 50001, 50002),
);
let fills = sim.check_fills("es, 1000);
assert_eq!(fills.len(), 1);
assert_eq!(fills[0].fill.price.0, Decimal::new(50000, 0));
}
#[test]
fn test_outcome_buy_uses_usdh_quote_balance() {
let mut balances = HashMap::new();
balances.insert(AssetId::new("USDH"), dec!(100));
let mut sim = FillSimulator::new_with_fee(balances, dec!(0.00025));
sim.register_instrument_meta(&outcome_meta());
sim.add_pending_order(PendingOrder {
client_id: ClientOrderId::new("outcome-buy"),
exchange_order_id: ExchangeOrderId::new("ex-outcome-buy"),
instrument: InstrumentId::new("#20-OUTCOME"),
side: OrderSide::Buy,
price: Price::new(dec!(0.39)),
qty: Qty::new(dec!(64)),
remaining_qty: Qty::new(dec!(64)),
created_at: 0,
});
let mut quotes = HashMap::new();
quotes.insert(
InstrumentId::new("#20-OUTCOME"),
Quote {
instrument: InstrumentId::new("#20-OUTCOME"),
bid: Price::new(dec!(0.388)),
ask: Price::new(dec!(0.389)),
bid_size: Qty::new(dec!(1000)),
ask_size: Qty::new(dec!(1000)),
ts: 0,
},
);
let fills = sim.check_fills("es, 1000);
assert_eq!(fills.len(), 1);
assert_eq!(fills[0].fill.fee.asset, AssetId::new("USDH"));
assert_eq!(fills[0].fill.fee.amount, dec!(0.00624));
assert_eq!(sim.balance(&AssetId::new("USDH")), dec!(75.03376));
assert_eq!(sim.balance(&AssetId::new("BTC > 79980")), dec!(64));
assert_eq!(sim.balance(&AssetId::new("USDC")), Decimal::ZERO);
}
#[test]
fn test_outcome_sell_uses_usdh_quote_fee() {
let mut balances = HashMap::new();
balances.insert(AssetId::new("BTC > 79980"), dec!(100));
balances.insert(AssetId::new("USDH"), dec!(0));
let mut sim = FillSimulator::new_with_fee(balances, dec!(0.00025));
sim.register_instrument_meta(&outcome_meta());
sim.add_pending_order(PendingOrder {
client_id: ClientOrderId::new("outcome-sell"),
exchange_order_id: ExchangeOrderId::new("ex-outcome-sell"),
instrument: InstrumentId::new("#20-OUTCOME"),
side: OrderSide::Sell,
price: Price::new(dec!(0.42)),
qty: Qty::new(dec!(10)),
remaining_qty: Qty::new(dec!(10)),
created_at: 0,
});
let mut quotes = HashMap::new();
quotes.insert(
InstrumentId::new("#20-OUTCOME"),
Quote {
instrument: InstrumentId::new("#20-OUTCOME"),
bid: Price::new(dec!(0.421)),
ask: Price::new(dec!(0.422)),
bid_size: Qty::new(dec!(1000)),
ask_size: Qty::new(dec!(1000)),
ts: 0,
},
);
let fills = sim.check_fills("es, 1000);
assert_eq!(fills.len(), 1);
assert_eq!(fills[0].fill.fee.asset, AssetId::new("USDH"));
assert_eq!(fills[0].fill.fee.amount, dec!(0.00105));
assert_eq!(sim.balance(&AssetId::new("USDH")), dec!(4.19895));
assert_eq!(sim.balance(&AssetId::new("BTC > 79980")), dec!(90));
}
#[test]
fn test_spot_buy_still_uses_base_asset_fee() {
let mut balances = HashMap::new();
balances.insert(AssetId::new("USDC"), dec!(100));
let mut sim = FillSimulator::new_with_fee(balances, dec!(0.00025));
sim.register_instrument_meta(&spot_meta());
sim.add_pending_order(PendingOrder {
client_id: ClientOrderId::new("spot-buy"),
exchange_order_id: ExchangeOrderId::new("ex-spot-buy"),
instrument: InstrumentId::new("HYPE-SPOT"),
side: OrderSide::Buy,
price: Price::new(dec!(10)),
qty: Qty::new(dec!(2)),
remaining_qty: Qty::new(dec!(2)),
created_at: 0,
});
let mut quotes = HashMap::new();
quotes.insert(
InstrumentId::new("HYPE-SPOT"),
Quote {
instrument: InstrumentId::new("HYPE-SPOT"),
bid: Price::new(dec!(9.99)),
ask: Price::new(dec!(10)),
bid_size: Qty::new(dec!(1000)),
ask_size: Qty::new(dec!(1000)),
ts: 0,
},
);
let fills = sim.check_fills("es, 1000);
assert_eq!(fills.len(), 1);
assert_eq!(fills[0].fill.fee.asset, AssetId::new("HYPE"));
assert_eq!(fills[0].fill.fee.amount, dec!(0.00050));
assert_eq!(sim.balance(&AssetId::new("USDC")), dec!(80));
assert_eq!(sim.balance(&AssetId::new("HYPE")), dec!(1.99950));
}
}