1use 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#[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 pub original_size: Decimal,
30 pub remaining_size: Decimal,
32 pub is_perp: bool,
33 pub mmp_enabled: bool,
34 pub client_id: Option<String>,
35 pub created_at: i64,
37}
38
39pub struct OpenSellPositionInfo {
43 pub premium: Decimal,
45 pub position: hypercall_margin::OptionPosition,
47}
48
49pub struct EngineOrderIndex {
54 orders_by_wallet: HashMap<WalletAddress, HashMap<u64, OrderSummary>>,
56 client_id_index: HashMap<(WalletAddress, String), u64>,
58 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 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 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 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 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 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 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 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 (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 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), };
199
200 positions.push(OpenSellPositionInfo { premium, position });
201 }
202 positions
203 }
204
205 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 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 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 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 pub fn add_order(&mut self, wallet: &WalletAddress, mut summary: OrderSummary) {
266 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 if let Some(ref cid) = summary.client_id {
274 self.client_id_index
275 .insert((*wallet, cid.clone()), order_id);
276 }
277
278 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 self.orders_by_wallet
290 .entry(*wallet)
291 .or_default()
292 .insert(order_id, summary);
293 }
294
295 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 let filled_qty = to_human_readable_decimal(&summary.symbol, filled_qty);
317
318 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 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 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 if let Some(ref cid) = removed.client_id {
361 self.client_id_index.remove(&(*wallet, cid.clone()));
362 }
363
364 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 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 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 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 idx.add_order(&w, make_summary(1, sym, Side::Buy, dec!(100), raw(dec!(5))));
499
500 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 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 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 idx.add_order(
578 &w,
579 make_summary(1, "ETH-20260131-4000-C", Side::Buy, dec!(100), raw(dec!(5))),
580 );
581 idx.add_order(
583 &w,
584 make_summary(2, "ETH-20260131-5000-P", Side::Buy, dec!(50), raw(dec!(2))),
585 );
586 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 idx.add_order(
609 &w,
610 make_summary(1, "ETH-20260131-4000-C", Side::Buy, dec!(100), raw(dec!(5))),
611 );
612 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 assert_eq!(idx.calculate_open_buy_premium(&w, &positions), dec!(100));
629
630 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 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 idx.fill_order(&w, 1, raw(dec!(2)));
664 assert_eq!(idx.get_open_sells_for_contract(&w, sym), dec!(6));
665
666 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 idx.add_order(
686 &w,
687 make_summary(3, "ETH-20260131-6000-C", Side::Buy, dec!(10), raw(dec!(1))),
688 );
689
690 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 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 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)); 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 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 #[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 #[test]
1123 fn open_sell_positions_returns_sell_options_only() {
1124 let mut idx = EngineOrderIndex::new();
1125 let w = wallet(1);
1126
1127 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 idx.add_order(
1140 &w,
1141 make_summary(2, "ETH-20260131-4000-C", Side::Buy, dec!(500), raw(dec!(2))),
1142 );
1143 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}