Skip to main content

bot_engine/testing/
fill_simulator.rs

1//! Fill simulator: Shared logic for simulating order fills.
2//!
3//! Used by both MockExchange (for test assertions) and PaperExchange (for paper trading).
4//!
5//! ## Performance Optimization
6//!
7//! Uses sorted VecDeques (by price) for O(1) front removal when filling orders.
8//! This reduces complexity from O(quotes × orders) to O(quotes × fillable_orders).
9//! For grid strategies with 100 levels, this provides ~10-15x speedup.
10
11use 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/// A pending order waiting to be filled
19#[derive(Debug, Clone)]
20pub struct PendingOrder {
21    /// Client order ID.
22    pub client_id: ClientOrderId,
23    /// Simulated exchange order ID.
24    pub exchange_order_id: ExchangeOrderId,
25    /// Instrument being traded.
26    pub instrument: InstrumentId,
27    /// Order side.
28    pub side: OrderSide,
29    /// Limit price.
30    pub price: Price,
31    /// Original order quantity.
32    pub qty: Qty,
33    /// Quantity not yet filled.
34    pub remaining_qty: Qty,
35    /// Creation timestamp in milliseconds.
36    pub created_at: i64,
37}
38
39/// Result of a simulated fill
40#[derive(Debug, Clone)]
41pub struct SimulatedFill {
42    /// Canonical fill emitted by the simulator.
43    pub fill: Fill,
44    /// Whether this fill completed the order.
45    pub order_fully_filled: bool,
46}
47
48/// Key for grouping orders by instrument and side
49#[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
62/// Simulates order fills based on quote price crossing.
63///
64/// Shared logic used by both MockExchange and PaperExchange.
65///
66/// ## Optimization
67///
68/// Orders are stored in sorted VecDeques by price (per instrument+side).
69/// - BUY orders: sorted descending by price (highest first)
70/// - SELL orders: sorted ascending by price (lowest first)
71///
72/// Using VecDeque allows O(1) pop_front for filling orders, avoiding O(n) shifts.
73pub struct FillSimulator {
74    /// Orders grouped by (instrument, side), sorted by price for fast lookups
75    /// BUY orders: sorted descending (highest price first - most aggressive)
76    /// SELL orders: sorted ascending (lowest price first - most aggressive)
77    order_groups: HashMap<OrderGroupKey, VecDeque<PendingOrder>>,
78    next_oid: u64,
79    balances: HashMap<AssetId, Decimal>,
80    instrument_assets: HashMap<InstrumentId, SimInstrumentAssets>,
81    /// Fee rate to apply to fills (e.g., 0.0004 = 0.04%)
82    /// For spot BUY orders, fee is deducted from base asset (received asset)
83    /// For spot SELL orders, fee is deducted from quote asset
84    fee_rate: Decimal,
85}
86
87impl FillSimulator {
88    /// Create a simulator with initial balances and no fees.
89    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, // Default: no fees
96        }
97    }
98
99    /// Create a new fill simulator with a specific fee rate
100    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    /// Set the fee rate (0.0004 = 0.04%)
111    pub fn set_fee_rate(&mut self, fee_rate: Decimal) {
112        self.fee_rate = fee_rate;
113    }
114
115    /// Register market metadata so paper/backtest accounting can use the right
116    /// base/quote assets. Outcome markets quote in USDH, not USDC.
117    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    /// Return `true` when the instrument is configured or inferred as a perp.
129    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    /// Static version of calculate_fee (for use when self is already borrowed)
176    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                    // Spot BUY: fee deducted from received base asset
197                    let fee_amount = qty.0 * fee_rate;
198                    Fee::new(fee_amount, assets.base_asset.clone())
199                }
200                OrderSide::Sell => {
201                    // Spot SELL: fee deducted from received quote asset
202                    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            // PERP: fee in the instrument quote asset
209            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    /// Generate next exchange order ID
216    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    /// Add a pending order (maintains sorted order for O(log n) lookups)
223    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        // Binary search insert to maintain sorted order
232        // BUY orders: sorted descending by price (highest = most aggressive first)
233        // SELL orders: sorted ascending by price (lowest = most aggressive first)
234        // VecDeque: convert to slice for partition_point, then insert
235        let insert_pos = {
236            let slice = orders.make_contiguous();
237            match order.side {
238                OrderSide::Buy => {
239                    // Descending: find first position where existing price < new price
240                    slice.partition_point(|o| o.price.0 > order.price.0)
241                }
242                OrderSide::Sell => {
243                    // Ascending: find first position where existing price > new price
244                    slice.partition_point(|o| o.price.0 < order.price.0)
245                }
246            }
247        };
248
249        orders.insert(insert_pos, order);
250    }
251
252    /// Remove pending order by client ID
253    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); // VecDeque::remove returns Option<T>
257            }
258        }
259        None
260    }
261
262    /// Remove all pending orders for an instrument
263    pub fn remove_orders_for_instrument(&mut self, instrument: &InstrumentId) -> Vec<PendingOrder> {
264        let mut removed = Vec::new();
265
266        // Remove from both Buy and Sell groups
267        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    /// Get current balance
281    pub fn balance(&self, asset: &AssetId) -> Decimal {
282        self.balances.get(asset).copied().unwrap_or_default()
283    }
284
285    /// Set balance directly
286    pub fn set_balance(&mut self, asset: AssetId, amount: Decimal) {
287        self.balances.insert(asset, amount);
288    }
289
290    /// Check pending orders against quotes and generate fills.
291    ///
292    /// ## Optimization
293    ///
294    /// Orders are grouped by (instrument, side) and sorted by price.
295    /// For each quote, we use binary search to find orders that could fill:
296    /// - BUY orders fill when ask <= order.price (scan from highest price down)
297    /// - SELL orders fill when bid >= order.price (scan from lowest price up)
298    ///
299    /// This reduces complexity from O(orders) to O(fillable_orders).
300    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        // Extract fee_rate before mutably borrowing order_groups
308        let fee_rate = self.fee_rate;
309        let instrument_assets = self.instrument_assets.clone();
310
311        // Process each quote
312        for (instrument, quote) in quotes {
313            // Check BUY orders: fill when ask <= order.price
314            // Orders are sorted descending by price (highest first)
315            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                // Find the partition point: orders with price >= ask can fill
322                // Since sorted descending, all orders from index 0 to partition_point can fill
323                let ask = quote.ask.0;
324
325                // Drain fillable orders (from front since they're highest price first)
326                // Using front() for comparison and pop_front() for O(1) removal
327                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            // Check SELL orders: fill when bid >= order.price
358            // Orders are sorted ascending by price (lowest first)
359            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                // Drain fillable orders (from front since they're lowest price first)
368                // Using front() for comparison and pop_front() for O(1) removal
369                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        // Apply balance updates
401        for sim_fill in &fills {
402            self.apply_fill_to_balances(&sim_fill.fill);
403        }
404
405        fills
406    }
407
408    /// Apply a fill to balances (buy: deduct quote, add base; sell: vice versa)
409    /// Also deducts fees from the appropriate asset
410    /// NOTE: For PERP instruments, we skip balance updates - Engine's position tracker is source of truth
411    fn apply_fill_to_balances(&mut self, fill: &Fill) {
412        let assets = self.assets_for(&fill.instrument);
413
414        // For PERP instruments, don't modify balances
415        // The Engine's PositionTracker handles PnL tracking
416        // This avoids phantom base asset balances (e.g., fake BTC holdings)
417        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                // Add base asset, but deduct fee if fee is in base asset
430                let received = if fill.fee.asset == base_asset {
431                    fill.qty.0 - fill.fee.amount // Fee deducted from received
432                } else {
433                    fill.qty.0
434                };
435                *self.balances.entry(base_asset).or_default() += received;
436                // If fee is in quote asset, deduct from quote
437                if fill.fee.asset == quote_asset {
438                    *self.balances.entry(quote_asset).or_default() -= fill.fee.amount;
439                }
440            }
441            OrderSide::Sell => {
442                // Add notional to quote, but deduct fee if fee is in quote
443                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 fee is in base asset (unusual for sell), deduct from base
451                if fill.fee.asset == base_asset {
452                    *self.balances.entry(base_asset).or_default() -= fill.fee.amount;
453                }
454            }
455        }
456    }
457
458    /// Check if balance is sufficient for an order
459    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    /// Apply a known fill to simulated spot-like balances.
494    ///
495    /// IOC fills are constructed by PaperExchange directly, while resting fills
496    /// come through `check_fills`.
497    pub fn apply_fill(&mut self, fill: &Fill) {
498        self.apply_fill_to_balances(fill);
499    }
500
501    /// Get pending orders count (sum across all groups)
502    pub fn pending_orders_count(&self) -> usize {
503        self.order_groups.values().map(|v| v.len()).sum()
504    }
505
506    /// Get all pending orders (for inspection) - flattened from all groups
507    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        // Place buy order at 50000
573        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        // Quote with ask at 49999 (below order price) - resting limit should fill at order price.
585        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(&quotes, 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        // Place sell order at 50000
605        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        // Quote with bid at 50001 (above order price) - resting limit should fill at order price.
617        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(&quotes, 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(&quotes, 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(&quotes, 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(&quotes, 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}