1use std::collections::{BTreeMap, HashMap, HashSet};
12
13use crate::types::{FillModel, OrderType, PositionId, PriceQuote, Side};
14
15#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
19struct AlertKey {
20 symbol: String,
21 price_micros: i64,
23}
24
25#[derive(Debug, Clone)]
27struct AlertEntry {
28 position_id: PositionId,
29 kind: AlertKind,
30 side: Side,
32}
33
34#[derive(Debug, Clone, PartialEq)]
36pub enum AlertKind {
37 Stoploss,
39 TakeProfit { close_ratio: f64 },
41 BreakevenTrigger,
43 PendingFill { order_type: OrderType, side: Side },
45}
46
47#[derive(Debug, Clone)]
49pub struct TriggeredAlert {
50 pub position_id: PositionId,
51 pub kind: AlertKind,
52 pub side: Side,
53 pub trigger_price: f64,
54}
55
56#[derive(Debug, Clone, Default)]
60struct DirectionalAlerts {
61 fire_on_drop: BTreeMap<AlertKey, Vec<AlertEntry>>,
63 fire_on_rise: BTreeMap<AlertKey, Vec<AlertEntry>>,
65}
66
67#[derive(Debug, Clone, Default)]
75pub struct PriceAlertRegister {
76 alerts: DirectionalAlerts,
78 position_alerts: HashMap<PositionId, HashSet<AlertKey>>,
80 tick_eval_positions: HashMap<String, HashSet<PositionId>>,
83}
84
85#[derive(Debug)]
86pub(crate) struct PriceAlertRegisterQuoteCheckpoint {
87 symbol: String,
88 alerts: DirectionalAlerts,
89 tick_eval_positions: Option<HashSet<PositionId>>,
90}
91
92fn price_to_micros(price: f64) -> i64 {
94 (price * 1_000_000.0).round() as i64
95}
96
97fn micros_to_price(micros: i64) -> f64 {
99 micros as f64 / 1_000_000.0
100}
101
102impl PriceAlertRegister {
103 pub fn new() -> Self {
105 Self::default()
106 }
107
108 pub(crate) fn checkpoint_for_quote(&self, symbol: &str) -> PriceAlertRegisterQuoteCheckpoint {
109 PriceAlertRegisterQuoteCheckpoint {
110 symbol: symbol.to_owned(),
111 alerts: DirectionalAlerts {
112 fire_on_drop: Self::entries_for_symbol(&self.alerts.fire_on_drop, symbol),
113 fire_on_rise: Self::entries_for_symbol(&self.alerts.fire_on_rise, symbol),
114 },
115 tick_eval_positions: self.tick_eval_positions.get(symbol).cloned(),
116 }
117 }
118
119 pub(crate) fn restore_quote(&mut self, checkpoint: PriceAlertRegisterQuoteCheckpoint) {
120 let PriceAlertRegisterQuoteCheckpoint {
121 symbol,
122 alerts,
123 tick_eval_positions,
124 } = checkpoint;
125
126 self.alerts
127 .fire_on_drop
128 .retain(|key, _| key.symbol.as_str() != symbol);
129 self.alerts
130 .fire_on_rise
131 .retain(|key, _| key.symbol.as_str() != symbol);
132 self.position_alerts.retain(|_, keys| {
133 keys.retain(|key| key.symbol.as_str() != symbol);
134 !keys.is_empty()
135 });
136
137 Self::restore_entries(
138 &mut self.alerts.fire_on_drop,
139 &mut self.position_alerts,
140 alerts.fire_on_drop,
141 );
142 Self::restore_entries(
143 &mut self.alerts.fire_on_rise,
144 &mut self.position_alerts,
145 alerts.fire_on_rise,
146 );
147
148 match tick_eval_positions {
149 Some(position_ids) => {
150 self.tick_eval_positions.insert(symbol, position_ids);
151 }
152 None => {
153 self.tick_eval_positions.remove(&symbol);
154 }
155 }
156 }
157
158 pub fn register(
162 &mut self,
163 symbol: &str,
164 price: f64,
165 position_id: PositionId,
166 side: Side,
167 kind: AlertKind,
168 ) {
169 let key = AlertKey {
170 symbol: symbol.to_owned(),
171 price_micros: price_to_micros(price),
172 };
173
174 let entry = AlertEntry {
175 position_id: position_id.clone(),
176 kind: kind.clone(),
177 side,
178 };
179
180 let map = match Self::trigger_direction(side, &kind) {
182 TriggerDirection::FireOnDrop => &mut self.alerts.fire_on_drop,
183 TriggerDirection::FireOnRise => &mut self.alerts.fire_on_rise,
184 };
185
186 map.entry(key.clone()).or_default().push(entry);
187
188 self.position_alerts
190 .entry(position_id)
191 .or_default()
192 .insert(key);
193 }
194
195 pub fn register_tick_eval(&mut self, symbol: &str, position_id: PositionId) {
197 self.tick_eval_positions
198 .entry(symbol.to_owned())
199 .or_default()
200 .insert(position_id);
201 }
202
203 pub fn unregister_tick_eval(&mut self, symbol: &str, position_id: &str) {
205 if let Some(set) = self.tick_eval_positions.get_mut(symbol) {
206 set.remove(position_id);
207 if set.is_empty() {
208 self.tick_eval_positions.remove(symbol);
209 }
210 }
211 }
212
213 pub fn deregister_position(&mut self, position_id: &str) {
217 if let Some(keys) = self.position_alerts.remove(position_id) {
218 for key in keys {
219 Self::remove_entry_from_map(&mut self.alerts.fire_on_drop, &key, position_id);
220 Self::remove_entry_from_map(&mut self.alerts.fire_on_rise, &key, position_id);
221 }
222 }
223 self.tick_eval_positions.retain(|_, ids| {
225 ids.remove(position_id);
226 !ids.is_empty()
227 });
228 }
229
230 pub fn deregister_alert(
232 &mut self,
233 symbol: &str,
234 price: f64,
235 position_id: &str,
236 side: Side,
237 kind: &AlertKind,
238 ) {
239 let key = AlertKey {
240 symbol: symbol.to_owned(),
241 price_micros: price_to_micros(price),
242 };
243
244 let map = match Self::trigger_direction(side, kind) {
245 TriggerDirection::FireOnDrop => &mut self.alerts.fire_on_drop,
246 TriggerDirection::FireOnRise => &mut self.alerts.fire_on_rise,
247 };
248
249 Self::remove_alert_from_map(map, &key, position_id, side, kind);
250
251 if !self.has_entry_for_position_at_key(&key, position_id)
253 && let Some(keys) = self.position_alerts.get_mut(position_id)
254 {
255 keys.remove(&key);
256 if keys.is_empty() {
257 self.position_alerts.remove(position_id);
258 }
259 }
260 }
261
262 pub fn clear_all(&mut self) {
264 self.alerts.fire_on_drop.clear();
265 self.alerts.fire_on_rise.clear();
266 self.position_alerts.clear();
267 self.tick_eval_positions.clear();
268 }
269
270 pub fn check(&mut self, quote: &PriceQuote, model: FillModel) -> Vec<TriggeredAlert> {
275 let mut triggered = Vec::new();
276
277 let buy_eval = quote.eval_price(Side::Buy, model);
285 let sell_eval = quote.eval_price(Side::Sell, model);
286 let buy_fill = quote.fill_price(Side::Buy, model);
287 let sell_fill = quote.fill_price(Side::Sell, model);
288
289 let buy_eval_micros = price_to_micros(buy_eval);
290 let sell_eval_micros = price_to_micros(sell_eval);
291 let buy_fill_micros = price_to_micros(buy_fill);
292 let sell_fill_micros = price_to_micros(sell_fill);
293
294 let sym = "e.symbol;
298 let range_start_drop = AlertKey {
299 symbol: sym.clone(),
300 price_micros: i64::MIN,
301 };
302
303 let range_end_drop = AlertKey {
308 symbol: sym.clone(),
309 price_micros: i64::MAX,
310 };
311
312 let mut keys_to_remove_drop: Vec<(AlertKey, Vec<usize>)> = Vec::new();
313
314 for (key, entries) in self
316 .alerts
317 .fire_on_drop
318 .range(range_start_drop..=range_end_drop.clone())
319 {
320 let mut indices = Vec::new();
321 for (i, entry) in entries.iter().enumerate() {
322 let check_micros = match &entry.kind {
323 AlertKind::PendingFill { .. } => match entry.side {
324 Side::Buy => buy_fill_micros,
325 Side::Sell => sell_fill_micros,
326 },
327 _ => match entry.side {
328 Side::Buy => buy_eval_micros,
329 Side::Sell => sell_eval_micros,
330 },
331 };
332
333 if check_micros <= key.price_micros {
335 triggered.push(TriggeredAlert {
336 position_id: entry.position_id.clone(),
337 kind: entry.kind.clone(),
338 side: entry.side,
339 trigger_price: micros_to_price(key.price_micros),
340 });
341 indices.push(i);
342 }
343 }
344 if !indices.is_empty() {
345 keys_to_remove_drop.push((key.clone(), indices));
346 }
347 }
348
349 let range_start_rise = AlertKey {
351 symbol: sym.clone(),
352 price_micros: i64::MIN,
353 };
354 let range_end_rise = AlertKey {
355 symbol: sym.clone(),
356 price_micros: i64::MAX,
357 };
358
359 let mut keys_to_remove_rise: Vec<(AlertKey, Vec<usize>)> = Vec::new();
360
361 for (key, entries) in self
362 .alerts
363 .fire_on_rise
364 .range(range_start_rise..=range_end_rise)
365 {
366 let mut indices = Vec::new();
367 for (i, entry) in entries.iter().enumerate() {
368 let check_micros = match &entry.kind {
369 AlertKind::PendingFill { .. } => match entry.side {
370 Side::Buy => buy_fill_micros,
371 Side::Sell => sell_fill_micros,
372 },
373 _ => match entry.side {
374 Side::Buy => buy_eval_micros,
375 Side::Sell => sell_eval_micros,
376 },
377 };
378
379 if check_micros >= key.price_micros {
381 triggered.push(TriggeredAlert {
382 position_id: entry.position_id.clone(),
383 kind: entry.kind.clone(),
384 side: entry.side,
385 trigger_price: micros_to_price(key.price_micros),
386 });
387 indices.push(i);
388 }
389 }
390 if !indices.is_empty() {
391 keys_to_remove_rise.push((key.clone(), indices));
392 }
393 }
394
395 for (key, mut indices) in keys_to_remove_drop {
397 indices.sort_unstable_by(|a, b| b.cmp(a));
398 if let Some(entries) = self.alerts.fire_on_drop.get_mut(&key) {
399 for i in &indices {
400 let removed = entries.remove(*i);
401 if let Some(keys) = self.position_alerts.get_mut(&removed.position_id) {
403 let still_present =
406 entries.iter().any(|e| e.position_id == removed.position_id);
407 if !still_present {
408 let in_rise = self.alerts.fire_on_rise.get(&key).is_some_and(|v| {
410 v.iter().any(|e| e.position_id == removed.position_id)
411 });
412 if !in_rise {
413 keys.remove(&key);
414 }
415 }
416 if keys.is_empty() {
417 self.position_alerts.remove(&removed.position_id);
418 }
419 }
420 }
421 if entries.is_empty() {
422 self.alerts.fire_on_drop.remove(&key);
423 }
424 }
425 }
426
427 for (key, mut indices) in keys_to_remove_rise {
428 indices.sort_unstable_by(|a, b| b.cmp(a));
429 if let Some(entries) = self.alerts.fire_on_rise.get_mut(&key) {
430 for i in &indices {
431 let removed = entries.remove(*i);
432 if let Some(keys) = self.position_alerts.get_mut(&removed.position_id) {
433 let still_present =
434 entries.iter().any(|e| e.position_id == removed.position_id);
435 if !still_present {
436 let in_drop = self.alerts.fire_on_drop.get(&key).is_some_and(|v| {
437 v.iter().any(|e| e.position_id == removed.position_id)
438 });
439 if !in_drop {
440 keys.remove(&key);
441 }
442 }
443 if keys.is_empty() {
444 self.position_alerts.remove(&removed.position_id);
445 }
446 }
447 }
448 if entries.is_empty() {
449 self.alerts.fire_on_rise.remove(&key);
450 }
451 }
452 }
453
454 triggered
455 }
456
457 pub fn tick_eval_ids(&self, symbol: &str) -> Vec<PositionId> {
459 self.tick_eval_positions
460 .get(symbol)
461 .map(|s| s.iter().cloned().collect())
462 .unwrap_or_default()
463 }
464
465 pub fn has_alerts(&self, position_id: &str) -> bool {
467 self.position_alerts
468 .get(position_id)
469 .is_some_and(|k| !k.is_empty())
470 }
471
472 pub fn is_tick_eval(&self, symbol: &str, position_id: &str) -> bool {
474 self.tick_eval_positions
475 .get(symbol)
476 .is_some_and(|s| s.contains(position_id))
477 }
478
479 pub fn alert_count(&self) -> usize {
481 let drop_count: usize = self.alerts.fire_on_drop.values().map(|v| v.len()).sum();
482 let rise_count: usize = self.alerts.fire_on_rise.values().map(|v| v.len()).sum();
483 drop_count + rise_count
484 }
485
486 pub fn position_count(&self) -> usize {
488 self.position_alerts.len()
489 }
490
491 fn entries_for_symbol(
494 entries: &BTreeMap<AlertKey, Vec<AlertEntry>>,
495 symbol: &str,
496 ) -> BTreeMap<AlertKey, Vec<AlertEntry>> {
497 let start = AlertKey {
498 symbol: symbol.to_owned(),
499 price_micros: i64::MIN,
500 };
501 let end = AlertKey {
502 symbol: symbol.to_owned(),
503 price_micros: i64::MAX,
504 };
505 entries
506 .range(start..=end)
507 .map(|(key, entries)| (key.clone(), entries.clone()))
508 .collect()
509 }
510
511 fn restore_entries(
512 destination: &mut BTreeMap<AlertKey, Vec<AlertEntry>>,
513 position_alerts: &mut HashMap<PositionId, HashSet<AlertKey>>,
514 entries: BTreeMap<AlertKey, Vec<AlertEntry>>,
515 ) {
516 for (key, alerts) in entries {
517 for alert in &alerts {
518 position_alerts
519 .entry(alert.position_id.clone())
520 .or_default()
521 .insert(key.clone());
522 }
523 destination.insert(key, alerts);
524 }
525 }
526
527 fn trigger_direction(side: Side, kind: &AlertKind) -> TriggerDirection {
529 match kind {
530 AlertKind::Stoploss => match side {
532 Side::Buy => TriggerDirection::FireOnDrop,
533 Side::Sell => TriggerDirection::FireOnRise,
534 },
535 AlertKind::TakeProfit { .. } => match side {
537 Side::Buy => TriggerDirection::FireOnRise,
538 Side::Sell => TriggerDirection::FireOnDrop,
539 },
540 AlertKind::BreakevenTrigger => match side {
542 Side::Buy => TriggerDirection::FireOnRise,
543 Side::Sell => TriggerDirection::FireOnDrop,
544 },
545 AlertKind::PendingFill {
547 order_type,
548 side: pending_side,
549 } => {
550 match (order_type, pending_side) {
551 (OrderType::Limit, Side::Buy) => TriggerDirection::FireOnDrop,
553 (OrderType::Limit, Side::Sell) => TriggerDirection::FireOnRise,
555 (OrderType::Stop, Side::Buy) => TriggerDirection::FireOnRise,
557 (OrderType::Stop, Side::Sell) => TriggerDirection::FireOnDrop,
559 (OrderType::Market, _) => TriggerDirection::FireOnRise,
561 }
562 }
563 }
564 }
565
566 fn remove_entry_from_map(
568 map: &mut BTreeMap<AlertKey, Vec<AlertEntry>>,
569 key: &AlertKey,
570 position_id: &str,
571 ) {
572 if let Some(entries) = map.get_mut(key) {
573 entries.retain(|e| e.position_id != position_id);
574 if entries.is_empty() {
575 map.remove(key);
576 }
577 }
578 }
579
580 fn remove_alert_from_map(
582 map: &mut BTreeMap<AlertKey, Vec<AlertEntry>>,
583 key: &AlertKey,
584 position_id: &str,
585 side: Side,
586 kind: &AlertKind,
587 ) {
588 if let Some(entries) = map.get_mut(key) {
589 entries.retain(|entry| {
590 !(entry.position_id == position_id && entry.side == side && &entry.kind == kind)
591 });
592 if entries.is_empty() {
593 map.remove(key);
594 }
595 }
596 }
597
598 fn has_entry_for_position_at_key(&self, key: &AlertKey, position_id: &str) -> bool {
599 self.alerts
600 .fire_on_drop
601 .get(key)
602 .is_some_and(|entries| entries.iter().any(|entry| entry.position_id == position_id))
603 || self
604 .alerts
605 .fire_on_rise
606 .get(key)
607 .is_some_and(|entries| entries.iter().any(|entry| entry.position_id == position_id))
608 }
609}
610
611enum TriggerDirection {
613 FireOnDrop,
614 FireOnRise,
615}
616
617#[cfg(test)]
620mod tests {
621 use super::*;
622 use crate::types::{FillModel, PriceQuote, Side};
623 use chrono::NaiveDate;
624
625 fn ts(h: u32, m: u32, s: u32) -> chrono::NaiveDateTime {
626 NaiveDate::from_ymd_opt(2026, 1, 1)
627 .unwrap()
628 .and_hms_opt(h, m, s)
629 .unwrap()
630 }
631
632 fn quote(sym: &str, bid: f64, ask: f64) -> PriceQuote {
633 PriceQuote {
634 symbol: sym.into(),
635 ts: ts(10, 0, 0),
636 bid,
637 ask,
638 }
639 }
640
641 fn assert_reverse_index_parity(reg: &PriceAlertRegister) {
642 let mut expected = HashMap::<PositionId, HashSet<AlertKey>>::new();
643 for map in [®.alerts.fire_on_drop, ®.alerts.fire_on_rise] {
644 for (key, entries) in map {
645 for entry in entries {
646 expected
647 .entry(entry.position_id.clone())
648 .or_default()
649 .insert(key.clone());
650 }
651 }
652 }
653
654 assert_eq!(®.position_alerts, &expected);
655 }
656
657 #[test]
658 fn register_and_check_buy_stoploss() {
659 let mut reg = PriceAlertRegister::new();
660 reg.register(
661 "EURUSD",
662 1.0800,
663 "p1".into(),
664 Side::Buy,
665 AlertKind::Stoploss,
666 );
667
668 let q = quote("EURUSD", 1.0850, 1.0852);
670 let t = reg.check(&q, FillModel::BidAsk);
671 assert!(t.is_empty());
672
673 let q = quote("EURUSD", 1.0800, 1.0802);
675 let t = reg.check(&q, FillModel::BidAsk);
676 assert_eq!(t.len(), 1);
677 assert_eq!(t[0].position_id, "p1");
678 assert!(matches!(t[0].kind, AlertKind::Stoploss));
679
680 let q = quote("EURUSD", 1.0750, 1.0752);
682 let t = reg.check(&q, FillModel::BidAsk);
683 assert!(t.is_empty());
684 }
685
686 #[test]
687 fn register_and_check_sell_stoploss() {
688 let mut reg = PriceAlertRegister::new();
689 reg.register(
690 "EURUSD",
691 1.0900,
692 "p1".into(),
693 Side::Sell,
694 AlertKind::Stoploss,
695 );
696
697 let q = quote("EURUSD", 1.0848, 1.0850);
699 let t = reg.check(&q, FillModel::BidAsk);
700 assert!(t.is_empty());
701
702 let q = quote("EURUSD", 1.0898, 1.0900);
704 let t = reg.check(&q, FillModel::BidAsk);
705 assert_eq!(t.len(), 1);
706 assert_eq!(t[0].position_id, "p1");
707 }
708
709 #[test]
710 fn register_and_check_take_profit() {
711 let mut reg = PriceAlertRegister::new();
712 reg.register(
713 "EURUSD",
714 1.0900,
715 "p1".into(),
716 Side::Buy,
717 AlertKind::TakeProfit { close_ratio: 0.5 },
718 );
719
720 let q = quote("EURUSD", 1.0848, 1.0850);
722 let t = reg.check(&q, FillModel::BidAsk);
723 assert!(t.is_empty());
724
725 let q = quote("EURUSD", 1.0900, 1.0905);
727 let t = reg.check(&q, FillModel::BidAsk);
728 assert_eq!(t.len(), 1);
729 assert!(
730 matches!(t[0].kind, AlertKind::TakeProfit { close_ratio } if (close_ratio - 0.5).abs() < f64::EPSILON)
731 );
732 }
733
734 #[test]
735 fn multiple_alerts_same_price() {
736 let mut reg = PriceAlertRegister::new();
737 reg.register(
738 "EURUSD",
739 1.0800,
740 "p1".into(),
741 Side::Buy,
742 AlertKind::Stoploss,
743 );
744 reg.register(
745 "EURUSD",
746 1.0800,
747 "p2".into(),
748 Side::Buy,
749 AlertKind::Stoploss,
750 );
751 reg.register(
752 "EURUSD",
753 1.0800,
754 "p3".into(),
755 Side::Buy,
756 AlertKind::Stoploss,
757 );
758
759 let q = quote("EURUSD", 1.0800, 1.0802);
760 let t = reg.check(&q, FillModel::BidAsk);
761 assert_eq!(t.len(), 3);
762 }
763
764 #[test]
765 fn alerts_different_symbols_independent() {
766 let mut reg = PriceAlertRegister::new();
767 reg.register(
768 "EURUSD",
769 1.0800,
770 "p1".into(),
771 Side::Buy,
772 AlertKind::Stoploss,
773 );
774 reg.register(
775 "XAUUSD",
776 2300.00,
777 "p2".into(),
778 Side::Buy,
779 AlertKind::Stoploss,
780 );
781
782 let q = quote("EURUSD", 1.0800, 1.0802);
784 let t = reg.check(&q, FillModel::BidAsk);
785 assert_eq!(t.len(), 1);
786 assert_eq!(t[0].position_id, "p1");
787
788 let q = quote("XAUUSD", 2300.00, 2301.00);
790 let t = reg.check(&q, FillModel::BidAsk);
791 assert_eq!(t.len(), 1);
792 assert_eq!(t[0].position_id, "p2");
793 }
794
795 #[test]
796 fn deregister_position_removes_all_alerts() {
797 let mut reg = PriceAlertRegister::new();
798 reg.register(
799 "EURUSD",
800 1.0800,
801 "p1".into(),
802 Side::Buy,
803 AlertKind::Stoploss,
804 );
805 reg.register(
806 "EURUSD",
807 1.0900,
808 "p1".into(),
809 Side::Buy,
810 AlertKind::TakeProfit { close_ratio: 0.5 },
811 );
812 reg.register(
813 "EURUSD",
814 1.0880,
815 "p1".into(),
816 Side::Buy,
817 AlertKind::BreakevenTrigger,
818 );
819 assert_eq!(reg.alert_count(), 3);
820
821 reg.deregister_position("p1");
822 assert_eq!(reg.alert_count(), 0);
823 assert!(!reg.has_alerts("p1"));
824
825 let q = quote("EURUSD", 1.0700, 1.0702);
827 let t = reg.check(&q, FillModel::BidAsk);
828 assert!(t.is_empty());
829
830 let q = quote("EURUSD", 1.0950, 1.0952);
831 let t = reg.check(&q, FillModel::BidAsk);
832 assert!(t.is_empty());
833 }
834
835 #[test]
836 fn deregister_single_alert() {
837 let mut reg = PriceAlertRegister::new();
838 reg.register(
839 "EURUSD",
840 1.0800,
841 "p1".into(),
842 Side::Buy,
843 AlertKind::Stoploss,
844 );
845 reg.register(
846 "EURUSD",
847 1.0900,
848 "p1".into(),
849 Side::Buy,
850 AlertKind::TakeProfit { close_ratio: 0.5 },
851 );
852
853 reg.deregister_alert("EURUSD", 1.0800, "p1", Side::Buy, &AlertKind::Stoploss);
855
856 let q = quote("EURUSD", 1.0800, 1.0802);
858 let t = reg.check(&q, FillModel::BidAsk);
859 assert!(t.is_empty());
860
861 let q = quote("EURUSD", 1.0900, 1.0902);
863 let t = reg.check(&q, FillModel::BidAsk);
864 assert_eq!(t.len(), 1);
865 assert!(matches!(t[0].kind, AlertKind::TakeProfit { .. }));
866 }
867
868 #[test]
869 fn deregister_alert_preserves_colocated_same_direction_kind() {
870 let mut reg = PriceAlertRegister::new();
871 reg.register(
872 "EURUSD",
873 1.0900,
874 "p1".into(),
875 Side::Buy,
876 AlertKind::TakeProfit { close_ratio: 0.5 },
877 );
878 reg.register(
879 "EURUSD",
880 1.0900,
881 "p1".into(),
882 Side::Buy,
883 AlertKind::BreakevenTrigger,
884 );
885 assert_reverse_index_parity(®);
886
887 reg.deregister_alert(
888 "EURUSD",
889 1.0900,
890 "p1",
891 Side::Buy,
892 &AlertKind::TakeProfit { close_ratio: 0.5 },
893 );
894
895 assert_eq!(reg.alert_count(), 1);
896 assert!(reg.has_alerts("p1"));
897 assert_reverse_index_parity(®);
898
899 let triggered = reg.check("e("EURUSD", 1.0900, 1.0902), FillModel::BidAsk);
900 assert!(matches!(
901 triggered.as_slice(),
902 [TriggeredAlert {
903 position_id,
904 kind: AlertKind::BreakevenTrigger,
905 ..
906 }] if position_id == "p1"
907 ));
908 assert!(!reg.has_alerts("p1"));
909 assert_reverse_index_parity(®);
910 }
911
912 #[test]
913 fn deregister_alert_preserves_colocated_other_direction_and_index() {
914 let mut reg = PriceAlertRegister::new();
915 reg.register(
916 "EURUSD",
917 1.0900,
918 "p1".into(),
919 Side::Buy,
920 AlertKind::TakeProfit { close_ratio: 0.5 },
921 );
922 reg.register(
923 "EURUSD",
924 1.0900,
925 "p1".into(),
926 Side::Buy,
927 AlertKind::Stoploss,
928 );
929
930 reg.deregister_alert(
931 "EURUSD",
932 1.0900,
933 "p1",
934 Side::Buy,
935 &AlertKind::TakeProfit { close_ratio: 0.5 },
936 );
937
938 assert_eq!(reg.alert_count(), 1);
939 assert!(reg.has_alerts("p1"));
940 assert_reverse_index_parity(®);
941
942 reg.deregister_position("p1");
943 assert_eq!(reg.alert_count(), 0);
944 assert!(!reg.has_alerts("p1"));
945 assert_reverse_index_parity(®);
946 }
947
948 #[test]
949 fn pending_fill_alert_limit_buy() {
950 let mut reg = PriceAlertRegister::new();
951 reg.register(
952 "EURUSD",
953 1.0800,
954 "p1".into(),
955 Side::Buy,
956 AlertKind::PendingFill {
957 order_type: OrderType::Limit,
958 side: Side::Buy,
959 },
960 );
961
962 let q = quote("EURUSD", 1.0848, 1.0850);
964 let t = reg.check(&q, FillModel::BidAsk);
965 assert!(t.is_empty());
966
967 let q = quote("EURUSD", 1.0798, 1.0800);
969 let t = reg.check(&q, FillModel::BidAsk);
970 assert_eq!(t.len(), 1);
971 assert!(matches!(
972 t[0].kind,
973 AlertKind::PendingFill {
974 order_type: OrderType::Limit,
975 side: Side::Buy
976 }
977 ));
978 }
979
980 #[test]
981 fn pending_fill_alert_stop_buy() {
982 let mut reg = PriceAlertRegister::new();
983 reg.register(
984 "EURUSD",
985 1.0900,
986 "p1".into(),
987 Side::Buy,
988 AlertKind::PendingFill {
989 order_type: OrderType::Stop,
990 side: Side::Buy,
991 },
992 );
993
994 let q = quote("EURUSD", 1.0848, 1.0850);
996 let t = reg.check(&q, FillModel::BidAsk);
997 assert!(t.is_empty());
998
999 let q = quote("EURUSD", 1.0898, 1.0900);
1001 let t = reg.check(&q, FillModel::BidAsk);
1002 assert_eq!(t.len(), 1);
1003 }
1004
1005 #[test]
1006 fn clear_all_removes_everything() {
1007 let mut reg = PriceAlertRegister::new();
1008 reg.register(
1009 "EURUSD",
1010 1.0800,
1011 "p1".into(),
1012 Side::Buy,
1013 AlertKind::Stoploss,
1014 );
1015 reg.register(
1016 "XAUUSD",
1017 2300.00,
1018 "p2".into(),
1019 Side::Buy,
1020 AlertKind::Stoploss,
1021 );
1022 reg.register_tick_eval("EURUSD", "p3".into());
1023
1024 reg.clear_all();
1025
1026 assert_eq!(reg.alert_count(), 0);
1027 assert_eq!(reg.position_count(), 0);
1028 assert!(reg.tick_eval_ids("EURUSD").is_empty());
1029
1030 let q = quote("EURUSD", 1.0700, 1.0702);
1031 let t = reg.check(&q, FillModel::BidAsk);
1032 assert!(t.is_empty());
1033 }
1034
1035 #[test]
1036 fn tick_eval_ids_returns_stateful_only() {
1037 let mut reg = PriceAlertRegister::new();
1038 reg.register_tick_eval("EURUSD", "p1".into());
1040 reg.register(
1042 "EURUSD",
1043 1.0800,
1044 "p2".into(),
1045 Side::Buy,
1046 AlertKind::Stoploss,
1047 );
1048 reg.register(
1049 "EURUSD",
1050 1.0900,
1051 "p2".into(),
1052 Side::Buy,
1053 AlertKind::TakeProfit { close_ratio: 1.0 },
1054 );
1055
1056 let tick_ids = reg.tick_eval_ids("EURUSD");
1057 assert_eq!(tick_ids.len(), 1);
1058 assert!(tick_ids.contains(&"p1".to_owned()));
1059 }
1060
1061 #[test]
1062 fn is_tick_eval_check() {
1063 let mut reg = PriceAlertRegister::new();
1064 reg.register_tick_eval("EURUSD", "p1".into());
1065
1066 assert!(reg.is_tick_eval("EURUSD", "p1"));
1067 assert!(!reg.is_tick_eval("EURUSD", "p2"));
1068 assert!(!reg.is_tick_eval("XAUUSD", "p1"));
1069 }
1070
1071 #[test]
1072 fn deregister_position_also_removes_tick_eval() {
1073 let mut reg = PriceAlertRegister::new();
1074 reg.register(
1075 "EURUSD",
1076 1.0800,
1077 "p1".into(),
1078 Side::Buy,
1079 AlertKind::Stoploss,
1080 );
1081 reg.register_tick_eval("EURUSD", "p1".into());
1082
1083 assert!(reg.is_tick_eval("EURUSD", "p1"));
1084 reg.deregister_position("p1");
1085 assert!(!reg.is_tick_eval("EURUSD", "p1"));
1086 assert_eq!(reg.alert_count(), 0);
1087 }
1088
1089 #[test]
1090 fn breakeven_trigger_buy() {
1091 let mut reg = PriceAlertRegister::new();
1092 reg.register(
1093 "EURUSD",
1094 1.0900,
1095 "p1".into(),
1096 Side::Buy,
1097 AlertKind::BreakevenTrigger,
1098 );
1099
1100 let q = quote("EURUSD", 1.0850, 1.0852);
1102 let t = reg.check(&q, FillModel::BidAsk);
1103 assert!(t.is_empty());
1104
1105 let q = quote("EURUSD", 1.0900, 1.0902);
1107 let t = reg.check(&q, FillModel::BidAsk);
1108 assert_eq!(t.len(), 1);
1109 assert!(matches!(t[0].kind, AlertKind::BreakevenTrigger));
1110 }
1111
1112 #[test]
1113 fn breakeven_trigger_sell() {
1114 let mut reg = PriceAlertRegister::new();
1115 reg.register(
1116 "EURUSD",
1117 1.0750,
1118 "p1".into(),
1119 Side::Sell,
1120 AlertKind::BreakevenTrigger,
1121 );
1122
1123 let q = quote("EURUSD", 1.0800, 1.0802);
1125 let t = reg.check(&q, FillModel::BidAsk);
1126 assert!(t.is_empty());
1127
1128 let q = quote("EURUSD", 1.0748, 1.0750);
1130 let t = reg.check(&q, FillModel::BidAsk);
1131 assert_eq!(t.len(), 1);
1132 assert!(matches!(t[0].kind, AlertKind::BreakevenTrigger));
1133 }
1134
1135 #[test]
1136 fn sell_take_profit_fires_on_drop() {
1137 let mut reg = PriceAlertRegister::new();
1138 reg.register(
1139 "EURUSD",
1140 1.0750,
1141 "p1".into(),
1142 Side::Sell,
1143 AlertKind::TakeProfit { close_ratio: 1.0 },
1144 );
1145
1146 let q = quote("EURUSD", 1.0800, 1.0802);
1148 let t = reg.check(&q, FillModel::BidAsk);
1149 assert!(t.is_empty());
1150
1151 let q = quote("EURUSD", 1.0748, 1.0750);
1153 let t = reg.check(&q, FillModel::BidAsk);
1154 assert_eq!(t.len(), 1);
1155 }
1156
1157 #[test]
1158 fn price_to_micros_precision() {
1159 assert_eq!(price_to_micros(1.08500), 1_085_000);
1160 assert_eq!(price_to_micros(2350.50), 2_350_500_000);
1161 assert_eq!(price_to_micros(0.0), 0);
1162 assert_eq!(price_to_micros(100_000.0), 100_000_000_000);
1163 }
1164
1165 #[test]
1166 fn micros_roundtrip() {
1167 let prices = [1.08500, 2350.50, 154.325, 0.00001, 100000.0];
1168 for p in prices {
1169 let m = price_to_micros(p);
1170 let back = micros_to_price(m);
1171 assert!(
1172 (back - p).abs() < 1e-6,
1173 "roundtrip failed for {p}: got {back}"
1174 );
1175 }
1176 }
1177
1178 #[test]
1179 fn fill_model_ask_only_buy_sl() {
1180 let mut reg = PriceAlertRegister::new();
1181 reg.register(
1182 "EURUSD",
1183 1.0800,
1184 "p1".into(),
1185 Side::Buy,
1186 AlertKind::Stoploss,
1187 );
1188
1189 let q = quote("EURUSD", 1.0790, 1.0810);
1192 let t = reg.check(&q, FillModel::AskOnly);
1193 assert!(t.is_empty());
1194
1195 let q = quote("EURUSD", 1.0790, 1.0800);
1197 let t = reg.check(&q, FillModel::AskOnly);
1198 assert_eq!(t.len(), 1);
1199 }
1200
1201 #[test]
1202 fn multiple_positions_partial_trigger() {
1203 let mut reg = PriceAlertRegister::new();
1204 reg.register(
1205 "EURUSD",
1206 1.0800,
1207 "p1".into(),
1208 Side::Buy,
1209 AlertKind::Stoploss,
1210 );
1211 reg.register(
1212 "EURUSD",
1213 1.0700,
1214 "p2".into(),
1215 Side::Buy,
1216 AlertKind::Stoploss,
1217 );
1218
1219 let q = quote("EURUSD", 1.0800, 1.0802);
1221 let t = reg.check(&q, FillModel::BidAsk);
1222 assert_eq!(t.len(), 1);
1223 assert_eq!(t[0].position_id, "p1");
1224
1225 assert!(reg.has_alerts("p2"));
1227 assert!(!reg.has_alerts("p1"));
1228 }
1229
1230 #[test]
1231 fn mixed_alert_types_same_position() {
1232 let mut reg = PriceAlertRegister::new();
1233 let pid: PositionId = "p1".into();
1234
1235 reg.register(
1238 "EURUSD",
1239 1.0800,
1240 pid.clone(),
1241 Side::Buy,
1242 AlertKind::Stoploss,
1243 );
1244 reg.register(
1245 "EURUSD",
1246 1.0900,
1247 pid.clone(),
1248 Side::Buy,
1249 AlertKind::TakeProfit { close_ratio: 0.5 },
1250 );
1251 reg.register(
1252 "EURUSD",
1253 1.0870,
1254 pid.clone(),
1255 Side::Buy,
1256 AlertKind::BreakevenTrigger,
1257 );
1258
1259 assert_eq!(reg.alert_count(), 3);
1260
1261 let q = quote("EURUSD", 1.0870, 1.0872);
1263 let t = reg.check(&q, FillModel::BidAsk);
1264 assert_eq!(t.len(), 1);
1265 assert!(matches!(t[0].kind, AlertKind::BreakevenTrigger));
1266
1267 assert_eq!(reg.alert_count(), 2);
1269 }
1270
1271 #[test]
1272 fn unregister_tick_eval() {
1273 let mut reg = PriceAlertRegister::new();
1274 reg.register_tick_eval("EURUSD", "p1".into());
1275 reg.register_tick_eval("EURUSD", "p2".into());
1276
1277 assert_eq!(reg.tick_eval_ids("EURUSD").len(), 2);
1278
1279 reg.unregister_tick_eval("EURUSD", "p1");
1280 let ids = reg.tick_eval_ids("EURUSD");
1281 assert_eq!(ids.len(), 1);
1282 assert!(ids.contains(&"p2".to_owned()));
1283 }
1284}