Skip to main content

wickra_exchange_core/exchanges/
paper.rs

1//! A deterministic paper-trading exchange.
2//!
3//! [`PaperExchange`] implements the very same [`Exchange`] trait as every live
4//! venue, but fills orders against an internal book and portfolio instead of a
5//! network. This is the library's headline differentiator: a strategy written
6//! against [`Exchange`] runs **paper ↔ live by swapping the implementation** —
7//! not a line of strategy code changes.
8//!
9//! ## Fill model
10//!
11//! The fill model mirrors the cost semantics of the Wickra backtest engine
12//! (`wickra-backtest-core`, `spec::Costs` / `engine::slippage_rate`) so that a
13//! paper run and a backtest agree on execution:
14//!
15//! - **Fixed-bps slippage.** A market (or marketable-limit) fill executes at
16//!   `mark * (1 + dir * slippage)`, where `dir` is `+1` for a buy and `-1` for a
17//!   sell — a buy pays up, a sell gives up, exactly as the engine models it.
18//! - **Maker / taker fees in basis points.** A fill pays `notional * rate`,
19//!   charged in the quote asset; immediate fills pay the taker rate.
20//!
21//! The engine's own fill routine is a bar-loop (orders decided on a close, filled
22//! on the next open) with no public single-order entry point, so the arithmetic
23//! is reimplemented here against the order-driven [`Execution`] API rather than
24//! taken as a dependency.
25//!
26//! ## Market data
27//!
28//! A paper exchange has no feed of its own: prices are injected with
29//! [`set_price`](PaperExchange::set_price). [`ticker`](MarketData::ticker) reports
30//! the current mark; historical [`klines`](MarketData::klines) and depth
31//! [`order_book`](MarketData::order_book) are unsupported — pair the paper
32//! exchange with a live or replay market-data feed. Execution events (order and
33//! balance updates) flow through [`poll_events`](MarketData::poll_events).
34
35use std::collections::{BTreeMap, HashMap, VecDeque};
36
37use rust_decimal::Decimal;
38use wickra_core::Candle;
39
40use crate::error::{Error, Result};
41use crate::events::{Event, OrderBookSnapshot};
42use crate::options::MarketType;
43use crate::symbol::Symbol;
44use crate::traits::{Exchange, Execution, MarketData};
45use crate::types::{Balance, Order, OrderRequest, OrderSide, OrderStatus, OrderType, Ticker};
46
47/// Basis points as a fraction: `bps / 10_000`.
48fn bps_fraction(bps: Decimal) -> Decimal {
49    bps / Decimal::from(10_000)
50}
51
52/// A deterministic, network-free exchange that simulates fills through an
53/// internal portfolio. The fill model is documented on the methods below.
54#[derive(Debug)]
55pub struct PaperExchange {
56    market_type: MarketType,
57    maker_bps: Decimal,
58    taker_bps: Decimal,
59    slippage_bps: Decimal,
60    /// Current mark price per symbol, injected via [`set_price`](Self::set_price).
61    marks: HashMap<Symbol, Decimal>,
62    /// Free/locked balance per asset.
63    balances: BTreeMap<String, Balance>,
64    /// Every order ever placed, keyed by venue id; open orders are those whose
65    /// status [`is_open`](OrderStatus::is_open).
66    orders: BTreeMap<String, Order>,
67    /// Pending execution events drained by [`poll_events`](Self::poll_events).
68    events: VecDeque<Event>,
69    next_id: u64,
70}
71
72impl Default for PaperExchange {
73    fn default() -> Self {
74        Self::new()
75    }
76}
77
78impl PaperExchange {
79    /// A frictionless spot paper exchange (zero fees, zero slippage). Add costs
80    /// with [`with_fees`](Self::with_fees) / [`with_slippage_bps`](Self::with_slippage_bps)
81    /// and seed the account with [`with_balance`](Self::with_balance).
82    #[must_use]
83    pub fn new() -> Self {
84        Self {
85            market_type: MarketType::Spot,
86            maker_bps: Decimal::ZERO,
87            taker_bps: Decimal::ZERO,
88            slippage_bps: Decimal::ZERO,
89            marks: HashMap::new(),
90            balances: BTreeMap::new(),
91            orders: BTreeMap::new(),
92            events: VecDeque::new(),
93            next_id: 0,
94        }
95    }
96
97    /// Set the market type this paper account trades (default [`MarketType::Spot`]).
98    #[must_use]
99    pub fn with_market_type(mut self, market_type: MarketType) -> Self {
100        self.market_type = market_type;
101        self
102    }
103
104    /// Set the maker and taker fees, in basis points.
105    #[must_use]
106    pub fn with_fees(mut self, maker_bps: Decimal, taker_bps: Decimal) -> Self {
107        self.maker_bps = maker_bps;
108        self.taker_bps = taker_bps;
109        self
110    }
111
112    /// Set the fixed slippage applied to every fill, in basis points.
113    #[must_use]
114    pub fn with_slippage_bps(mut self, slippage_bps: Decimal) -> Self {
115        self.slippage_bps = slippage_bps;
116        self
117    }
118
119    /// Seed (or top up) the free balance of `asset`.
120    #[must_use]
121    pub fn with_balance(mut self, asset: impl Into<String>, amount: Decimal) -> Self {
122        let asset = asset.into();
123        let entry = self.balances.entry(asset.clone()).or_insert(Balance {
124            asset,
125            free: Decimal::ZERO,
126            locked: Decimal::ZERO,
127        });
128        entry.free += amount;
129        self
130    }
131
132    /// The market type this account trades.
133    #[must_use]
134    pub fn market_type(&self) -> MarketType {
135        self.market_type
136    }
137
138    /// Set the current mark price for `symbol`, used for fills and the ticker.
139    pub fn set_price(&mut self, symbol: &Symbol, price: Decimal) {
140        self.marks.insert(symbol.clone(), price);
141    }
142
143    /// Cancel every open order, releasing the funds they locked, and return the
144    /// number cancelled. This is the actuator a
145    /// [`DeadMansSwitch`](crate::DeadMansSwitch) fires on disconnect.
146    pub fn cancel_all(&mut self) -> u32 {
147        let open: Vec<(Symbol, String)> = self
148            .orders
149            .values()
150            .filter(|order| order.is_open())
151            .map(|order| (order.symbol.clone(), order.id.clone()))
152            .collect();
153        let mut cancelled = 0;
154        for (symbol, id) in open {
155            if self.cancel_order(&symbol, &id).is_ok() {
156                cancelled += 1;
157            }
158        }
159        cancelled
160    }
161
162    /// The configured maker fee, in basis points.
163    #[must_use]
164    pub fn maker_bps(&self) -> Decimal {
165        self.maker_bps
166    }
167
168    /// The configured taker fee, in basis points.
169    #[must_use]
170    pub fn taker_bps(&self) -> Decimal {
171        self.taker_bps
172    }
173
174    fn next_order_id(&mut self) -> String {
175        self.next_id += 1;
176        format!("paper-{}", self.next_id)
177    }
178
179    fn balance_mut(&mut self, asset: &str) -> &mut Balance {
180        self.balances
181            .entry(asset.to_string())
182            .or_insert_with(|| Balance {
183                asset: asset.to_string(),
184                free: Decimal::ZERO,
185                locked: Decimal::ZERO,
186            })
187    }
188
189    fn free_of(&self, asset: &str) -> Decimal {
190        self.balances.get(asset).map_or(Decimal::ZERO, |b| b.free)
191    }
192
193    fn balance_snapshot(&self) -> Vec<Balance> {
194        self.balances.values().cloned().collect()
195    }
196
197    /// The fill price for a `side` order against `mark`, after fixed-bps slippage:
198    /// a buy fills higher, a sell lower.
199    fn fill_price(&self, side: OrderSide, mark: Decimal) -> Decimal {
200        let slip = bps_fraction(self.slippage_bps);
201        match side {
202            OrderSide::Buy => mark * (Decimal::ONE + slip),
203            OrderSide::Sell => mark * (Decimal::ONE - slip),
204        }
205    }
206
207    /// Settle a fill against the portfolio and record the order. Assumes the
208    /// pre-flight balance check has already passed.
209    fn settle_fill(&mut self, request: &OrderRequest, fill_price: Decimal) -> Order {
210        let base = request.symbol.base().to_string();
211        let quote = request.symbol.quote().to_string();
212        let qty = request.quantity;
213        let notional = qty * fill_price;
214        let fee = notional * bps_fraction(self.taker_bps);
215
216        match request.side {
217            OrderSide::Buy => {
218                self.balance_mut(&quote).free -= notional + fee;
219                self.balance_mut(&base).free += qty;
220            }
221            OrderSide::Sell => {
222                self.balance_mut(&base).free -= qty;
223                self.balance_mut(&quote).free += notional - fee;
224            }
225        }
226
227        let id = self.next_order_id();
228        let order = Order {
229            id: id.clone(),
230            client_order_id: request.client_order_id.clone(),
231            symbol: request.symbol.clone(),
232            side: request.side,
233            order_type: request.order_type,
234            status: OrderStatus::Filled,
235            quantity: qty,
236            filled_quantity: qty,
237            price: request.price,
238            average_price: Some(fill_price),
239        };
240        self.orders.insert(id, order.clone());
241        self.events.push_back(Event::OrderUpdate(order.clone()));
242        self.events
243            .push_back(Event::BalanceUpdate(self.balance_snapshot()));
244        order
245    }
246
247    /// Rest a non-marketable limit order, locking the funds it reserves.
248    fn rest_order(&mut self, request: &OrderRequest, price: Decimal) -> Order {
249        let (asset, amount) = match request.side {
250            OrderSide::Buy => (request.symbol.quote().to_string(), request.quantity * price),
251            OrderSide::Sell => (request.symbol.base().to_string(), request.quantity),
252        };
253        let balance = self.balance_mut(&asset);
254        balance.free -= amount;
255        balance.locked += amount;
256
257        let id = self.next_order_id();
258        let order = Order {
259            id: id.clone(),
260            client_order_id: request.client_order_id.clone(),
261            symbol: request.symbol.clone(),
262            side: request.side,
263            order_type: request.order_type,
264            status: OrderStatus::New,
265            quantity: request.quantity,
266            filled_quantity: Decimal::ZERO,
267            price: Some(price),
268            average_price: None,
269        };
270        self.orders.insert(id, order.clone());
271        self.events.push_back(Event::OrderUpdate(order.clone()));
272        order
273    }
274}
275
276impl MarketData for PaperExchange {
277    fn ticker(&mut self, symbol: &Symbol) -> Result<Ticker> {
278        let mark = self
279            .marks
280            .get(symbol)
281            .copied()
282            .ok_or_else(|| Error::NotFound(format!("no paper price set for {symbol}")))?;
283        Ok(Ticker {
284            symbol: symbol.clone(),
285            last: mark,
286            bid: mark,
287            ask: mark,
288            volume: Decimal::ZERO,
289            // A paper price was set by the caller, not published by a venue,
290            // so there is no venue timestamp to report and none is invented.
291            timestamp: 0,
292        })
293    }
294
295    fn klines(&mut self, _symbol: &Symbol, _interval: &str, _limit: u32) -> Result<Vec<Candle>> {
296        Err(Error::Exchange {
297            code: "unsupported".to_string(),
298            message: "paper exchange has no historical data; pair it with a live or replay feed"
299                .to_string(),
300        })
301    }
302
303    fn order_book(&mut self, _symbol: &Symbol, _depth: u32) -> Result<OrderBookSnapshot> {
304        Err(Error::Exchange {
305            code: "unsupported".to_string(),
306            message: "paper exchange has no depth feed; pair it with a live or replay feed"
307                .to_string(),
308        })
309    }
310
311    fn subscribe_trades(&mut self, _symbol: &Symbol) -> Result<()> {
312        Ok(())
313    }
314
315    fn subscribe_book(&mut self, _symbol: &Symbol) -> Result<()> {
316        Ok(())
317    }
318
319    fn subscribe_ticker(&mut self, _symbol: &Symbol) -> Result<()> {
320        Ok(())
321    }
322
323    fn poll_events(&mut self) -> Vec<Event> {
324        self.events.drain(..).collect()
325    }
326}
327
328impl Execution for PaperExchange {
329    fn place_order(&mut self, request: &OrderRequest) -> Result<Order> {
330        request.validate()?;
331        if request.order_type.is_trigger() {
332            return Err(Error::InvalidOrder(
333                "paper exchange supports only market and limit orders",
334            ));
335        }
336        let mark =
337            self.marks.get(&request.symbol).copied().ok_or_else(|| {
338                Error::NotFound(format!("no paper price set for {}", request.symbol))
339            })?;
340
341        // A limit order fills now only if it crosses the mark; otherwise it rests.
342        // Stop types are rejected above, so they never reach this match; they are
343        // grouped with the live arms only to keep it exhaustive without a catch-all.
344        let crosses = match request.order_type {
345            OrderType::Market | OrderType::StopMarket => true,
346            OrderType::Limit | OrderType::StopLimit => match request.side {
347                OrderSide::Buy => request.price.unwrap_or(mark) >= mark,
348                OrderSide::Sell => request.price.unwrap_or(mark) <= mark,
349            },
350        };
351
352        if !crosses {
353            // A resting limit: check the funds it must lock, then rest it.
354            let price = request.price.unwrap_or(mark);
355            let (asset, amount) = match request.side {
356                OrderSide::Buy => (request.symbol.quote(), request.quantity * price),
357                OrderSide::Sell => (request.symbol.base(), request.quantity),
358            };
359            if self.free_of(asset) < amount {
360                return Err(Error::InsufficientBalance);
361            }
362            return Ok(self.rest_order(request, price));
363        }
364
365        if request.post_only {
366            return Err(Error::OrderRejected {
367                code: "post_only".to_string(),
368                message: "post-only order would take liquidity".to_string(),
369            });
370        }
371
372        let fill_price = self.fill_price(request.side, mark);
373        let notional = request.quantity * fill_price;
374        let fee = notional * bps_fraction(self.taker_bps);
375
376        // Pre-flight balance check: buys spend quote (+ fee), sells spend base.
377        match request.side {
378            OrderSide::Buy => {
379                if self.free_of(request.symbol.quote()) < notional + fee {
380                    return Err(Error::InsufficientBalance);
381                }
382            }
383            OrderSide::Sell => {
384                if self.free_of(request.symbol.base()) < request.quantity {
385                    return Err(Error::InsufficientBalance);
386                }
387            }
388        }
389
390        // A marketable order fills in full immediately, so fill-or-kill and
391        // immediate-or-cancel are both satisfied by this single fill.
392        Ok(self.settle_fill(request, fill_price))
393    }
394
395    fn cancel_order(&mut self, _symbol: &Symbol, order_id: &str) -> Result<()> {
396        let order = self
397            .orders
398            .get_mut(order_id)
399            .filter(|o| o.is_open())
400            .ok_or_else(|| Error::NotFound(format!("no open paper order {order_id}")))?;
401        order.status = OrderStatus::Canceled;
402        let order = order.clone();
403
404        // Release the funds the resting order had locked.
405        let price = order.price.unwrap_or_default();
406        let (asset, amount) = match order.side {
407            OrderSide::Buy => (order.symbol.quote().to_string(), order.quantity * price),
408            OrderSide::Sell => (order.symbol.base().to_string(), order.quantity),
409        };
410        let balance = self.balance_mut(&asset);
411        balance.locked -= amount;
412        balance.free += amount;
413
414        self.events.push_back(Event::OrderUpdate(order));
415        self.events
416            .push_back(Event::BalanceUpdate(self.balance_snapshot()));
417        Ok(())
418    }
419
420    fn query_order(&mut self, _symbol: &Symbol, order_id: &str) -> Result<Order> {
421        self.orders
422            .get(order_id)
423            .cloned()
424            .ok_or_else(|| Error::NotFound(format!("no paper order {order_id}")))
425    }
426
427    fn open_orders(&mut self, symbol: Option<&Symbol>) -> Result<Vec<Order>> {
428        Ok(self
429            .orders
430            .values()
431            .filter(|o| o.is_open())
432            .filter(|o| symbol.is_none_or(|s| &o.symbol == s))
433            .cloned()
434            .collect())
435    }
436
437    fn balances(&mut self) -> Result<Vec<Balance>> {
438        Ok(self.balance_snapshot())
439    }
440}
441
442impl Exchange for PaperExchange {
443    fn name(&self) -> &'static str {
444        "paper"
445    }
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451    use rust_decimal_macros::dec;
452
453    fn sym() -> Symbol {
454        Symbol::new("BTC", "USDT")
455    }
456
457    fn seeded() -> PaperExchange {
458        let mut ex = PaperExchange::new()
459            .with_fees(dec!(1), dec!(5))
460            .with_slippage_bps(dec!(10))
461            .with_balance("USDT", dec!(100000))
462            .with_balance("BTC", dec!(2));
463        ex.set_price(&sym(), dec!(20000));
464        ex
465    }
466
467    #[test]
468    fn market_buy_fills_with_slippage_and_fee() {
469        let mut ex = seeded();
470        let order = ex
471            .place_order(&OrderRequest::market_buy(sym(), dec!(1)))
472            .unwrap();
473        assert_eq!(order.status, OrderStatus::Filled);
474        // 10 bps slippage on a buy: 20000 * 1.001 = 20020.
475        assert_eq!(order.average_price, Some(dec!(20020)));
476        assert_eq!(order.filled_quantity, dec!(1));
477
478        // Quote spent = 20020 notional + 5 bps fee (10.01) = 20030.01.
479        let usdt = ex
480            .balances()
481            .unwrap()
482            .into_iter()
483            .find(|b| b.asset == "USDT")
484            .unwrap();
485        assert_eq!(usdt.free, dec!(100000) - dec!(20020) - dec!(10.01));
486        let btc = ex
487            .balances()
488            .unwrap()
489            .into_iter()
490            .find(|b| b.asset == "BTC")
491            .unwrap();
492        assert_eq!(btc.free, dec!(3));
493    }
494
495    #[test]
496    fn market_sell_credits_quote_net_of_fee() {
497        let mut ex = seeded();
498        ex.place_order(&OrderRequest::market_sell(sym(), dec!(1)))
499            .unwrap();
500        // 10 bps slippage on a sell: 20000 * 0.999 = 19980; fee 5 bps = 9.99.
501        let usdt = ex.free_of("USDT");
502        assert_eq!(usdt, dec!(100000) + dec!(19980) - dec!(9.99));
503        assert_eq!(ex.free_of("BTC"), dec!(1));
504    }
505
506    #[test]
507    fn frictionless_fill_matches_the_mark_exactly() {
508        let mut ex = PaperExchange::new().with_balance("USDT", dec!(50000));
509        ex.set_price(&sym(), dec!(20000));
510        let order = ex
511            .place_order(&OrderRequest::market_buy(sym(), dec!(1)))
512            .unwrap();
513        assert_eq!(order.average_price, Some(dec!(20000)));
514        assert_eq!(ex.free_of("USDT"), dec!(30000));
515    }
516
517    #[test]
518    fn poll_events_emits_order_then_balance_update() {
519        let mut ex = seeded();
520        ex.place_order(&OrderRequest::market_buy(sym(), dec!(1)))
521            .unwrap();
522        let events = ex.poll_events();
523        assert_eq!(events.len(), 2);
524        assert!(matches!(events[0], Event::OrderUpdate(_)));
525        assert!(matches!(events[1], Event::BalanceUpdate(_)));
526        // Draining clears the buffer.
527        assert_eq!(ex.poll_events(), Vec::new());
528    }
529
530    #[test]
531    fn resting_limit_locks_funds_and_is_cancelable() {
532        let mut ex = seeded();
533        // Buy limit below the mark rests instead of filling.
534        let order = ex
535            .place_order(&OrderRequest::limit_buy(sym(), dec!(1), dec!(19000)))
536            .unwrap();
537        assert_eq!(order.status, OrderStatus::New);
538        assert_eq!(ex.free_of("USDT"), dec!(100000) - dec!(19000));
539        assert_eq!(ex.open_orders(None).unwrap().len(), 1);
540
541        ex.cancel_order(&sym(), &order.id).unwrap();
542        assert_eq!(ex.free_of("USDT"), dec!(100000));
543        assert_eq!(ex.open_orders(None).unwrap(), Vec::new());
544        assert_eq!(
545            ex.query_order(&sym(), &order.id).unwrap().status,
546            OrderStatus::Canceled
547        );
548    }
549
550    #[test]
551    fn dead_mans_switch_fires_cancel_all_on_lost_heartbeat() {
552        use crate::DeadMansSwitch;
553        use std::time::Duration;
554
555        let mut ex = seeded();
556        // Two resting orders on opposite sides of the mark (20000).
557        ex.place_order(&OrderRequest::limit_buy(sym(), dec!(1), dec!(19000)))
558            .unwrap();
559        ex.place_order(&OrderRequest::limit_sell(sym(), dec!(1), dec!(21000)))
560            .unwrap();
561        assert_eq!(ex.open_orders(None).unwrap().len(), 2);
562
563        let mut switch = DeadMansSwitch::new(Duration::from_secs(5));
564        switch.heartbeat(1_000);
565        assert!(!switch.is_expired(3_000)); // still in contact
566
567        // The heartbeat is lost: the switch trips and the actuator cancels all.
568        assert!(switch.is_expired(6_000));
569        let cancelled = ex.cancel_all();
570        assert_eq!(cancelled, 2);
571        assert_eq!(ex.open_orders(None).unwrap(), Vec::new());
572        // Locked funds are released back to free on both sides.
573        assert_eq!(ex.free_of("USDT"), dec!(100000));
574        assert_eq!(ex.free_of("BTC"), dec!(2));
575    }
576
577    #[test]
578    fn marketable_limit_buy_fills_immediately() {
579        let mut ex = seeded();
580        // Buy limit above the mark crosses and fills.
581        let order = ex
582            .place_order(&OrderRequest::limit_buy(sym(), dec!(1), dec!(21000)))
583            .unwrap();
584        assert_eq!(order.status, OrderStatus::Filled);
585        assert_eq!(order.average_price, Some(dec!(20020)));
586    }
587
588    #[test]
589    fn post_only_crossing_order_is_rejected() {
590        let mut ex = seeded();
591        let err = ex
592            .place_order(&OrderRequest::limit_buy(sym(), dec!(1), dec!(21000)).post_only())
593            .unwrap_err();
594        assert!(matches!(err, Error::OrderRejected { .. }));
595    }
596
597    #[test]
598    fn insufficient_balance_is_rejected() {
599        let mut ex = PaperExchange::new().with_balance("USDT", dec!(100));
600        ex.set_price(&sym(), dec!(20000));
601        let err = ex
602            .place_order(&OrderRequest::market_buy(sym(), dec!(1)))
603            .unwrap_err();
604        assert!(matches!(err, Error::InsufficientBalance));
605    }
606
607    #[test]
608    fn insufficient_base_rejects_a_sell() {
609        let mut ex = PaperExchange::new().with_balance("BTC", dec!(0.5));
610        ex.set_price(&sym(), dec!(20000));
611        let err = ex
612            .place_order(&OrderRequest::market_sell(sym(), dec!(1)))
613            .unwrap_err();
614        assert!(matches!(err, Error::InsufficientBalance));
615    }
616
617    #[test]
618    fn stop_orders_are_unsupported() {
619        let mut ex = seeded();
620        let request = OrderRequest {
621            order_type: OrderType::StopMarket,
622            stop_price: Some(dec!(19000)),
623            ..OrderRequest::market_sell(sym(), dec!(1))
624        };
625        assert!(matches!(
626            ex.place_order(&request).unwrap_err(),
627            Error::InvalidOrder(_)
628        ));
629    }
630
631    #[test]
632    fn order_without_a_price_needs_a_mark() {
633        let mut ex = PaperExchange::new().with_balance("USDT", dec!(50000));
634        let err = ex
635            .place_order(&OrderRequest::market_buy(sym(), dec!(1)))
636            .unwrap_err();
637        assert!(matches!(err, Error::NotFound(_)));
638    }
639
640    #[test]
641    fn ticker_reports_the_mark_and_klines_are_unsupported() {
642        let mut ex = seeded();
643        let ticker = ex.ticker(&sym()).unwrap();
644        assert_eq!(ticker.last, dec!(20000));
645        assert_eq!(ticker.bid, ticker.ask);
646        assert!(ex.klines(&sym(), "1m", 10).is_err());
647        assert!(ex.order_book(&sym(), 10).is_err());
648        assert!(ex.ticker(&Symbol::new("ETH", "USDT")).is_err());
649    }
650
651    #[test]
652    fn subscriptions_are_inert_and_name_is_paper() {
653        let mut ex = seeded();
654        assert!(ex.subscribe_trades(&sym()).is_ok());
655        assert!(ex.subscribe_book(&sym()).is_ok());
656        assert!(ex.subscribe_ticker(&sym()).is_ok());
657        assert_eq!(ex.poll_events(), Vec::new());
658        assert_eq!(ex.name(), "paper");
659        assert_eq!(ex.market_type(), MarketType::Spot);
660    }
661
662    #[test]
663    fn cancel_unknown_order_is_not_found() {
664        let mut ex = seeded();
665        assert!(matches!(
666            ex.cancel_order(&sym(), "paper-999").unwrap_err(),
667            Error::NotFound(_)
668        ));
669        assert!(matches!(
670            ex.query_order(&sym(), "paper-999").unwrap_err(),
671            Error::NotFound(_)
672        ));
673    }
674
675    #[test]
676    fn builder_configuration_is_readable() {
677        let ex = PaperExchange::default()
678            .with_market_type(MarketType::UsdMFutures)
679            .with_fees(dec!(2), dec!(7));
680        assert_eq!(ex.market_type(), MarketType::UsdMFutures);
681        assert_eq!(ex.maker_bps(), dec!(2));
682        assert_eq!(ex.taker_bps(), dec!(7));
683    }
684
685    /// The derived `Debug` is part of the public surface: a paper client ends up
686    /// in test output and error messages.
687    #[test]
688    fn debug_renders() {
689        let rendered = format!("{:?}", seeded());
690        assert!(rendered.starts_with("PaperExchange {"));
691    }
692}