Skip to main content

melin_server/
exchange_app.rs

1//! `Application` impl for the trading engine.
2//!
3//! `melin-exchange-core` owns the matching domain (`Exchange`) and knows nothing
4//! about the LMAX transport pipeline. The transport's `Application`
5//! contract lives in `melin-app`, and `melin-server` is what wires the
6//! two together — so the trait impl lives here, on a thin newtype around
7//! `Exchange` that satisfies the orphan rule.
8//!
9//! The newtype is transparent: `Deref`/`DerefMut` forward every non-trait
10//! call to the inner `Exchange`, so callers that need direct engine
11//! methods (`set_max_orders_per_second`, `add_instrument`, etc.) keep
12//! their existing call sites unchanged.
13
14use std::io::{self, Read, Write};
15use std::ops::{Deref, DerefMut};
16
17use melin_app::{Application, ApplyCtx, RejectReason as TransportRejectReason};
18use melin_exchange_core::exchange::Exchange;
19use melin_exchange_core::snapshot as engine_snapshot;
20use melin_trading::trading_event::TradingEvent;
21use melin_types::types::{
22    AccountId, ExecutionReport, OrderId, QueryResponse, RejectReason as EngineRejectReason, Symbol,
23};
24
25// Hot-path size budget. Disruptor slots are copied by value on every
26// publish/consume — growing these silently would tax cache footprint
27// across the whole pipeline. A prior review caught `ExecutionReport`
28// ballooning from 64 B → 392 B via an inlined `Position` variant; these
29// assertions would have failed at compile time and tripped CI.
30// Numbers match the layout on x86_64 Linux; bump deliberately if a
31// genuine field addition requires it.
32//
33// Forced to 128 by `#[repr(align(64))]` on `InputSlot` itself (natural
34// layout is 104 B without `latency-trace`, 120 B with it). The alignment
35// attribute rounds either configuration up to two cache lines, so the
36// production footprint stays constant whether trace timestamps are
37// included or not — the assertion no longer needs a cfg-gate.
38const _: () = assert!(size_of::<melin_transport_core::pipeline::InputSlot<TradingEvent>>() == 128);
39// Bumped from 416 → 424 (one extra u64) when `OutputSlot.wire_seq` was
40// added so the response stage's durability gate can compare against
41// replica metrics in wire-seq space rather than the unsound local-vs-wire
42// mix that previously let the gate open on un-replicated events on a
43// recovered primary. Correctness > footprint here.
44#[cfg(not(feature = "latency-trace"))]
45const _: () = assert!(
46    size_of::<melin_transport_core::pipeline::OutputSlot<ExecutionReport, QueryResponse>>() == 424
47);
48const _: () = assert!(size_of::<melin_journal::JournalEvent<TradingEvent>>() == 64);
49const _: () = assert!(size_of::<ExecutionReport>() == 64);
50
51/// Transparent newtype around [`Exchange`] that carries the
52/// `Application` trait impl. Exists solely so the impl can live in
53/// `melin-server` (the wiring crate) without violating the orphan rule —
54/// neither `Application` (in `melin-app`) nor `Exchange` (in
55/// `melin-exchange-core`) is local to `melin-server`, but `ServerApp` is.
56///
57/// The inner field is `pub` because the server frequently constructs an
58/// `Exchange` directly (`Exchange::with_capacity`) and wraps it; making
59/// the wrap explicit at every construction site is
60/// cheaper than introducing a parallel set of constructors here.
61pub struct ServerApp(pub Exchange);
62
63impl ServerApp {
64    /// Construct a `ServerApp` wrapping a freshly-initialised `Exchange`.
65    /// Convenience for tests and bootstrap paths that want the default
66    /// `Exchange::new()` sizing without spelling the wrap.
67    pub fn new() -> Self {
68        ServerApp(Exchange::new())
69    }
70}
71
72impl Default for ServerApp {
73    fn default() -> Self {
74        Self::new()
75    }
76}
77
78impl Deref for ServerApp {
79    type Target = Exchange;
80
81    #[inline]
82    fn deref(&self) -> &Exchange {
83        &self.0
84    }
85}
86
87impl DerefMut for ServerApp {
88    #[inline]
89    fn deref_mut(&mut self) -> &mut Exchange {
90        &mut self.0
91    }
92}
93
94impl Application for ServerApp {
95    type Event = TradingEvent;
96    type Report = ExecutionReport;
97    type QueryResponse = QueryResponse;
98
99    /// Schema version for the snapshot payload. Tracks the underlying
100    /// `snapshot` module's `PAYLOAD_VERSION` — any change there forces a
101    /// bump here too, surfaced through the transport-owned frame.
102    const APP_VERSION: u16 = engine_snapshot::PAYLOAD_VERSION;
103
104    /// Thin dispatcher over `TradingEvent`. Marked `#[inline]` so the
105    /// matching stage's monomorphised hot loop can see through to each
106    /// concrete `Exchange` method: the inner methods (`execute`, `cancel`,
107    /// …) own the real work and keep their own inlining attrs.
108    #[inline]
109    fn apply(
110        &mut self,
111        event: Self::Event,
112        ctx: &ApplyCtx,
113        out: &mut Vec<Self::Report>,
114    ) -> Option<Self::QueryResponse> {
115        // Stash the journaled event timestamp so per-event methods
116        // (`execute` and friends) can read a deterministic clock for the
117        // SEC-04 rate limiter without taking a `now_ns` parameter. Set
118        // unconditionally so the value reflects exactly the event being
119        // applied — no risk of reading a stale stamp from an earlier event.
120        self.0.set_current_event_ts_ns(ctx.now_ns);
121        match event {
122            TradingEvent::AddInstrument { spec } => {
123                self.0.add_instrument(spec);
124                None
125            }
126            TradingEvent::Deposit {
127                account,
128                currency,
129                amount,
130            } => {
131                self.0.deposit(account, currency, amount);
132                None
133            }
134            TradingEvent::SubmitOrder { symbol, order } => {
135                self.0.execute(symbol, order, out);
136                None
137            }
138            TradingEvent::CancelOrder {
139                symbol,
140                account,
141                order_id,
142            } => {
143                self.0.cancel(symbol, account, order_id, out);
144                None
145            }
146            TradingEvent::SetRiskLimits { symbol, limits } => {
147                self.0.set_risk_limits(symbol, limits);
148                None
149            }
150            TradingEvent::CancelAll { account } => {
151                self.0.cancel_all(account, out);
152                None
153            }
154            TradingEvent::SetCircuitBreaker { symbol, config } => {
155                self.0.set_circuit_breaker(symbol, config);
156                None
157            }
158            TradingEvent::CancelReplace {
159                symbol,
160                account,
161                order_id,
162                new_price,
163                new_quantity,
164            } => {
165                self.0
166                    .cancel_replace(symbol, account, order_id, new_price, new_quantity, out);
167                None
168            }
169            TradingEvent::SetFeeSchedule { symbol, schedule } => {
170                self.0.set_fee_schedule(symbol, schedule, out);
171                None
172            }
173            TradingEvent::ProvisionAccount { account, amount } => {
174                self.0.provision_account(account, amount);
175                None
176            }
177            TradingEvent::Withdraw {
178                account,
179                currency,
180                amount,
181            } => {
182                if let Err(reason) = self.0.withdraw(account, currency, amount) {
183                    // Withdraw carries no order id or symbol — mirror the
184                    // shape used by other non-order rejections (see
185                    // `extract_order_id` / `extract_symbol`, which both
186                    // return zero for `Withdraw`). Don't route this
187                    // through `Application::build_reject`: that path
188                    // only carries `TransportRejectReason` (dedup /
189                    // replica disconnect) and would lose the engine's
190                    // specific `RejectReason` we want to surface.
191                    out.push(ExecutionReport::Rejected {
192                        order_id: OrderId(0),
193                        symbol: Symbol(0),
194                        account,
195                        reason,
196                    });
197                }
198                None
199            }
200            TradingEvent::EndOfDay => {
201                self.0.end_of_day(out);
202                None
203            }
204            TradingEvent::DisableInstrument { symbol } => {
205                self.0.disable_instrument(symbol, out);
206                None
207            }
208            TradingEvent::EnableInstrument { symbol } => {
209                self.0.enable_instrument(symbol, out);
210                None
211            }
212            TradingEvent::RemoveInstrument { symbol } => {
213                self.0.remove_instrument(symbol, out);
214                None
215            }
216            TradingEvent::QueryStats => {
217                // Read-only query: the transport owns the counters, so
218                // the app synthesises the report directly from the
219                // `ApplyCtx` it was handed. No `Exchange` state touched.
220                Some(QueryResponse::Stats {
221                    active_connections: ctx.active_connections,
222                    events_processed: ctx.events_processed,
223                    journal_sequence: ctx.journal_sequence,
224                })
225            }
226            TradingEvent::QueryPosition { account } => {
227                let (balances, count) = self.0.accounts().balances_for(account);
228                Some(QueryResponse::Position {
229                    account,
230                    balances,
231                    count,
232                })
233            }
234            TradingEvent::QueryRequestSeq => {
235                // Self-introspection: read the dedup HWM for the
236                // calling connection's key (transport-supplied via
237                // `ApplyCtx`). The event itself carries no identity,
238                // so a client cannot ask about other keys.
239                Some(QueryResponse::RequestSeqHwm {
240                    hwm: self.0.request_seq_hwm(ctx.key_hash),
241                })
242            }
243        }
244    }
245
246    #[inline]
247    fn tick(&mut self, now_ns: u64, out: &mut Vec<Self::Report>) {
248        self.0.drain_due_scheduled_tasks(now_ns, out);
249    }
250
251    #[inline]
252    fn check_request_seq(&mut self, key_hash: u64, seq: u64) -> bool {
253        Exchange::check_request_seq(&mut self.0, key_hash, seq)
254    }
255
256    /// Route through `Exchange::prefault`, which walks the pre-allocated
257    /// slabs and indices so the first hot-path access after startup
258    /// doesn't soft-fault. Avoids the default snapshot-round-trip
259    /// implementation on a cold allocator.
260    fn prefault(&mut self) {
261        Exchange::prefault(&mut self.0);
262    }
263
264    /// `Exchange` exposes an in-memory `clone_via_snapshot` that skips
265    /// the byte serialisation — faster than the default
266    /// serialise-then-deserialise path. Keep the optimisation for the
267    /// shadow-snapshot stage.
268    fn clone_via_snapshot(&self) -> std::io::Result<Self> {
269        Ok(ServerApp(Exchange::clone_via_snapshot(&self.0)))
270    }
271
272    fn build_reject(event: &Self::Event, reason: TransportRejectReason) -> Self::Report {
273        let engine_reason = match reason {
274            TransportRejectReason::DuplicateRequest => EngineRejectReason::DuplicateRequest,
275            TransportRejectReason::ReplicaDisconnected => EngineRejectReason::ReplicaDisconnected,
276        };
277        ExecutionReport::Rejected {
278            order_id: extract_order_id(event),
279            symbol: extract_symbol(event),
280            account: extract_account_id(event),
281            reason: engine_reason,
282        }
283    }
284
285    /// Writes the engine payload bytes verbatim. The transport stores
286    /// `APP_VERSION` in its frame and rejects mismatching files before
287    /// `restore` is ever called, so duplicating the version in the
288    /// payload would be unreachable. If multi-version migration ever
289    /// lands, drop the transport-side `APP_VERSION` check and reintroduce
290    /// an in-payload version prefix here.
291    fn snapshot<W: Write>(&self, w: &mut W) -> io::Result<()> {
292        let bytes = engine_snapshot::encode_exchange_payload(&self.0);
293        w.write_all(&bytes)
294    }
295
296    fn restore<R: Read>(r: &mut R) -> io::Result<Self> {
297        let mut bytes = Vec::new();
298        r.read_to_end(&mut bytes)?;
299        engine_snapshot::decode_exchange_payload(&bytes)
300            .map(ServerApp)
301            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
302    }
303}
304
305/// Order ID attached to reject reports, or `OrderId(0)` if the variant
306/// does not carry one. Mirrors `journal::pipeline::MatchingStage::extract_order_id`
307/// so the reject-report shape stays consistent across the pipeline.
308fn extract_order_id(event: &TradingEvent) -> OrderId {
309    match event {
310        TradingEvent::SubmitOrder { order, .. } => order.id,
311        TradingEvent::CancelOrder { order_id, .. }
312        | TradingEvent::CancelReplace { order_id, .. } => *order_id,
313        _ => OrderId(0),
314    }
315}
316
317fn extract_account_id(event: &TradingEvent) -> AccountId {
318    match event {
319        TradingEvent::SubmitOrder { order, .. } => order.account,
320        TradingEvent::CancelOrder { account, .. }
321        | TradingEvent::CancelAll { account }
322        | TradingEvent::CancelReplace { account, .. }
323        | TradingEvent::Deposit { account, .. }
324        | TradingEvent::Withdraw { account, .. }
325        | TradingEvent::ProvisionAccount { account, .. }
326        | TradingEvent::QueryPosition { account } => *account,
327        _ => AccountId(0),
328    }
329}
330
331fn extract_symbol(event: &TradingEvent) -> Symbol {
332    match event {
333        TradingEvent::SubmitOrder { symbol, .. }
334        | TradingEvent::CancelOrder { symbol, .. }
335        | TradingEvent::CancelReplace { symbol, .. }
336        | TradingEvent::SetRiskLimits { symbol, .. }
337        | TradingEvent::SetCircuitBreaker { symbol, .. }
338        | TradingEvent::SetFeeSchedule { symbol, .. }
339        | TradingEvent::DisableInstrument { symbol }
340        | TradingEvent::EnableInstrument { symbol }
341        | TradingEvent::RemoveInstrument { symbol } => *symbol,
342        _ => Symbol(0),
343    }
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349
350    use std::io::Cursor;
351    use std::num::NonZeroU64;
352
353    use melin_types::types::{
354        CurrencyId, InstrumentSpec, Order, OrderType, Price, Quantity, SelfTradeProtection, Side,
355        TimeInForce,
356    };
357
358    fn price(p: u64) -> Price {
359        Price(NonZeroU64::new(p).unwrap())
360    }
361    fn qty(q: u64) -> Quantity {
362        Quantity(NonZeroU64::new(q).unwrap())
363    }
364
365    /// A freshly-constructed `ServerApp` with one registered instrument
366    /// and a deposited account. Enough to exercise the full `apply` path.
367    fn seeded_app() -> ServerApp {
368        let mut ex = Exchange::new();
369        ex.add_instrument(InstrumentSpec {
370            symbol: Symbol(1),
371            base: CurrencyId(1),
372            quote: CurrencyId(2),
373        });
374        ex.deposit(AccountId(1), CurrencyId(2), 1_000_000);
375        ServerApp(ex)
376    }
377
378    #[test]
379    fn apply_submit_order_produces_placed_report() {
380        let mut app = seeded_app();
381        let mut reports = Vec::new();
382        let ctx = ApplyCtx {
383            now_ns: 0,
384            journal_sequence: 0,
385            active_connections: 0,
386            events_processed: 0,
387            key_hash: 0,
388        };
389        let ev = TradingEvent::SubmitOrder {
390            symbol: Symbol(1),
391            order: Order {
392                id: OrderId(1),
393                account: AccountId(1),
394                side: Side::Buy,
395                order_type: OrderType::Limit {
396                    price: price(100),
397                    post_only: false,
398                },
399                quantity: qty(10),
400                time_in_force: TimeInForce::GTC,
401                stp: SelfTradeProtection::Allow,
402                expiry_ns: 0,
403            },
404        };
405        <ServerApp as Application>::apply(&mut app, ev, &ctx, &mut reports);
406        assert!(
407            !reports.is_empty(),
408            "apply should emit at least one report for a resting order"
409        );
410    }
411
412    #[test]
413    fn tick_advances_scheduler_clock() {
414        // No scheduled tasks yet — just assert the method is callable via
415        // the trait without panicking. Real scheduler exercise is covered
416        // by exchange.rs unit tests.
417        let mut app = ServerApp(Exchange::new());
418        let mut reports = Vec::new();
419        <ServerApp as Application>::tick(&mut app, 1_000_000_000, &mut reports);
420        assert!(reports.is_empty());
421    }
422
423    #[test]
424    fn apply_query_request_seq_returns_per_key_hwm() {
425        let mut app = seeded_app();
426
427        // Advance two distinct keys to different HWMs via the dedup gate.
428        // Same key+seq combinations the live pipeline would emit.
429        let key_a: u64 = 0xAAAA_AAAA_AAAA_AAAA;
430        let key_b: u64 = 0xBBBB_BBBB_BBBB_BBBB;
431        for seq in 1..=7 {
432            assert!(<ServerApp as Application>::check_request_seq(
433                &mut app, key_a, seq
434            ));
435        }
436        for seq in 1..=3 {
437            assert!(<ServerApp as Application>::check_request_seq(
438                &mut app, key_b, seq
439            ));
440        }
441
442        let mut reports = Vec::new();
443        let mk_ctx = |kh| ApplyCtx {
444            now_ns: 0,
445            journal_sequence: 0,
446            active_connections: 0,
447            events_processed: 0,
448            key_hash: kh,
449        };
450
451        // Each key sees only its own HWM — the engine reads ctx.key_hash,
452        // not anything from the (payloadless) event itself.
453        let resp_a = <ServerApp as Application>::apply(
454            &mut app,
455            TradingEvent::QueryRequestSeq,
456            &mk_ctx(key_a),
457            &mut reports,
458        );
459        assert_eq!(resp_a, Some(QueryResponse::RequestSeqHwm { hwm: 7 }));
460
461        let resp_b = <ServerApp as Application>::apply(
462            &mut app,
463            TradingEvent::QueryRequestSeq,
464            &mk_ctx(key_b),
465            &mut reports,
466        );
467        assert_eq!(resp_b, Some(QueryResponse::RequestSeqHwm { hwm: 3 }));
468
469        // A key with no prior activity reads back as zero.
470        let resp_unknown = <ServerApp as Application>::apply(
471            &mut app,
472            TradingEvent::QueryRequestSeq,
473            &mk_ctx(0xDEAD_BEEF),
474            &mut reports,
475        );
476        assert_eq!(resp_unknown, Some(QueryResponse::RequestSeqHwm { hwm: 0 }));
477
478        // Query is read-only: HWMs are unchanged after the queries above.
479        assert_eq!(app.0.request_seq_hwm(key_a), 7);
480        assert_eq!(app.0.request_seq_hwm(key_b), 3);
481    }
482
483    #[test]
484    fn check_request_seq_rejects_duplicates() {
485        let mut app = ServerApp(Exchange::new());
486        assert!(<ServerApp as Application>::check_request_seq(
487            &mut app, 42, 1
488        ));
489        assert!(<ServerApp as Application>::check_request_seq(
490            &mut app, 42, 2
491        ));
492        assert!(!<ServerApp as Application>::check_request_seq(
493            &mut app, 42, 2
494        ));
495        assert!(!<ServerApp as Application>::check_request_seq(
496            &mut app, 42, 1
497        ));
498    }
499
500    #[test]
501    fn build_reject_maps_transport_reasons() {
502        let ev = TradingEvent::SubmitOrder {
503            symbol: Symbol(7),
504            order: Order {
505                id: OrderId(42),
506                account: AccountId(3),
507                side: Side::Buy,
508                order_type: OrderType::Market,
509                quantity: qty(1),
510                time_in_force: TimeInForce::IOC,
511                stp: SelfTradeProtection::Allow,
512                expiry_ns: 0,
513            },
514        };
515        let r =
516            <ServerApp as Application>::build_reject(&ev, TransportRejectReason::DuplicateRequest);
517        match r {
518            ExecutionReport::Rejected {
519                order_id,
520                symbol,
521                account,
522                reason,
523            } => {
524                assert_eq!(order_id, OrderId(42));
525                assert_eq!(symbol, Symbol(7));
526                assert_eq!(account, AccountId(3));
527                assert_eq!(reason, EngineRejectReason::DuplicateRequest);
528            }
529            other => panic!("expected Rejected, got {other:?}"),
530        }
531
532        let r = <ServerApp as Application>::build_reject(
533            &TradingEvent::CancelAll {
534                account: AccountId(9),
535            },
536            TransportRejectReason::ReplicaDisconnected,
537        );
538        match r {
539            ExecutionReport::Rejected {
540                order_id,
541                symbol,
542                account,
543                reason,
544            } => {
545                assert_eq!(order_id, OrderId(0));
546                assert_eq!(symbol, Symbol(0));
547                assert_eq!(account, AccountId(9));
548                assert_eq!(reason, EngineRejectReason::ReplicaDisconnected);
549            }
550            other => panic!("expected Rejected, got {other:?}"),
551        }
552    }
553
554    #[test]
555    fn apply_withdraw_emits_rejection_on_failure() {
556        let mut app = seeded_app();
557        let ctx = ApplyCtx {
558            now_ns: 0,
559            journal_sequence: 0,
560            active_connections: 0,
561            events_processed: 0,
562            key_hash: 0,
563        };
564
565        // 1. Insufficient balance: account has 1_000_000 in CurrencyId(2),
566        //    so a 2_000_000 withdrawal must reject.
567        let mut reports = Vec::new();
568        <ServerApp as Application>::apply(
569            &mut app,
570            TradingEvent::Withdraw {
571                account: AccountId(1),
572                currency: CurrencyId(2),
573                amount: 2_000_000,
574            },
575            &ctx,
576            &mut reports,
577        );
578        assert_eq!(reports.len(), 1);
579        match reports[0] {
580            ExecutionReport::Rejected {
581                order_id,
582                symbol,
583                account,
584                reason,
585            } => {
586                assert_eq!(order_id, OrderId(0));
587                assert_eq!(symbol, Symbol(0));
588                assert_eq!(account, AccountId(1));
589                assert_eq!(reason, EngineRejectReason::InsufficientBalance);
590            }
591            ref other => panic!("expected Rejected, got {other:?}"),
592        }
593
594        // 2. Unknown account: withdraw from an account that was never
595        //    provisioned/deposited.
596        let mut reports = Vec::new();
597        <ServerApp as Application>::apply(
598            &mut app,
599            TradingEvent::Withdraw {
600                account: AccountId(999),
601                currency: CurrencyId(2),
602                amount: 1,
603            },
604            &ctx,
605            &mut reports,
606        );
607        assert_eq!(reports.len(), 1);
608        match reports[0] {
609            ExecutionReport::Rejected {
610                reason, account, ..
611            } => {
612                assert_eq!(account, AccountId(999));
613                assert_eq!(reason, EngineRejectReason::UnknownAccount);
614            }
615            ref other => panic!("expected Rejected, got {other:?}"),
616        }
617
618        // 3. Has resting orders: place an order, then attempt to withdraw.
619        let mut placed = Vec::new();
620        <ServerApp as Application>::apply(
621            &mut app,
622            TradingEvent::SubmitOrder {
623                symbol: Symbol(1),
624                order: Order {
625                    id: OrderId(1),
626                    account: AccountId(1),
627                    side: Side::Buy,
628                    order_type: OrderType::Limit {
629                        price: price(100),
630                        post_only: false,
631                    },
632                    quantity: qty(10),
633                    time_in_force: TimeInForce::GTC,
634                    stp: SelfTradeProtection::Allow,
635                    expiry_ns: 0,
636                },
637            },
638            &ctx,
639            &mut placed,
640        );
641
642        let mut reports = Vec::new();
643        <ServerApp as Application>::apply(
644            &mut app,
645            TradingEvent::Withdraw {
646                account: AccountId(1),
647                currency: CurrencyId(2),
648                amount: 1,
649            },
650            &ctx,
651            &mut reports,
652        );
653        assert_eq!(reports.len(), 1);
654        match reports[0] {
655            ExecutionReport::Rejected {
656                reason, account, ..
657            } => {
658                assert_eq!(account, AccountId(1));
659                assert_eq!(reason, EngineRejectReason::HasRestingOrders);
660            }
661            ref other => panic!("expected Rejected, got {other:?}"),
662        }
663
664        // 4. Successful withdraw on a clean account emits nothing.
665        let mut reports = Vec::new();
666        let mut clean = ServerApp::new();
667        clean.0.deposit(AccountId(7), CurrencyId(2), 500);
668        <ServerApp as Application>::apply(
669            &mut clean,
670            TradingEvent::Withdraw {
671                account: AccountId(7),
672                currency: CurrencyId(2),
673                amount: 200,
674            },
675            &ctx,
676            &mut reports,
677        );
678        assert!(
679            reports.is_empty(),
680            "successful withdraw must not emit reports"
681        );
682    }
683
684    #[test]
685    fn snapshot_restore_round_trip_preserves_state() {
686        let mut before = seeded_app();
687        let mut reports = Vec::new();
688        // Submit a resting order so there's non-trivial book state to
689        // round-trip through the snapshot.
690        before.0.execute(
691            Symbol(1),
692            Order {
693                id: OrderId(1),
694                account: AccountId(1),
695                side: Side::Buy,
696                order_type: OrderType::Limit {
697                    price: price(100),
698                    post_only: false,
699                },
700                quantity: qty(10),
701                time_in_force: TimeInForce::GTC,
702                stp: SelfTradeProtection::Allow,
703                expiry_ns: 0,
704            },
705            &mut reports,
706        );
707        let reports_before = reports.clone();
708
709        let mut buf = Vec::new();
710        <ServerApp as Application>::snapshot(&before, &mut buf).expect("snapshot");
711
712        let mut cursor = Cursor::new(buf);
713        let mut after = <ServerApp as Application>::restore(&mut cursor).expect("restore");
714
715        // Placing an additional order against both and comparing the
716        // emitted reports is a cheap proxy for structural equality —
717        // the restored book must match price-time priority.
718        let mut reports_after = reports_before.clone();
719        reports_after.clear();
720        after.0.execute(
721            Symbol(1),
722            Order {
723                id: OrderId(2),
724                account: AccountId(1),
725                side: Side::Buy,
726                order_type: OrderType::Limit {
727                    price: price(99),
728                    post_only: false,
729                },
730                quantity: qty(5),
731                time_in_force: TimeInForce::GTC,
732                stp: SelfTradeProtection::Allow,
733                expiry_ns: 0,
734            },
735            &mut reports_after,
736        );
737        assert!(
738            !reports_after.is_empty(),
739            "restored exchange must accept orders"
740        );
741    }
742}