Skip to main content

qs_core/
alert_register.rs

1//! Price alert register — BTreeMap-indexed rule evaluation for O(log N + K) per tick.
2//!
3//! Static rules (fixed SL, TP, breakeven trigger, pending fills) are stored as
4//! sorted price alerts. On each tick only a range scan is needed to find
5//! triggered alerts, instead of iterating every rule on every position.
6//!
7//! Stateful rules (trailing stop, time exit, breakeven-after-targets) remain
8//! tick-by-tick but are tracked in a separate set so only those positions are
9//! visited.
10
11use std::collections::{BTreeMap, HashMap, HashSet};
12
13use crate::types::{FillModel, OrderType, PositionId, PriceQuote, Side};
14
15// ─── Alert key & entry types ────────────────────────────────────────────────
16
17/// Composite key for sorted storage. Ordered by (symbol, price_micros).
18#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
19struct AlertKey {
20    symbol: String,
21    /// Price stored as integer micros (price × 1_000_000) for exact Ord.
22    price_micros: i64,
23}
24
25/// What to do when this alert fires.
26#[derive(Debug, Clone)]
27struct AlertEntry {
28    position_id: PositionId,
29    kind: AlertKind,
30    /// Side of the position that owns this alert.
31    side: Side,
32}
33
34/// The type of alert — determines what effect to produce when triggered.
35#[derive(Debug, Clone, PartialEq)]
36pub enum AlertKind {
37    /// Close the position fully (fixed stoploss hit).
38    Stoploss,
39    /// Partial or full close at this take-profit level.
40    TakeProfit { close_ratio: f64 },
41    /// Move stoploss to entry price (breakeven trigger).
42    BreakevenTrigger,
43    /// Fill a pending order.
44    PendingFill { order_type: OrderType, side: Side },
45}
46
47/// An alert that has been triggered by a price movement.
48#[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// ─── Directional alert storage ──────────────────────────────────────────────
57
58/// Two BTreeMaps split by trigger direction for efficient range scans.
59#[derive(Debug, Clone, Default)]
60struct DirectionalAlerts {
61    /// Alerts that fire when eval_price <= threshold (buy SL, sell TP, limit buy fill, stop sell fill).
62    fire_on_drop: BTreeMap<AlertKey, Vec<AlertEntry>>,
63    /// Alerts that fire when eval_price >= threshold (sell SL, buy TP, limit sell fill, stop buy fill).
64    fire_on_rise: BTreeMap<AlertKey, Vec<AlertEntry>>,
65}
66
67// ─── PriceAlertRegister ─────────────────────────────────────────────────────
68
69/// BTreeMap-indexed price alert register for O(log N + K) rule evaluation.
70///
71/// Static rules (SL, TP, breakeven, pending fills) are stored as sorted price
72/// alerts. Stateful rules (trailing stop, time exit) are tracked in a separate
73/// set for tick-by-tick evaluation on only those positions.
74#[derive(Debug, Clone, Default)]
75pub struct PriceAlertRegister {
76    /// Directional alert storage.
77    alerts: DirectionalAlerts,
78    /// Reverse index: position_id → set of registered alert keys.
79    position_alerts: HashMap<PositionId, HashSet<AlertKey>>,
80    /// Positions that require tick-by-tick evaluation (have stateful rules).
81    /// symbol → set of position IDs.
82    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
92/// Convert an f64 price to integer micros for BTreeMap keys.
93fn price_to_micros(price: f64) -> i64 {
94    (price * 1_000_000.0).round() as i64
95}
96
97/// Convert integer micros back to f64 price.
98fn micros_to_price(micros: i64) -> f64 {
99    micros as f64 / 1_000_000.0
100}
101
102impl PriceAlertRegister {
103    /// Create an empty alert register.
104    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    // ── Registration ────────────────────────────────────────────────────
159
160    /// Register a price alert for a position.
161    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        // Decide which BTreeMap based on trigger direction.
181        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        // Maintain reverse index.
189        self.position_alerts
190            .entry(position_id)
191            .or_default()
192            .insert(key);
193    }
194
195    /// Register a position for tick-by-tick evaluation (has stateful rules).
196    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    /// Unregister a position from tick-by-tick evaluation.
204    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    // ── Deregistration ──────────────────────────────────────────────────
214
215    /// Remove all alerts for a specific position.
216    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        // Also remove from tick eval sets.
224        self.tick_eval_positions.retain(|_, ids| {
225            ids.remove(position_id);
226            !ids.is_empty()
227        });
228    }
229
230    /// Remove a specific alert for a position at a given price and kind.
231    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        // Clean up the reverse index only when no co-located alert remains.
252        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    /// Remove all alerts across all positions.
263    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    // ── Querying ────────────────────────────────────────────────────────
271
272    /// Check all alerts for a symbol against the current quote.
273    /// Returns triggered alerts and removes them from the register.
274    pub fn check(&mut self, quote: &PriceQuote, model: FillModel) -> Vec<TriggeredAlert> {
275        let mut triggered = Vec::new();
276
277        // For fire_on_drop: alerts fire when eval_price <= alert_price.
278        // We need the eval_price for the side of each alert's position.
279        // Since buy and sell positions use different eval prices, we check
280        // both sides using the bid (for buy-side eval under BidAsk) and
281        // ask (for sell-side eval under BidAsk).
282
283        // Compute all possible eval prices we need.
284        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        // ── fire_on_drop: alert fires when market price ≤ alert_price ──
295        // Range: all keys for this symbol with price_micros >= -∞ up to the
296        // relevant eval/fill price.
297        let sym = &quote.symbol;
298        let range_start_drop = AlertKey {
299            symbol: sym.clone(),
300            price_micros: i64::MIN,
301        };
302
303        // We need to scan all fire_on_drop entries for this symbol and check
304        // if the appropriate eval/fill price has dropped to or below the
305        // alert price. Since different alerts use different prices (buy vs
306        // sell, eval vs fill), we scan all entries for this symbol.
307        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        // Collect from fire_on_drop.
315        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                // fire_on_drop: fires when check_price <= alert_price
334                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        // ── fire_on_rise: alert fires when market price ≥ alert_price ──
350        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                // fire_on_rise: fires when check_price >= alert_price
380                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        // Remove triggered entries (in reverse index order to preserve indices).
396        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                    // Clean up reverse index.
402                    if let Some(keys) = self.position_alerts.get_mut(&removed.position_id) {
403                        // Only remove the key from reverse index if no more entries
404                        // for this position at this key.
405                        let still_present =
406                            entries.iter().any(|e| e.position_id == removed.position_id);
407                        if !still_present {
408                            // Also check fire_on_rise for this key.
409                            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    /// Get position IDs that need tick-by-tick evaluation for a symbol.
458    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    /// Check if a position has any registered alerts.
466    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    /// Check if a position is registered for tick evaluation.
473    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    /// Total number of alert entries across both directional maps.
480    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    /// Number of positions with registered alerts.
487    pub fn position_count(&self) -> usize {
488        self.position_alerts.len()
489    }
490
491    // ── Internal helpers ────────────────────────────────────────────────
492
493    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    /// Determine which BTreeMap to use based on position side and alert kind.
528    fn trigger_direction(side: Side, kind: &AlertKind) -> TriggerDirection {
529        match kind {
530            // Buy SL fires when price drops to or below SL → fire_on_drop
531            AlertKind::Stoploss => match side {
532                Side::Buy => TriggerDirection::FireOnDrop,
533                Side::Sell => TriggerDirection::FireOnRise,
534            },
535            // Buy TP fires when price rises to or above TP → fire_on_rise
536            AlertKind::TakeProfit { .. } => match side {
537                Side::Buy => TriggerDirection::FireOnRise,
538                Side::Sell => TriggerDirection::FireOnDrop,
539            },
540            // Buy breakeven fires when price rises to trigger → fire_on_rise
541            AlertKind::BreakevenTrigger => match side {
542                Side::Buy => TriggerDirection::FireOnRise,
543                Side::Sell => TriggerDirection::FireOnDrop,
544            },
545            // Pending fills depend on order type and side.
546            AlertKind::PendingFill {
547                order_type,
548                side: pending_side,
549            } => {
550                match (order_type, pending_side) {
551                    // Limit Buy: fill when price drops to limit → fire_on_drop
552                    (OrderType::Limit, Side::Buy) => TriggerDirection::FireOnDrop,
553                    // Limit Sell: fill when price rises to limit → fire_on_rise
554                    (OrderType::Limit, Side::Sell) => TriggerDirection::FireOnRise,
555                    // Stop Buy: fill when price rises to stop → fire_on_rise
556                    (OrderType::Stop, Side::Buy) => TriggerDirection::FireOnRise,
557                    // Stop Sell: fill when price drops to stop → fire_on_drop
558                    (OrderType::Stop, Side::Sell) => TriggerDirection::FireOnDrop,
559                    // Market orders should never be pending.
560                    (OrderType::Market, _) => TriggerDirection::FireOnRise,
561                }
562            }
563        }
564    }
565
566    /// Remove entries for a position from a BTreeMap at a specific key.
567    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    /// Remove only the requested alert entry from a BTreeMap at a specific key.
581    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
611/// Which directional BTreeMap to store/check the alert in.
612enum TriggerDirection {
613    FireOnDrop,
614    FireOnRise,
615}
616
617// ─── Tests ──────────────────────────────────────────────────────────────────
618
619#[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 [&reg.alerts.fire_on_drop, &reg.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!(&reg.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        // Price above SL — no trigger.
669        let q = quote("EURUSD", 1.0850, 1.0852);
670        let t = reg.check(&q, FillModel::BidAsk);
671        assert!(t.is_empty());
672
673        // Price drops to SL — triggers.
674        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        // Already removed — no trigger again.
681        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        // Price below SL — no trigger.
698        let q = quote("EURUSD", 1.0848, 1.0850);
699        let t = reg.check(&q, FillModel::BidAsk);
700        assert!(t.is_empty());
701
702        // Price rises to SL — triggers (sell SL checks ask).
703        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        // Price below TP — no trigger.
721        let q = quote("EURUSD", 1.0848, 1.0850);
722        let t = reg.check(&q, FillModel::BidAsk);
723        assert!(t.is_empty());
724
725        // Price reaches TP — triggers (buy TP checks bid for BidAsk).
726        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        // Only EURUSD tick — only p1 triggers.
783        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        // XAUUSD alert still alive.
789        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        // Nothing triggers.
826        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        // Deregister only the SL.
854        reg.deregister_alert("EURUSD", 1.0800, "p1", Side::Buy, &AlertKind::Stoploss);
855
856        // SL price — no trigger.
857        let q = quote("EURUSD", 1.0800, 1.0802);
858        let t = reg.check(&q, FillModel::BidAsk);
859        assert!(t.is_empty());
860
861        // TP price — still triggers.
862        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(&reg);
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(&reg);
898
899        let triggered = reg.check(&quote("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(&reg);
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(&reg);
941
942        reg.deregister_position("p1");
943        assert_eq!(reg.alert_count(), 0);
944        assert!(!reg.has_alerts("p1"));
945        assert_reverse_index_parity(&reg);
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        // Price above limit — no fill.
963        let q = quote("EURUSD", 1.0848, 1.0850);
964        let t = reg.check(&q, FillModel::BidAsk);
965        assert!(t.is_empty());
966
967        // Price drops to limit — fills.
968        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        // Price below stop — no fill.
995        let q = quote("EURUSD", 1.0848, 1.0850);
996        let t = reg.check(&q, FillModel::BidAsk);
997        assert!(t.is_empty());
998
999        // Price rises to stop — fills (Stop Buy checks ask for BidAsk fill_price).
1000        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        // p1 has trailing stop → tick eval.
1039        reg.register_tick_eval("EURUSD", "p1".into());
1040        // p2 has only SL+TP → alert register only.
1041        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        // Price below trigger — no fire.
1101        let q = quote("EURUSD", 1.0850, 1.0852);
1102        let t = reg.check(&q, FillModel::BidAsk);
1103        assert!(t.is_empty());
1104
1105        // Price reaches trigger — fires.
1106        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        // Price above trigger — no fire.
1124        let q = quote("EURUSD", 1.0800, 1.0802);
1125        let t = reg.check(&q, FillModel::BidAsk);
1126        assert!(t.is_empty());
1127
1128        // Price drops to trigger — fires (sell breakeven checks ask dropping to level).
1129        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        // Price above TP — no trigger.
1147        let q = quote("EURUSD", 1.0800, 1.0802);
1148        let t = reg.check(&q, FillModel::BidAsk);
1149        assert!(t.is_empty());
1150
1151        // Price drops to TP — triggers (sell TP checks ask).
1152        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        // AskOnly: buy eval price is ask.
1190        // Ask at 1.0810 > 1.0800 — no trigger.
1191        let q = quote("EURUSD", 1.0790, 1.0810);
1192        let t = reg.check(&q, FillModel::AskOnly);
1193        assert!(t.is_empty());
1194
1195        // Ask drops to 1.0800 — triggers.
1196        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        // Price drops to 1.0800 — only p1 triggers.
1220        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        // p2 alert still alive.
1226        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        // Buy position: SL at 1.0800 (fire_on_drop), TP at 1.0900 (fire_on_rise),
1236        // breakeven at 1.0870 (fire_on_rise).
1237        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        // Price at 1.0870 — breakeven fires.
1262        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        // SL and TP still registered.
1268        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}