Skip to main content

hypercall_engine/
order_index.rs

1//! In-process order index for the engine.
2//!
3//! `EngineOrderIndex` tracks all open orders inline as the engine processes them,
4//! providing zero-latency, zero-divergence lookups for cancel, margin, and MMP
5//! operations. All methods are synchronous — no locks, no async — because the
6//! engine is single-threaded and owns this struct via `EngineCtx`.
7
8use crate::instrument::{contract_key, ParsedInstrument};
9use crate::position::EnginePosition;
10use hypercall_types::{to_human_readable_decimal, Side, WalletAddress};
11use rust_decimal::Decimal;
12use rust_decimal_macros::dec;
13use serde::{Deserialize, Serialize};
14use std::collections::HashMap;
15
16/// Summary of an open order tracked by the engine.
17///
18/// **Unit convention**: All sizes are stored in **human-readable** units
19/// (e.g., 1.0 = 1 contract). Callers pass raw contract units (from `OrderInfo`,
20/// `Fill`, orderbooks); the boundary methods (`add_order`, `fill_order`) convert
21/// to human-readable on entry. See CALL-160 for the long-term newtype fix.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct OrderSummary {
24    pub order_id: u64,
25    pub symbol: String,
26    pub side: Side,
27    pub price: Decimal,
28    /// Original order size in human-readable units (converted from raw on insert).
29    pub original_size: Decimal,
30    /// Remaining unfilled size in human-readable units (converted from raw on insert).
31    pub remaining_size: Decimal,
32    pub is_perp: bool,
33    pub mmp_enabled: bool,
34    pub client_id: Option<String>,
35    /// Creation timestamp in milliseconds (from engine ACK time).
36    pub created_at: i64,
37}
38
39/// Info about an open SELL option order as a hypothetical short position.
40///
41/// Used by Standard margin to calculate IM for pending shorts.
42pub struct OpenSellPositionInfo {
43    /// Premium that would be credited when this order fills.
44    pub premium: Decimal,
45    /// Hypothetical short position for margin calculation.
46    pub position: hypercall_margin::OptionPosition,
47}
48
49/// In-process index of all open orders, owned by `EngineCtx`.
50///
51/// Provides synchronous lookups for cancel, margin, MMP, and order-limit checks.
52/// Mutated inline by the engine after ACK, fill, and cancel.
53pub struct EngineOrderIndex {
54    /// wallet → order_id → OrderSummary
55    orders_by_wallet: HashMap<WalletAddress, HashMap<u64, OrderSummary>>,
56    /// (wallet, client_id) → order_id
57    client_id_index: HashMap<(WalletAddress, String), u64>,
58    /// (wallet, contract_key) → open sell quantity (in human-readable units)
59    open_sells_by_contract: HashMap<(WalletAddress, String), Decimal>,
60}
61
62impl Default for EngineOrderIndex {
63    fn default() -> Self {
64        Self::new()
65    }
66}
67
68impl EngineOrderIndex {
69    /// Create an empty index.
70    pub fn new() -> Self {
71        Self {
72            orders_by_wallet: HashMap::new(),
73            client_id_index: HashMap::new(),
74            open_sells_by_contract: HashMap::new(),
75        }
76    }
77
78    // ===== Read methods =====
79
80    /// Look up the symbol for an order by wallet + order_id.
81    pub fn get_order_symbol(&self, wallet: &WalletAddress, order_id: u64) -> Option<&str> {
82        self.orders_by_wallet
83            .get(wallet)?
84            .get(&order_id)
85            .map(|s| s.symbol.as_str())
86    }
87
88    /// Look up (order_id, symbol) by wallet + client_id.
89    pub fn get_order_by_client_id(
90        &self,
91        wallet: &WalletAddress,
92        client_id: &str,
93    ) -> Option<(u64, &str)> {
94        let order_id = self
95            .client_id_index
96            .get(&(*wallet, client_id.to_string()))?;
97        let summary = self.orders_by_wallet.get(wallet)?.get(order_id)?;
98        Some((*order_id, summary.symbol.as_str()))
99    }
100
101    /// Count of open orders for a wallet.
102    pub fn open_order_count(&self, wallet: &WalletAddress) -> usize {
103        self.orders_by_wallet
104            .get(wallet)
105            .map(|m| m.len())
106            .unwrap_or(0)
107    }
108
109    /// Sum of (price × remaining_qty) for open BUY option orders that are
110    /// NOT closing existing short positions. Buys that reduce a short are
111    /// risk-reducing and should not reserve premium.
112    pub fn calculate_open_buy_premium(
113        &self,
114        wallet: &WalletAddress,
115        positions: &HashMap<(WalletAddress, String), EnginePosition>,
116    ) -> Decimal {
117        let Some(orders) = self.orders_by_wallet.get(wallet) else {
118            return dec!(0);
119        };
120
121        let mut total = dec!(0);
122        for summary in orders.values() {
123            if !matches!(summary.side, Side::Buy) {
124                continue;
125            }
126            // Only options (not perps)
127            if summary.is_perp {
128                continue;
129            }
130            if ParsedInstrument::parse(&summary.symbol).is_err() {
131                continue;
132            }
133            if summary.remaining_size <= dec!(0) {
134                continue;
135            }
136            // Only reserve premium for the portion that exceeds the existing short.
137            // If short 2 and buying 5, only 3 contracts need premium reserved.
138            let existing_qty = positions
139                .get(&(*wallet, summary.symbol.clone()))
140                .map(|p| p.quantity)
141                .unwrap_or(dec!(0));
142            let reservable_size = if existing_qty < dec!(0) {
143                // Short position: only the excess beyond the close needs premium
144                (summary.remaining_size + existing_qty).max(dec!(0))
145            } else {
146                summary.remaining_size
147            };
148            if reservable_size <= dec!(0) {
149                continue;
150            }
151            total += summary.price * reservable_size;
152        }
153        total
154    }
155
156    /// Open SELL option orders as hypothetical short positions for Standard margin.
157    pub fn get_open_sell_option_positions(
158        &self,
159        wallet: &WalletAddress,
160    ) -> Vec<OpenSellPositionInfo> {
161        let Some(orders) = self.orders_by_wallet.get(wallet) else {
162            return Vec::new();
163        };
164
165        let mut positions = Vec::new();
166        for summary in orders.values() {
167            if !matches!(summary.side, Side::Sell) {
168                continue;
169            }
170            if summary.is_perp {
171                continue;
172            }
173            let parsed = match ParsedInstrument::parse(&summary.symbol) {
174                Ok(p) => p,
175                Err(_) => continue,
176            };
177            if summary.remaining_size <= dec!(0) {
178                continue;
179            }
180
181            let premium = summary.price * summary.remaining_size;
182
183            let expiry_ts = match parsed.expiry_timestamp() {
184                Ok(ts) => ts,
185                Err(_) => continue,
186            };
187
188            let position = hypercall_margin::OptionPosition {
189                symbol: summary.symbol.clone(),
190                underlying: parsed.underlying.clone(),
191                expiry_ts,
192                strike: parsed.strike,
193                is_call: matches!(parsed.option_type, hypercall_types::OptionType::Call),
194                size: -summary.remaining_size,
195                mark_price: summary.price,
196                entry_price: summary.price,
197                spot_price: dec!(0), // Caller updates with actual spot
198            };
199
200            positions.push(OpenSellPositionInfo { premium, position });
201        }
202        positions
203    }
204
205    /// Total open sell quantity for a specific contract (wallet + symbol).
206    pub fn get_open_sells_for_contract(&self, wallet: &WalletAddress, symbol: &str) -> Decimal {
207        if let Some(key) = contract_key(symbol) {
208            self.open_sells_by_contract
209                .get(&(*wallet, key))
210                .copied()
211                .unwrap_or(dec!(0))
212        } else {
213            dec!(0)
214        }
215    }
216
217    /// Clone all orders for snapshot publication via ArcSwap.
218    pub fn snapshot_orders(&self) -> HashMap<WalletAddress, Vec<OrderSummary>> {
219        self.orders_by_wallet
220            .iter()
221            .map(|(wallet, orders)| (*wallet, orders.values().cloned().collect()))
222            .collect()
223    }
224
225    /// Get all order summaries for a wallet (used by margin manager).
226    pub fn get_all_orders_for_wallet(&self, wallet: &WalletAddress) -> Vec<&OrderSummary> {
227        match self.orders_by_wallet.get(wallet) {
228            Some(orders) => orders.values().collect(),
229            None => Vec::new(),
230        }
231    }
232
233    /// Get (order_id, symbol) pairs for all MMP-enabled orders matching wallet + underlying.
234    pub fn get_mmp_order_ids(
235        &self,
236        wallet: &WalletAddress,
237        underlying: &str,
238    ) -> Vec<(u64, String)> {
239        let Some(orders) = self.orders_by_wallet.get(wallet) else {
240            return Vec::new();
241        };
242
243        orders
244            .values()
245            .filter(|s| {
246                if !s.mmp_enabled {
247                    return false;
248                }
249                if let Ok(parsed) = ParsedInstrument::parse(&s.symbol) {
250                    parsed.underlying == underlying
251                } else {
252                    false
253                }
254            })
255            .map(|s| (s.order_id, s.symbol.clone()))
256            .collect()
257    }
258
259    // ===== Mutation methods =====
260
261    /// Track a new order after ACK.
262    ///
263    /// Sizes in `summary` are expected in **raw contract units** (from `OrderInfo`);
264    /// this method converts them to human-readable before storing.
265    pub fn add_order(&mut self, wallet: &WalletAddress, mut summary: OrderSummary) {
266        // Convert raw contract units → human-readable at the boundary
267        summary.original_size = to_human_readable_decimal(&summary.symbol, summary.original_size);
268        summary.remaining_size = to_human_readable_decimal(&summary.symbol, summary.remaining_size);
269
270        let order_id = summary.order_id;
271
272        // Update client_id index
273        if let Some(ref cid) = summary.client_id {
274            self.client_id_index
275                .insert((*wallet, cid.clone()), order_id);
276        }
277
278        // Update open sells index (now in human-readable units)
279        if matches!(summary.side, Side::Sell) {
280            if let Some(key) = contract_key(&summary.symbol) {
281                *self
282                    .open_sells_by_contract
283                    .entry((*wallet, key))
284                    .or_insert(dec!(0)) += summary.remaining_size;
285            }
286        }
287
288        // Insert order
289        self.orders_by_wallet
290            .entry(*wallet)
291            .or_default()
292            .insert(order_id, summary);
293    }
294
295    /// Update after a partial or full fill. Returns `true` if fully filled (removed).
296    ///
297    /// `filled_qty` is expected in **raw contract units** (from `Fill`);
298    /// this method converts it to human-readable before applying.
299    pub fn fill_order(
300        &mut self,
301        wallet: &WalletAddress,
302        order_id: u64,
303        filled_qty: Decimal,
304    ) -> bool {
305        let wallet_orders = match self.orders_by_wallet.get_mut(wallet) {
306            Some(m) => m,
307            None => return false,
308        };
309
310        let summary = match wallet_orders.get_mut(&order_id) {
311            Some(s) => s,
312            None => return false,
313        };
314
315        // Convert raw → human-readable at the boundary
316        let filled_qty = to_human_readable_decimal(&summary.symbol, filled_qty);
317
318        // Update open sells tracking
319        if matches!(summary.side, Side::Sell) {
320            if let Some(key) = contract_key(&summary.symbol) {
321                let map_key = (*wallet, key);
322                let should_remove = if let Some(qty) = self.open_sells_by_contract.get_mut(&map_key)
323                {
324                    *qty = (*qty - filled_qty).max(dec!(0));
325                    *qty == dec!(0)
326                } else {
327                    false
328                };
329                if should_remove {
330                    self.open_sells_by_contract.remove(&map_key);
331                }
332            }
333        }
334
335        summary.remaining_size -= filled_qty;
336        if summary.remaining_size <= dec!(0) {
337            // Fully filled — remove from all indexes
338            let removed = wallet_orders.remove(&order_id).unwrap();
339            if let Some(ref cid) = removed.client_id {
340                self.client_id_index.remove(&(*wallet, cid.clone()));
341            }
342            if wallet_orders.is_empty() {
343                self.orders_by_wallet.remove(wallet);
344            }
345            true
346        } else {
347            false
348        }
349    }
350
351    /// Remove an order after cancel/reject.
352    pub fn remove_order(&mut self, wallet: &WalletAddress, order_id: u64) {
353        let wallet_orders = match self.orders_by_wallet.get_mut(wallet) {
354            Some(m) => m,
355            None => return,
356        };
357
358        if let Some(removed) = wallet_orders.remove(&order_id) {
359            // Clean up client_id index
360            if let Some(ref cid) = removed.client_id {
361                self.client_id_index.remove(&(*wallet, cid.clone()));
362            }
363
364            // Clean up open sells
365            if matches!(removed.side, Side::Sell) {
366                if let Some(key) = contract_key(&removed.symbol) {
367                    let map_key = (*wallet, key);
368                    let should_remove =
369                        if let Some(qty) = self.open_sells_by_contract.get_mut(&map_key) {
370                            *qty = (*qty - removed.remaining_size).max(dec!(0));
371                            *qty == dec!(0)
372                        } else {
373                            false
374                        };
375                    if should_remove {
376                        self.open_sells_by_contract.remove(&map_key);
377                    }
378                }
379            }
380
381            if wallet_orders.is_empty() {
382                self.orders_by_wallet.remove(wallet);
383            }
384        }
385    }
386
387    /// Remove an order by ID without knowing the wallet.
388    ///
389    /// Used by post-startup reconciliation where we only have order_ids from the DB.
390    /// Scans all wallets to find the owner — O(n) in wallet count but only
391    /// called during startup for a small number of ghost orders.
392    pub fn remove_order_by_id(&mut self, order_id: u64) {
393        let wallet = self
394            .orders_by_wallet
395            .iter()
396            .find(|(_, orders)| orders.contains_key(&order_id))
397            .map(|(w, _)| *w);
398
399        if let Some(wallet) = wallet {
400            self.remove_order(&wallet, order_id);
401        }
402    }
403
404    /// Populate from orderbooks after snapshot restore.
405    ///
406    /// For snapshot-loaded orders, client_id and mmp_enabled are unknown,
407    /// so they default to `None`/`false`. Replay will add subsequent orders
408    /// with full metadata.
409    pub fn rebuild_from_orderbooks(
410        &mut self,
411        orderbooks: &HashMap<String, crate::orderbook::OrderBook>,
412    ) {
413        self.orders_by_wallet.clear();
414        self.client_id_index.clear();
415        self.open_sells_by_contract.clear();
416
417        for (symbol, orderbook) in orderbooks {
418            let is_perp = ParsedInstrument::parse(symbol).is_err();
419            for r in orderbook.get_all_orders() {
420                let summary = OrderSummary {
421                    order_id: r.order_id,
422                    symbol: symbol.clone(),
423                    side: r.side,
424                    price: r.price,
425                    original_size: r.original_size,
426                    remaining_size: r.quantity,
427                    is_perp,
428                    mmp_enabled: r.mmp_enabled,
429                    client_id: r.client_id,
430                    created_at: r.timestamp as i64,
431                };
432                self.add_order(&r.wallet, summary);
433            }
434        }
435    }
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441    use hypercall_types::CONTRACT_UNIT_MULTIPLIER_DECIMAL;
442    use rust_decimal_macros::dec;
443
444    /// Convert a human-readable quantity to raw contract units for test inputs.
445    /// add_order/fill_order expect raw units and convert internally.
446    fn raw(human: Decimal) -> Decimal {
447        human * CONTRACT_UNIT_MULTIPLIER_DECIMAL
448    }
449
450    fn wallet(byte: u8) -> WalletAddress {
451        WalletAddress::from(alloy::primitives::Address::repeat_byte(byte))
452    }
453
454    fn make_summary(
455        order_id: u64,
456        symbol: &str,
457        side: Side,
458        price: Decimal,
459        size_raw: Decimal,
460    ) -> OrderSummary {
461        OrderSummary {
462            order_id,
463            symbol: symbol.to_string(),
464            side,
465            price,
466            original_size: size_raw,
467            remaining_size: size_raw,
468            is_perp: ParsedInstrument::parse(symbol).is_err(),
469            mmp_enabled: false,
470            client_id: None,
471            created_at: 0,
472        }
473    }
474
475    #[test]
476    fn test_add_and_lookup() {
477        let mut idx = EngineOrderIndex::new();
478        let w = wallet(1);
479        let sym = "ETH-20260131-4000-C";
480
481        idx.add_order(
482            &w,
483            make_summary(1, sym, Side::Buy, dec!(100), raw(dec!(10))),
484        );
485
486        assert_eq!(idx.get_order_symbol(&w, 1), Some("ETH-20260131-4000-C"));
487        assert_eq!(idx.open_order_count(&w), 1);
488        assert_eq!(idx.get_order_symbol(&w, 999), None);
489    }
490
491    #[test]
492    fn test_add_converts_to_human_readable() {
493        let mut idx = EngineOrderIndex::new();
494        let w = wallet(1);
495        let sym = "ETH-20260131-4000-C";
496
497        // Pass 5 contracts in raw units (5_000_000)
498        idx.add_order(&w, make_summary(1, sym, Side::Buy, dec!(100), raw(dec!(5))));
499
500        // Stored size should be human-readable (5.0)
501        let orders = idx.get_all_orders_for_wallet(&w);
502        assert_eq!(orders.len(), 1);
503        assert_eq!(orders[0].remaining_size, dec!(5));
504        assert_eq!(orders[0].original_size, dec!(5));
505    }
506
507    #[test]
508    fn test_client_id_lookup() {
509        let mut idx = EngineOrderIndex::new();
510        let w = wallet(1);
511        let mut summary =
512            make_summary(42, "ETH-20260131-4000-C", Side::Buy, dec!(50), raw(dec!(5)));
513        summary.client_id = Some("my-order-1".to_string());
514
515        idx.add_order(&w, summary);
516
517        let result = idx.get_order_by_client_id(&w, "my-order-1");
518        assert_eq!(result, Some((42, "ETH-20260131-4000-C")));
519        assert_eq!(idx.get_order_by_client_id(&w, "nonexistent"), None);
520    }
521
522    #[test]
523    fn test_fill_partial_and_full() {
524        let mut idx = EngineOrderIndex::new();
525        let w = wallet(1);
526        idx.add_order(
527            &w,
528            make_summary(
529                1,
530                "ETH-20260131-4000-C",
531                Side::Buy,
532                dec!(100),
533                raw(dec!(10)),
534            ),
535        );
536
537        // Partial fill (3 contracts in raw units)
538        let fully_filled = idx.fill_order(&w, 1, raw(dec!(3)));
539        assert!(!fully_filled);
540        assert_eq!(idx.open_order_count(&w), 1);
541
542        // Full fill (remaining 7 contracts in raw units)
543        let fully_filled = idx.fill_order(&w, 1, raw(dec!(7)));
544        assert!(fully_filled);
545        assert_eq!(idx.open_order_count(&w), 0);
546    }
547
548    #[test]
549    fn test_remove_order() {
550        let mut idx = EngineOrderIndex::new();
551        let w = wallet(1);
552        let mut summary = make_summary(
553            1,
554            "ETH-20260131-4000-C",
555            Side::Sell,
556            dec!(100),
557            raw(dec!(10)),
558        );
559        summary.client_id = Some("cid-1".to_string());
560        idx.add_order(&w, summary);
561
562        assert_eq!(idx.open_order_count(&w), 1);
563        assert!(idx.get_order_by_client_id(&w, "cid-1").is_some());
564
565        idx.remove_order(&w, 1);
566
567        assert_eq!(idx.open_order_count(&w), 0);
568        assert!(idx.get_order_by_client_id(&w, "cid-1").is_none());
569    }
570
571    #[test]
572    fn test_open_buy_premium() {
573        let mut idx = EngineOrderIndex::new();
574        let w = wallet(1);
575
576        // Buy option: price=100, qty=5 contracts → premium=500
577        idx.add_order(
578            &w,
579            make_summary(1, "ETH-20260131-4000-C", Side::Buy, dec!(100), raw(dec!(5))),
580        );
581        // Buy option: price=50, qty=2 contracts → premium=100
582        idx.add_order(
583            &w,
584            make_summary(2, "ETH-20260131-5000-P", Side::Buy, dec!(50), raw(dec!(2))),
585        );
586        // Sell option: should NOT be counted
587        idx.add_order(
588            &w,
589            make_summary(
590                3,
591                "ETH-20260131-4000-C",
592                Side::Sell,
593                dec!(200),
594                raw(dec!(1)),
595            ),
596        );
597
598        let no_positions = std::collections::HashMap::new();
599        assert_eq!(idx.calculate_open_buy_premium(&w, &no_positions), dec!(600));
600    }
601
602    #[test]
603    fn test_open_buy_premium_skips_closing_buys() {
604        let mut idx = EngineOrderIndex::new();
605        let w = wallet(1);
606
607        // Buy order on a symbol where wallet has a SHORT position → closing buy, skip premium
608        idx.add_order(
609            &w,
610            make_summary(1, "ETH-20260131-4000-C", Side::Buy, dec!(100), raw(dec!(5))),
611        );
612        // Buy order on a symbol where wallet has NO position → new buy, reserve premium
613        idx.add_order(
614            &w,
615            make_summary(2, "ETH-20260131-5000-P", Side::Buy, dec!(50), raw(dec!(2))),
616        );
617
618        let mut positions = std::collections::HashMap::new();
619        positions.insert(
620            (w, "ETH-20260131-4000-C".to_string()),
621            EnginePosition {
622                quantity: dec!(-5),
623                entry_price: dec!(100),
624            },
625        );
626
627        // Only the 5000-P buy (no position) should reserve premium: 50 * 2 = 100
628        assert_eq!(idx.calculate_open_buy_premium(&w, &positions), dec!(100));
629
630        // Partial close: short 2 but buying 5 → only 3 contracts need premium
631        let mut idx2 = EngineOrderIndex::new();
632        idx2.add_order(
633            &w,
634            make_summary(1, "ETH-20260131-4000-C", Side::Buy, dec!(100), raw(dec!(5))),
635        );
636        let mut positions2 = std::collections::HashMap::new();
637        positions2.insert(
638            (w, "ETH-20260131-4000-C".to_string()),
639            EnginePosition {
640                quantity: dec!(-2),
641                entry_price: dec!(100),
642            },
643        );
644        // 5 buy - 2 short = 3 excess → premium = 100 * 3 = 300
645        assert_eq!(idx2.calculate_open_buy_premium(&w, &positions2), dec!(300));
646    }
647
648    #[test]
649    fn test_open_sells_for_contract() {
650        let mut idx = EngineOrderIndex::new();
651        let w = wallet(1);
652        let sym = "ETH-20260131-4000-C";
653
654        idx.add_order(
655            &w,
656            make_summary(1, sym, Side::Sell, dec!(100), raw(dec!(5))),
657        );
658        idx.add_order(&w, make_summary(2, sym, Side::Sell, dec!(90), raw(dec!(3))));
659
660        assert_eq!(idx.get_open_sells_for_contract(&w, sym), dec!(8));
661
662        // Partial fill on order 1 (2 contracts in raw units)
663        idx.fill_order(&w, 1, raw(dec!(2)));
664        assert_eq!(idx.get_open_sells_for_contract(&w, sym), dec!(6));
665
666        // Cancel order 2
667        idx.remove_order(&w, 2);
668        assert_eq!(idx.get_open_sells_for_contract(&w, sym), dec!(3));
669    }
670
671    #[test]
672    fn test_mmp_order_ids() {
673        let mut idx = EngineOrderIndex::new();
674        let w = wallet(1);
675
676        let mut s1 = make_summary(1, "ETH-20260131-4000-C", Side::Buy, dec!(100), raw(dec!(5)));
677        s1.mmp_enabled = true;
678        idx.add_order(&w, s1);
679
680        let mut s2 = make_summary(2, "ETH-20260131-5000-P", Side::Sell, dec!(50), raw(dec!(3)));
681        s2.mmp_enabled = true;
682        idx.add_order(&w, s2);
683
684        // Not MMP
685        idx.add_order(
686            &w,
687            make_summary(3, "ETH-20260131-6000-C", Side::Buy, dec!(10), raw(dec!(1))),
688        );
689
690        // Different underlying
691        let mut s4 = make_summary(
692            4,
693            "BTC-20260131-100000-C",
694            Side::Buy,
695            dec!(500),
696            raw(dec!(1)),
697        );
698        s4.mmp_enabled = true;
699        idx.add_order(&w, s4);
700
701        let mmp_eth = idx.get_mmp_order_ids(&w, "ETH");
702        assert_eq!(mmp_eth.len(), 2);
703        let ids: Vec<u64> = mmp_eth.iter().map(|(id, _)| *id).collect();
704        assert!(ids.contains(&1));
705        assert!(ids.contains(&2));
706
707        let mmp_btc = idx.get_mmp_order_ids(&w, "BTC");
708        assert_eq!(mmp_btc.len(), 1);
709        assert_eq!(mmp_btc[0].0, 4);
710    }
711
712    #[test]
713    fn test_open_sell_option_positions() {
714        let mut idx = EngineOrderIndex::new();
715        let w = wallet(1);
716
717        // Sell option: 5 contracts
718        idx.add_order(
719            &w,
720            make_summary(
721                1,
722                "ETH-20260131-4000-C",
723                Side::Sell,
724                dec!(100),
725                raw(dec!(5)),
726            ),
727        );
728        // Buy option — should not appear
729        idx.add_order(
730            &w,
731            make_summary(2, "ETH-20260131-4000-C", Side::Buy, dec!(80), raw(dec!(3))),
732        );
733
734        let positions = idx.get_open_sell_option_positions(&w);
735        assert_eq!(positions.len(), 1);
736        assert_eq!(positions[0].premium, dec!(500)); // 100 * 5
737        assert_eq!(positions[0].position.size, dec!(-5));
738        assert_eq!(positions[0].position.strike, dec!(4000));
739        assert!(positions[0].position.is_call);
740    }
741
742    #[test]
743    fn test_fill_cleans_client_id_index() {
744        let mut idx = EngineOrderIndex::new();
745        let w = wallet(1);
746        let mut summary =
747            make_summary(1, "ETH-20260131-4000-C", Side::Buy, dec!(100), raw(dec!(5)));
748        summary.client_id = Some("cid-fill".to_string());
749        idx.add_order(&w, summary);
750
751        assert!(idx.get_order_by_client_id(&w, "cid-fill").is_some());
752
753        idx.fill_order(&w, 1, raw(dec!(5)));
754        assert!(idx.get_order_by_client_id(&w, "cid-fill").is_none());
755    }
756
757    #[test]
758    fn test_multiple_wallets() {
759        let mut idx = EngineOrderIndex::new();
760        let w1 = wallet(1);
761        let w2 = wallet(2);
762
763        idx.add_order(
764            &w1,
765            make_summary(1, "ETH-20260131-4000-C", Side::Buy, dec!(100), raw(dec!(5))),
766        );
767        idx.add_order(
768            &w2,
769            make_summary(2, "ETH-20260131-4000-C", Side::Buy, dec!(50), raw(dec!(3))),
770        );
771
772        assert_eq!(idx.open_order_count(&w1), 1);
773        assert_eq!(idx.open_order_count(&w2), 1);
774        assert_eq!(idx.get_order_symbol(&w1, 1), Some("ETH-20260131-4000-C"));
775        assert_eq!(idx.get_order_symbol(&w2, 2), Some("ETH-20260131-4000-C"));
776        assert_eq!(idx.get_order_symbol(&w1, 2), None);
777    }
778
779    #[test]
780    fn test_remove_nonexistent_is_noop() {
781        let mut idx = EngineOrderIndex::new();
782        let w = wallet(1);
783        // Should not panic
784        idx.remove_order(&w, 999);
785        assert_eq!(idx.open_order_count(&w), 0);
786    }
787
788    #[test]
789    fn test_fill_nonexistent_returns_false() {
790        let mut idx = EngineOrderIndex::new();
791        let w = wallet(1);
792        assert!(!idx.fill_order(&w, 999, raw(dec!(5))));
793    }
794
795    #[test]
796    fn test_partial_fill_reduces_remaining() {
797        let mut idx = EngineOrderIndex::new();
798        let w = wallet(1);
799        idx.add_order(
800            &w,
801            make_summary(
802                1,
803                "ETH-20260131-4000-C",
804                Side::Buy,
805                dec!(100),
806                raw(dec!(10)),
807            ),
808        );
809
810        let fully_filled = idx.fill_order(&w, 1, raw(dec!(3)));
811        assert!(!fully_filled);
812        assert_eq!(idx.open_order_count(&w), 1);
813
814        let orders = idx.get_all_orders_for_wallet(&w);
815        assert_eq!(orders[0].remaining_size, dec!(7));
816    }
817
818    #[test]
819    fn test_full_fill_removes_order() {
820        let mut idx = EngineOrderIndex::new();
821        let w = wallet(1);
822        idx.add_order(
823            &w,
824            make_summary(
825                1,
826                "ETH-20260131-4000-C",
827                Side::Buy,
828                dec!(100),
829                raw(dec!(10)),
830            ),
831        );
832
833        let fully_filled = idx.fill_order(&w, 1, raw(dec!(10)));
834        assert!(fully_filled);
835        assert_eq!(idx.open_order_count(&w), 0);
836    }
837
838    #[test]
839    fn test_overfill_removes_order() {
840        let mut idx = EngineOrderIndex::new();
841        let w = wallet(1);
842        idx.add_order(
843            &w,
844            make_summary(1, "ETH-20260131-4000-C", Side::Buy, dec!(100), raw(dec!(5))),
845        );
846
847        let fully_filled = idx.fill_order(&w, 1, raw(dec!(10)));
848        assert!(fully_filled);
849        assert_eq!(idx.open_order_count(&w), 0);
850    }
851
852    #[test]
853    fn test_fill_sell_updates_open_sells() {
854        let mut idx = EngineOrderIndex::new();
855        let w = wallet(1);
856        idx.add_order(
857            &w,
858            make_summary(
859                1,
860                "ETH-20260131-4000-C",
861                Side::Sell,
862                dec!(100),
863                raw(dec!(10)),
864            ),
865        );
866
867        let initial_sells = idx.get_open_sells_for_contract(&w, "ETH-20260131-4000-C");
868        assert!(initial_sells > dec!(0));
869
870        idx.fill_order(&w, 1, raw(dec!(5)));
871        let after_partial = idx.get_open_sells_for_contract(&w, "ETH-20260131-4000-C");
872        assert!(after_partial < initial_sells);
873
874        idx.fill_order(&w, 1, raw(dec!(5)));
875        let after_full = idx.get_open_sells_for_contract(&w, "ETH-20260131-4000-C");
876        assert_eq!(after_full, dec!(0));
877    }
878
879    #[test]
880    fn test_premium_calculation_single_buy() {
881        let mut idx = EngineOrderIndex::new();
882        let w = wallet(1);
883        idx.add_order(
884            &w,
885            make_summary(1, "ETH-20260131-4000-C", Side::Buy, dec!(500), raw(dec!(2))),
886        );
887
888        let premium = idx.calculate_open_buy_premium(&w, &HashMap::new());
889        assert_eq!(premium, dec!(500) * dec!(2));
890    }
891
892    #[test]
893    fn test_premium_reduces_for_existing_short() {
894        let mut idx = EngineOrderIndex::new();
895        let w = wallet(1);
896        idx.add_order(
897            &w,
898            make_summary(1, "ETH-20260131-4000-C", Side::Buy, dec!(100), raw(dec!(5))),
899        );
900
901        let mut positions = HashMap::new();
902        positions.insert(
903            (w, "ETH-20260131-4000-C".to_string()),
904            EnginePosition {
905                quantity: dec!(-3),
906                entry_price: dec!(100),
907            },
908        );
909
910        let premium = idx.calculate_open_buy_premium(&w, &positions);
911        assert_eq!(premium, dec!(100) * dec!(2));
912    }
913
914    #[test]
915    fn test_premium_zero_when_fully_closing_short() {
916        let mut idx = EngineOrderIndex::new();
917        let w = wallet(1);
918        idx.add_order(
919            &w,
920            make_summary(1, "ETH-20260131-4000-C", Side::Buy, dec!(100), raw(dec!(3))),
921        );
922
923        let mut positions = HashMap::new();
924        positions.insert(
925            (w, "ETH-20260131-4000-C".to_string()),
926            EnginePosition {
927                quantity: dec!(-5),
928                entry_price: dec!(100),
929            },
930        );
931
932        let premium = idx.calculate_open_buy_premium(&w, &positions);
933        assert_eq!(premium, dec!(0));
934    }
935
936    #[test]
937    fn test_premium_ignores_perps() {
938        let mut idx = EngineOrderIndex::new();
939        let w = wallet(1);
940        idx.add_order(
941            &w,
942            make_summary(1, "ETH-PERP", Side::Buy, dec!(3000), raw(dec!(10))),
943        );
944
945        let premium = idx.calculate_open_buy_premium(&w, &HashMap::new());
946        assert_eq!(premium, dec!(0));
947    }
948
949    #[test]
950    fn test_premium_ignores_sell_orders() {
951        let mut idx = EngineOrderIndex::new();
952        let w = wallet(1);
953        idx.add_order(
954            &w,
955            make_summary(
956                1,
957                "ETH-20260131-4000-C",
958                Side::Sell,
959                dec!(500),
960                raw(dec!(5)),
961            ),
962        );
963
964        let premium = idx.calculate_open_buy_premium(&w, &HashMap::new());
965        assert_eq!(premium, dec!(0));
966    }
967
968    // --- rebuild_from_orderbooks round-trip tests ---
969
970    #[test]
971    fn rebuild_preserves_orders_from_single_book() {
972        let w = wallet(1);
973        let mut book = crate::orderbook::OrderBook::with_symbol(
974            20260131,
975            dec!(4000),
976            hypercall_types::OptionType::Call,
977            "ETH-20260131-4000-C".to_string(),
978        );
979        book.add_order_with_metadata(
980            1,
981            dec!(500),
982            raw(dec!(3)),
983            Side::Buy,
984            w,
985            1000,
986            Some("client-1".to_string()),
987            false,
988            raw(dec!(3)),
989        );
990        book.add_order_with_metadata(
991            2,
992            dec!(600),
993            raw(dec!(5)),
994            Side::Sell,
995            w,
996            1001,
997            None,
998            true,
999            raw(dec!(5)),
1000        );
1001
1002        let mut orderbooks = HashMap::new();
1003        orderbooks.insert("ETH-20260131-4000-C".to_string(), book);
1004
1005        let mut idx = EngineOrderIndex::new();
1006        idx.rebuild_from_orderbooks(&orderbooks);
1007
1008        assert_eq!(idx.open_order_count(&w), 2);
1009        assert_eq!(idx.get_order_symbol(&w, 1), Some("ETH-20260131-4000-C"));
1010        assert_eq!(idx.get_order_symbol(&w, 2), Some("ETH-20260131-4000-C"));
1011        assert_eq!(
1012            idx.get_order_by_client_id(&w, "client-1").map(|(id, _)| id),
1013            Some(1)
1014        );
1015    }
1016
1017    #[test]
1018    fn rebuild_preserves_orders_across_multiple_books() {
1019        let w = wallet(1);
1020        let mut book1 = crate::orderbook::OrderBook::with_symbol(
1021            20260131,
1022            dec!(4000),
1023            hypercall_types::OptionType::Call,
1024            "ETH-20260131-4000-C".to_string(),
1025        );
1026        book1.add_order_with_metadata(
1027            1,
1028            dec!(500),
1029            raw(dec!(2)),
1030            Side::Buy,
1031            w,
1032            1000,
1033            None,
1034            false,
1035            raw(dec!(2)),
1036        );
1037
1038        let mut book2 = crate::orderbook::OrderBook::with_symbol(
1039            20260131,
1040            dec!(100000),
1041            hypercall_types::OptionType::Put,
1042            "BTC-20260131-100000-P".to_string(),
1043        );
1044        book2.add_order_with_metadata(
1045            2,
1046            dec!(8000),
1047            raw(dec!(1)),
1048            Side::Sell,
1049            w,
1050            1001,
1051            None,
1052            false,
1053            raw(dec!(1)),
1054        );
1055
1056        let mut orderbooks = HashMap::new();
1057        orderbooks.insert("ETH-20260131-4000-C".to_string(), book1);
1058        orderbooks.insert("BTC-20260131-100000-P".to_string(), book2);
1059
1060        let mut idx = EngineOrderIndex::new();
1061        idx.rebuild_from_orderbooks(&orderbooks);
1062
1063        assert_eq!(idx.open_order_count(&w), 2);
1064        assert_eq!(idx.get_order_symbol(&w, 1), Some("ETH-20260131-4000-C"));
1065        assert_eq!(idx.get_order_symbol(&w, 2), Some("BTC-20260131-100000-P"));
1066    }
1067
1068    #[test]
1069    fn rebuild_tracks_open_sells() {
1070        let w = wallet(1);
1071        let mut book = crate::orderbook::OrderBook::with_symbol(
1072            20260131,
1073            dec!(4000),
1074            hypercall_types::OptionType::Call,
1075            "ETH-20260131-4000-C".to_string(),
1076        );
1077        book.add_order_with_metadata(
1078            1,
1079            dec!(500),
1080            raw(dec!(7)),
1081            Side::Sell,
1082            w,
1083            1000,
1084            None,
1085            false,
1086            raw(dec!(7)),
1087        );
1088
1089        let mut orderbooks = HashMap::new();
1090        orderbooks.insert("ETH-20260131-4000-C".to_string(), book);
1091
1092        let mut idx = EngineOrderIndex::new();
1093        idx.rebuild_from_orderbooks(&orderbooks);
1094
1095        let sells = idx.get_open_sells_for_contract(&w, "ETH-20260131-4000-C");
1096        assert!(sells > dec!(0));
1097    }
1098
1099    #[test]
1100    fn rebuild_clears_previous_state() {
1101        let w = wallet(1);
1102        let mut idx = EngineOrderIndex::new();
1103        idx.add_order(
1104            &w,
1105            make_summary(
1106                99,
1107                "OLD-20260131-1000-C",
1108                Side::Buy,
1109                dec!(100),
1110                raw(dec!(1)),
1111            ),
1112        );
1113        assert_eq!(idx.open_order_count(&w), 1);
1114
1115        let orderbooks = HashMap::new();
1116        idx.rebuild_from_orderbooks(&orderbooks);
1117        assert_eq!(idx.open_order_count(&w), 0);
1118    }
1119
1120    // --- get_open_sell_option_positions tests ---
1121
1122    #[test]
1123    fn open_sell_positions_returns_sell_options_only() {
1124        let mut idx = EngineOrderIndex::new();
1125        let w = wallet(1);
1126
1127        // Sell option — should appear
1128        idx.add_order(
1129            &w,
1130            make_summary(
1131                1,
1132                "ETH-20260131-4000-C",
1133                Side::Sell,
1134                dec!(500),
1135                raw(dec!(3)),
1136            ),
1137        );
1138        // Buy option — should NOT appear
1139        idx.add_order(
1140            &w,
1141            make_summary(2, "ETH-20260131-4000-C", Side::Buy, dec!(500), raw(dec!(2))),
1142        );
1143        // Sell perp — should NOT appear
1144        let mut perp_sell = make_summary(3, "ETH-PERP", Side::Sell, dec!(3000), raw(dec!(1)));
1145        perp_sell.is_perp = true;
1146        idx.add_order(&w, perp_sell);
1147
1148        let positions = idx.get_open_sell_option_positions(&w);
1149        assert_eq!(positions.len(), 1);
1150    }
1151
1152    #[test]
1153    fn open_sell_positions_has_negative_size() {
1154        let mut idx = EngineOrderIndex::new();
1155        let w = wallet(1);
1156        idx.add_order(
1157            &w,
1158            make_summary(
1159                1,
1160                "ETH-20260131-4000-C",
1161                Side::Sell,
1162                dec!(500),
1163                raw(dec!(3)),
1164            ),
1165        );
1166
1167        let positions = idx.get_open_sell_option_positions(&w);
1168        assert_eq!(positions.len(), 1);
1169        assert!(
1170            positions[0].position.size < dec!(0),
1171            "sell position size should be negative, got {}",
1172            positions[0].position.size
1173        );
1174    }
1175
1176    #[test]
1177    fn open_sell_positions_premium_equals_price_times_size() {
1178        let mut idx = EngineOrderIndex::new();
1179        let w = wallet(1);
1180        idx.add_order(
1181            &w,
1182            make_summary(
1183                1,
1184                "ETH-20260131-4000-C",
1185                Side::Sell,
1186                dec!(500),
1187                raw(dec!(4)),
1188            ),
1189        );
1190
1191        let positions = idx.get_open_sell_option_positions(&w);
1192        assert_eq!(positions.len(), 1);
1193        assert_eq!(positions[0].premium, dec!(500) * dec!(4));
1194    }
1195
1196    #[test]
1197    fn open_sell_positions_parses_instrument_fields() {
1198        let mut idx = EngineOrderIndex::new();
1199        let w = wallet(1);
1200        idx.add_order(
1201            &w,
1202            make_summary(
1203                1,
1204                "ETH-20260131-4000-C",
1205                Side::Sell,
1206                dec!(500),
1207                raw(dec!(1)),
1208            ),
1209        );
1210
1211        let positions = idx.get_open_sell_option_positions(&w);
1212        assert_eq!(positions.len(), 1);
1213        assert_eq!(positions[0].position.underlying, "ETH");
1214        assert_eq!(positions[0].position.strike, dec!(4000));
1215        assert!(positions[0].position.is_call);
1216        assert_eq!(positions[0].position.symbol, "ETH-20260131-4000-C");
1217    }
1218
1219    #[test]
1220    fn open_sell_positions_empty_for_no_orders() {
1221        let idx = EngineOrderIndex::new();
1222        let w = wallet(1);
1223        assert!(idx.get_open_sell_option_positions(&w).is_empty());
1224    }
1225}