1use bot_core::{
12 AssetId, ClientOrderId, ExchangeOrderId, Fee, Fill, InstrumentId, InstrumentKind,
13 InstrumentMeta, OrderSide, Price, Qty, Quote, TradeId,
14};
15use rust_decimal::Decimal;
16use std::collections::{HashMap, VecDeque};
17
18#[derive(Debug, Clone)]
20pub struct PendingOrder {
21 pub client_id: ClientOrderId,
23 pub exchange_order_id: ExchangeOrderId,
25 pub instrument: InstrumentId,
27 pub side: OrderSide,
29 pub price: Price,
31 pub qty: Qty,
33 pub remaining_qty: Qty,
35 pub created_at: i64,
37}
38
39#[derive(Debug, Clone)]
41pub struct SimulatedFill {
42 pub fill: Fill,
44 pub order_fully_filled: bool,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Hash)]
50struct OrderGroupKey {
51 instrument: InstrumentId,
52 side: OrderSide,
53}
54
55#[derive(Debug, Clone)]
56struct SimInstrumentAssets {
57 base_asset: AssetId,
58 quote_asset: AssetId,
59 kind: InstrumentKind,
60}
61
62pub struct FillSimulator {
74 order_groups: HashMap<OrderGroupKey, VecDeque<PendingOrder>>,
78 next_oid: u64,
79 balances: HashMap<AssetId, Decimal>,
80 instrument_assets: HashMap<InstrumentId, SimInstrumentAssets>,
81 fee_rate: Decimal,
85}
86
87impl FillSimulator {
88 pub fn new(initial_balances: HashMap<AssetId, Decimal>) -> Self {
90 Self {
91 order_groups: HashMap::new(),
92 next_oid: 1000,
93 balances: initial_balances,
94 instrument_assets: HashMap::new(),
95 fee_rate: Decimal::ZERO, }
97 }
98
99 pub fn new_with_fee(initial_balances: HashMap<AssetId, Decimal>, fee_rate: Decimal) -> Self {
101 Self {
102 order_groups: HashMap::new(),
103 next_oid: 1000,
104 balances: initial_balances,
105 instrument_assets: HashMap::new(),
106 fee_rate,
107 }
108 }
109
110 pub fn set_fee_rate(&mut self, fee_rate: Decimal) {
112 self.fee_rate = fee_rate;
113 }
114
115 pub fn register_instrument_meta(&mut self, meta: &InstrumentMeta) {
118 self.instrument_assets.insert(
119 meta.instrument_id.clone(),
120 SimInstrumentAssets {
121 base_asset: meta.base_asset.clone(),
122 quote_asset: meta.quote_asset.clone(),
123 kind: meta.kind,
124 },
125 );
126 }
127
128 pub fn instrument_is_perp(&self, instrument: &InstrumentId) -> bool {
130 self.assets_for(instrument).kind == InstrumentKind::Perp
131 }
132
133 fn assets_for(&self, instrument: &InstrumentId) -> SimInstrumentAssets {
134 Self::assets_for_from(&self.instrument_assets, instrument)
135 }
136
137 fn assets_for_from(
138 instrument_assets: &HashMap<InstrumentId, SimInstrumentAssets>,
139 instrument: &InstrumentId,
140 ) -> SimInstrumentAssets {
141 if let Some(assets) = instrument_assets.get(instrument) {
142 return assets.clone();
143 }
144
145 let instrument_str = instrument.to_string();
146 let kind = if instrument_str.ends_with("-PERP") {
147 InstrumentKind::Perp
148 } else if instrument_str.ends_with("-OUTCOME") {
149 InstrumentKind::Outcome
150 } else {
151 InstrumentKind::Spot
152 };
153 let quote_asset = if kind == InstrumentKind::Outcome {
154 AssetId::new("USDH")
155 } else {
156 AssetId::new("USDC")
157 };
158 let base_asset = Self::fallback_base_asset(&instrument_str);
159
160 SimInstrumentAssets {
161 base_asset,
162 quote_asset,
163 kind,
164 }
165 }
166
167 fn fallback_base_asset(instrument: &str) -> AssetId {
168 if let Some(pos) = instrument.rfind('-') {
169 AssetId::new(&instrument[..pos])
170 } else {
171 AssetId::new(instrument)
172 }
173 }
174
175 fn calculate_fee_static(
177 fee_rate: Decimal,
178 assets: &SimInstrumentAssets,
179 side: OrderSide,
180 qty: Qty,
181 price: Price,
182 ) -> Fee {
183 if fee_rate == Decimal::ZERO {
184 return Fee::new(Decimal::ZERO, assets.quote_asset.clone());
185 }
186
187 if assets.kind == InstrumentKind::Outcome {
188 let notional = qty.0 * price.0;
189 let fee_amount = notional * fee_rate;
190 return Fee::new(fee_amount, assets.quote_asset.clone());
191 }
192
193 if assets.kind.is_spot_like() {
194 match side {
195 OrderSide::Buy => {
196 let fee_amount = qty.0 * fee_rate;
198 Fee::new(fee_amount, assets.base_asset.clone())
199 }
200 OrderSide::Sell => {
201 let notional = qty.0 * price.0;
203 let fee_amount = notional * fee_rate;
204 Fee::new(fee_amount, assets.quote_asset.clone())
205 }
206 }
207 } else {
208 let notional = qty.0 * price.0;
210 let fee_amount = notional * fee_rate;
211 Fee::new(fee_amount, assets.quote_asset.clone())
212 }
213 }
214
215 pub fn next_exchange_order_id(&mut self, prefix: &str) -> ExchangeOrderId {
217 let oid = self.next_oid;
218 self.next_oid += 1;
219 ExchangeOrderId::new(format!("{}_{}", prefix, oid))
220 }
221
222 pub fn add_pending_order(&mut self, order: PendingOrder) {
224 let key = OrderGroupKey {
225 instrument: order.instrument.clone(),
226 side: order.side,
227 };
228
229 let orders = self.order_groups.entry(key).or_default();
230
231 let insert_pos = {
236 let slice = orders.make_contiguous();
237 match order.side {
238 OrderSide::Buy => {
239 slice.partition_point(|o| o.price.0 > order.price.0)
241 }
242 OrderSide::Sell => {
243 slice.partition_point(|o| o.price.0 < order.price.0)
245 }
246 }
247 };
248
249 orders.insert(insert_pos, order);
250 }
251
252 pub fn remove_order(&mut self, client_id: &ClientOrderId) -> Option<PendingOrder> {
254 for orders in self.order_groups.values_mut() {
255 if let Some(pos) = orders.iter().position(|o| &o.client_id == client_id) {
256 return orders.remove(pos); }
258 }
259 None
260 }
261
262 pub fn remove_orders_for_instrument(&mut self, instrument: &InstrumentId) -> Vec<PendingOrder> {
264 let mut removed = Vec::new();
265
266 for side in [OrderSide::Buy, OrderSide::Sell] {
268 let key = OrderGroupKey {
269 instrument: instrument.clone(),
270 side,
271 };
272 if let Some(orders) = self.order_groups.remove(&key) {
273 removed.extend(orders);
274 }
275 }
276
277 removed
278 }
279
280 pub fn balance(&self, asset: &AssetId) -> Decimal {
282 self.balances.get(asset).copied().unwrap_or_default()
283 }
284
285 pub fn set_balance(&mut self, asset: AssetId, amount: Decimal) {
287 self.balances.insert(asset, amount);
288 }
289
290 pub fn check_fills(
301 &mut self,
302 quotes: &HashMap<InstrumentId, Quote>,
303 time_ms: i64,
304 ) -> Vec<SimulatedFill> {
305 let mut fills = Vec::new();
306
307 let fee_rate = self.fee_rate;
309 let instrument_assets = self.instrument_assets.clone();
310
311 for (instrument, quote) in quotes {
313 let buy_key = OrderGroupKey {
316 instrument: instrument.clone(),
317 side: OrderSide::Buy,
318 };
319
320 if let Some(orders) = self.order_groups.get_mut(&buy_key) {
321 let ask = quote.ask.0;
324
325 while orders.front().map(|o| o.price.0 >= ask).unwrap_or(false) {
328 let order = orders.pop_front().unwrap();
329 let assets = Self::assets_for_from(&instrument_assets, &order.instrument);
330 let fee = Self::calculate_fee_static(
331 fee_rate,
332 &assets,
333 order.side,
334 order.remaining_qty.clone(),
335 order.price.clone(),
336 );
337
338 let fill = Fill {
339 trade_id: TradeId::new(format!("sim_{}", order.exchange_order_id.0)),
340 client_id: Some(order.client_id.clone()),
341 exchange_order_id: Some(order.exchange_order_id.clone()),
342 instrument: order.instrument.clone(),
343 side: order.side,
344 price: order.price.clone(),
345 qty: order.remaining_qty.clone(),
346 fee,
347 ts: time_ms,
348 };
349
350 fills.push(SimulatedFill {
351 fill,
352 order_fully_filled: true,
353 });
354 }
355 }
356
357 let sell_key = OrderGroupKey {
360 instrument: instrument.clone(),
361 side: OrderSide::Sell,
362 };
363
364 if let Some(orders) = self.order_groups.get_mut(&sell_key) {
365 let bid = quote.bid.0;
366
367 while orders.front().map(|o| o.price.0 <= bid).unwrap_or(false) {
370 let order = orders.pop_front().unwrap();
371 let assets = Self::assets_for_from(&instrument_assets, &order.instrument);
372 let fee = Self::calculate_fee_static(
373 fee_rate,
374 &assets,
375 order.side,
376 order.remaining_qty.clone(),
377 order.price.clone(),
378 );
379
380 let fill = Fill {
381 trade_id: TradeId::new(format!("sim_{}", order.exchange_order_id.0)),
382 client_id: Some(order.client_id.clone()),
383 exchange_order_id: Some(order.exchange_order_id.clone()),
384 instrument: order.instrument.clone(),
385 side: order.side,
386 price: order.price.clone(),
387 qty: order.remaining_qty.clone(),
388 fee,
389 ts: time_ms,
390 };
391
392 fills.push(SimulatedFill {
393 fill,
394 order_fully_filled: true,
395 });
396 }
397 }
398 }
399
400 for sim_fill in &fills {
402 self.apply_fill_to_balances(&sim_fill.fill);
403 }
404
405 fills
406 }
407
408 fn apply_fill_to_balances(&mut self, fill: &Fill) {
412 let assets = self.assets_for(&fill.instrument);
413
414 if assets.kind == InstrumentKind::Perp {
418 return;
419 }
420
421 let quote_asset = assets.quote_asset;
422 let base_asset = assets.base_asset;
423
424 let notional = fill.price.0 * fill.qty.0;
425
426 match fill.side {
427 OrderSide::Buy => {
428 *self.balances.entry(quote_asset.clone()).or_default() -= notional;
429 let received = if fill.fee.asset == base_asset {
431 fill.qty.0 - fill.fee.amount } else {
433 fill.qty.0
434 };
435 *self.balances.entry(base_asset).or_default() += received;
436 if fill.fee.asset == quote_asset {
438 *self.balances.entry(quote_asset).or_default() -= fill.fee.amount;
439 }
440 }
441 OrderSide::Sell => {
442 let received = if fill.fee.asset == quote_asset {
444 notional - fill.fee.amount
445 } else {
446 notional
447 };
448 *self.balances.entry(quote_asset).or_default() += received;
449 *self.balances.entry(base_asset.clone()).or_default() -= fill.qty.0;
450 if fill.fee.asset == base_asset {
452 *self.balances.entry(base_asset).or_default() -= fill.fee.amount;
453 }
454 }
455 }
456 }
457
458 pub fn check_balance(
460 &self,
461 instrument: &InstrumentId,
462 side: OrderSide,
463 price: Decimal,
464 qty: Decimal,
465 ) -> Result<(), String> {
466 let assets = self.assets_for(instrument);
467
468 if assets.kind == InstrumentKind::Perp {
469 return Ok(());
470 }
471
472 if side == OrderSide::Buy {
473 let required = price * qty;
474 let available = self.balance(&assets.quote_asset);
475 if required > available {
476 return Err(format!(
477 "Insufficient balance: need {} {}, have {}",
478 required, assets.quote_asset, available
479 ));
480 }
481 } else {
482 let available = self.balance(&assets.base_asset);
483 if qty > available {
484 return Err(format!(
485 "Insufficient balance: need {} {}, have {}",
486 qty, assets.base_asset, available
487 ));
488 }
489 }
490 Ok(())
491 }
492
493 pub fn apply_fill(&mut self, fill: &Fill) {
498 self.apply_fill_to_balances(fill);
499 }
500
501 pub fn pending_orders_count(&self) -> usize {
503 self.order_groups.values().map(|v| v.len()).sum()
504 }
505
506 pub fn pending_orders(&self) -> Vec<&PendingOrder> {
508 self.order_groups.values().flat_map(|v| v.iter()).collect()
509 }
510}
511
512impl Default for FillSimulator {
513 fn default() -> Self {
514 Self::new(HashMap::new())
515 }
516}
517
518#[cfg(test)]
519mod tests {
520 use super::*;
521 use bot_core::MarketIndex;
522 use rust_decimal_macros::dec;
523
524 fn make_quote(instrument: &str, bid: i64, ask: i64) -> Quote {
525 Quote {
526 instrument: InstrumentId::new(instrument),
527 bid: Price::new(Decimal::new(bid, 0)),
528 ask: Price::new(Decimal::new(ask, 0)),
529 bid_size: Qty::new(Decimal::new(10, 0)),
530 ask_size: Qty::new(Decimal::new(10, 0)),
531 ts: 0,
532 }
533 }
534
535 fn outcome_meta() -> InstrumentMeta {
536 InstrumentMeta {
537 instrument_id: InstrumentId::new("#20-OUTCOME"),
538 market_index: MarketIndex::new(100_000_020),
539 base_asset: AssetId::new("BTC > 79980"),
540 quote_asset: AssetId::new("USDH"),
541 tick_size: dec!(0.001),
542 lot_size: dec!(1),
543 min_qty: Some(dec!(1)),
544 min_notional: Some(dec!(10)),
545 fee_asset_default: Some(AssetId::new("USDH")),
546 kind: InstrumentKind::Outcome,
547 }
548 }
549
550 fn spot_meta() -> InstrumentMeta {
551 InstrumentMeta {
552 instrument_id: InstrumentId::new("HYPE-SPOT"),
553 market_index: MarketIndex::new(200),
554 base_asset: AssetId::new("HYPE"),
555 quote_asset: AssetId::new("USDC"),
556 tick_size: dec!(0.001),
557 lot_size: dec!(0.01),
558 min_qty: Some(dec!(0.01)),
559 min_notional: Some(dec!(10)),
560 fee_asset_default: Some(AssetId::new("HYPE")),
561 kind: InstrumentKind::Spot,
562 }
563 }
564
565 #[test]
566 fn test_buy_order_fills_when_ask_crosses() {
567 let mut balances = HashMap::new();
568 balances.insert(AssetId::new("USDC"), Decimal::new(100000, 0));
569
570 let mut sim = FillSimulator::new(balances);
571
572 sim.add_pending_order(PendingOrder {
574 client_id: ClientOrderId::new("order1"),
575 exchange_order_id: ExchangeOrderId::new("ex1"),
576 instrument: InstrumentId::new("BTC-PERP"),
577 side: OrderSide::Buy,
578 price: Price::new(Decimal::new(50000, 0)),
579 qty: Qty::new(Decimal::new(1, 0)),
580 remaining_qty: Qty::new(Decimal::new(1, 0)),
581 created_at: 0,
582 });
583
584 let mut quotes = HashMap::new();
586 quotes.insert(
587 InstrumentId::new("BTC-PERP"),
588 make_quote("BTC-PERP", 49998, 49999),
589 );
590
591 let fills = sim.check_fills("es, 1000);
592 assert_eq!(fills.len(), 1);
593 assert_eq!(fills[0].fill.price.0, Decimal::new(50000, 0));
594 assert_eq!(sim.pending_orders_count(), 0);
595 }
596
597 #[test]
598 fn test_sell_order_fills_when_bid_crosses() {
599 let mut balances = HashMap::new();
600 balances.insert(AssetId::new("BTC"), Decimal::new(10, 0));
601
602 let mut sim = FillSimulator::new(balances);
603
604 sim.add_pending_order(PendingOrder {
606 client_id: ClientOrderId::new("order1"),
607 exchange_order_id: ExchangeOrderId::new("ex1"),
608 instrument: InstrumentId::new("BTC-PERP"),
609 side: OrderSide::Sell,
610 price: Price::new(Decimal::new(50000, 0)),
611 qty: Qty::new(Decimal::new(1, 0)),
612 remaining_qty: Qty::new(Decimal::new(1, 0)),
613 created_at: 0,
614 });
615
616 let mut quotes = HashMap::new();
618 quotes.insert(
619 InstrumentId::new("BTC-PERP"),
620 make_quote("BTC-PERP", 50001, 50002),
621 );
622
623 let fills = sim.check_fills("es, 1000);
624 assert_eq!(fills.len(), 1);
625 assert_eq!(fills[0].fill.price.0, Decimal::new(50000, 0));
626 }
627
628 #[test]
629 fn test_outcome_buy_uses_usdh_quote_balance() {
630 let mut balances = HashMap::new();
631 balances.insert(AssetId::new("USDH"), dec!(100));
632
633 let mut sim = FillSimulator::new_with_fee(balances, dec!(0.00025));
634 sim.register_instrument_meta(&outcome_meta());
635
636 sim.add_pending_order(PendingOrder {
637 client_id: ClientOrderId::new("outcome-buy"),
638 exchange_order_id: ExchangeOrderId::new("ex-outcome-buy"),
639 instrument: InstrumentId::new("#20-OUTCOME"),
640 side: OrderSide::Buy,
641 price: Price::new(dec!(0.39)),
642 qty: Qty::new(dec!(64)),
643 remaining_qty: Qty::new(dec!(64)),
644 created_at: 0,
645 });
646
647 let mut quotes = HashMap::new();
648 quotes.insert(
649 InstrumentId::new("#20-OUTCOME"),
650 Quote {
651 instrument: InstrumentId::new("#20-OUTCOME"),
652 bid: Price::new(dec!(0.388)),
653 ask: Price::new(dec!(0.389)),
654 bid_size: Qty::new(dec!(1000)),
655 ask_size: Qty::new(dec!(1000)),
656 ts: 0,
657 },
658 );
659
660 let fills = sim.check_fills("es, 1000);
661 assert_eq!(fills.len(), 1);
662 assert_eq!(fills[0].fill.fee.asset, AssetId::new("USDH"));
663 assert_eq!(fills[0].fill.fee.amount, dec!(0.00624));
664 assert_eq!(sim.balance(&AssetId::new("USDH")), dec!(75.03376));
665 assert_eq!(sim.balance(&AssetId::new("BTC > 79980")), dec!(64));
666 assert_eq!(sim.balance(&AssetId::new("USDC")), Decimal::ZERO);
667 }
668
669 #[test]
670 fn test_outcome_sell_uses_usdh_quote_fee() {
671 let mut balances = HashMap::new();
672 balances.insert(AssetId::new("BTC > 79980"), dec!(100));
673 balances.insert(AssetId::new("USDH"), dec!(0));
674
675 let mut sim = FillSimulator::new_with_fee(balances, dec!(0.00025));
676 sim.register_instrument_meta(&outcome_meta());
677
678 sim.add_pending_order(PendingOrder {
679 client_id: ClientOrderId::new("outcome-sell"),
680 exchange_order_id: ExchangeOrderId::new("ex-outcome-sell"),
681 instrument: InstrumentId::new("#20-OUTCOME"),
682 side: OrderSide::Sell,
683 price: Price::new(dec!(0.42)),
684 qty: Qty::new(dec!(10)),
685 remaining_qty: Qty::new(dec!(10)),
686 created_at: 0,
687 });
688
689 let mut quotes = HashMap::new();
690 quotes.insert(
691 InstrumentId::new("#20-OUTCOME"),
692 Quote {
693 instrument: InstrumentId::new("#20-OUTCOME"),
694 bid: Price::new(dec!(0.421)),
695 ask: Price::new(dec!(0.422)),
696 bid_size: Qty::new(dec!(1000)),
697 ask_size: Qty::new(dec!(1000)),
698 ts: 0,
699 },
700 );
701
702 let fills = sim.check_fills("es, 1000);
703 assert_eq!(fills.len(), 1);
704 assert_eq!(fills[0].fill.fee.asset, AssetId::new("USDH"));
705 assert_eq!(fills[0].fill.fee.amount, dec!(0.00105));
706 assert_eq!(sim.balance(&AssetId::new("USDH")), dec!(4.19895));
707 assert_eq!(sim.balance(&AssetId::new("BTC > 79980")), dec!(90));
708 }
709
710 #[test]
711 fn test_spot_buy_still_uses_base_asset_fee() {
712 let mut balances = HashMap::new();
713 balances.insert(AssetId::new("USDC"), dec!(100));
714
715 let mut sim = FillSimulator::new_with_fee(balances, dec!(0.00025));
716 sim.register_instrument_meta(&spot_meta());
717
718 sim.add_pending_order(PendingOrder {
719 client_id: ClientOrderId::new("spot-buy"),
720 exchange_order_id: ExchangeOrderId::new("ex-spot-buy"),
721 instrument: InstrumentId::new("HYPE-SPOT"),
722 side: OrderSide::Buy,
723 price: Price::new(dec!(10)),
724 qty: Qty::new(dec!(2)),
725 remaining_qty: Qty::new(dec!(2)),
726 created_at: 0,
727 });
728
729 let mut quotes = HashMap::new();
730 quotes.insert(
731 InstrumentId::new("HYPE-SPOT"),
732 Quote {
733 instrument: InstrumentId::new("HYPE-SPOT"),
734 bid: Price::new(dec!(9.99)),
735 ask: Price::new(dec!(10)),
736 bid_size: Qty::new(dec!(1000)),
737 ask_size: Qty::new(dec!(1000)),
738 ts: 0,
739 },
740 );
741
742 let fills = sim.check_fills("es, 1000);
743 assert_eq!(fills.len(), 1);
744 assert_eq!(fills[0].fill.fee.asset, AssetId::new("HYPE"));
745 assert_eq!(fills[0].fill.fee.amount, dec!(0.00050));
746 assert_eq!(sim.balance(&AssetId::new("USDC")), dec!(80));
747 assert_eq!(sim.balance(&AssetId::new("HYPE")), dec!(1.99950));
748 }
749}