Skip to main content

algo_sdk/
lib.rs

1//! Sequence Algo SDK - Ultra Low Latency Trading
2//!
3//! Write HFT algorithms in Rust, compile to WASM, deploy to Sequence.
4//!
5//! # Example
6//! ```rust,ignore
7//! use algo_sdk::*;
8//!
9//! struct MyAlgo { next_id: u64 }
10//!
11//! impl Algo for MyAlgo {
12//!     fn on_book(&mut self, book: &L2Book, state: &AlgoState, actions: &mut Actions) {
13//!         if book.spread_bps() > 10 && state.position_1e8.abs() < 100_000_000 {
14//!             self.next_id += 1;
15//!             actions.buy(self.next_id, 1_000_000, book.bids[0].px_1e9 + 100);
16//!         }
17//!     }
18//!     fn on_fill(&mut self, _: &Fill, _: &AlgoState) {}
19//!     fn on_reject(&mut self, _: &Reject) {}
20//!     fn on_shutdown(&mut self, _: &AlgoState, _: &mut Actions) {}
21//! }
22//!
23//! export_algo!(MyAlgo { next_id: 0 });
24//! ```
25
26#![cfg_attr(not(feature = "std"), no_std)]
27#![allow(non_snake_case)]
28
29#[cfg(not(feature = "std"))]
30extern crate alloc;
31
32#[cfg(feature = "std")]
33extern crate std as alloc;
34
35// =============================================================================
36// PRICE LEVEL
37// =============================================================================
38
39/// Single price level in the order book.
40#[derive(Debug, Clone, Copy, Default)]
41#[repr(C)]
42pub struct Level {
43    pub px_1e9: u64, // Price × 10⁹
44    pub sz_1e8: u64, // Size × 10⁸
45}
46
47impl Level {
48    pub const EMPTY: Self = Self {
49        px_1e9: 0,
50        sz_1e8: 0,
51    };
52
53    #[inline(always)]
54    pub fn is_valid(&self) -> bool {
55        self.px_1e9 > 0
56    }
57}
58
59// =============================================================================
60// L2 ORDER BOOK - 20 levels each side
61// =============================================================================
62
63/// L2 order book with up to 20 levels per side.
64/// Total size: 688 bytes (fits in L1 cache).
65#[derive(Clone, Copy)]
66#[repr(C)]
67pub struct L2Book {
68    pub bids: [Level; 20], // Best (index 0) to worst
69    pub asks: [Level; 20], // Best (index 0) to worst
70    pub bid_ct: u8,        // Valid bid levels
71    pub ask_ct: u8,        // Valid ask levels
72    pub symbol_id: u16,
73    pub _pad: u32,
74    pub recv_ns: u64, // Receive timestamp
75}
76
77impl Default for L2Book {
78    fn default() -> Self {
79        Self {
80            bids: [Level::EMPTY; 20],
81            asks: [Level::EMPTY; 20],
82            bid_ct: 0,
83            ask_ct: 0,
84            symbol_id: 0,
85            _pad: 0,
86            recv_ns: 0,
87        }
88    }
89}
90
91impl L2Book {
92    #[inline(always)]
93    pub fn best_bid(&self) -> Option<&Level> {
94        if self.bid_ct > 0 && self.bids[0].px_1e9 > 0 {
95            Some(&self.bids[0])
96        } else {
97            None
98        }
99    }
100
101    #[inline(always)]
102    pub fn best_ask(&self) -> Option<&Level> {
103        if self.ask_ct > 0 && self.asks[0].px_1e9 > 0 {
104            Some(&self.asks[0])
105        } else {
106            None
107        }
108    }
109
110    #[inline(always)]
111    pub fn mid_px_1e9(&self) -> u64 {
112        if self.bid_ct == 0 || self.ask_ct == 0 {
113            return 0;
114        }
115        (self.bids[0].px_1e9 + self.asks[0].px_1e9) / 2
116    }
117
118    /// Raw unsigned spread in 1e9 price units.
119    /// Returns 0 for crossed books (ask < bid) due to `saturating_sub`.
120    /// Use `spread_signed_1e9()` or `is_crossed()` for proper handling.
121    #[inline(always)]
122    pub fn spread_1e9(&self) -> u64 {
123        if self.bid_ct == 0 || self.ask_ct == 0 {
124            return u64::MAX;
125        }
126        self.asks[0].px_1e9.saturating_sub(self.bids[0].px_1e9)
127    }
128
129    /// Signed spread in 1e9 price units. Negative = crossed book.
130    #[inline(always)]
131    pub fn spread_signed_1e9(&self) -> i64 {
132        if self.bid_ct == 0 || self.ask_ct == 0 {
133            return i64::MAX;
134        }
135        self.asks[0].px_1e9 as i64 - self.bids[0].px_1e9 as i64
136    }
137
138    /// Spread in integer basis points (truncated).
139    /// WARNING: Returns 0 for sub-bps spreads common on liquid assets (BTC, ETH).
140    /// Use `spread_bps_x1000()` for milli-bps precision on liquid markets.
141    #[inline(always)]
142    pub fn spread_bps(&self) -> u32 {
143        let mid = self.mid_px_1e9();
144        if mid == 0 {
145            return u32::MAX;
146        }
147        ((self.spread_1e9() * 10_000) / mid) as u32
148    }
149
150    /// Spread in milli-basis-points (1 bps = 1000 milli-bps), signed.
151    /// Handles sub-bps precision for liquid markets and negative values for crossed books.
152    /// Uses u128 intermediate to avoid overflow on high-priced assets.
153    #[inline(always)]
154    pub fn spread_bps_x1000(&self) -> i32 {
155        let mid = self.mid_px_1e9();
156        if mid == 0 {
157            return i32::MAX;
158        }
159        let spread = self.asks[0].px_1e9 as i128 - self.bids[0].px_1e9 as i128;
160        ((spread * 10_000_000) / mid as i128) as i32
161    }
162
163    /// Whether the book is crossed (best bid > best ask).
164    /// Common in consolidated NBBO when different venues have different prices.
165    #[inline(always)]
166    pub fn is_crossed(&self) -> bool {
167        self.bid_ct > 0
168            && self.ask_ct > 0
169            && self.bids[0].px_1e9 > self.asks[0].px_1e9
170    }
171
172    #[inline(always)]
173    pub fn bid_depth_1e8(&self, levels: usize) -> u64 {
174        let n = levels.min(self.bid_ct as usize);
175        let mut sum = 0u64;
176        for i in 0..n {
177            sum += self.bids[i].sz_1e8;
178        }
179        sum
180    }
181
182    #[inline(always)]
183    pub fn ask_depth_1e8(&self, levels: usize) -> u64 {
184        let n = levels.min(self.ask_ct as usize);
185        let mut sum = 0u64;
186        for i in 0..n {
187            sum += self.asks[i].sz_1e8;
188        }
189        sum
190    }
191
192    #[inline(always)]
193    pub fn imbalance_bps(&self, levels: usize) -> i32 {
194        let bid_depth = self.bid_depth_1e8(levels);
195        let ask_depth = self.ask_depth_1e8(levels);
196        let total = bid_depth + ask_depth;
197        if total == 0 {
198            return 0;
199        }
200        (((bid_depth as i64 - ask_depth as i64) * 10_000) / total as i64) as i32
201    }
202}
203
204// =============================================================================
205// OPEN ORDER
206// =============================================================================
207
208/// Order status.
209pub mod Status {
210    pub const PENDING: u8 = 0; // Sent, awaiting ack
211    pub const ACKED: u8 = 1; // Acknowledged by exchange
212    pub const PARTIAL: u8 = 2; // Partially filled
213    pub const DEAD: u8 = 3; // Filled/cancelled/rejected
214}
215
216/// Open order tracked by server.
217#[derive(Debug, Clone, Copy, Default)]
218#[repr(C)]
219pub struct OpenOrder {
220    pub order_id: u64,
221    pub px_1e9: u64,
222    pub qty_1e8: i64,    // Signed: positive=buy, negative=sell
223    pub filled_1e8: i64, // Amount filled
224    pub side: i8,        // 1=buy, -1=sell
225    pub status: u8,
226    pub _pad: [u8; 6],
227}
228
229impl OpenOrder {
230    pub const EMPTY: Self = Self {
231        order_id: 0,
232        px_1e9: 0,
233        qty_1e8: 0,
234        filled_1e8: 0,
235        side: 0,
236        status: 0,
237        _pad: [0; 6],
238    };
239
240    #[inline(always)]
241    pub fn is_live(&self) -> bool {
242        self.status == Status::ACKED || self.status == Status::PARTIAL
243    }
244
245    #[inline(always)]
246    pub fn is_pending(&self) -> bool {
247        self.status == Status::PENDING
248    }
249
250    #[inline(always)]
251    pub fn remaining_1e8(&self) -> i64 {
252        self.qty_1e8.abs() - self.filled_1e8.abs()
253    }
254}
255
256// =============================================================================
257// SYMBOL METADATA (server-injected, read-only)
258// =============================================================================
259
260/// Symbol trading specifications from the exchange.
261/// Injected by runtime — prevents rejects from wrong lot size / tick size.
262/// Fields set to 0 mean "unknown" — helpers return input unchanged.
263#[derive(Debug, Clone, Copy)]
264#[repr(C)]
265pub struct SymbolMeta {
266    /// Minimum price increment (1e9 scaled, e.g. 10_000_000 = $0.01). 0 = unknown.
267    pub tick_size_1e9: u64,
268    /// Minimum qty increment (1e8 scaled, e.g. 1_000_000 = 0.01 units). 0 = unknown.
269    pub lot_size_1e8: u64,
270    /// Minimum order quantity (1e8 scaled). 0 = unknown.
271    pub min_qty_1e8: u64,
272    /// Minimum order notional value (1e9 scaled, e.g. 10_000_000_000 = $10). 0 = no minimum.
273    pub min_notional_1e9: u64,
274    /// Price decimal precision (e.g. 2 = $100.00).
275    pub price_precision: u8,
276    /// Quantity decimal precision (e.g. 8 = 0.00000001).
277    pub qty_precision: u8,
278    pub _pad: [u8; 6],
279}
280
281impl Default for SymbolMeta {
282    fn default() -> Self {
283        Self {
284            tick_size_1e9: 0,
285            lot_size_1e8: 0,
286            min_qty_1e8: 0,
287            min_notional_1e9: 0,
288            price_precision: 0,
289            qty_precision: 0,
290            _pad: [0; 6],
291        }
292    }
293}
294
295impl SymbolMeta {
296    pub const EMPTY: Self = Self {
297        tick_size_1e9: 0,
298        lot_size_1e8: 0,
299        min_qty_1e8: 0,
300        min_notional_1e9: 0,
301        price_precision: 0,
302        qty_precision: 0,
303        _pad: [0; 6],
304    };
305
306    /// Round price DOWN to nearest tick. Returns raw if tick_size == 0 (unknown).
307    #[inline(always)]
308    pub fn round_px(&self, px_1e9: u64) -> u64 {
309        if self.tick_size_1e9 == 0 {
310            return px_1e9;
311        }
312        (px_1e9 / self.tick_size_1e9) * self.tick_size_1e9
313    }
314
315    /// Round qty DOWN to nearest lot. Returns raw if lot_size == 0 (unknown).
316    #[inline(always)]
317    pub fn round_qty(&self, qty_1e8: i64) -> i64 {
318        if self.lot_size_1e8 == 0 {
319            return qty_1e8;
320        }
321        let lot = self.lot_size_1e8 as i64;
322        (qty_1e8 / lot) * lot
323    }
324
325    /// Check min notional. Returns true if unknown (0) — skip check, don't false-reject.
326    #[inline(always)]
327    pub fn check_notional(&self, qty_1e8: i64, px_1e9: u64) -> bool {
328        if self.min_notional_1e9 == 0 {
329            return true;
330        }
331        let notional = (qty_1e8.unsigned_abs() as u128 * px_1e9 as u128 / 100_000_000) as u64;
332        notional >= self.min_notional_1e9
333    }
334
335    /// Check min quantity. Returns true if unknown (0).
336    #[inline(always)]
337    pub fn check_min_qty(&self, qty_1e8: i64) -> bool {
338        if self.min_qty_1e8 == 0 {
339            return true;
340        }
341        qty_1e8.unsigned_abs() >= self.min_qty_1e8
342    }
343}
344
345// =============================================================================
346// RISK SNAPSHOT (server-injected, read-only)
347// =============================================================================
348
349/// Current risk limits — read-only view for algos.
350/// Allows pre-checking orders before placing (avoids wasting actions on rejects).
351#[derive(Debug, Clone, Copy)]
352#[repr(C)]
353pub struct RiskSnapshot {
354    /// Max absolute net position (1e8 scaled).
355    pub max_position_1e8: i64,
356    /// Daily loss limit (1e9 scaled). Algo paused if session PnL < -max_daily_loss. 0 = disabled.
357    pub max_daily_loss_1e9: i64,
358    /// Max single order notional (1e9 scaled).
359    pub max_order_notional_1e9: u64,
360    /// Max orders per second.
361    pub max_order_rate: u32,
362    /// 1 = reduce-only mode (can only close position).
363    pub reduce_only: u8,
364    pub _pad: [u8; 3],
365}
366
367impl Default for RiskSnapshot {
368    fn default() -> Self {
369        Self {
370            max_position_1e8: 0,
371            max_daily_loss_1e9: 0,
372            max_order_notional_1e9: 0,
373            max_order_rate: 0,
374            reduce_only: 0,
375            _pad: [0; 3],
376        }
377    }
378}
379
380impl RiskSnapshot {
381    pub const EMPTY: Self = Self {
382        max_position_1e8: 0,
383        max_daily_loss_1e9: 0,
384        max_order_notional_1e9: 0,
385        max_order_rate: 0,
386        reduce_only: 0,
387        _pad: [0; 3],
388    };
389
390    /// Check if adding `delta_1e8` to `current_position_1e8` would exceed position limit.
391    /// Returns true if order is safe. Returns true if limit is 0 (unknown/unlimited).
392    #[inline(always)]
393    pub fn check_position(&self, current_position_1e8: i64, delta_1e8: i64) -> bool {
394        if self.max_position_1e8 == 0 {
395            return true;
396        }
397        let projected = current_position_1e8.saturating_add(delta_1e8);
398        projected.abs() <= self.max_position_1e8
399    }
400}
401
402// =============================================================================
403// ALGO STATE - Position + Orders (server-managed)
404// =============================================================================
405
406/// Maximum open orders per algo.
407pub const MAX_ORDERS: usize = 32;
408
409/// Algo state: position, orders, session stats, symbol metadata, and risk limits.
410/// All managed by server — read-only from algo's perspective.
411///
412/// New fields (v0.2+) are appended after `_pad` for ABI backward compatibility.
413/// Old WASM binaries compiled with v0.1.x read only up to `_pad` and ignore the rest.
414#[derive(Clone, Copy)]
415#[repr(C)]
416pub struct AlgoState {
417    // --- Position (v0.1) ---
418    pub position_1e8: i64,       // Net position (positive=long)
419    pub avg_entry_1e9: u64,      // Average entry price
420    pub realized_pnl_1e9: i64,   // Realized PnL (lifetime)
421    pub unrealized_pnl_1e9: i64, // Unrealized PnL (mark-to-market)
422    // --- Orders (v0.1) ---
423    pub orders: [OpenOrder; MAX_ORDERS],
424    pub order_ct: u8,
425    pub _pad: [u8; 7],
426    // --- Session stats (v0.2) ---
427    pub session_pnl_1e9: i64, // Session realized PnL (resets at UTC midnight)
428    pub total_fill_count: u64, // Lifetime fill count (never resets)
429    // --- Symbol metadata (v0.2) ---
430    pub symbol: SymbolMeta, // Tick size, lot size, min notional
431    // --- Risk limits (v0.2) ---
432    pub risk: RiskSnapshot, // Max position, daily loss limit, etc.
433}
434
435impl Default for AlgoState {
436    fn default() -> Self {
437        Self {
438            position_1e8: 0,
439            avg_entry_1e9: 0,
440            realized_pnl_1e9: 0,
441            unrealized_pnl_1e9: 0,
442            orders: [OpenOrder::EMPTY; MAX_ORDERS],
443            order_ct: 0,
444            _pad: [0; 7],
445            session_pnl_1e9: 0,
446            total_fill_count: 0,
447            symbol: SymbolMeta::EMPTY,
448            risk: RiskSnapshot::EMPTY,
449        }
450    }
451}
452
453/// PnL snapshot in fixed-point units (1e9 = $1.00).
454#[derive(Debug, Clone, Copy, Default)]
455pub struct PnlSnapshot {
456    pub realized_1e9: i64,
457    pub unrealized_1e9: i64,
458    pub total_1e9: i64,
459}
460
461impl AlgoState {
462    #[inline(always)]
463    pub fn is_flat(&self) -> bool {
464        self.position_1e8 == 0
465    }
466
467    #[inline(always)]
468    pub fn is_long(&self) -> bool {
469        self.position_1e8 > 0
470    }
471
472    #[inline(always)]
473    pub fn is_short(&self) -> bool {
474        self.position_1e8 < 0
475    }
476
477    #[inline(always)]
478    pub fn has_orders(&self) -> bool {
479        self.order_ct > 0
480    }
481
482    #[inline(always)]
483    pub fn live_order_count(&self) -> usize {
484        let mut ct = 0;
485        for i in 0..self.order_ct as usize {
486            if self.orders[i].is_live() {
487                ct += 1;
488            }
489        }
490        ct
491    }
492
493    #[inline(always)]
494    pub fn find_order(&self, order_id: u64) -> Option<&OpenOrder> {
495        for i in 0..self.order_ct as usize {
496            if self.orders[i].order_id == order_id {
497                return Some(&self.orders[i]);
498            }
499        }
500        None
501    }
502
503    #[inline(always)]
504    pub fn open_buy_qty_1e8(&self) -> i64 {
505        let mut sum = 0i64;
506        for i in 0..self.order_ct as usize {
507            let o = &self.orders[i];
508            if o.is_live() && o.side > 0 {
509                sum += o.remaining_1e8();
510            }
511        }
512        sum
513    }
514
515    #[inline(always)]
516    pub fn open_sell_qty_1e8(&self) -> i64 {
517        let mut sum = 0i64;
518        for i in 0..self.order_ct as usize {
519            let o = &self.orders[i];
520            if o.is_live() && o.side < 0 {
521                sum += o.remaining_1e8();
522            }
523        }
524        sum
525    }
526
527    #[inline(always)]
528    pub fn total_pnl_1e9(&self) -> i64 {
529        self.realized_pnl_1e9 + self.unrealized_pnl_1e9
530    }
531
532    /// Ergonomic PnL accessor for strategies that want a single call.
533    #[inline(always)]
534    pub fn get_pnl(&self) -> PnlSnapshot {
535        PnlSnapshot {
536            realized_1e9: self.realized_pnl_1e9,
537            unrealized_1e9: self.unrealized_pnl_1e9,
538            total_1e9: self.total_pnl_1e9(),
539        }
540    }
541
542    /// Realized PnL as USD float.
543    #[inline(always)]
544    pub fn realized_pnl_usd(&self) -> f64 {
545        self.realized_pnl_1e9 as f64 / 1e9
546    }
547
548    /// Unrealized PnL as USD float.
549    #[inline(always)]
550    pub fn unrealized_pnl_usd(&self) -> f64 {
551        self.unrealized_pnl_1e9 as f64 / 1e9
552    }
553
554    /// Total PnL as USD float.
555    #[inline(always)]
556    pub fn total_pnl_usd(&self) -> f64 {
557        self.total_pnl_1e9() as f64 / 1e9
558    }
559
560    /// Session realized PnL as USD float. Resets at UTC midnight.
561    #[inline(always)]
562    pub fn session_pnl_usd(&self) -> f64 {
563        self.session_pnl_1e9 as f64 / 1e9
564    }
565}
566
567// =============================================================================
568// FILL EVENT
569// =============================================================================
570
571#[derive(Debug, Clone, Copy)]
572#[repr(C)]
573pub struct Fill {
574    pub order_id: u64,
575    pub px_1e9: u64,
576    pub qty_1e8: i64,
577    pub recv_ns: u64, // Timestamp when fill was received
578    pub side: i8,
579    pub _pad: [u8; 7],
580}
581
582impl Fill {
583    /// Elapsed time since a caller-provided start timestamp.
584    /// Typical use: `fill.since_ms(start_ns)` where `start_ns` came from `book.recv_ns`.
585    #[inline(always)]
586    pub fn since_ns(&self, start_ns: u64) -> u64 {
587        self.recv_ns.saturating_sub(start_ns)
588    }
589
590    #[inline(always)]
591    pub fn since_us(&self, start_ns: u64) -> u64 {
592        self.since_ns(start_ns) / 1_000
593    }
594
595    #[inline(always)]
596    pub fn since_ms(&self, start_ns: u64) -> u64 {
597        self.since_ns(start_ns) / 1_000_000
598    }
599
600    /// Symbol identifier (v0.4+). 0 = single-symbol or unset.
601    /// In multi-symbol backtests, matches L2Book.symbol_id.
602    /// Stored in _pad[0..2] as little-endian u16 for ABI backward compat.
603    #[inline(always)]
604    pub fn symbol_id(&self) -> u16 {
605        u16::from_le_bytes([self._pad[0], self._pad[1]])
606    }
607
608    /// Set symbol identifier. Used by sim-engine orchestrator.
609    #[inline(always)]
610    pub fn set_symbol_id(&mut self, id: u16) {
611        let bytes = id.to_le_bytes();
612        self._pad[0] = bytes[0];
613        self._pad[1] = bytes[1];
614    }
615}
616
617// ABI size assertions — these must match the WASM memory layout expectations
618const _: () = assert!(core::mem::size_of::<Fill>() == 40, "Fill size changed — ABI break");
619const _: () = assert!(core::mem::size_of::<Reject>() == 16, "Reject size changed — ABI break");
620
621// =============================================================================
622// FILL EXTENDED METADATA
623// =============================================================================
624
625/// Extended fill metadata (NOT in WASM memory — delivered via separate channel).
626///
627/// Provides additional context about a fill that doesn't fit in the fixed-size
628/// `Fill` struct in WASM shared memory. Delivered out-of-band by the runtime.
629#[derive(Debug, Clone, Copy, Default)]
630pub struct FillExt {
631    /// 1 = maker, 0 = taker, 255 = unknown.
632    pub is_maker: u8,
633    /// Fee in 1e9 units. Negative = rebate.
634    pub fee_1e9: i64,
635    /// Estimated queue depth ahead at time of fill (1e8 units), or -1 if unknown.
636    pub queue_ahead_1e8: i64,
637}
638
639// =============================================================================
640// TIME HELPERS
641// =============================================================================
642
643/// Simple timing helpers for client-controlled latency measurement.
644/// SDK/runtime does not force any logging; strategy decides how/when to log.
645pub mod time {
646    /// Start a timer from an event timestamp (usually `book.recv_ns` or `fill.recv_ns`).
647    #[inline(always)]
648    pub fn start(now_ns: u64) -> u64 {
649        now_ns
650    }
651
652    /// Stop a timer and return elapsed nanoseconds.
653    #[inline(always)]
654    pub fn stop_ns(start_ns: u64, now_ns: u64) -> u64 {
655        now_ns.saturating_sub(start_ns)
656    }
657
658    #[inline(always)]
659    pub fn stop_us(start_ns: u64, now_ns: u64) -> u64 {
660        stop_ns(start_ns, now_ns) / 1_000
661    }
662
663    #[inline(always)]
664    pub fn stop_ms(start_ns: u64, now_ns: u64) -> u64 {
665        stop_ns(start_ns, now_ns) / 1_000_000
666    }
667
668    /// Stateful timer for strategies that prefer start/stop on a struct.
669    #[derive(Debug, Clone, Copy, Default)]
670    pub struct Timer {
671        start_ns: u64,
672    }
673
674    impl Timer {
675        #[inline(always)]
676        pub const fn new() -> Self {
677            Self { start_ns: 0 }
678        }
679
680        #[inline(always)]
681        pub fn start(&mut self, now_ns: u64) {
682            self.start_ns = now_ns;
683        }
684
685        #[inline(always)]
686        pub fn stop_ns(&self, now_ns: u64) -> u64 {
687            now_ns.saturating_sub(self.start_ns)
688        }
689
690        #[inline(always)]
691        pub fn stop_us(&self, now_ns: u64) -> u64 {
692            self.stop_ns(now_ns) / 1_000
693        }
694
695        #[inline(always)]
696        pub fn stop_ms(&self, now_ns: u64) -> u64 {
697            self.stop_ns(now_ns) / 1_000_000
698        }
699    }
700}
701
702// =============================================================================
703// REJECT EVENT
704// =============================================================================
705
706/// Reject codes from exchange (matches cc_proto::RejectClass).
707pub mod RejectCode {
708    /// Unknown error
709    pub const UNKNOWN: u8 = 0;
710    /// Insufficient balance/funds
711    pub const INSUFFICIENT_BALANCE: u8 = 1;
712    /// Invalid parameters (price, qty, symbol)
713    pub const INVALID_PARAMS: u8 = 2;
714    /// Exchange rate limit hit
715    pub const RATE_LIMIT: u8 = 3;
716    /// Exchange temporarily unavailable
717    pub const EXCHANGE_BUSY: u8 = 4;
718    /// Network error
719    pub const NETWORK: u8 = 5;
720    /// Authentication error (invalid API key/secret)
721    pub const AUTH: u8 = 6;
722
723    // Internal reject codes (from risk engine, >=100)
724    /// Risk check failed
725    pub const RISK: u8 = 100;
726    /// Position limit exceeded
727    pub const POSITION_LIMIT: u8 = 101;
728    /// Kill switch triggered
729    pub const KILL_SWITCH: u8 = 102;
730    /// Fat-finger: price deviates too far from reference (best bid/ask)
731    pub const PRICE_DEVIATION: u8 = 103;
732    /// Daily P&L loss limit breached — algo paused
733    pub const DAILY_LOSS_LIMIT: u8 = 104;
734    /// Venue ID not in registered venue set (multi-venue)
735    pub const BAD_VENUE: u8 = 105;
736    /// Target venue data is stale (multi-venue)
737    pub const STALE_VENUE: u8 = 106;
738
739    /// Get human-readable description for a reject code.
740    pub fn to_str(code: u8) -> &'static str {
741        match code {
742            UNKNOWN => "UNKNOWN",
743            INSUFFICIENT_BALANCE => "INSUFFICIENT_BALANCE",
744            INVALID_PARAMS => "INVALID_PARAMS",
745            RATE_LIMIT => "RATE_LIMIT",
746            EXCHANGE_BUSY => "EXCHANGE_BUSY",
747            NETWORK => "NETWORK",
748            AUTH => "AUTH",
749            RISK => "RISK_CHECK_FAILED",
750            POSITION_LIMIT => "POSITION_LIMIT",
751            KILL_SWITCH => "KILL_SWITCH",
752            PRICE_DEVIATION => "PRICE_DEVIATION",
753            DAILY_LOSS_LIMIT => "DAILY_LOSS_LIMIT",
754            BAD_VENUE => "BAD_VENUE",
755            STALE_VENUE => "STALE_VENUE",
756            _ => "UNKNOWN",
757        }
758    }
759}
760
761#[derive(Debug, Clone, Copy)]
762#[repr(C)]
763pub struct Reject {
764    pub order_id: u64,
765    pub code: u8,
766    pub _pad: [u8; 7],
767}
768
769impl Reject {
770    /// Get human-readable description of the reject reason.
771    #[inline]
772    pub fn reason(&self) -> &'static str {
773        RejectCode::to_str(self.code)
774    }
775
776    /// Symbol identifier (v0.4+). 0 = single-symbol or unset.
777    #[inline(always)]
778    pub fn symbol_id(&self) -> u16 {
779        u16::from_le_bytes([self._pad[0], self._pad[1]])
780    }
781
782    #[inline(always)]
783    pub fn set_symbol_id(&mut self, id: u16) {
784        let bytes = id.to_le_bytes();
785        self._pad[0] = bytes[0];
786        self._pad[1] = bytes[1];
787    }
788}
789
790// =============================================================================
791// ORDER TYPES
792// =============================================================================
793
794/// Order type for execution semantics.
795pub mod OrderType {
796    /// Limit order - sits on book until filled or canceled (default).
797    pub const LIMIT: u8 = 0;
798    /// Market order - fills immediately at best available price.
799    pub const MARKET: u8 = 1;
800    /// Immediate-Or-Cancel - fill what you can, cancel the rest.
801    pub const IOC: u8 = 2;
802    /// Fill-Or-Kill - fill entire qty or reject completely.
803    pub const FOK: u8 = 3;
804    /// Post-only - sits on book, rejected if it would cross (maker only).
805    /// Maps to venue-native post-only (Kraken `post_only:true`, Binance `LIMIT_MAKER`).
806    /// Venues without native post-only reject with `RejectCode::INVALID_PARAMS`.
807    pub const POST_ONLY: u8 = 4;
808}
809
810// =============================================================================
811// ACTIONS BUFFER
812// =============================================================================
813
814// ── Action type constants ────────────────────────────────────────────────
815/// New order action (place on book).
816pub const ACTION_NEW: u8 = 0;
817/// Cancel an existing order.
818pub const ACTION_CANCEL: u8 = 1;
819/// Amend an existing order (atomic modify price/qty).
820pub const ACTION_AMEND: u8 = 2;
821
822/// Order action (place, cancel, or amend).
823///
824/// 32 bytes, `#[repr(C)]`. Byte 27 is `venue_id`:
825/// - `0` = default venue (backward compatible — old algos never set this byte)
826/// - `1..N` = specific VenueId (from `NbboSnapshot.venue_ids[]`)
827///
828/// `is_cancel` field doubles as action type:
829/// - `0` = new order (ACTION_NEW)
830/// - `1` = cancel (ACTION_CANCEL)
831/// - `2` = amend (ACTION_AMEND) — modifies price/qty of existing order
832#[derive(Debug, Clone, Copy, Default)]
833#[repr(C)]
834pub struct Action {
835    pub order_id: u64,
836    pub px_1e9: u64,
837    pub qty_1e8: i64,
838    pub side: i8, // 1=buy, -1=sell, 0=cancel
839    pub is_cancel: u8,
840    pub order_type: u8, // OrderType::LIMIT, MARKET, IOC, FOK
841    pub venue_id: u8,   // 0=default, 1..N=specific venue (was _pad[0])
842    pub _pad: [u8; 4],
843}
844
845/// Max actions per callback.
846pub const MAX_ACTIONS: usize = 16;
847
848/// Actions buffer - orders to send.
849#[repr(C)]
850pub struct Actions {
851    actions: [Action; MAX_ACTIONS],
852    len: usize,
853}
854
855impl Actions {
856    #[inline(always)]
857    pub const fn new() -> Self {
858        Self {
859            actions: [Action {
860                order_id: 0,
861                px_1e9: 0,
862                qty_1e8: 0,
863                side: 0,
864                is_cancel: 0,
865                order_type: 0,
866                venue_id: 0,
867                _pad: [0; 4],
868            }; MAX_ACTIONS],
869            len: 0,
870        }
871    }
872
873    #[inline(always)]
874    pub fn clear(&mut self) {
875        self.len = 0;
876    }
877
878    #[inline(always)]
879    pub fn len(&self) -> usize {
880        self.len
881    }
882
883    #[inline(always)]
884    pub fn is_empty(&self) -> bool {
885        self.len == 0
886    }
887
888    #[inline(always)]
889    pub fn is_full(&self) -> bool {
890        self.len >= MAX_ACTIONS
891    }
892
893    // =========================================================================
894    // LIMIT ORDERS (default) - sit on book until filled or canceled
895    // =========================================================================
896
897    /// Place a limit buy order (GTC - Good Till Canceled).
898    #[inline(always)]
899    pub fn buy(&mut self, order_id: u64, qty_1e8: i64, px_1e9: u64) -> bool {
900        self.order_typed(order_id, 1, qty_1e8, px_1e9, OrderType::LIMIT)
901    }
902
903    /// Place a limit sell order (GTC - Good Till Canceled).
904    #[inline(always)]
905    pub fn sell(&mut self, order_id: u64, qty_1e8: i64, px_1e9: u64) -> bool {
906        self.order_typed(order_id, -1, qty_1e8, px_1e9, OrderType::LIMIT)
907    }
908
909    /// Place limit order with explicit side (1=buy, -1=sell).
910    #[inline(always)]
911    pub fn order(&mut self, order_id: u64, side: i8, qty_1e8: i64, px_1e9: u64) -> bool {
912        self.order_typed(order_id, side, qty_1e8, px_1e9, OrderType::LIMIT)
913    }
914
915    // =========================================================================
916    // MARKET ORDERS - fill immediately at best available price
917    // =========================================================================
918
919    /// Place a market buy order (fills immediately).
920    #[inline(always)]
921    pub fn market_buy(&mut self, order_id: u64, qty_1e8: i64) -> bool {
922        self.order_typed(order_id, 1, qty_1e8, 0, OrderType::MARKET)
923    }
924
925    /// Place a market sell order (fills immediately).
926    #[inline(always)]
927    pub fn market_sell(&mut self, order_id: u64, qty_1e8: i64) -> bool {
928        self.order_typed(order_id, -1, qty_1e8, 0, OrderType::MARKET)
929    }
930
931    // =========================================================================
932    // IOC ORDERS - Immediate-Or-Cancel (fill what you can, cancel rest)
933    // =========================================================================
934
935    /// Place IOC buy - fills available liquidity, cancels unfilled portion.
936    #[inline(always)]
937    pub fn ioc_buy(&mut self, order_id: u64, qty_1e8: i64, px_1e9: u64) -> bool {
938        self.order_typed(order_id, 1, qty_1e8, px_1e9, OrderType::IOC)
939    }
940
941    /// Place IOC sell - fills available liquidity, cancels unfilled portion.
942    #[inline(always)]
943    pub fn ioc_sell(&mut self, order_id: u64, qty_1e8: i64, px_1e9: u64) -> bool {
944        self.order_typed(order_id, -1, qty_1e8, px_1e9, OrderType::IOC)
945    }
946
947    // =========================================================================
948    // FOK ORDERS - Fill-Or-Kill (fill entire qty or reject)
949    // =========================================================================
950
951    /// Place FOK buy - must fill entire quantity or rejected.
952    #[inline(always)]
953    pub fn fok_buy(&mut self, order_id: u64, qty_1e8: i64, px_1e9: u64) -> bool {
954        self.order_typed(order_id, 1, qty_1e8, px_1e9, OrderType::FOK)
955    }
956
957    /// Place FOK sell - must fill entire quantity or rejected.
958    #[inline(always)]
959    pub fn fok_sell(&mut self, order_id: u64, qty_1e8: i64, px_1e9: u64) -> bool {
960        self.order_typed(order_id, -1, qty_1e8, px_1e9, OrderType::FOK)
961    }
962
963    // =========================================================================
964    // POST-ONLY ORDERS - maker only, rejected if would cross book
965    // =========================================================================
966
967    /// Place a post-only buy order (maker only, rejected if would cross).
968    #[inline(always)]
969    pub fn post_only_buy(&mut self, order_id: u64, qty_1e8: i64, px_1e9: u64) -> bool {
970        self.order_typed(order_id, 1, qty_1e8, px_1e9, OrderType::POST_ONLY)
971    }
972
973    /// Place a post-only sell order (maker only, rejected if would cross).
974    #[inline(always)]
975    pub fn post_only_sell(&mut self, order_id: u64, qty_1e8: i64, px_1e9: u64) -> bool {
976        self.order_typed(order_id, -1, qty_1e8, px_1e9, OrderType::POST_ONLY)
977    }
978
979    // =========================================================================
980    // CORE ORDER PLACEMENT
981    // =========================================================================
982
983    /// Place order with explicit type.
984    #[inline(always)]
985    pub fn order_typed(
986        &mut self,
987        order_id: u64,
988        side: i8,
989        qty_1e8: i64,
990        px_1e9: u64,
991        order_type: u8,
992    ) -> bool {
993        if self.len >= MAX_ACTIONS {
994            return false;
995        }
996        self.actions[self.len] = Action {
997            order_id,
998            px_1e9,
999            qty_1e8,
1000            side,
1001            is_cancel: 0,
1002            order_type,
1003            venue_id: 0,
1004            _pad: [0; 4],
1005        };
1006        self.len += 1;
1007        true
1008    }
1009
1010    /// Cancel an order.
1011    #[inline(always)]
1012    pub fn cancel(&mut self, order_id: u64) -> bool {
1013        if self.len >= MAX_ACTIONS {
1014            return false;
1015        }
1016        self.actions[self.len] = Action {
1017            order_id,
1018            px_1e9: 0,
1019            qty_1e8: 0,
1020            side: 0,
1021            is_cancel: 1,
1022            order_type: 0,
1023            venue_id: 0,
1024            _pad: [0; 4],
1025        };
1026        self.len += 1;
1027        true
1028    }
1029
1030    /// Cancel all open orders.
1031    #[inline(always)]
1032    pub fn cancel_all(&mut self, state: &AlgoState) {
1033        for i in 0..state.order_ct as usize {
1034            let o = &state.orders[i];
1035            if o.is_live() && self.len < MAX_ACTIONS {
1036                self.cancel(o.order_id);
1037            }
1038        }
1039    }
1040
1041    // =========================================================================
1042    // AMEND ORDERS - atomic modify price/qty of existing order
1043    // =========================================================================
1044
1045    /// Amend an existing order's price and/or quantity.
1046    ///
1047    /// Uses `is_cancel = ACTION_AMEND (2)`. The venue performs an atomic
1048    /// edit — the order keeps its ID and (when only qty decreases) its
1049    /// queue position.
1050    ///
1051    /// Returns `false` if the actions buffer is full.
1052    #[inline(always)]
1053    pub fn amend(&mut self, order_id: u64, new_qty_1e8: i64, new_px_1e9: u64) -> bool {
1054        if self.len >= MAX_ACTIONS {
1055            return false;
1056        }
1057        self.actions[self.len] = Action {
1058            order_id,
1059            px_1e9: new_px_1e9,
1060            qty_1e8: new_qty_1e8,
1061            side: 0,                // not relevant for amend
1062            is_cancel: ACTION_AMEND,
1063            order_type: 0,
1064            venue_id: 0,
1065            _pad: [0; 4],
1066        };
1067        self.len += 1;
1068        true
1069    }
1070
1071    /// Amend an existing order on a specific venue.
1072    #[inline(always)]
1073    pub fn amend_on(&mut self, venue_id: u8, order_id: u64, new_qty_1e8: i64, new_px_1e9: u64) -> bool {
1074        if self.len >= MAX_ACTIONS {
1075            return false;
1076        }
1077        self.actions[self.len] = Action {
1078            order_id,
1079            px_1e9: new_px_1e9,
1080            qty_1e8: new_qty_1e8,
1081            side: 0,
1082            is_cancel: ACTION_AMEND,
1083            order_type: 0,
1084            venue_id,
1085            _pad: [0; 4],
1086        };
1087        self.len += 1;
1088        true
1089    }
1090
1091    // =========================================================================
1092    // VENUE-TARGETED ORDERS (multi-venue algos)
1093    // =========================================================================
1094
1095    /// Place a limit buy on a specific venue.
1096    /// `venue_id` is a VenueId value from `NbboSnapshot.venue_ids[]`.
1097    #[inline(always)]
1098    pub fn buy_on(&mut self, venue_id: u8, order_id: u64, qty_1e8: i64, px_1e9: u64) -> bool {
1099        self.order_on_venue(venue_id, order_id, 1, qty_1e8, px_1e9, OrderType::LIMIT)
1100    }
1101
1102    /// Place a limit sell on a specific venue.
1103    #[inline(always)]
1104    pub fn sell_on(&mut self, venue_id: u8, order_id: u64, qty_1e8: i64, px_1e9: u64) -> bool {
1105        self.order_on_venue(venue_id, order_id, -1, qty_1e8, px_1e9, OrderType::LIMIT)
1106    }
1107
1108    /// Place an IOC buy on a specific venue.
1109    #[inline(always)]
1110    pub fn ioc_buy_on(&mut self, venue_id: u8, order_id: u64, qty_1e8: i64, px_1e9: u64) -> bool {
1111        self.order_on_venue(venue_id, order_id, 1, qty_1e8, px_1e9, OrderType::IOC)
1112    }
1113
1114    /// Place an IOC sell on a specific venue.
1115    #[inline(always)]
1116    pub fn ioc_sell_on(&mut self, venue_id: u8, order_id: u64, qty_1e8: i64, px_1e9: u64) -> bool {
1117        self.order_on_venue(venue_id, order_id, -1, qty_1e8, px_1e9, OrderType::IOC)
1118    }
1119
1120    /// Place a venue-targeted order with explicit side and order type.
1121    /// Used by the runtime to replay actions read from WASM memory.
1122    #[inline(always)]
1123    pub fn order_on_venue(
1124        &mut self,
1125        venue_id: u8,
1126        order_id: u64,
1127        side: i8,
1128        qty_1e8: i64,
1129        px_1e9: u64,
1130        order_type: u8,
1131    ) -> bool {
1132        if self.len >= MAX_ACTIONS {
1133            return false;
1134        }
1135        self.actions[self.len] = Action {
1136            order_id,
1137            px_1e9,
1138            qty_1e8,
1139            side,
1140            is_cancel: 0,
1141            order_type,
1142            venue_id,
1143            _pad: [0; 4],
1144        };
1145        self.len += 1;
1146        true
1147    }
1148
1149    /// Zero out an action at index (marks as no-op).
1150    /// Used by risk engine to neutralize rejected actions in-place.
1151    #[inline(always)]
1152    pub fn clear_at(&mut self, idx: usize) {
1153        if idx < self.len {
1154            self.actions[idx] = Action {
1155                order_id: 0,
1156                px_1e9: 0,
1157                qty_1e8: 0,
1158                side: 0,
1159                is_cancel: 0,
1160                order_type: 0,
1161                venue_id: 0,
1162                _pad: [0; 4],
1163            };
1164        }
1165    }
1166
1167    #[inline(always)]
1168    pub fn get(&self, idx: usize) -> Option<&Action> {
1169        if idx < self.len {
1170            Some(&self.actions[idx])
1171        } else {
1172            None
1173        }
1174    }
1175
1176    #[inline(always)]
1177    pub fn iter(&self) -> impl Iterator<Item = &Action> {
1178        self.actions[..self.len].iter()
1179    }
1180}
1181
1182impl Default for Actions {
1183    fn default() -> Self {
1184        Self::new()
1185    }
1186}
1187
1188// =============================================================================
1189// ALGO TRAIT
1190// =============================================================================
1191
1192/// Trading algorithm trait.
1193/// All methods run on HOT PATH - avoid heap allocations.
1194pub trait Algo: Send {
1195    /// Called on every book update.
1196    /// - book: L2 order book (20 levels)
1197    /// - state: Your position + open orders (server-managed)
1198    /// - actions: Buffer to place/cancel orders
1199    fn on_book(&mut self, book: &L2Book, state: &AlgoState, actions: &mut Actions);
1200
1201    /// Order filled.
1202    fn on_fill(&mut self, fill: &Fill, state: &AlgoState);
1203
1204    /// Order rejected.
1205    fn on_reject(&mut self, reject: &Reject);
1206
1207    /// Shutdown - cancel open orders here.
1208    fn on_shutdown(&mut self, state: &AlgoState, actions: &mut Actions);
1209}
1210
1211// =============================================================================
1212// ALGO V3 TRAIT (single-venue + OnlineFeatures)
1213// =============================================================================
1214
1215/// Extended algo trait with OnlineFeatures access.
1216///
1217/// Detected at load time via WASM export probe for `algo_on_book_v3`.
1218/// Falls back to `Algo` trait if not present.
1219///
1220/// All methods run on HOT PATH — avoid heap allocations.
1221pub trait AlgoV3: Send {
1222    /// Called on every book update with OnlineFeatures from the data engine.
1223    fn on_book(
1224        &mut self,
1225        book: &L2Book,
1226        state: &AlgoState,
1227        features: &OnlineFeatures,
1228        actions: &mut Actions,
1229    );
1230
1231    /// Order filled.
1232    fn on_fill(&mut self, fill: &Fill, state: &AlgoState);
1233
1234    /// Order rejected.
1235    fn on_reject(&mut self, reject: &Reject);
1236
1237    /// Shutdown — cancel open orders here.
1238    fn on_shutdown(&mut self, state: &AlgoState, actions: &mut Actions);
1239
1240    /// Heartbeat callback (1 Hz, optional). Default: no-op.
1241    fn on_heartbeat(
1242        &mut self,
1243        _state: &AlgoState,
1244        _features: &OnlineFeatures,
1245        _actions: &mut Actions,
1246    ) {
1247    }
1248}
1249
1250// =============================================================================
1251// MULTI-VENUE ALGO (cross-venue arbitrage, cross-venue market making)
1252// =============================================================================
1253
1254/// Maximum number of venues in a multi-venue snapshot.
1255pub const MAX_VENUES: usize = 20; // 9 CEX + 7 DEX + 4 headroom
1256
1257// =============================================================================
1258// VENUE ID CONSTANTS (wire format = CC VenueId + 1, 0 = default/local)
1259// =============================================================================
1260
1261/// Kraken (CEX)
1262pub const VENUE_KRAKEN: u8 = 1;
1263/// Coinbase (CEX)
1264pub const VENUE_COINBASE: u8 = 2;
1265/// Binance (CEX)
1266pub const VENUE_BINANCE: u8 = 3;
1267/// Bitget (CEX)
1268pub const VENUE_BITGET: u8 = 4;
1269/// Crypto.com (CEX)
1270pub const VENUE_CRYPTOCOM: u8 = 5;
1271/// Bitmart (CEX)
1272pub const VENUE_BITMART: u8 = 6;
1273/// Generic DEX (aggregated across chains)
1274pub const VENUE_DEX: u8 = 7;
1275/// OKX (CEX)
1276pub const VENUE_OKX: u8 = 8;
1277/// Bybit (CEX)
1278pub const VENUE_BYBIT: u8 = 9;
1279/// Unknown venue (wire ID 10, maps to CC VenueId::Unknown)
1280pub const VENUE_UNKNOWN: u8 = 10;
1281/// DEX — Ethereum mainnet (Uniswap V2/V3, Curve, Balancer V2)
1282pub const VENUE_DEX_ETH: u8 = 11;
1283/// DEX — Arbitrum (Uniswap V3, Camelot)
1284pub const VENUE_DEX_ARB: u8 = 12;
1285/// DEX — Base (Uniswap V3, Aerodrome)
1286pub const VENUE_DEX_BASE: u8 = 13;
1287/// DEX — Optimism (Uniswap V3, Velodrome)
1288pub const VENUE_DEX_OP: u8 = 14;
1289/// DEX — Polygon (Uniswap V2, Balancer V2, Curve)
1290pub const VENUE_DEX_POLY: u8 = 15;
1291/// DEX — Solana (Raydium, Orca, Jupiter aggregated)
1292pub const VENUE_DEX_SOL: u8 = 16;
1293/// Hyperliquid (CEX — L1 with on-chain settlement, EIP-712 signing)
1294pub const VENUE_HYPERLIQUID: u8 = 17;
1295
1296/// Returns `true` if this venue is a DEX (on-chain liquidity).
1297#[inline(always)]
1298pub fn is_dex(venue_id: u8) -> bool {
1299    venue_id == VENUE_DEX
1300        || (venue_id >= VENUE_DEX_ETH && venue_id <= VENUE_DEX_POLY)
1301        || venue_id == VENUE_DEX_SOL
1302}
1303
1304/// Returns `true` if this venue is a CEX (centralized exchange).
1305#[inline(always)]
1306pub fn is_cex(venue_id: u8) -> bool {
1307    (venue_id >= VENUE_KRAKEN && venue_id <= VENUE_BYBIT && venue_id != VENUE_DEX)
1308        || venue_id == VENUE_HYPERLIQUID
1309}
1310
1311/// Human-readable name for a venue ID.
1312pub fn venue_name(venue_id: u8) -> &'static str {
1313    match venue_id {
1314        0 => "default",
1315        VENUE_KRAKEN => "kraken",
1316        VENUE_COINBASE => "coinbase",
1317        VENUE_BINANCE => "binance",
1318        VENUE_BITGET => "bitget",
1319        VENUE_CRYPTOCOM => "cryptocom",
1320        VENUE_BITMART => "bitmart",
1321        VENUE_DEX => "dex",
1322        VENUE_OKX => "okx",
1323        VENUE_BYBIT => "bybit",
1324        VENUE_UNKNOWN => "unknown",
1325        VENUE_DEX_ETH => "dex-eth",
1326        VENUE_DEX_ARB => "dex-arb",
1327        VENUE_DEX_BASE => "dex-base",
1328        VENUE_DEX_OP => "dex-op",
1329        VENUE_DEX_POLY => "dex-poly",
1330        VENUE_DEX_SOL => "dex-sol",
1331        VENUE_HYPERLIQUID => "hyperliquid",
1332        _ => "unknown",
1333    }
1334}
1335
1336/// Cross-venue NBBO snapshot delivered to multi-venue algos.
1337///
1338/// Contains the global NBBO plus per-venue BBO data in struct-of-arrays
1339/// layout for cache efficiency. ~480 bytes, fits in a single WASM page.
1340///
1341/// ## Venue-index convention (frozen — all paths use this):
1342/// - `venue_ids[slot]` = VenueId for array slot `slot` (populated by runtime, sorted ascending)
1343/// - `venue_bid_px[slot]`, `venue_ask_px[slot]`, etc. all use the same `slot` index
1344/// - `nbbo_bid_venue`, `nbbo_ask_venue` = array slot index (0..venue_ct-1), NOT VenueId
1345///   - Use `venue_ids[nbbo_bid_venue]` to get the VenueId of the best bidder
1346/// - `Action.venue_id` = VenueId VALUE (not array slot). Runtime maps via `venue_ids[]`
1347#[derive(Clone, Copy, Debug)]
1348#[repr(C)]
1349pub struct NbboSnapshot {
1350    // --- Global NBBO ---
1351    pub nbbo_bid_px_1e9: u64,
1352    pub nbbo_ask_px_1e9: u64,
1353    pub nbbo_bid_sz_1e8: u64,
1354    pub nbbo_ask_sz_1e8: u64,
1355    // --- Venue identification ---
1356    pub nbbo_bid_venue: u8, // array slot index of best bid venue
1357    pub nbbo_ask_venue: u8, // array slot index of best ask venue
1358    pub venue_ct: u8,       // number of active venues
1359    pub _pad0: u8,
1360    pub sequence: u32, // monotonic counter — algo can detect stale snapshots
1361    // --- Venue ID mapping: slot → VenueId (sorted ascending) ---
1362    pub venue_ids: [u8; MAX_VENUES],
1363    // --- Per-venue BBO (struct-of-arrays for cache efficiency) ---
1364    pub venue_bid_px: [u64; MAX_VENUES],
1365    pub venue_ask_px: [u64; MAX_VENUES],
1366    pub venue_bid_sz: [u32; MAX_VENUES],
1367    pub venue_ask_sz: [u32; MAX_VENUES],
1368    // --- Per-venue staleness ---
1369    pub venue_update_ms: [u16; MAX_VENUES], // ms since last update (0=fresh, 65535=stale)
1370    // --- Timestamp ---
1371    pub recv_ns: u64,
1372}
1373
1374impl Default for NbboSnapshot {
1375    fn default() -> Self {
1376        Self {
1377            nbbo_bid_px_1e9: 0,
1378            nbbo_ask_px_1e9: 0,
1379            nbbo_bid_sz_1e8: 0,
1380            nbbo_ask_sz_1e8: 0,
1381            nbbo_bid_venue: 0,
1382            nbbo_ask_venue: 0,
1383            venue_ct: 0,
1384            _pad0: 0,
1385            sequence: 0,
1386            venue_ids: [0; MAX_VENUES],
1387            venue_bid_px: [0; MAX_VENUES],
1388            venue_ask_px: [0; MAX_VENUES],
1389            venue_bid_sz: [0; MAX_VENUES],
1390            venue_ask_sz: [0; MAX_VENUES],
1391            venue_update_ms: [0; MAX_VENUES],
1392            recv_ns: 0,
1393        }
1394    }
1395}
1396
1397impl NbboSnapshot {
1398    /// NBBO spread in integer basis points. Returns `u32::MAX` if no valid NBBO.
1399    /// WARNING: Returns 0 for sub-bps spreads. Use `nbbo_spread_bps_x1000()` for precision.
1400    #[inline(always)]
1401    pub fn nbbo_spread_bps(&self) -> u32 {
1402        if self.nbbo_bid_px_1e9 == 0 || self.nbbo_ask_px_1e9 == 0 {
1403            return u32::MAX;
1404        }
1405        let mid = (self.nbbo_bid_px_1e9 + self.nbbo_ask_px_1e9) / 2;
1406        if mid == 0 {
1407            return u32::MAX;
1408        }
1409        ((self.nbbo_ask_px_1e9.saturating_sub(self.nbbo_bid_px_1e9)) * 10_000 / mid) as u32
1410    }
1411
1412    /// NBBO spread in milli-basis-points (1 bps = 1000), signed.
1413    /// Negative = crossed market. Uses u128 to avoid overflow.
1414    #[inline(always)]
1415    pub fn nbbo_spread_bps_x1000(&self) -> i32 {
1416        if self.nbbo_bid_px_1e9 == 0 || self.nbbo_ask_px_1e9 == 0 {
1417            return i32::MAX;
1418        }
1419        let mid = (self.nbbo_bid_px_1e9 + self.nbbo_ask_px_1e9) / 2;
1420        if mid == 0 {
1421            return i32::MAX;
1422        }
1423        let spread = self.nbbo_ask_px_1e9 as i128 - self.nbbo_bid_px_1e9 as i128;
1424        ((spread * 10_000_000) / mid as i128) as i32
1425    }
1426
1427    /// Whether any venue's bid > another venue's ask (crossed market = arb opportunity).
1428    #[inline(always)]
1429    pub fn is_crossed(&self) -> bool {
1430        self.nbbo_bid_px_1e9 > 0
1431            && self.nbbo_ask_px_1e9 > 0
1432            && self.nbbo_bid_px_1e9 > self.nbbo_ask_px_1e9
1433            && self.nbbo_bid_venue != self.nbbo_ask_venue
1434    }
1435
1436    /// Get bid price for a venue slot.
1437    #[inline(always)]
1438    pub fn venue_bid(&self, slot: usize) -> u64 {
1439        if slot < self.venue_ct as usize {
1440            self.venue_bid_px[slot]
1441        } else {
1442            0
1443        }
1444    }
1445
1446    /// Get ask price for a venue slot.
1447    #[inline(always)]
1448    pub fn venue_ask(&self, slot: usize) -> u64 {
1449        if slot < self.venue_ct as usize {
1450            self.venue_ask_px[slot]
1451        } else {
1452            0
1453        }
1454    }
1455
1456    /// Check if a venue's data is stale.
1457    #[inline(always)]
1458    pub fn is_venue_stale(&self, slot: usize, max_ms: u16) -> bool {
1459        if slot < self.venue_ct as usize {
1460            self.venue_update_ms[slot] > max_ms
1461        } else {
1462            true
1463        }
1464    }
1465
1466    /// Find the array slot for a given VenueId. Linear scan of `venue_ids[0..venue_ct]`.
1467    #[inline]
1468    pub fn slot_for_venue(&self, venue_id: u8) -> Option<usize> {
1469        let ct = self.venue_ct as usize;
1470        for i in 0..ct {
1471            if self.venue_ids[i] == venue_id {
1472                return Some(i);
1473            }
1474        }
1475        None
1476    }
1477
1478    /// VenueId of the best bid venue.
1479    #[inline(always)]
1480    pub fn best_bid_venue_id(&self) -> u8 {
1481        self.venue_ids[self.nbbo_bid_venue as usize]
1482    }
1483
1484    /// VenueId of the best ask venue.
1485    #[inline(always)]
1486    pub fn best_ask_venue_id(&self) -> u8 {
1487        self.venue_ids[self.nbbo_ask_venue as usize]
1488    }
1489}
1490
1491// =============================================================================
1492// VENUE BOOKS (per-venue full-depth order books for multi-venue algos)
1493// =============================================================================
1494
1495/// WASM memory offset where VenueBooks is written by the runtime.
1496/// Multi-venue algos read per-venue depth from this fixed address.
1497pub const VENUE_BOOKS_WASM_OFFSET: u32 = 0x10000; // page 2 start
1498
1499/// Per-venue full-depth order books delivered to multi-venue algos.
1500///
1501/// Contains a merged cross-venue L2Book plus individual per-venue L2Books.
1502/// Venue ordering matches `NbboSnapshot.venue_ids[]` — same slot index,
1503/// same VenueId mapping.
1504///
1505/// ## DEX depth
1506///
1507/// DEX books are **synthetic**: constructed by probing AMM pools (Uniswap V2/V3,
1508/// Curve, Balancer, etc.) at discrete USD sizes ($10 → $500K). Prices are
1509/// gas-adjusted. Below $10K: single best-pool routing. Above $10K: greedy split
1510/// across multiple pools. Accuracy is validated every 10th emission and
1511/// suppressed if error exceeds 25 bps vs live requote.
1512///
1513/// ## Size
1514///
1515/// ~14 KB (at MAX_VENUES=20). Written to WASM memory at offset `VENUE_BOOKS_WASM_OFFSET` (0x10000,
1516/// page 2). Well within the 16 MB default WASM memory allocation.
1517#[derive(Clone)]
1518#[repr(C)]
1519pub struct VenueBooks {
1520    /// Merged cross-venue depth book (all venues aggregated, same-price sizes summed).
1521    /// Same data previously available as the `book` parameter in v0.2 `on_nbbo`.
1522    pub merged: L2Book,
1523    /// Number of valid per-venue books (0..MAX_VENUES).
1524    pub book_ct: u8,
1525    pub _pad: [u8; 7],
1526    /// Wire VenueIds for each book slot (same ordering as `NbboSnapshot.venue_ids`).
1527    /// Use `VENUE_KRAKEN`, `VENUE_DEX_ARB`, etc. to identify venues.
1528    pub venue_ids: [u8; MAX_VENUES],
1529    /// Per-venue L2 order books. `books[i]` corresponds to `venue_ids[i]`.
1530    /// Each book has up to 20 levels per side. `bid_ct`/`ask_ct` indicate valid levels.
1531    /// A venue with no depth data will have `bid_ct == 0 && ask_ct == 0`.
1532    pub books: [L2Book; MAX_VENUES],
1533}
1534
1535impl Default for VenueBooks {
1536    fn default() -> Self {
1537        Self {
1538            merged: L2Book::default(),
1539            book_ct: 0,
1540            _pad: [0; 7],
1541            venue_ids: [0; MAX_VENUES],
1542            books: [L2Book::default(); MAX_VENUES],
1543        }
1544    }
1545}
1546
1547impl VenueBooks {
1548    /// Look up the L2Book for a specific venue by VenueId.
1549    /// Returns `None` if the venue is not present.
1550    #[inline]
1551    pub fn book_for_venue(&self, venue_id: u8) -> Option<&L2Book> {
1552        let ct = self.book_ct as usize;
1553        for i in 0..ct {
1554            if self.venue_ids[i] == venue_id {
1555                return Some(&self.books[i]);
1556            }
1557        }
1558        None
1559    }
1560
1561    /// Direct access to the L2Book at a given slot index.
1562    /// Returns default (empty) book if slot is out of range.
1563    #[inline(always)]
1564    pub fn book_at_slot(&self, slot: usize) -> &L2Book {
1565        if slot < self.book_ct as usize {
1566            &self.books[slot]
1567        } else {
1568            // Return the first book as a default (all zeros if no books)
1569            &self.books[0]
1570        }
1571    }
1572
1573    /// Whether a venue has depth data (at least one bid or ask level).
1574    #[inline]
1575    pub fn has_depth_for(&self, venue_id: u8) -> bool {
1576        self.book_for_venue(venue_id)
1577            .map_or(false, |b| b.bid_ct > 0 || b.ask_ct > 0)
1578    }
1579
1580    /// Get the VenueId at a given slot.
1581    #[inline(always)]
1582    pub fn venue_id_at(&self, slot: usize) -> u8 {
1583        if slot < self.book_ct as usize {
1584            self.venue_ids[slot]
1585        } else {
1586            0
1587        }
1588    }
1589
1590    /// Number of CEX venues with book data.
1591    #[inline]
1592    pub fn cex_count(&self) -> usize {
1593        let ct = self.book_ct as usize;
1594        (0..ct).filter(|&i| is_cex(self.venue_ids[i])).count()
1595    }
1596
1597    /// Number of DEX venues with book data.
1598    #[inline]
1599    pub fn dex_count(&self) -> usize {
1600        let ct = self.book_ct as usize;
1601        (0..ct).filter(|&i| is_dex(self.venue_ids[i])).count()
1602    }
1603}
1604
1605// =============================================================================
1606// Per-Pool Books (A6 — separate from NBBO hot path)
1607// =============================================================================
1608
1609/// Maximum number of individual pool slots tracked per symbol.
1610pub const MAX_POOLS: usize = 32;
1611
1612/// WASM memory offset where PoolBooks is written by the runtime.
1613/// Located after VenueBooks to avoid overlap.
1614pub const POOL_BOOKS_WASM_OFFSET: u32 = 0x14000;
1615
1616/// Per-pool metadata: compact identity for a single DEX pool.
1617///
1618/// Fixed-size (40 bytes) to avoid heap allocations in WASM memory.
1619/// Layout: address(32) + pair_index(2) + fee_bps(2) + venue_id(1) + protocol_id(1) + pad(2) = 40.
1620#[derive(Clone, Copy)]
1621#[repr(C)]
1622pub struct PoolMeta {
1623    /// Pool contract address: 32 bytes (EVM uses first 20, Solana uses all 32).
1624    pub address: [u8; 32],
1625    /// Pair index for multi-asset pools (0 for standard 2-token pools).
1626    pub pair_index: u16,
1627    /// Pool fee in basis points.
1628    pub fee_bps: u16,
1629    /// Venue ID (VENUE_DEX_ARB, VENUE_DEX_SOL, etc.)
1630    pub venue_id: u8,
1631    /// Protocol: 0=unknown, 1=uniswap_v2, 2=uniswap_v3, 3=curve,
1632    /// 4=balancer_v2, 5=aerodrome, 6=velodrome, 7=camelot,
1633    /// 8=raydium_clmm, 9=orca_whirlpool
1634    pub protocol_id: u8,
1635    /// Padding to align to 8 bytes (40 is already 8-aligned, but keep for future).
1636    pub _pad: [u8; 2],
1637}
1638
1639impl Default for PoolMeta {
1640    fn default() -> Self {
1641        Self {
1642            address: [0; 32],
1643            pair_index: 0,
1644            fee_bps: 0,
1645            venue_id: 0,
1646            protocol_id: 0,
1647            _pad: [0; 2],
1648        }
1649    }
1650}
1651
1652/// Per-pool depth books delivered to multi-venue algos.
1653///
1654/// Written to WASM memory at `POOL_BOOKS_WASM_OFFSET` (0x14000) on a
1655/// separate cadence from NBBO — typically per-block, not per-tick.
1656/// Algos read this on demand during `on_nbbo()` via fixed WASM offset.
1657///
1658/// ~23 KB total. Conditional memcpy: skipped when `pool_ct == 0`.
1659#[derive(Clone)]
1660#[repr(C)]
1661pub struct PoolBooks {
1662    /// Number of valid pool slots (0..MAX_POOLS).
1663    pub pool_ct: u8,
1664    pub _pad: [u8; 7],
1665    /// Metadata for each pool slot.
1666    pub metas: [PoolMeta; MAX_POOLS],
1667    /// L2 books for each pool. `books[i]` corresponds to `metas[i]`.
1668    pub books: [L2Book; MAX_POOLS],
1669}
1670
1671impl Default for PoolBooks {
1672    fn default() -> Self {
1673        Self {
1674            pool_ct: 0,
1675            _pad: [0; 7],
1676            metas: [PoolMeta::default(); MAX_POOLS],
1677            books: [L2Book::default(); MAX_POOLS],
1678        }
1679    }
1680}
1681
1682impl PoolBooks {
1683    /// Look up the L2Book for a specific pool by address and pair_index.
1684    #[inline]
1685    pub fn book_for_pool(&self, addr: &[u8; 32], pair_index: u16) -> Option<&L2Book> {
1686        let ct = self.pool_ct as usize;
1687        for i in 0..ct {
1688            if self.metas[i].address == *addr && self.metas[i].pair_index == pair_index {
1689                return Some(&self.books[i]);
1690            }
1691        }
1692        None
1693    }
1694
1695    /// Direct access to the L2Book at a given slot index.
1696    #[inline(always)]
1697    pub fn book_at_slot(&self, slot: usize) -> &L2Book {
1698        if slot < self.pool_ct as usize {
1699            &self.books[slot]
1700        } else {
1701            &self.books[0]
1702        }
1703    }
1704
1705    /// Direct access to the PoolMeta at a given slot index.
1706    #[inline(always)]
1707    pub fn meta_at_slot(&self, slot: usize) -> &PoolMeta {
1708        if slot < self.pool_ct as usize {
1709            &self.metas[slot]
1710        } else {
1711            &self.metas[0]
1712        }
1713    }
1714}
1715
1716// =============================================================================
1717// Online Features (Phase 2B — microstructure features delivered to algos)
1718// =============================================================================
1719
1720/// WASM offset for OnlineFeatures — page-aligned after PoolBooks.
1721/// PoolBooks at 0x14000, size ~23KB -> ends well before 0x1A000.
1722pub const ONLINE_FEATURES_WASM_OFFSET: u32 = 0x1A000;
1723
1724/// Online microstructure features delivered by the CC data engine.
1725/// Written to WASM memory before each algo callback.
1726/// Old algos that never read 0x1A000 are unaffected (backward compatible).
1727#[derive(Clone, Copy, Debug)]
1728#[repr(C)]
1729pub struct OnlineFeatures {
1730    /// ABI version (currently 1).
1731    pub version: u16,
1732    /// Bit flags: bit 0 = vpin_valid.
1733    pub flags: u16,
1734    pub _pad0: [u8; 4],
1735
1736    // ── Microprice ───────────────────────────────────────────────────────
1737    /// Microprice scaled 1e9.
1738    pub microprice_1e9: u64,
1739
1740    // ── OFI / MLOFI (scaled 1e8) ────────────────────────────────────────
1741    /// Order flow imbalance, top-of-book, scaled 1e8.
1742    pub ofi_1level_1e8: i64,
1743    /// Order flow imbalance, 5 levels, scaled 1e8.
1744    pub ofi_5level_1e8: i64,
1745    /// Multi-level OFI (10 levels), scaled 1e8.
1746    pub mlofi_10_1e8: i64,
1747    /// OFI EWMA, scaled 1e6.
1748    pub ofi_ewma_1e6: i64,
1749
1750    // ── Trade flow ───────────────────────────────────────────────────────
1751    /// Trade sign imbalance [-1e6, +1e6].
1752    pub trade_sign_imbalance_1e6: i64,
1753    /// Trades/sec x 1000.
1754    pub trade_arrival_rate_1e3: u32,
1755    /// VPIN [0, 10000] — only valid when flags bit 0 is set.
1756    pub vpin_1e4: u16,
1757    pub _pad1: u16,
1758
1759    // ── Spread state ─────────────────────────────────────────────────────
1760    /// 0=tight, 1=normal, 2=wide, 3=crisis.
1761    pub spread_regime: u8,
1762    pub _pad2a: u8,
1763    /// Spread z-score x 1000.
1764    pub spread_zscore_1e3: i16,
1765
1766    // ── Depth analytics ──────────────────────────────────────────────────
1767    /// Cancel rate [0, 10000].
1768    pub cancel_rate_1e4: u16,
1769    /// Depth imbalance [-10000, +10000].
1770    pub depth_imbalance_1e4: i16,
1771
1772    // ── Realized vol ─────────────────────────────────────────────────────
1773    /// 1-minute realized vol in basis points.
1774    pub rv_1m_bps: u32,
1775    /// 5-minute realized vol in basis points.
1776    pub rv_5m_bps: u32,
1777    /// 1-hour realized vol in basis points.
1778    pub rv_1h_bps: u32,
1779    pub _pad3: u32,
1780
1781    // ── Multi-head prediction (populated by Phase 6 sidecar) ─────────────
1782    /// Probability of up direction [0, 10000].
1783    pub pred_dir_up_1e4: u16,
1784    /// Probability of flat direction [0, 10000].
1785    pub pred_dir_flat_1e4: u16,
1786    /// Probability of down direction [0, 10000].
1787    pub pred_dir_down_1e4: u16,
1788    /// Probability of normal stress [0, 10000].
1789    pub pred_stress_normal_1e4: u16,
1790    /// Probability of widening stress [0, 10000].
1791    pub pred_stress_widening_1e4: u16,
1792    /// Probability of crisis stress [0, 10000].
1793    pub pred_stress_crisis_1e4: u16,
1794    /// Probability of toxic flow [0, 10000].
1795    pub pred_toxic_1e4: u16,
1796    /// Age of prediction in ms.
1797    pub prediction_age_ms: u16,
1798
1799    // ── Fill probability (populated by Phase 6 sidecar) ──────────────────
1800    /// Fill probability on bid side [0, 10000].
1801    pub fill_prob_bid_1e4: u16,
1802    /// Fill probability on ask side [0, 10000].
1803    pub fill_prob_ask_1e4: u16,
1804    /// Queue decay rate [0, 10000].
1805    pub queue_decay_rate_1e4: u16,
1806    pub _fill_pad: u16,
1807
1808    // ── Timestamp ────────────────────────────────────────────────────────
1809    /// Feature computation timestamp in nanoseconds.
1810    pub feature_ts_ns: u64,
1811
1812    /// Reserved for future expansion.
1813    pub _reserved: [u8; 136],
1814}
1815
1816impl Default for OnlineFeatures {
1817    fn default() -> Self {
1818        // Safety: all-zero is valid for every field, then set version=1
1819        let mut f = unsafe { core::mem::zeroed::<Self>() };
1820        f.version = 1;
1821        f
1822    }
1823}
1824
1825impl OnlineFeatures {
1826    /// Whether the VPIN field is valid (flags bit 0).
1827    #[inline(always)]
1828    pub fn vpin_valid(&self) -> bool {
1829        self.flags & 1 != 0
1830    }
1831
1832    /// Microprice as f64 (divide by 1e9).
1833    #[inline(always)]
1834    pub fn microprice_f64(&self) -> f64 {
1835        self.microprice_1e9 as f64 / 1_000_000_000.0
1836    }
1837
1838    /// OFI 1-level as f64 (divide by 1e8).
1839    #[inline(always)]
1840    pub fn ofi_1level_f64(&self) -> f64 {
1841        self.ofi_1level_1e8 as f64 / 100_000_000.0
1842    }
1843
1844    /// Trade sign imbalance as f64 in [-1.0, +1.0].
1845    #[inline(always)]
1846    pub fn trade_sign_imbalance_f64(&self) -> f64 {
1847        self.trade_sign_imbalance_1e6 as f64 / 1_000_000.0
1848    }
1849}
1850
1851// Compile-time ABI checks for OnlineFeatures
1852const _: () = assert!(
1853    core::mem::size_of::<OnlineFeatures>() == 256,
1854    "OnlineFeatures must be exactly 256 bytes"
1855);
1856const _: () = assert!(
1857    0x1A000 >= 0x14000 + core::mem::size_of::<PoolBooks>(),
1858    "ONLINE_FEATURES_WASM_OFFSET overlaps with PoolBooks"
1859);
1860const _: () = assert!(
1861    ONLINE_FEATURES_WASM_OFFSET as usize + core::mem::size_of::<OnlineFeatures>() < 0x1000000,
1862    "OnlineFeatures exceeds WASM 16MB memory limit"
1863);
1864
1865/// Multi-venue trading algorithm trait.
1866///
1867/// Parallel to `Algo` (single-venue). The runtime detects which trait a WASM
1868/// module implements by probing for the `algo_on_nbbo` export. If present,
1869/// the multi-venue path is used; otherwise, the single-venue `Algo` path.
1870///
1871/// All methods run on HOT PATH — avoid heap allocations.
1872pub trait MultiVenueAlgo: Send {
1873    /// Called on every cross-venue NBBO update.
1874    ///
1875    /// `nbbo`: Cross-venue NBBO with per-venue BBO and staleness tracking.
1876    /// `books`: Per-venue full-depth L2 books + merged cross-venue depth.
1877    ///   - `books.merged` — aggregated depth across all venues (same-price sizes summed)
1878    ///   - `books.books[slot]` — individual venue depth, indexed by slot
1879    ///   - Use `books.book_for_venue(VENUE_KRAKEN)` to look up by VenueId
1880    /// `state`: Your position + open orders (server-managed).
1881    /// `actions`: Buffer to place/cancel orders. Use `Action.venue_id` to target specific venues.
1882    fn on_nbbo(
1883        &mut self,
1884        nbbo: &NbboSnapshot,
1885        books: &VenueBooks,
1886        state: &AlgoState,
1887        actions: &mut Actions,
1888    );
1889
1890    /// Order filled.
1891    fn on_fill(&mut self, fill: &Fill, state: &AlgoState);
1892
1893    /// Order rejected.
1894    fn on_reject(&mut self, reject: &Reject);
1895
1896    /// Shutdown — cancel open orders here.
1897    fn on_shutdown(&mut self, state: &AlgoState, actions: &mut Actions);
1898}
1899
1900// =============================================================================
1901// WASM EXPORTS
1902// =============================================================================
1903
1904/// Wire format for actions buffer.
1905#[repr(C)]
1906pub struct WasmActions {
1907    pub count: u32,
1908    pub _pad: u32,
1909    pub actions: [Action; MAX_ACTIONS],
1910}
1911
1912impl WasmActions {
1913    pub const fn new() -> Self {
1914        Self {
1915            count: 0,
1916            _pad: 0,
1917            actions: [Action {
1918                order_id: 0,
1919                px_1e9: 0,
1920                qty_1e8: 0,
1921                side: 0,
1922                is_cancel: 0,
1923                order_type: 0,
1924                venue_id: 0,
1925                _pad: [0; 4],
1926            }; MAX_ACTIONS],
1927        }
1928    }
1929
1930    pub fn from_actions(&mut self, actions: &Actions) {
1931        self.count = actions.len() as u32;
1932        for i in 0..actions.len() {
1933            self.actions[i] = actions.actions[i];
1934        }
1935    }
1936}
1937
1938/// Generate WASM exports for your algo (only for WASM builds).
1939#[cfg(target_arch = "wasm32")]
1940#[macro_export]
1941macro_rules! export_algo {
1942    ($init:expr) => {
1943        extern crate alloc;
1944        static mut ALGO: Option<alloc::boxed::Box<dyn $crate::Algo>> = None;
1945        static mut ACTIONS: $crate::Actions = $crate::Actions::new();
1946        static mut WASM_OUT: $crate::WasmActions = $crate::WasmActions::new();
1947
1948        #[inline(always)]
1949        fn init() {
1950            unsafe {
1951                if ALGO.is_none() {
1952                    ALGO = Some(alloc::boxed::Box::new($init));
1953                }
1954            }
1955        }
1956
1957        #[no_mangle]
1958        pub extern "C" fn algo_on_book(book_ptr: u32, state_ptr: u32) -> u32 {
1959            init();
1960            unsafe {
1961                let book = &*(book_ptr as *const $crate::L2Book);
1962                let state = &*(state_ptr as *const $crate::AlgoState);
1963                ACTIONS.clear();
1964                if let Some(algo) = ALGO.as_mut() {
1965                    algo.on_book(book, state, &mut ACTIONS);
1966                }
1967                WASM_OUT.from_actions(&ACTIONS);
1968                &WASM_OUT as *const _ as u32
1969            }
1970        }
1971
1972        #[no_mangle]
1973        pub extern "C" fn algo_on_fill(fill_ptr: u32, state_ptr: u32) {
1974            init();
1975            unsafe {
1976                let fill = &*(fill_ptr as *const $crate::Fill);
1977                let state = &*(state_ptr as *const $crate::AlgoState);
1978                if let Some(algo) = ALGO.as_mut() {
1979                    algo.on_fill(fill, state);
1980                }
1981            }
1982        }
1983
1984        #[no_mangle]
1985        pub extern "C" fn algo_on_reject(reject_ptr: u32) {
1986            init();
1987            unsafe {
1988                let reject = &*(reject_ptr as *const $crate::Reject);
1989                if let Some(algo) = ALGO.as_mut() {
1990                    algo.on_reject(reject);
1991                }
1992            }
1993        }
1994
1995        #[no_mangle]
1996        pub extern "C" fn algo_on_shutdown(state_ptr: u32) -> u32 {
1997            init();
1998            unsafe {
1999                let state = &*(state_ptr as *const $crate::AlgoState);
2000                ACTIONS.clear();
2001                if let Some(algo) = ALGO.as_mut() {
2002                    algo.on_shutdown(state, &mut ACTIONS);
2003                }
2004                WASM_OUT.from_actions(&ACTIONS);
2005                &WASM_OUT as *const _ as u32
2006            }
2007        }
2008
2009        #[no_mangle]
2010        pub extern "C" fn algo_alloc(size: u32) -> u32 {
2011            let layout = core::alloc::Layout::from_size_align(size as usize, 8).unwrap();
2012            unsafe { alloc::alloc::alloc(layout) as u32 }
2013        }
2014    };
2015}
2016
2017/// Stub macro for native builds (no-op).
2018#[cfg(not(target_arch = "wasm32"))]
2019#[macro_export]
2020macro_rules! export_algo {
2021    ($init:expr) => {};
2022}
2023
2024/// Generate WASM exports for an AlgoV3 (single-venue with OnlineFeatures).
2025///
2026/// Exports:
2027/// - `algo_on_book_v3(book_ptr, state_ptr) -> action_ptr` — main entry, reads OnlineFeatures from 0x1A000
2028/// - `algo_on_book` (no-op stub so runtime detects V3 via `algo_on_book_v3` probe)
2029/// - `algo_on_heartbeat(state_ptr) -> action_ptr` — 1Hz heartbeat
2030/// - `algo_on_fill`, `algo_on_reject`, `algo_on_shutdown`, `algo_alloc`
2031#[cfg(target_arch = "wasm32")]
2032#[macro_export]
2033macro_rules! export_algo_v3 {
2034    ($init:expr) => {
2035        extern crate alloc;
2036        static mut ALGO: Option<alloc::boxed::Box<dyn $crate::AlgoV3>> = None;
2037        static mut ACTIONS: $crate::Actions = $crate::Actions::new();
2038        static mut WASM_OUT: $crate::WasmActions = $crate::WasmActions::new();
2039
2040        #[inline(always)]
2041        fn init() {
2042            unsafe {
2043                if ALGO.is_none() {
2044                    ALGO = Some(alloc::boxed::Box::new($init));
2045                }
2046            }
2047        }
2048
2049        /// V3 entry point: book + OnlineFeatures from fixed WASM offset.
2050        #[no_mangle]
2051        pub extern "C" fn algo_on_book_v3(book_ptr: u32, state_ptr: u32) -> u32 {
2052            init();
2053            unsafe {
2054                let book = &*(book_ptr as *const $crate::L2Book);
2055                let state = &*(state_ptr as *const $crate::AlgoState);
2056                let features =
2057                    &*($crate::ONLINE_FEATURES_WASM_OFFSET as *const $crate::OnlineFeatures);
2058                ACTIONS.clear();
2059                if let Some(algo) = ALGO.as_mut() {
2060                    algo.on_book(book, state, features, &mut ACTIONS);
2061                }
2062                WASM_OUT.from_actions(&ACTIONS);
2063                &WASM_OUT as *const _ as u32
2064            }
2065        }
2066
2067        /// Stub: runtime probes for algo_on_book_v3 to detect V3 support.
2068        #[no_mangle]
2069        pub extern "C" fn algo_on_book(_book_ptr: u32, _state_ptr: u32) -> u32 {
2070            0 // no-op — V3 algos use algo_on_book_v3
2071        }
2072
2073        /// 1Hz heartbeat with OnlineFeatures.
2074        #[no_mangle]
2075        pub extern "C" fn algo_on_heartbeat(state_ptr: u32) -> u32 {
2076            init();
2077            unsafe {
2078                let state = &*(state_ptr as *const $crate::AlgoState);
2079                let features =
2080                    &*($crate::ONLINE_FEATURES_WASM_OFFSET as *const $crate::OnlineFeatures);
2081                ACTIONS.clear();
2082                if let Some(algo) = ALGO.as_mut() {
2083                    algo.on_heartbeat(state, features, &mut ACTIONS);
2084                }
2085                WASM_OUT.from_actions(&ACTIONS);
2086                &WASM_OUT as *const _ as u32
2087            }
2088        }
2089
2090        #[no_mangle]
2091        pub extern "C" fn algo_on_fill(fill_ptr: u32, state_ptr: u32) {
2092            init();
2093            unsafe {
2094                let fill = &*(fill_ptr as *const $crate::Fill);
2095                let state = &*(state_ptr as *const $crate::AlgoState);
2096                if let Some(algo) = ALGO.as_mut() {
2097                    algo.on_fill(fill, state);
2098                }
2099            }
2100        }
2101
2102        #[no_mangle]
2103        pub extern "C" fn algo_on_reject(reject_ptr: u32) {
2104            init();
2105            unsafe {
2106                let reject = &*(reject_ptr as *const $crate::Reject);
2107                if let Some(algo) = ALGO.as_mut() {
2108                    algo.on_reject(reject);
2109                }
2110            }
2111        }
2112
2113        #[no_mangle]
2114        pub extern "C" fn algo_on_shutdown(state_ptr: u32) -> u32 {
2115            init();
2116            unsafe {
2117                let state = &*(state_ptr as *const $crate::AlgoState);
2118                ACTIONS.clear();
2119                if let Some(algo) = ALGO.as_mut() {
2120                    algo.on_shutdown(state, &mut ACTIONS);
2121                }
2122                WASM_OUT.from_actions(&ACTIONS);
2123                &WASM_OUT as *const _ as u32
2124            }
2125        }
2126
2127        #[no_mangle]
2128        pub extern "C" fn algo_alloc(size: u32) -> u32 {
2129            let layout = core::alloc::Layout::from_size_align(size as usize, 8).unwrap();
2130            unsafe { alloc::alloc::alloc(layout) as u32 }
2131        }
2132    };
2133}
2134
2135/// Stub macro for native builds (no-op).
2136#[cfg(not(target_arch = "wasm32"))]
2137#[macro_export]
2138macro_rules! export_algo_v3 {
2139    ($init:expr) => {};
2140}
2141
2142/// Generate WASM exports for a multi-venue algo (only for WASM builds).
2143///
2144/// Exports:
2145/// - `algo_on_nbbo(nbbo_ptr, book_ptr, state_ptr) -> action_ptr`
2146/// - `algo_on_book` (no-op stub so runtime can probe for multi-venue support)
2147/// - `algo_on_fill`, `algo_on_reject`, `algo_on_shutdown` (same as `export_algo!`)
2148/// - `algo_alloc`
2149///
2150/// The runtime writes `VenueBooks` at `VENUE_BOOKS_WASM_OFFSET` (0x10000) and
2151/// a merged `L2Book` at `BOOK_OFFSET` (0x8000). The macro reads `VenueBooks`
2152/// from the fixed offset and passes it to `on_nbbo`. The `book_ptr` parameter
2153/// is kept for ABI compatibility with old runtimes.
2154#[cfg(target_arch = "wasm32")]
2155#[macro_export]
2156macro_rules! export_multi_venue_algo {
2157    ($init:expr) => {
2158        extern crate alloc;
2159        static mut ALGO: Option<alloc::boxed::Box<dyn $crate::MultiVenueAlgo>> = None;
2160        static mut ACTIONS: $crate::Actions = $crate::Actions::new();
2161        static mut WASM_OUT: $crate::WasmActions = $crate::WasmActions::new();
2162
2163        #[inline(always)]
2164        fn init() {
2165            unsafe {
2166                if ALGO.is_none() {
2167                    ALGO = Some(alloc::boxed::Box::new($init));
2168                }
2169            }
2170        }
2171
2172        /// Multi-venue entry point: called on every cross-venue NBBO update.
2173        /// Runtime writes VenueBooks at 0x10000 before calling this.
2174        #[no_mangle]
2175        pub extern "C" fn algo_on_nbbo(nbbo_ptr: u32, _book_ptr: u32, state_ptr: u32) -> u32 {
2176            init();
2177            unsafe {
2178                let nbbo = &*(nbbo_ptr as *const $crate::NbboSnapshot);
2179                let books = &*($crate::VENUE_BOOKS_WASM_OFFSET as *const $crate::VenueBooks);
2180                let state = &*(state_ptr as *const $crate::AlgoState);
2181                ACTIONS.clear();
2182                if let Some(algo) = ALGO.as_mut() {
2183                    algo.on_nbbo(nbbo, books, state, &mut ACTIONS);
2184                }
2185                WASM_OUT.from_actions(&ACTIONS);
2186                &WASM_OUT as *const _ as u32
2187            }
2188        }
2189
2190        /// Stub: runtime probes for this to detect algo type.
2191        /// Multi-venue algos export algo_on_nbbo as the real entry point.
2192        #[no_mangle]
2193        pub extern "C" fn algo_on_book(_book_ptr: u32, _state_ptr: u32) -> u32 {
2194            0 // no-op — multi-venue algos use algo_on_nbbo
2195        }
2196
2197        #[no_mangle]
2198        pub extern "C" fn algo_on_fill(fill_ptr: u32, state_ptr: u32) {
2199            init();
2200            unsafe {
2201                let fill = &*(fill_ptr as *const $crate::Fill);
2202                let state = &*(state_ptr as *const $crate::AlgoState);
2203                if let Some(algo) = ALGO.as_mut() {
2204                    algo.on_fill(fill, state);
2205                }
2206            }
2207        }
2208
2209        #[no_mangle]
2210        pub extern "C" fn algo_on_reject(reject_ptr: u32) {
2211            init();
2212            unsafe {
2213                let reject = &*(reject_ptr as *const $crate::Reject);
2214                if let Some(algo) = ALGO.as_mut() {
2215                    algo.on_reject(reject);
2216                }
2217            }
2218        }
2219
2220        #[no_mangle]
2221        pub extern "C" fn algo_on_shutdown(state_ptr: u32) -> u32 {
2222            init();
2223            unsafe {
2224                let state = &*(state_ptr as *const $crate::AlgoState);
2225                ACTIONS.clear();
2226                if let Some(algo) = ALGO.as_mut() {
2227                    algo.on_shutdown(state, &mut ACTIONS);
2228                }
2229                WASM_OUT.from_actions(&ACTIONS);
2230                &WASM_OUT as *const _ as u32
2231            }
2232        }
2233
2234        #[no_mangle]
2235        pub extern "C" fn algo_alloc(size: u32) -> u32 {
2236            let layout = core::alloc::Layout::from_size_align(size as usize, 8).unwrap();
2237            unsafe { alloc::alloc::alloc(layout) as u32 }
2238        }
2239    };
2240}
2241
2242/// Stub macro for native builds (no-op).
2243#[cfg(not(target_arch = "wasm32"))]
2244#[macro_export]
2245macro_rules! export_multi_venue_algo {
2246    ($init:expr) => {};
2247}
2248
2249// =============================================================================
2250// LOGGING - HFT-safe async logging
2251// =============================================================================
2252
2253/// Log levels for algo logging.
2254#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2255#[repr(u8)]
2256pub enum LogLevel {
2257    Trace = 0,
2258    Debug = 1,
2259    Info = 2,
2260    Warn = 3,
2261    Error = 4,
2262}
2263
2264// Host import - only for WASM builds
2265#[cfg(target_arch = "wasm32")]
2266extern "C" {
2267    fn host_log_impl(level: u8, ptr: *const u8, len: u32);
2268}
2269
2270#[cfg(target_arch = "wasm32")]
2271#[inline(always)]
2272fn host_log(level: u8, ptr: *const u8, len: u32) {
2273    unsafe {
2274        host_log_impl(level, ptr, len);
2275    }
2276}
2277
2278// Stub for native builds (no-op)
2279#[cfg(not(target_arch = "wasm32"))]
2280#[inline(always)]
2281fn host_log(_level: u8, _ptr: *const u8, _len: u32) {}
2282
2283/// Algo logging - non-blocking, ~100ns per call.
2284/// Logs are batched and viewable via dashboard with 1-2s delay.
2285pub mod log {
2286    use super::LogLevel;
2287    use core::fmt::Write;
2288
2289    /// Log buffer for formatting.
2290    struct LogBuf {
2291        buf: [u8; 255],
2292        pos: usize,
2293    }
2294
2295    impl LogBuf {
2296        #[inline(always)]
2297        const fn new() -> Self {
2298            Self {
2299                buf: [0u8; 255],
2300                pos: 0,
2301            }
2302        }
2303
2304        #[inline(always)]
2305        fn as_slice(&self) -> &[u8] {
2306            &self.buf[..self.pos]
2307        }
2308    }
2309
2310    impl Write for LogBuf {
2311        fn write_str(&mut self, s: &str) -> core::fmt::Result {
2312            let bytes = s.as_bytes();
2313            let space = self.buf.len() - self.pos;
2314            let n = bytes.len().min(space);
2315            self.buf[self.pos..self.pos + n].copy_from_slice(&bytes[..n]);
2316            self.pos += n;
2317            Ok(())
2318        }
2319    }
2320
2321    #[inline(always)]
2322    fn send(level: LogLevel, msg: &[u8]) {
2323        super::host_log(level as u8, msg.as_ptr(), msg.len() as u32);
2324    }
2325
2326    /// Log a trace message.
2327    #[inline(always)]
2328    pub fn trace(msg: &str) {
2329        send(LogLevel::Trace, msg.as_bytes());
2330    }
2331
2332    /// Log a debug message.
2333    #[inline(always)]
2334    pub fn debug(msg: &str) {
2335        send(LogLevel::Debug, msg.as_bytes());
2336    }
2337
2338    /// Log an info message.
2339    #[inline(always)]
2340    pub fn info(msg: &str) {
2341        send(LogLevel::Info, msg.as_bytes());
2342    }
2343
2344    /// Log a warning message.
2345    #[inline(always)]
2346    pub fn warn(msg: &str) {
2347        send(LogLevel::Warn, msg.as_bytes());
2348    }
2349
2350    /// Log an error message.
2351    #[inline(always)]
2352    pub fn error(msg: &str) {
2353        send(LogLevel::Error, msg.as_bytes());
2354    }
2355
2356    /// Log with formatting (slightly slower).
2357    #[inline]
2358    pub fn log_fmt(level: LogLevel, args: core::fmt::Arguments<'_>) {
2359        let mut buf = LogBuf::new();
2360        let _ = buf.write_fmt(args);
2361        send(level, buf.as_slice());
2362    }
2363
2364    /// Formatted info log.
2365    #[inline]
2366    pub fn info_fmt(args: core::fmt::Arguments<'_>) {
2367        log_fmt(LogLevel::Info, args);
2368    }
2369
2370    /// Formatted warn log.
2371    #[inline]
2372    pub fn warn_fmt(args: core::fmt::Arguments<'_>) {
2373        log_fmt(LogLevel::Warn, args);
2374    }
2375
2376    /// Formatted error log.
2377    #[inline]
2378    pub fn error_fmt(args: core::fmt::Arguments<'_>) {
2379        log_fmt(LogLevel::Error, args);
2380    }
2381
2382    /// Formatted debug log.
2383    #[inline]
2384    pub fn debug_fmt(args: core::fmt::Arguments<'_>) {
2385        log_fmt(LogLevel::Debug, args);
2386    }
2387}
2388
2389/// Log info message with formatting.
2390#[macro_export]
2391macro_rules! log_info {
2392    ($($arg:tt)*) => {
2393        $crate::log::info_fmt(format_args!($($arg)*))
2394    };
2395}
2396
2397/// Log warning with formatting.
2398#[macro_export]
2399macro_rules! log_warn {
2400    ($($arg:tt)*) => {
2401        $crate::log::warn_fmt(format_args!($($arg)*))
2402    };
2403}
2404
2405/// Log error with formatting.
2406#[macro_export]
2407macro_rules! log_error {
2408    ($($arg:tt)*) => {
2409        $crate::log::error_fmt(format_args!($($arg)*))
2410    };
2411}
2412
2413/// Log debug with formatting.
2414#[macro_export]
2415macro_rules! log_debug {
2416    ($($arg:tt)*) => {
2417        $crate::log::debug_fmt(format_args!($($arg)*))
2418    };
2419}
2420
2421// =============================================================================
2422// TESTS
2423// =============================================================================
2424
2425#[cfg(test)]
2426mod tests {
2427    use super::*;
2428
2429    // ── helper ───────────────────────────────────────────────────────────
2430    fn make_book(bid_px: u64, bid_sz: u64, ask_px: u64, ask_sz: u64) -> L2Book {
2431        let mut book = L2Book::default();
2432        if bid_px > 0 {
2433            book.bids[0] = Level {
2434                px_1e9: bid_px,
2435                sz_1e8: bid_sz,
2436            };
2437            book.bid_ct = 1;
2438        }
2439        if ask_px > 0 {
2440            book.asks[0] = Level {
2441                px_1e9: ask_px,
2442                sz_1e8: ask_sz,
2443            };
2444            book.ask_ct = 1;
2445        }
2446        book
2447    }
2448
2449    // ── Level ────────────────────────────────────────────────────────────
2450
2451    #[test]
2452    fn level_empty_is_zero() {
2453        let l = Level::EMPTY;
2454        assert_eq!(l.px_1e9, 0);
2455        assert_eq!(l.sz_1e8, 0);
2456    }
2457
2458    #[test]
2459    fn level_is_valid() {
2460        assert!(!Level::EMPTY.is_valid());
2461        assert!(Level {
2462            px_1e9: 1,
2463            sz_1e8: 0
2464        }
2465        .is_valid());
2466    }
2467
2468    // ── L2Book ───────────────────────────────────────────────────────────
2469
2470    #[test]
2471    fn book_best_bid_ask_empty() {
2472        let book = L2Book::default();
2473        assert!(book.best_bid().is_none());
2474        assert!(book.best_ask().is_none());
2475    }
2476
2477    #[test]
2478    fn book_best_bid_ask_populated() {
2479        let book = make_book(99_000_000_000, 1_000_000, 101_000_000_000, 2_000_000);
2480        let bid = book.best_bid().unwrap();
2481        assert_eq!(bid.px_1e9, 99_000_000_000);
2482        let ask = book.best_ask().unwrap();
2483        assert_eq!(ask.px_1e9, 101_000_000_000);
2484    }
2485
2486    #[test]
2487    fn book_mid_px_empty() {
2488        assert_eq!(L2Book::default().mid_px_1e9(), 0);
2489    }
2490
2491    #[test]
2492    fn book_mid_px_correct() {
2493        let book = make_book(99_000_000_000, 1, 101_000_000_000, 1);
2494        assert_eq!(book.mid_px_1e9(), 100_000_000_000);
2495    }
2496
2497    #[test]
2498    fn book_spread_empty() {
2499        assert_eq!(L2Book::default().spread_1e9(), u64::MAX);
2500    }
2501
2502    #[test]
2503    fn book_spread_correct() {
2504        let book = make_book(99_000_000_000, 1, 101_000_000_000, 1);
2505        assert_eq!(book.spread_1e9(), 2_000_000_000);
2506    }
2507
2508    #[test]
2509    fn book_spread_bps_empty() {
2510        assert_eq!(L2Book::default().spread_bps(), u32::MAX);
2511    }
2512
2513    #[test]
2514    fn book_spread_bps_correct() {
2515        // bid=99e9, ask=101e9 -> spread=2e9, mid=100e9, bps = 2e9*10000/100e9 = 200
2516        let book = make_book(99_000_000_000, 1, 101_000_000_000, 1);
2517        assert_eq!(book.spread_bps(), 200);
2518    }
2519
2520    #[test]
2521    fn book_spread_bps_x1000_sub_bps() {
2522        // BTC-like: bid=67270.00e9, ask=67270.10e9 -> spread=0.10e9=100_000_000
2523        // 0.10/67270.05 * 10000 = 0.00149 bps -> spread_bps() truncates to 0
2524        // spread_bps_x1000() should give ~15 milli-bps
2525        let book = make_book(67_270_000_000_000, 1, 67_270_100_000_000, 1);
2526        assert_eq!(book.spread_bps(), 0); // proves the precision loss
2527        let mbps = book.spread_bps_x1000();
2528        assert!(mbps > 0, "milli-bps should be positive: {}", mbps);
2529        assert_eq!(mbps, 14); // 0.014 bps * 1000 = 14 milli-bps
2530    }
2531
2532    #[test]
2533    fn book_spread_bps_x1000_crossed() {
2534        // Crossed book: bid > ask (common in multi-venue NBBO)
2535        let book = make_book(67_270_000_000_000, 1, 67_261_990_000_000, 1);
2536        assert!(book.is_crossed());
2537        let mbps = book.spread_bps_x1000();
2538        assert!(mbps < 0, "crossed book should have negative milli-bps: {}", mbps);
2539    }
2540
2541    #[test]
2542    fn book_spread_bps_x1000_normal() {
2543        // Normal spread: bid=99, ask=101 -> 200 bps -> 200_000 milli-bps
2544        let book = make_book(99_000_000_000, 1, 101_000_000_000, 1);
2545        assert!(!book.is_crossed());
2546        assert_eq!(book.spread_bps_x1000(), 200_000);
2547    }
2548
2549    #[test]
2550    fn book_is_crossed() {
2551        // Not crossed
2552        let book = make_book(99_000_000_000, 1, 101_000_000_000, 1);
2553        assert!(!book.is_crossed());
2554
2555        // Crossed
2556        let book = make_book(101_000_000_000, 1, 99_000_000_000, 1);
2557        assert!(book.is_crossed());
2558
2559        // Empty book
2560        assert!(!L2Book::default().is_crossed());
2561    }
2562
2563    #[test]
2564    fn book_spread_signed_1e9() {
2565        // Normal
2566        let book = make_book(99_000_000_000, 1, 101_000_000_000, 1);
2567        assert_eq!(book.spread_signed_1e9(), 2_000_000_000);
2568
2569        // Crossed
2570        let book = make_book(101_000_000_000, 1, 99_000_000_000, 1);
2571        assert_eq!(book.spread_signed_1e9(), -2_000_000_000);
2572    }
2573
2574    #[test]
2575    fn book_depth_and_imbalance() {
2576        let mut book = make_book(100_000_000_000, 500, 101_000_000_000, 300);
2577        // Add a second bid level
2578        book.bids[1] = Level {
2579            px_1e9: 99_000_000_000,
2580            sz_1e8: 200,
2581        };
2582        book.bid_ct = 2;
2583
2584        assert_eq!(book.bid_depth_1e8(1), 500);
2585        assert_eq!(book.bid_depth_1e8(5), 700); // capped at bid_ct=2
2586        assert_eq!(book.ask_depth_1e8(1), 300);
2587
2588        // imbalance: (700-300)*10000 / 1000 = 4000 bps (bids dominate)
2589        let imb = book.imbalance_bps(5);
2590        assert_eq!(imb, 4000);
2591    }
2592
2593    #[test]
2594    fn book_imbalance_empty() {
2595        assert_eq!(L2Book::default().imbalance_bps(5), 0);
2596    }
2597
2598    // ── OpenOrder ────────────────────────────────────────────────────────
2599
2600    #[test]
2601    fn open_order_is_live() {
2602        let mut o = OpenOrder::EMPTY;
2603        o.status = Status::ACKED;
2604        assert!(o.is_live());
2605        o.status = Status::PARTIAL;
2606        assert!(o.is_live());
2607        o.status = Status::PENDING;
2608        assert!(!o.is_live());
2609        o.status = Status::DEAD;
2610        assert!(!o.is_live());
2611    }
2612
2613    #[test]
2614    fn open_order_is_pending() {
2615        let mut o = OpenOrder::EMPTY;
2616        o.status = Status::PENDING;
2617        assert!(o.is_pending());
2618        o.status = Status::ACKED;
2619        assert!(!o.is_pending());
2620    }
2621
2622    #[test]
2623    fn open_order_remaining() {
2624        let o = OpenOrder {
2625            qty_1e8: 100,
2626            filled_1e8: 30,
2627            ..OpenOrder::EMPTY
2628        };
2629        assert_eq!(o.remaining_1e8(), 70);
2630
2631        // Sell side (negative qty)
2632        let o2 = OpenOrder {
2633            qty_1e8: -100,
2634            filled_1e8: -40,
2635            ..OpenOrder::EMPTY
2636        };
2637        assert_eq!(o2.remaining_1e8(), 60);
2638    }
2639
2640    // ── SymbolMeta ───────────────────────────────────────────────────────
2641
2642    #[test]
2643    fn symbol_round_px() {
2644        let meta = SymbolMeta {
2645            tick_size_1e9: 10_000_000,
2646            ..SymbolMeta::EMPTY
2647        };
2648        // 95_000_000 / 10_000_000 = 9 * 10_000_000 = 90_000_000
2649        assert_eq!(meta.round_px(95_000_000), 90_000_000);
2650        // pass-through when tick_size = 0
2651        assert_eq!(SymbolMeta::EMPTY.round_px(95_000_000), 95_000_000);
2652    }
2653
2654    #[test]
2655    fn symbol_round_qty() {
2656        let meta = SymbolMeta {
2657            lot_size_1e8: 1_000_000,
2658            ..SymbolMeta::EMPTY
2659        };
2660        assert_eq!(meta.round_qty(1_500_000), 1_000_000);
2661        assert_eq!(SymbolMeta::EMPTY.round_qty(1_500_000), 1_500_000);
2662    }
2663
2664    #[test]
2665    fn symbol_check_notional() {
2666        // Unknown min_notional -> true
2667        assert!(SymbolMeta::EMPTY.check_notional(1, 1));
2668        let meta = SymbolMeta {
2669            min_notional_1e9: 10_000_000_000,
2670            ..SymbolMeta::EMPTY
2671        };
2672        // notional = (100_000_000 * 100_000_000_000) / 1e8 = 100_000_000_000 >= 10e9 -> true
2673        assert!(meta.check_notional(100_000_000, 100_000_000_000));
2674        // notional = (1 * 1_000_000_000) / 1e8 = 10 < 10e9 -> false
2675        assert!(!meta.check_notional(1, 1_000_000_000));
2676    }
2677
2678    #[test]
2679    fn symbol_check_min_qty() {
2680        assert!(SymbolMeta::EMPTY.check_min_qty(1));
2681        let meta = SymbolMeta {
2682            min_qty_1e8: 100,
2683            ..SymbolMeta::EMPTY
2684        };
2685        assert!(meta.check_min_qty(100));
2686        assert!(meta.check_min_qty(-100)); // abs check
2687        assert!(!meta.check_min_qty(99));
2688    }
2689
2690    // ── RiskSnapshot ─────────────────────────────────────────────────────
2691
2692    #[test]
2693    fn risk_check_position() {
2694        // max_position = 0 -> unlimited
2695        assert!(RiskSnapshot::EMPTY.check_position(999, 999));
2696
2697        let risk = RiskSnapshot {
2698            max_position_1e8: 100,
2699            ..RiskSnapshot::default()
2700        };
2701        assert!(risk.check_position(50, 40)); // projected 90 <= 100
2702        assert!(risk.check_position(50, 50)); // projected 100 <= 100
2703        assert!(!risk.check_position(50, 51)); // projected 101 > 100
2704                                               // Negative side
2705        assert!(risk.check_position(-50, -50)); // projected -100, abs=100 <= 100
2706        assert!(!risk.check_position(-50, -51));
2707    }
2708
2709    // ── AlgoState ────────────────────────────────────────────────────────
2710
2711    #[test]
2712    fn algo_state_position_flags() {
2713        let mut s = AlgoState::default();
2714        assert!(s.is_flat());
2715        assert!(!s.is_long());
2716        assert!(!s.is_short());
2717
2718        s.position_1e8 = 100;
2719        assert!(s.is_long());
2720        assert!(!s.is_flat());
2721
2722        s.position_1e8 = -100;
2723        assert!(s.is_short());
2724    }
2725
2726    #[test]
2727    fn algo_state_live_order_count() {
2728        let mut s = AlgoState::default();
2729        s.order_ct = 3;
2730        s.orders[0] = OpenOrder {
2731            status: Status::ACKED,
2732            ..OpenOrder::EMPTY
2733        };
2734        s.orders[1] = OpenOrder {
2735            status: Status::PENDING,
2736            ..OpenOrder::EMPTY
2737        };
2738        s.orders[2] = OpenOrder {
2739            status: Status::PARTIAL,
2740            ..OpenOrder::EMPTY
2741        };
2742        assert_eq!(s.live_order_count(), 2); // ACKED + PARTIAL
2743    }
2744
2745    #[test]
2746    fn algo_state_find_order() {
2747        let mut s = AlgoState::default();
2748        s.order_ct = 1;
2749        s.orders[0] = OpenOrder {
2750            order_id: 42,
2751            ..OpenOrder::EMPTY
2752        };
2753        assert!(s.find_order(42).is_some());
2754        assert!(s.find_order(99).is_none());
2755    }
2756
2757    #[test]
2758    fn algo_state_open_buy_sell_qty() {
2759        let mut s = AlgoState::default();
2760        s.order_ct = 2;
2761        s.orders[0] = OpenOrder {
2762            order_id: 1,
2763            side: 1,
2764            qty_1e8: 100,
2765            filled_1e8: 20,
2766            status: Status::ACKED,
2767            ..OpenOrder::EMPTY
2768        };
2769        s.orders[1] = OpenOrder {
2770            order_id: 2,
2771            side: -1,
2772            qty_1e8: -200,
2773            filled_1e8: -50,
2774            status: Status::PARTIAL,
2775            ..OpenOrder::EMPTY
2776        };
2777        assert_eq!(s.open_buy_qty_1e8(), 80); // 100 - 20
2778        assert_eq!(s.open_sell_qty_1e8(), 150); // 200 - 50
2779    }
2780
2781    #[test]
2782    fn algo_state_pnl() {
2783        let mut s = AlgoState::default();
2784        s.realized_pnl_1e9 = 5_000_000_000;
2785        s.unrealized_pnl_1e9 = -2_000_000_000;
2786        s.session_pnl_1e9 = 1_500_000_000;
2787
2788        assert_eq!(s.total_pnl_1e9(), 3_000_000_000);
2789
2790        let snap = s.get_pnl();
2791        assert_eq!(snap.realized_1e9, 5_000_000_000);
2792        assert_eq!(snap.unrealized_1e9, -2_000_000_000);
2793        assert_eq!(snap.total_1e9, 3_000_000_000);
2794
2795        assert!((s.realized_pnl_usd() - 5.0).abs() < 1e-9);
2796        assert!((s.unrealized_pnl_usd() - (-2.0)).abs() < 1e-9);
2797        assert!((s.total_pnl_usd() - 3.0).abs() < 1e-9);
2798        assert!((s.session_pnl_usd() - 1.5).abs() < 1e-9);
2799    }
2800
2801    // ── Fill ─────────────────────────────────────────────────────────────
2802
2803    #[test]
2804    fn fill_since() {
2805        let fill = Fill {
2806            order_id: 1,
2807            px_1e9: 0,
2808            qty_1e8: 0,
2809            recv_ns: 10_000_000,
2810            side: 1,
2811            _pad: [0; 7],
2812        };
2813        assert_eq!(fill.since_ns(5_000_000), 5_000_000);
2814        assert_eq!(fill.since_us(5_000_000), 5_000);
2815        assert_eq!(fill.since_ms(5_000_000), 5);
2816        // Saturating: start > recv
2817        assert_eq!(fill.since_ns(99_000_000), 0);
2818    }
2819
2820    // ── time module ──────────────────────────────────────────────────────
2821
2822    #[test]
2823    fn time_helpers() {
2824        let t = time::start(1000);
2825        assert_eq!(t, 1000);
2826        assert_eq!(time::stop_ns(1000, 2500), 1500);
2827        assert_eq!(time::stop_us(1000, 2_001_000), 2_000);
2828        assert_eq!(time::stop_ms(1000, 5_000_001_000), 5_000);
2829        // Saturating
2830        assert_eq!(time::stop_ns(5000, 1000), 0);
2831    }
2832
2833    #[test]
2834    fn timer_struct() {
2835        let mut t = time::Timer::new();
2836        t.start(1_000_000);
2837        assert_eq!(t.stop_ns(3_000_000), 2_000_000);
2838        assert_eq!(t.stop_us(3_000_000), 2_000);
2839        assert_eq!(t.stop_ms(3_000_000), 2);
2840        // Saturating
2841        assert_eq!(t.stop_ns(500_000), 0);
2842    }
2843
2844    // ── RejectCode / Reject ──────────────────────────────────────────────
2845
2846    #[test]
2847    fn reject_code_to_str() {
2848        assert_eq!(RejectCode::to_str(RejectCode::UNKNOWN), "UNKNOWN");
2849        assert_eq!(
2850            RejectCode::to_str(RejectCode::INSUFFICIENT_BALANCE),
2851            "INSUFFICIENT_BALANCE"
2852        );
2853        assert_eq!(RejectCode::to_str(RejectCode::RATE_LIMIT), "RATE_LIMIT");
2854        assert_eq!(RejectCode::to_str(RejectCode::RISK), "RISK_CHECK_FAILED");
2855        assert_eq!(
2856            RejectCode::to_str(RejectCode::DAILY_LOSS_LIMIT),
2857            "DAILY_LOSS_LIMIT"
2858        );
2859        assert_eq!(RejectCode::to_str(255), "UNKNOWN");
2860    }
2861
2862    #[test]
2863    fn reject_reason_delegates() {
2864        let r = Reject {
2865            order_id: 1,
2866            code: RejectCode::AUTH,
2867            _pad: [0; 7],
2868        };
2869        assert_eq!(r.reason(), "AUTH");
2870    }
2871
2872    // ── OrderType constants ──────────────────────────────────────────────
2873
2874    #[test]
2875    fn order_type_values() {
2876        assert_eq!(OrderType::LIMIT, 0);
2877        assert_eq!(OrderType::MARKET, 1);
2878        assert_eq!(OrderType::IOC, 2);
2879        assert_eq!(OrderType::FOK, 3);
2880        assert_eq!(OrderType::POST_ONLY, 4);
2881    }
2882
2883    // ── LogLevel ─────────────────────────────────────────────────────────
2884
2885    #[test]
2886    fn log_level_discriminants() {
2887        assert_eq!(LogLevel::Trace as u8, 0);
2888        assert_eq!(LogLevel::Debug as u8, 1);
2889        assert_eq!(LogLevel::Info as u8, 2);
2890        assert_eq!(LogLevel::Warn as u8, 3);
2891        assert_eq!(LogLevel::Error as u8, 4);
2892    }
2893
2894    // ── Actions ──────────────────────────────────────────────────────────
2895
2896    #[test]
2897    fn actions_new_is_empty() {
2898        let a = Actions::new();
2899        assert_eq!(a.len(), 0);
2900        assert!(a.is_empty());
2901        assert!(!a.is_full());
2902    }
2903
2904    #[test]
2905    fn actions_buy_sell() {
2906        let mut a = Actions::new();
2907        assert!(a.buy(1, 100, 50_000));
2908        assert!(a.sell(2, 200, 60_000));
2909        assert_eq!(a.len(), 2);
2910
2911        let buy = a.get(0).unwrap();
2912        assert_eq!(buy.side, 1);
2913        assert_eq!(buy.qty_1e8, 100);
2914        assert_eq!(buy.px_1e9, 50_000);
2915        assert_eq!(buy.order_type, OrderType::LIMIT);
2916        assert_eq!(buy.is_cancel, 0);
2917
2918        let sell = a.get(1).unwrap();
2919        assert_eq!(sell.side, -1);
2920        assert_eq!(sell.qty_1e8, 200);
2921    }
2922
2923    #[test]
2924    fn actions_cancel() {
2925        let mut a = Actions::new();
2926        assert!(a.cancel(42));
2927        let c = a.get(0).unwrap();
2928        assert_eq!(c.is_cancel, 1);
2929        assert_eq!(c.order_id, 42);
2930    }
2931
2932    #[test]
2933    fn actions_full_rejects() {
2934        let mut a = Actions::new();
2935        for i in 0..MAX_ACTIONS {
2936            assert!(a.buy(i as u64, 1, 1));
2937        }
2938        assert!(a.is_full());
2939        assert!(!a.buy(999, 1, 1));
2940    }
2941
2942    #[test]
2943    fn actions_clear() {
2944        let mut a = Actions::new();
2945        a.buy(1, 1, 1);
2946        a.buy(2, 1, 1);
2947        a.clear();
2948        assert_eq!(a.len(), 0);
2949        assert!(a.is_empty());
2950    }
2951
2952    #[test]
2953    fn actions_market_orders() {
2954        let mut a = Actions::new();
2955        a.market_buy(1, 100);
2956        a.market_sell(2, 200);
2957        let mb = a.get(0).unwrap();
2958        assert_eq!(mb.order_type, OrderType::MARKET);
2959        assert_eq!(mb.px_1e9, 0);
2960        assert_eq!(mb.side, 1);
2961        let ms = a.get(1).unwrap();
2962        assert_eq!(ms.order_type, OrderType::MARKET);
2963        assert_eq!(ms.side, -1);
2964    }
2965
2966    #[test]
2967    fn actions_ioc_fok_post_only() {
2968        let mut a = Actions::new();
2969        a.ioc_buy(1, 10, 100);
2970        a.fok_buy(2, 20, 200);
2971        a.post_only_buy(3, 30, 300);
2972        assert_eq!(a.get(0).unwrap().order_type, OrderType::IOC);
2973        assert_eq!(a.get(1).unwrap().order_type, OrderType::FOK);
2974        assert_eq!(a.get(2).unwrap().order_type, OrderType::POST_ONLY);
2975    }
2976
2977    #[test]
2978    fn actions_cancel_all() {
2979        let mut state = AlgoState::default();
2980        state.order_ct = 3;
2981        state.orders[0] = OpenOrder {
2982            order_id: 10,
2983            status: Status::ACKED,
2984            ..OpenOrder::EMPTY
2985        };
2986        state.orders[1] = OpenOrder {
2987            order_id: 11,
2988            status: Status::DEAD,
2989            ..OpenOrder::EMPTY
2990        };
2991        state.orders[2] = OpenOrder {
2992            order_id: 12,
2993            status: Status::PARTIAL,
2994            ..OpenOrder::EMPTY
2995        };
2996
2997        let mut a = Actions::new();
2998        a.cancel_all(&state);
2999        // Only ACKED and PARTIAL are live -> 2 cancels
3000        assert_eq!(a.len(), 2);
3001        assert_eq!(a.get(0).unwrap().order_id, 10);
3002        assert_eq!(a.get(1).unwrap().order_id, 12);
3003    }
3004
3005    #[test]
3006    fn actions_clear_at() {
3007        let mut a = Actions::new();
3008        a.buy(1, 100, 50_000);
3009        a.buy(2, 200, 60_000);
3010        a.clear_at(0);
3011        let zeroed = a.get(0).unwrap();
3012        assert_eq!(zeroed.order_id, 0);
3013        assert_eq!(zeroed.qty_1e8, 0);
3014        // len unchanged — slot is zeroed, not removed
3015        assert_eq!(a.len(), 2);
3016    }
3017
3018    #[test]
3019    fn actions_get_out_of_bounds() {
3020        let a = Actions::new();
3021        assert!(a.get(0).is_none());
3022        assert!(a.get(100).is_none());
3023    }
3024
3025    #[test]
3026    fn actions_iter() {
3027        let mut a = Actions::new();
3028        a.buy(1, 1, 1);
3029        a.sell(2, 1, 1);
3030        a.cancel(3);
3031        assert_eq!(a.iter().count(), 3);
3032    }
3033
3034    // ── ABI tests — static compile-time layout verification ─────────────
3035
3036    #[test]
3037    fn abi_action_size_is_32() {
3038        assert_eq!(core::mem::size_of::<Action>(), 32);
3039    }
3040
3041    #[test]
3042    fn abi_action_field_offsets() {
3043        use core::mem;
3044        // order_id at 0, px_1e9 at 8, qty_1e8 at 16, side at 24, is_cancel at 25,
3045        // order_type at 26, venue_id at 27, _pad at 28
3046        assert_eq!(mem::offset_of!(Action, order_id), 0);
3047        assert_eq!(mem::offset_of!(Action, px_1e9), 8);
3048        assert_eq!(mem::offset_of!(Action, qty_1e8), 16);
3049        assert_eq!(mem::offset_of!(Action, side), 24);
3050        assert_eq!(mem::offset_of!(Action, is_cancel), 25);
3051        assert_eq!(mem::offset_of!(Action, order_type), 26);
3052        assert_eq!(mem::offset_of!(Action, venue_id), 27);
3053        assert_eq!(mem::offset_of!(Action, _pad), 28);
3054    }
3055
3056    #[test]
3057    fn abi_nbbo_snapshot_fits_512() {
3058        assert!(core::mem::size_of::<NbboSnapshot>() <= 640);
3059    }
3060
3061    #[test]
3062    fn abi_l2book_size_unchanged() {
3063        // L2Book: 20 levels × 16 bytes × 2 sides + metadata
3064        let size = core::mem::size_of::<L2Book>();
3065        assert_eq!(size, 656, "L2Book size changed — ABI break!");
3066    }
3067
3068    #[test]
3069    fn abi_fill_size_unchanged() {
3070        assert_eq!(core::mem::size_of::<Fill>(), 40);
3071    }
3072
3073    #[test]
3074    fn abi_reject_size_unchanged() {
3075        assert_eq!(core::mem::size_of::<Reject>(), 16);
3076    }
3077
3078    #[test]
3079    fn abi_action_default_venue_is_zero() {
3080        // Old algos never set venue_id → Default produces 0 → "default venue"
3081        let a = Action::default();
3082        assert_eq!(a.venue_id, 0);
3083    }
3084
3085    // ── Venue-index convention tests ────────────────────────────────────
3086
3087    #[test]
3088    fn venue_index_convention_slot_to_id() {
3089        let mut snap = NbboSnapshot::default();
3090        snap.venue_ct = 3;
3091        snap.venue_ids = [10, 20, 30, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
3092        snap.venue_bid_px[0] = 100; // slot 0 = VenueId 10
3093        snap.venue_bid_px[1] = 200; // slot 1 = VenueId 20
3094        snap.venue_bid_px[2] = 150; // slot 2 = VenueId 30
3095        snap.nbbo_bid_venue = 1; // best bid is slot 1
3096
3097        // nbbo_bid_venue is an array INDEX, not a VenueId
3098        assert_eq!(snap.venue_ids[snap.nbbo_bid_venue as usize], 20);
3099        assert_eq!(snap.venue_bid_px[snap.nbbo_bid_venue as usize], 200);
3100
3101        // slot_for_venue maps VenueId → slot
3102        assert_eq!(snap.slot_for_venue(20), Some(1));
3103        assert_eq!(snap.slot_for_venue(10), Some(0));
3104        assert_eq!(snap.slot_for_venue(30), Some(2));
3105        assert_eq!(snap.slot_for_venue(99), None);
3106    }
3107
3108    #[test]
3109    fn venue_index_convention_best_venue_id() {
3110        let mut snap = NbboSnapshot::default();
3111        snap.venue_ct = 2;
3112        snap.venue_ids = [5, 15, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
3113        snap.nbbo_bid_venue = 0; // slot 0
3114        snap.nbbo_ask_venue = 1; // slot 1
3115
3116        assert_eq!(snap.best_bid_venue_id(), 5);
3117        assert_eq!(snap.best_ask_venue_id(), 15);
3118    }
3119
3120    #[test]
3121    fn action_venue_id_is_value_not_slot() {
3122        // Action.venue_id stores the VenueId VALUE (e.g., 20), not the array slot
3123        let mut a = Actions::new();
3124        a.buy_on(20, 1, 100, 50_000);
3125        let action = a.get(0).unwrap();
3126        assert_eq!(action.venue_id, 20); // VenueId 20, NOT slot 20
3127        assert_eq!(action.side, 1);
3128        assert_eq!(action.order_type, OrderType::LIMIT);
3129    }
3130
3131    #[test]
3132    fn nbbo_spread_bps_empty() {
3133        assert_eq!(NbboSnapshot::default().nbbo_spread_bps(), u32::MAX);
3134    }
3135
3136    #[test]
3137    fn nbbo_spread_bps_correct() {
3138        let mut snap = NbboSnapshot::default();
3139        snap.nbbo_bid_px_1e9 = 99_000_000_000;
3140        snap.nbbo_ask_px_1e9 = 101_000_000_000;
3141        // spread = 2e9, mid = 100e9, bps = 2e9 * 10000 / 100e9 = 200
3142        assert_eq!(snap.nbbo_spread_bps(), 200);
3143    }
3144
3145    #[test]
3146    fn nbbo_spread_bps_x1000_sub_bps() {
3147        // BTC-like sub-bps spread
3148        let mut snap = NbboSnapshot::default();
3149        snap.nbbo_bid_px_1e9 = 67_270_000_000_000;
3150        snap.nbbo_ask_px_1e9 = 67_270_100_000_000;
3151        assert_eq!(snap.nbbo_spread_bps(), 0); // truncated
3152        let mbps = snap.nbbo_spread_bps_x1000();
3153        assert!(mbps > 0);
3154        assert_eq!(mbps, 14); // ~0.014 bps * 1000
3155    }
3156
3157    #[test]
3158    fn nbbo_spread_bps_x1000_crossed() {
3159        let mut snap = NbboSnapshot::default();
3160        snap.nbbo_bid_px_1e9 = 67_270_000_000_000;
3161        snap.nbbo_ask_px_1e9 = 67_261_990_000_000;
3162        let mbps = snap.nbbo_spread_bps_x1000();
3163        assert!(mbps < 0, "crossed NBBO should have negative milli-bps");
3164    }
3165
3166    #[test]
3167    fn nbbo_is_crossed() {
3168        let mut snap = NbboSnapshot::default();
3169        snap.venue_ct = 2;
3170        // Not crossed: bid < ask
3171        snap.nbbo_bid_px_1e9 = 99_000_000_000;
3172        snap.nbbo_ask_px_1e9 = 101_000_000_000;
3173        snap.nbbo_bid_venue = 0;
3174        snap.nbbo_ask_venue = 1;
3175        assert!(!snap.is_crossed());
3176
3177        // Crossed: bid > ask on different venues
3178        snap.nbbo_bid_px_1e9 = 101_500_000_000;
3179        snap.nbbo_ask_px_1e9 = 101_000_000_000;
3180        assert!(snap.is_crossed());
3181
3182        // Same venue → not a real cross
3183        snap.nbbo_bid_venue = 1;
3184        snap.nbbo_ask_venue = 1;
3185        assert!(!snap.is_crossed());
3186    }
3187
3188    #[test]
3189    fn nbbo_venue_staleness() {
3190        let mut snap = NbboSnapshot::default();
3191        snap.venue_ct = 3;
3192        snap.venue_update_ms = [0, 100, 6000, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
3193
3194        assert!(!snap.is_venue_stale(0, 5000)); // 0ms < 5000ms
3195        assert!(!snap.is_venue_stale(1, 5000)); // 100ms < 5000ms
3196        assert!(snap.is_venue_stale(2, 5000)); // 6000ms > 5000ms
3197        assert!(snap.is_venue_stale(15, 5000)); // out of range = stale
3198    }
3199
3200    #[test]
3201    fn actions_venue_targeted() {
3202        let mut a = Actions::new();
3203        assert!(a.buy_on(5, 1, 100, 50_000));
3204        assert!(a.sell_on(10, 2, 200, 60_000));
3205        assert!(a.ioc_buy_on(5, 3, 50, 49_000));
3206        assert!(a.ioc_sell_on(10, 4, 75, 61_000));
3207        assert_eq!(a.len(), 4);
3208
3209        let b = a.get(0).unwrap();
3210        assert_eq!(b.venue_id, 5);
3211        assert_eq!(b.side, 1);
3212        assert_eq!(b.order_type, OrderType::LIMIT);
3213
3214        let s = a.get(1).unwrap();
3215        assert_eq!(s.venue_id, 10);
3216        assert_eq!(s.side, -1);
3217
3218        let ib = a.get(2).unwrap();
3219        assert_eq!(ib.order_type, OrderType::IOC);
3220        assert_eq!(ib.venue_id, 5);
3221
3222        let is = a.get(3).unwrap();
3223        assert_eq!(is.order_type, OrderType::IOC);
3224        assert_eq!(is.venue_id, 10);
3225    }
3226
3227    // ── Venue ID constants tests ──────────────────────────────────────
3228
3229    #[test]
3230    fn venue_id_constants_are_correct() {
3231        // Wire format = CC VenueId + 1
3232        assert_eq!(VENUE_KRAKEN, 1);
3233        assert_eq!(VENUE_COINBASE, 2);
3234        assert_eq!(VENUE_BINANCE, 3);
3235        assert_eq!(VENUE_BITGET, 4);
3236        assert_eq!(VENUE_CRYPTOCOM, 5);
3237        assert_eq!(VENUE_BITMART, 6);
3238        assert_eq!(VENUE_DEX, 7);
3239        assert_eq!(VENUE_OKX, 8);
3240        assert_eq!(VENUE_BYBIT, 9);
3241        assert_eq!(VENUE_UNKNOWN, 10);
3242        assert_eq!(VENUE_DEX_ETH, 11);
3243        assert_eq!(VENUE_DEX_ARB, 12);
3244        assert_eq!(VENUE_DEX_BASE, 13);
3245        assert_eq!(VENUE_DEX_OP, 14);
3246        assert_eq!(VENUE_DEX_POLY, 15);
3247        assert_eq!(VENUE_DEX_SOL, 16);
3248    }
3249
3250    #[test]
3251    fn is_dex_classification() {
3252        assert!(is_dex(VENUE_DEX));
3253        assert!(is_dex(VENUE_DEX_ETH));
3254        assert!(is_dex(VENUE_DEX_ARB));
3255        assert!(is_dex(VENUE_DEX_BASE));
3256        assert!(is_dex(VENUE_DEX_OP));
3257        assert!(is_dex(VENUE_DEX_POLY));
3258        assert!(is_dex(VENUE_DEX_SOL));
3259        assert!(!is_dex(VENUE_KRAKEN));
3260        assert!(!is_dex(VENUE_BINANCE));
3261        assert!(!is_dex(VENUE_OKX));
3262        assert!(!is_dex(VENUE_UNKNOWN));
3263        assert!(!is_dex(0)); // default venue
3264    }
3265
3266    #[test]
3267    fn is_cex_classification() {
3268        assert!(is_cex(VENUE_KRAKEN));
3269        assert!(is_cex(VENUE_COINBASE));
3270        assert!(is_cex(VENUE_BINANCE));
3271        assert!(is_cex(VENUE_BITGET));
3272        assert!(is_cex(VENUE_CRYPTOCOM));
3273        assert!(is_cex(VENUE_BITMART));
3274        assert!(is_cex(VENUE_OKX));
3275        assert!(is_cex(VENUE_BYBIT));
3276        assert!(!is_cex(VENUE_DEX));
3277        assert!(!is_cex(VENUE_DEX_ARB));
3278        assert!(!is_cex(VENUE_UNKNOWN));
3279        assert!(!is_cex(0)); // default venue
3280    }
3281
3282    #[test]
3283    fn venue_name_all_known() {
3284        assert_eq!(venue_name(VENUE_KRAKEN), "kraken");
3285        assert_eq!(venue_name(VENUE_COINBASE), "coinbase");
3286        assert_eq!(venue_name(VENUE_BINANCE), "binance");
3287        assert_eq!(venue_name(VENUE_DEX), "dex");
3288        assert_eq!(venue_name(VENUE_DEX_ARB), "dex-arb");
3289        assert_eq!(venue_name(VENUE_DEX_BASE), "dex-base");
3290        assert_eq!(venue_name(VENUE_DEX_SOL), "dex-sol");
3291        assert_eq!(venue_name(VENUE_UNKNOWN), "unknown");
3292        assert_eq!(venue_name(0), "default");
3293        assert_eq!(venue_name(255), "unknown");
3294    }
3295
3296    #[test]
3297    fn venue_id_cc_wire_mapping() {
3298        // Wire ID = CC VenueId ordinal + 1 (0 reserved for default/local)
3299        // CC: Kraken=0, Coinbase=1, Binance=2, Bitget=3, Cryptocom=4,
3300        //     Bitmart=5, Dex=6, Okx=7, Bybit=8, Unknown=9,
3301        //     DexEth=10, DexArb=11, DexBase=12, DexOp=13, DexPoly=14
3302        assert_eq!(VENUE_KRAKEN, 0 + 1);
3303        assert_eq!(VENUE_COINBASE, 1 + 1);
3304        assert_eq!(VENUE_BINANCE, 2 + 1);
3305        assert_eq!(VENUE_BITGET, 3 + 1);
3306        assert_eq!(VENUE_CRYPTOCOM, 4 + 1);
3307        assert_eq!(VENUE_BITMART, 5 + 1);
3308        assert_eq!(VENUE_DEX, 6 + 1);
3309        assert_eq!(VENUE_OKX, 7 + 1);
3310        assert_eq!(VENUE_BYBIT, 8 + 1);
3311        assert_eq!(VENUE_UNKNOWN, 9 + 1);
3312        assert_eq!(VENUE_DEX_ETH, 10 + 1);
3313        assert_eq!(VENUE_DEX_ARB, 11 + 1);
3314        assert_eq!(VENUE_DEX_BASE, 12 + 1);
3315        assert_eq!(VENUE_DEX_OP, 13 + 1);
3316        assert_eq!(VENUE_DEX_POLY, 14 + 1);
3317        assert_eq!(VENUE_DEX_SOL, 15 + 1);
3318
3319        // Unknown is neither CEX nor DEX
3320        assert!(!is_cex(VENUE_UNKNOWN));
3321        assert!(!is_dex(VENUE_UNKNOWN));
3322        assert_eq!(venue_name(VENUE_UNKNOWN), "unknown");
3323    }
3324
3325    // ── VenueBooks tests ──────────────────────────────────────────────
3326
3327    #[test]
3328    fn abi_venue_books_layout() {
3329        let size = core::mem::size_of::<VenueBooks>();
3330        // merged (656) + book_ct+pad (8) + venue_ids (20) + pad (4) + books (656*20 = 13120)
3331        assert_eq!(size, 13808, "VenueBooks size changed — ABI break!");
3332
3333        // Verify alignment: VenueBooks must be 8-byte aligned (L2Book contains u64)
3334        assert_eq!(core::mem::align_of::<VenueBooks>(), 8);
3335    }
3336
3337    #[test]
3338    fn venue_books_book_for_venue() {
3339        let mut vb = VenueBooks::default();
3340        vb.book_ct = 3;
3341        vb.venue_ids[0] = VENUE_KRAKEN;
3342        vb.venue_ids[1] = VENUE_COINBASE;
3343        vb.venue_ids[2] = VENUE_DEX_ARB;
3344
3345        // Set distinct bid prices per venue
3346        vb.books[0].bid_ct = 1;
3347        vb.books[0].bids[0].px_1e9 = 100_000_000_000;
3348        vb.books[1].bid_ct = 2;
3349        vb.books[1].bids[0].px_1e9 = 100_100_000_000;
3350        vb.books[2].bid_ct = 3;
3351        vb.books[2].bids[0].px_1e9 = 99_900_000_000;
3352
3353        // Lookup by VenueId
3354        let kraken = vb.book_for_venue(VENUE_KRAKEN).unwrap();
3355        assert_eq!(kraken.bids[0].px_1e9, 100_000_000_000);
3356        assert_eq!(kraken.bid_ct, 1);
3357
3358        let coinbase = vb.book_for_venue(VENUE_COINBASE).unwrap();
3359        assert_eq!(coinbase.bids[0].px_1e9, 100_100_000_000);
3360        assert_eq!(coinbase.bid_ct, 2);
3361
3362        let dex = vb.book_for_venue(VENUE_DEX_ARB).unwrap();
3363        assert_eq!(dex.bids[0].px_1e9, 99_900_000_000);
3364        assert_eq!(dex.bid_ct, 3);
3365
3366        // Not present
3367        assert!(vb.book_for_venue(VENUE_BINANCE).is_none());
3368    }
3369
3370    #[test]
3371    fn venue_books_has_depth_for() {
3372        let mut vb = VenueBooks::default();
3373        vb.book_ct = 2;
3374        vb.venue_ids[0] = VENUE_KRAKEN;
3375        vb.venue_ids[1] = VENUE_DEX_ARB;
3376        vb.books[0].bid_ct = 5; // Kraken has depth
3377                                // DEX_ARB has no levels (bid_ct=0, ask_ct=0)
3378
3379        assert!(vb.has_depth_for(VENUE_KRAKEN));
3380        assert!(!vb.has_depth_for(VENUE_DEX_ARB));
3381        assert!(!vb.has_depth_for(VENUE_BINANCE)); // not present
3382    }
3383
3384    #[test]
3385    fn venue_books_cex_dex_counts() {
3386        let mut vb = VenueBooks::default();
3387        vb.book_ct = 5;
3388        vb.venue_ids[0] = VENUE_KRAKEN;
3389        vb.venue_ids[1] = VENUE_COINBASE;
3390        vb.venue_ids[2] = VENUE_DEX_ARB;
3391        vb.venue_ids[3] = VENUE_DEX_BASE;
3392        vb.venue_ids[4] = VENUE_BINANCE;
3393
3394        assert_eq!(vb.cex_count(), 3);
3395        assert_eq!(vb.dex_count(), 2);
3396    }
3397
3398    #[test]
3399    fn venue_books_book_at_slot() {
3400        let mut vb = VenueBooks::default();
3401        vb.book_ct = 2;
3402        vb.venue_ids[0] = VENUE_KRAKEN;
3403        vb.venue_ids[1] = VENUE_BINANCE;
3404        vb.books[0].bid_ct = 10;
3405        vb.books[1].bid_ct = 15;
3406
3407        assert_eq!(vb.book_at_slot(0).bid_ct, 10);
3408        assert_eq!(vb.book_at_slot(1).bid_ct, 15);
3409    }
3410
3411    #[test]
3412    fn venue_books_venue_id_at() {
3413        let mut vb = VenueBooks::default();
3414        vb.book_ct = 2;
3415        vb.venue_ids[0] = VENUE_KRAKEN;
3416        vb.venue_ids[1] = VENUE_DEX_ETH;
3417
3418        assert_eq!(vb.venue_id_at(0), VENUE_KRAKEN);
3419        assert_eq!(vb.venue_id_at(1), VENUE_DEX_ETH);
3420        assert_eq!(vb.venue_id_at(99), 0); // out of range
3421    }
3422
3423    // ── PoolBooks tests ──────────────────────────────────────────────────
3424
3425    #[test]
3426    fn pool_meta_is_40_bytes() {
3427        // 32 (address) + 2 (pair_index) + 2 (fee_bps) + 1 (venue_id) + 1 (protocol_id) + 2 (pad) = 40
3428        assert_eq!(core::mem::size_of::<PoolMeta>(), 40);
3429    }
3430
3431    #[test]
3432    fn pool_books_repr_c_layout() {
3433        // PoolBooks must be repr(C) for zero-copy WASM memcpy.
3434        let pb = PoolBooks::default();
3435        assert_eq!(pb.pool_ct, 0);
3436        assert_eq!(pb.metas[0].address, [0; 32]);
3437        assert_eq!(pb.books[0].bid_ct, 0);
3438    }
3439
3440    #[test]
3441    fn pool_books_wasm_offset_after_venue_books() {
3442        let venue_end = VENUE_BOOKS_WASM_OFFSET as usize + core::mem::size_of::<VenueBooks>();
3443        assert!(
3444            (POOL_BOOKS_WASM_OFFSET as usize) >= venue_end,
3445            "PoolBooks offset {:#x} would overlap VenueBooks ending at {:#x}",
3446            POOL_BOOKS_WASM_OFFSET,
3447            venue_end,
3448        );
3449    }
3450
3451    #[test]
3452    fn pool_books_book_for_pool() {
3453        let mut pb = PoolBooks::default();
3454        pb.pool_ct = 2;
3455        pb.metas[0].address = [0x11; 32];
3456        pb.metas[0].pair_index = 0;
3457        pb.books[0].bid_ct = 3;
3458
3459        pb.metas[1].address = [0x22; 32];
3460        pb.metas[1].pair_index = 2;
3461        pb.books[1].ask_ct = 5;
3462
3463        let addr1 = [0x11u8; 32];
3464        let book1 = pb.book_for_pool(&addr1, 0).unwrap();
3465        assert_eq!(book1.bid_ct, 3);
3466
3467        let addr2 = [0x22u8; 32];
3468        let book2 = pb.book_for_pool(&addr2, 2).unwrap();
3469        assert_eq!(book2.ask_ct, 5);
3470
3471        // Wrong pair_index → None
3472        assert!(pb.book_for_pool(&addr2, 0).is_none());
3473        // Unknown address → None
3474        let unknown = [0xFF; 32];
3475        assert!(pb.book_for_pool(&unknown, 0).is_none());
3476    }
3477
3478    // ── OnlineFeatures tests ────────────────────────────────────────────
3479
3480    #[test]
3481    fn online_features_size_is_256() {
3482        assert_eq!(core::mem::size_of::<OnlineFeatures>(), 256);
3483    }
3484
3485    #[test]
3486    fn online_features_default_version() {
3487        let f = OnlineFeatures::default();
3488        assert_eq!(f.version, 1);
3489        assert_eq!(f.flags, 0);
3490        assert_eq!(f.microprice_1e9, 0);
3491        assert_eq!(f.feature_ts_ns, 0);
3492    }
3493
3494    #[test]
3495    fn online_features_vpin_flag() {
3496        let mut f = OnlineFeatures::default();
3497        assert!(!f.vpin_valid());
3498        f.flags = 1;
3499        assert!(f.vpin_valid());
3500        f.flags = 0b11;
3501        assert!(f.vpin_valid());
3502        f.flags = 0b10;
3503        assert!(!f.vpin_valid());
3504    }
3505
3506    #[test]
3507    fn online_features_wasm_offset_after_pool_books() {
3508        let pool_end = POOL_BOOKS_WASM_OFFSET as usize + core::mem::size_of::<PoolBooks>();
3509        assert!(
3510            (ONLINE_FEATURES_WASM_OFFSET as usize) >= pool_end,
3511            "OnlineFeatures offset {:#x} would overlap PoolBooks ending at {:#x}",
3512            ONLINE_FEATURES_WASM_OFFSET,
3513            pool_end,
3514        );
3515    }
3516
3517    #[test]
3518    fn online_features_helpers() {
3519        let mut f = OnlineFeatures::default();
3520        f.microprice_1e9 = 50_000_000_000_000; // $50,000
3521        assert!((f.microprice_f64() - 50_000.0).abs() < 0.001);
3522
3523        f.ofi_1level_1e8 = 150_000_000; // 1.5
3524        assert!((f.ofi_1level_f64() - 1.5).abs() < 0.001);
3525
3526        f.trade_sign_imbalance_1e6 = -500_000; // -0.5
3527        assert!((f.trade_sign_imbalance_f64() - (-0.5)).abs() < 0.001);
3528    }
3529
3530    #[test]
3531    fn online_features_is_copy() {
3532        let f = OnlineFeatures::default();
3533        let g = f; // Copy
3534        assert_eq!(g.version, f.version);
3535    }
3536}