use std::collections::{BTreeMap, HashMap, HashSet};
use crate::types::{FillModel, OrderType, PositionId, PriceQuote, Side};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
struct AlertKey {
symbol: String,
price_micros: i64,
}
#[derive(Debug, Clone)]
struct AlertEntry {
position_id: PositionId,
kind: AlertKind,
side: Side,
}
#[derive(Debug, Clone, PartialEq)]
pub enum AlertKind {
Stoploss,
TakeProfit { close_ratio: f64 },
BreakevenTrigger,
PendingFill { order_type: OrderType, side: Side },
}
#[derive(Debug, Clone)]
pub struct TriggeredAlert {
pub position_id: PositionId,
pub kind: AlertKind,
pub side: Side,
pub trigger_price: f64,
}
#[derive(Debug, Clone, Default)]
struct DirectionalAlerts {
fire_on_drop: BTreeMap<AlertKey, Vec<AlertEntry>>,
fire_on_rise: BTreeMap<AlertKey, Vec<AlertEntry>>,
}
#[derive(Debug, Clone, Default)]
pub struct PriceAlertRegister {
alerts: DirectionalAlerts,
position_alerts: HashMap<PositionId, HashSet<AlertKey>>,
tick_eval_positions: HashMap<String, HashSet<PositionId>>,
}
#[derive(Debug)]
pub(crate) struct PriceAlertRegisterQuoteCheckpoint {
symbol: String,
alerts: DirectionalAlerts,
tick_eval_positions: Option<HashSet<PositionId>>,
}
fn price_to_micros(price: f64) -> i64 {
(price * 1_000_000.0).round() as i64
}
fn micros_to_price(micros: i64) -> f64 {
micros as f64 / 1_000_000.0
}
impl PriceAlertRegister {
pub fn new() -> Self {
Self::default()
}
pub(crate) fn checkpoint_for_quote(&self, symbol: &str) -> PriceAlertRegisterQuoteCheckpoint {
PriceAlertRegisterQuoteCheckpoint {
symbol: symbol.to_owned(),
alerts: DirectionalAlerts {
fire_on_drop: Self::entries_for_symbol(&self.alerts.fire_on_drop, symbol),
fire_on_rise: Self::entries_for_symbol(&self.alerts.fire_on_rise, symbol),
},
tick_eval_positions: self.tick_eval_positions.get(symbol).cloned(),
}
}
pub(crate) fn restore_quote(&mut self, checkpoint: PriceAlertRegisterQuoteCheckpoint) {
let PriceAlertRegisterQuoteCheckpoint {
symbol,
alerts,
tick_eval_positions,
} = checkpoint;
self.alerts
.fire_on_drop
.retain(|key, _| key.symbol.as_str() != symbol);
self.alerts
.fire_on_rise
.retain(|key, _| key.symbol.as_str() != symbol);
self.position_alerts.retain(|_, keys| {
keys.retain(|key| key.symbol.as_str() != symbol);
!keys.is_empty()
});
Self::restore_entries(
&mut self.alerts.fire_on_drop,
&mut self.position_alerts,
alerts.fire_on_drop,
);
Self::restore_entries(
&mut self.alerts.fire_on_rise,
&mut self.position_alerts,
alerts.fire_on_rise,
);
match tick_eval_positions {
Some(position_ids) => {
self.tick_eval_positions.insert(symbol, position_ids);
}
None => {
self.tick_eval_positions.remove(&symbol);
}
}
}
pub fn register(
&mut self,
symbol: &str,
price: f64,
position_id: PositionId,
side: Side,
kind: AlertKind,
) {
let key = AlertKey {
symbol: symbol.to_owned(),
price_micros: price_to_micros(price),
};
let entry = AlertEntry {
position_id: position_id.clone(),
kind: kind.clone(),
side,
};
let map = match Self::trigger_direction(side, &kind) {
TriggerDirection::FireOnDrop => &mut self.alerts.fire_on_drop,
TriggerDirection::FireOnRise => &mut self.alerts.fire_on_rise,
};
map.entry(key.clone()).or_default().push(entry);
self.position_alerts
.entry(position_id)
.or_default()
.insert(key);
}
pub fn register_tick_eval(&mut self, symbol: &str, position_id: PositionId) {
self.tick_eval_positions
.entry(symbol.to_owned())
.or_default()
.insert(position_id);
}
pub fn unregister_tick_eval(&mut self, symbol: &str, position_id: &str) {
if let Some(set) = self.tick_eval_positions.get_mut(symbol) {
set.remove(position_id);
if set.is_empty() {
self.tick_eval_positions.remove(symbol);
}
}
}
pub fn deregister_position(&mut self, position_id: &str) {
if let Some(keys) = self.position_alerts.remove(position_id) {
for key in keys {
Self::remove_entry_from_map(&mut self.alerts.fire_on_drop, &key, position_id);
Self::remove_entry_from_map(&mut self.alerts.fire_on_rise, &key, position_id);
}
}
self.tick_eval_positions.retain(|_, ids| {
ids.remove(position_id);
!ids.is_empty()
});
}
pub fn deregister_alert(
&mut self,
symbol: &str,
price: f64,
position_id: &str,
side: Side,
kind: &AlertKind,
) {
let key = AlertKey {
symbol: symbol.to_owned(),
price_micros: price_to_micros(price),
};
let map = match Self::trigger_direction(side, kind) {
TriggerDirection::FireOnDrop => &mut self.alerts.fire_on_drop,
TriggerDirection::FireOnRise => &mut self.alerts.fire_on_rise,
};
Self::remove_alert_from_map(map, &key, position_id, side, kind);
if !self.has_entry_for_position_at_key(&key, position_id)
&& let Some(keys) = self.position_alerts.get_mut(position_id)
{
keys.remove(&key);
if keys.is_empty() {
self.position_alerts.remove(position_id);
}
}
}
pub fn clear_all(&mut self) {
self.alerts.fire_on_drop.clear();
self.alerts.fire_on_rise.clear();
self.position_alerts.clear();
self.tick_eval_positions.clear();
}
pub fn check(&mut self, quote: &PriceQuote, model: FillModel) -> Vec<TriggeredAlert> {
let mut triggered = Vec::new();
let buy_eval = quote.eval_price(Side::Buy, model);
let sell_eval = quote.eval_price(Side::Sell, model);
let buy_fill = quote.fill_price(Side::Buy, model);
let sell_fill = quote.fill_price(Side::Sell, model);
let buy_eval_micros = price_to_micros(buy_eval);
let sell_eval_micros = price_to_micros(sell_eval);
let buy_fill_micros = price_to_micros(buy_fill);
let sell_fill_micros = price_to_micros(sell_fill);
let sym = "e.symbol;
let range_start_drop = AlertKey {
symbol: sym.clone(),
price_micros: i64::MIN,
};
let range_end_drop = AlertKey {
symbol: sym.clone(),
price_micros: i64::MAX,
};
let mut keys_to_remove_drop: Vec<(AlertKey, Vec<usize>)> = Vec::new();
for (key, entries) in self
.alerts
.fire_on_drop
.range(range_start_drop..=range_end_drop.clone())
{
let mut indices = Vec::new();
for (i, entry) in entries.iter().enumerate() {
let check_micros = match &entry.kind {
AlertKind::PendingFill { .. } => match entry.side {
Side::Buy => buy_fill_micros,
Side::Sell => sell_fill_micros,
},
_ => match entry.side {
Side::Buy => buy_eval_micros,
Side::Sell => sell_eval_micros,
},
};
if check_micros <= key.price_micros {
triggered.push(TriggeredAlert {
position_id: entry.position_id.clone(),
kind: entry.kind.clone(),
side: entry.side,
trigger_price: micros_to_price(key.price_micros),
});
indices.push(i);
}
}
if !indices.is_empty() {
keys_to_remove_drop.push((key.clone(), indices));
}
}
let range_start_rise = AlertKey {
symbol: sym.clone(),
price_micros: i64::MIN,
};
let range_end_rise = AlertKey {
symbol: sym.clone(),
price_micros: i64::MAX,
};
let mut keys_to_remove_rise: Vec<(AlertKey, Vec<usize>)> = Vec::new();
for (key, entries) in self
.alerts
.fire_on_rise
.range(range_start_rise..=range_end_rise)
{
let mut indices = Vec::new();
for (i, entry) in entries.iter().enumerate() {
let check_micros = match &entry.kind {
AlertKind::PendingFill { .. } => match entry.side {
Side::Buy => buy_fill_micros,
Side::Sell => sell_fill_micros,
},
_ => match entry.side {
Side::Buy => buy_eval_micros,
Side::Sell => sell_eval_micros,
},
};
if check_micros >= key.price_micros {
triggered.push(TriggeredAlert {
position_id: entry.position_id.clone(),
kind: entry.kind.clone(),
side: entry.side,
trigger_price: micros_to_price(key.price_micros),
});
indices.push(i);
}
}
if !indices.is_empty() {
keys_to_remove_rise.push((key.clone(), indices));
}
}
for (key, mut indices) in keys_to_remove_drop {
indices.sort_unstable_by(|a, b| b.cmp(a));
if let Some(entries) = self.alerts.fire_on_drop.get_mut(&key) {
for i in &indices {
let removed = entries.remove(*i);
if let Some(keys) = self.position_alerts.get_mut(&removed.position_id) {
let still_present =
entries.iter().any(|e| e.position_id == removed.position_id);
if !still_present {
let in_rise = self.alerts.fire_on_rise.get(&key).is_some_and(|v| {
v.iter().any(|e| e.position_id == removed.position_id)
});
if !in_rise {
keys.remove(&key);
}
}
if keys.is_empty() {
self.position_alerts.remove(&removed.position_id);
}
}
}
if entries.is_empty() {
self.alerts.fire_on_drop.remove(&key);
}
}
}
for (key, mut indices) in keys_to_remove_rise {
indices.sort_unstable_by(|a, b| b.cmp(a));
if let Some(entries) = self.alerts.fire_on_rise.get_mut(&key) {
for i in &indices {
let removed = entries.remove(*i);
if let Some(keys) = self.position_alerts.get_mut(&removed.position_id) {
let still_present =
entries.iter().any(|e| e.position_id == removed.position_id);
if !still_present {
let in_drop = self.alerts.fire_on_drop.get(&key).is_some_and(|v| {
v.iter().any(|e| e.position_id == removed.position_id)
});
if !in_drop {
keys.remove(&key);
}
}
if keys.is_empty() {
self.position_alerts.remove(&removed.position_id);
}
}
}
if entries.is_empty() {
self.alerts.fire_on_rise.remove(&key);
}
}
}
triggered
}
pub fn tick_eval_ids(&self, symbol: &str) -> Vec<PositionId> {
self.tick_eval_positions
.get(symbol)
.map(|s| s.iter().cloned().collect())
.unwrap_or_default()
}
pub fn has_alerts(&self, position_id: &str) -> bool {
self.position_alerts
.get(position_id)
.is_some_and(|k| !k.is_empty())
}
pub fn is_tick_eval(&self, symbol: &str, position_id: &str) -> bool {
self.tick_eval_positions
.get(symbol)
.is_some_and(|s| s.contains(position_id))
}
pub fn alert_count(&self) -> usize {
let drop_count: usize = self.alerts.fire_on_drop.values().map(|v| v.len()).sum();
let rise_count: usize = self.alerts.fire_on_rise.values().map(|v| v.len()).sum();
drop_count + rise_count
}
pub fn position_count(&self) -> usize {
self.position_alerts.len()
}
fn entries_for_symbol(
entries: &BTreeMap<AlertKey, Vec<AlertEntry>>,
symbol: &str,
) -> BTreeMap<AlertKey, Vec<AlertEntry>> {
let start = AlertKey {
symbol: symbol.to_owned(),
price_micros: i64::MIN,
};
let end = AlertKey {
symbol: symbol.to_owned(),
price_micros: i64::MAX,
};
entries
.range(start..=end)
.map(|(key, entries)| (key.clone(), entries.clone()))
.collect()
}
fn restore_entries(
destination: &mut BTreeMap<AlertKey, Vec<AlertEntry>>,
position_alerts: &mut HashMap<PositionId, HashSet<AlertKey>>,
entries: BTreeMap<AlertKey, Vec<AlertEntry>>,
) {
for (key, alerts) in entries {
for alert in &alerts {
position_alerts
.entry(alert.position_id.clone())
.or_default()
.insert(key.clone());
}
destination.insert(key, alerts);
}
}
fn trigger_direction(side: Side, kind: &AlertKind) -> TriggerDirection {
match kind {
AlertKind::Stoploss => match side {
Side::Buy => TriggerDirection::FireOnDrop,
Side::Sell => TriggerDirection::FireOnRise,
},
AlertKind::TakeProfit { .. } => match side {
Side::Buy => TriggerDirection::FireOnRise,
Side::Sell => TriggerDirection::FireOnDrop,
},
AlertKind::BreakevenTrigger => match side {
Side::Buy => TriggerDirection::FireOnRise,
Side::Sell => TriggerDirection::FireOnDrop,
},
AlertKind::PendingFill {
order_type,
side: pending_side,
} => {
match (order_type, pending_side) {
(OrderType::Limit, Side::Buy) => TriggerDirection::FireOnDrop,
(OrderType::Limit, Side::Sell) => TriggerDirection::FireOnRise,
(OrderType::Stop, Side::Buy) => TriggerDirection::FireOnRise,
(OrderType::Stop, Side::Sell) => TriggerDirection::FireOnDrop,
(OrderType::Market, _) => TriggerDirection::FireOnRise,
}
}
}
}
fn remove_entry_from_map(
map: &mut BTreeMap<AlertKey, Vec<AlertEntry>>,
key: &AlertKey,
position_id: &str,
) {
if let Some(entries) = map.get_mut(key) {
entries.retain(|e| e.position_id != position_id);
if entries.is_empty() {
map.remove(key);
}
}
}
fn remove_alert_from_map(
map: &mut BTreeMap<AlertKey, Vec<AlertEntry>>,
key: &AlertKey,
position_id: &str,
side: Side,
kind: &AlertKind,
) {
if let Some(entries) = map.get_mut(key) {
entries.retain(|entry| {
!(entry.position_id == position_id && entry.side == side && &entry.kind == kind)
});
if entries.is_empty() {
map.remove(key);
}
}
}
fn has_entry_for_position_at_key(&self, key: &AlertKey, position_id: &str) -> bool {
self.alerts
.fire_on_drop
.get(key)
.is_some_and(|entries| entries.iter().any(|entry| entry.position_id == position_id))
|| self
.alerts
.fire_on_rise
.get(key)
.is_some_and(|entries| entries.iter().any(|entry| entry.position_id == position_id))
}
}
enum TriggerDirection {
FireOnDrop,
FireOnRise,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{FillModel, PriceQuote, Side};
use chrono::NaiveDate;
fn ts(h: u32, m: u32, s: u32) -> chrono::NaiveDateTime {
NaiveDate::from_ymd_opt(2026, 1, 1)
.unwrap()
.and_hms_opt(h, m, s)
.unwrap()
}
fn quote(sym: &str, bid: f64, ask: f64) -> PriceQuote {
PriceQuote {
symbol: sym.into(),
ts: ts(10, 0, 0),
bid,
ask,
}
}
fn assert_reverse_index_parity(reg: &PriceAlertRegister) {
let mut expected = HashMap::<PositionId, HashSet<AlertKey>>::new();
for map in [®.alerts.fire_on_drop, ®.alerts.fire_on_rise] {
for (key, entries) in map {
for entry in entries {
expected
.entry(entry.position_id.clone())
.or_default()
.insert(key.clone());
}
}
}
assert_eq!(®.position_alerts, &expected);
}
#[test]
fn register_and_check_buy_stoploss() {
let mut reg = PriceAlertRegister::new();
reg.register(
"EURUSD",
1.0800,
"p1".into(),
Side::Buy,
AlertKind::Stoploss,
);
let q = quote("EURUSD", 1.0850, 1.0852);
let t = reg.check(&q, FillModel::BidAsk);
assert!(t.is_empty());
let q = quote("EURUSD", 1.0800, 1.0802);
let t = reg.check(&q, FillModel::BidAsk);
assert_eq!(t.len(), 1);
assert_eq!(t[0].position_id, "p1");
assert!(matches!(t[0].kind, AlertKind::Stoploss));
let q = quote("EURUSD", 1.0750, 1.0752);
let t = reg.check(&q, FillModel::BidAsk);
assert!(t.is_empty());
}
#[test]
fn register_and_check_sell_stoploss() {
let mut reg = PriceAlertRegister::new();
reg.register(
"EURUSD",
1.0900,
"p1".into(),
Side::Sell,
AlertKind::Stoploss,
);
let q = quote("EURUSD", 1.0848, 1.0850);
let t = reg.check(&q, FillModel::BidAsk);
assert!(t.is_empty());
let q = quote("EURUSD", 1.0898, 1.0900);
let t = reg.check(&q, FillModel::BidAsk);
assert_eq!(t.len(), 1);
assert_eq!(t[0].position_id, "p1");
}
#[test]
fn register_and_check_take_profit() {
let mut reg = PriceAlertRegister::new();
reg.register(
"EURUSD",
1.0900,
"p1".into(),
Side::Buy,
AlertKind::TakeProfit { close_ratio: 0.5 },
);
let q = quote("EURUSD", 1.0848, 1.0850);
let t = reg.check(&q, FillModel::BidAsk);
assert!(t.is_empty());
let q = quote("EURUSD", 1.0900, 1.0905);
let t = reg.check(&q, FillModel::BidAsk);
assert_eq!(t.len(), 1);
assert!(
matches!(t[0].kind, AlertKind::TakeProfit { close_ratio } if (close_ratio - 0.5).abs() < f64::EPSILON)
);
}
#[test]
fn multiple_alerts_same_price() {
let mut reg = PriceAlertRegister::new();
reg.register(
"EURUSD",
1.0800,
"p1".into(),
Side::Buy,
AlertKind::Stoploss,
);
reg.register(
"EURUSD",
1.0800,
"p2".into(),
Side::Buy,
AlertKind::Stoploss,
);
reg.register(
"EURUSD",
1.0800,
"p3".into(),
Side::Buy,
AlertKind::Stoploss,
);
let q = quote("EURUSD", 1.0800, 1.0802);
let t = reg.check(&q, FillModel::BidAsk);
assert_eq!(t.len(), 3);
}
#[test]
fn alerts_different_symbols_independent() {
let mut reg = PriceAlertRegister::new();
reg.register(
"EURUSD",
1.0800,
"p1".into(),
Side::Buy,
AlertKind::Stoploss,
);
reg.register(
"XAUUSD",
2300.00,
"p2".into(),
Side::Buy,
AlertKind::Stoploss,
);
let q = quote("EURUSD", 1.0800, 1.0802);
let t = reg.check(&q, FillModel::BidAsk);
assert_eq!(t.len(), 1);
assert_eq!(t[0].position_id, "p1");
let q = quote("XAUUSD", 2300.00, 2301.00);
let t = reg.check(&q, FillModel::BidAsk);
assert_eq!(t.len(), 1);
assert_eq!(t[0].position_id, "p2");
}
#[test]
fn deregister_position_removes_all_alerts() {
let mut reg = PriceAlertRegister::new();
reg.register(
"EURUSD",
1.0800,
"p1".into(),
Side::Buy,
AlertKind::Stoploss,
);
reg.register(
"EURUSD",
1.0900,
"p1".into(),
Side::Buy,
AlertKind::TakeProfit { close_ratio: 0.5 },
);
reg.register(
"EURUSD",
1.0880,
"p1".into(),
Side::Buy,
AlertKind::BreakevenTrigger,
);
assert_eq!(reg.alert_count(), 3);
reg.deregister_position("p1");
assert_eq!(reg.alert_count(), 0);
assert!(!reg.has_alerts("p1"));
let q = quote("EURUSD", 1.0700, 1.0702);
let t = reg.check(&q, FillModel::BidAsk);
assert!(t.is_empty());
let q = quote("EURUSD", 1.0950, 1.0952);
let t = reg.check(&q, FillModel::BidAsk);
assert!(t.is_empty());
}
#[test]
fn deregister_single_alert() {
let mut reg = PriceAlertRegister::new();
reg.register(
"EURUSD",
1.0800,
"p1".into(),
Side::Buy,
AlertKind::Stoploss,
);
reg.register(
"EURUSD",
1.0900,
"p1".into(),
Side::Buy,
AlertKind::TakeProfit { close_ratio: 0.5 },
);
reg.deregister_alert("EURUSD", 1.0800, "p1", Side::Buy, &AlertKind::Stoploss);
let q = quote("EURUSD", 1.0800, 1.0802);
let t = reg.check(&q, FillModel::BidAsk);
assert!(t.is_empty());
let q = quote("EURUSD", 1.0900, 1.0902);
let t = reg.check(&q, FillModel::BidAsk);
assert_eq!(t.len(), 1);
assert!(matches!(t[0].kind, AlertKind::TakeProfit { .. }));
}
#[test]
fn deregister_alert_preserves_colocated_same_direction_kind() {
let mut reg = PriceAlertRegister::new();
reg.register(
"EURUSD",
1.0900,
"p1".into(),
Side::Buy,
AlertKind::TakeProfit { close_ratio: 0.5 },
);
reg.register(
"EURUSD",
1.0900,
"p1".into(),
Side::Buy,
AlertKind::BreakevenTrigger,
);
assert_reverse_index_parity(®);
reg.deregister_alert(
"EURUSD",
1.0900,
"p1",
Side::Buy,
&AlertKind::TakeProfit { close_ratio: 0.5 },
);
assert_eq!(reg.alert_count(), 1);
assert!(reg.has_alerts("p1"));
assert_reverse_index_parity(®);
let triggered = reg.check("e("EURUSD", 1.0900, 1.0902), FillModel::BidAsk);
assert!(matches!(
triggered.as_slice(),
[TriggeredAlert {
position_id,
kind: AlertKind::BreakevenTrigger,
..
}] if position_id == "p1"
));
assert!(!reg.has_alerts("p1"));
assert_reverse_index_parity(®);
}
#[test]
fn deregister_alert_preserves_colocated_other_direction_and_index() {
let mut reg = PriceAlertRegister::new();
reg.register(
"EURUSD",
1.0900,
"p1".into(),
Side::Buy,
AlertKind::TakeProfit { close_ratio: 0.5 },
);
reg.register(
"EURUSD",
1.0900,
"p1".into(),
Side::Buy,
AlertKind::Stoploss,
);
reg.deregister_alert(
"EURUSD",
1.0900,
"p1",
Side::Buy,
&AlertKind::TakeProfit { close_ratio: 0.5 },
);
assert_eq!(reg.alert_count(), 1);
assert!(reg.has_alerts("p1"));
assert_reverse_index_parity(®);
reg.deregister_position("p1");
assert_eq!(reg.alert_count(), 0);
assert!(!reg.has_alerts("p1"));
assert_reverse_index_parity(®);
}
#[test]
fn pending_fill_alert_limit_buy() {
let mut reg = PriceAlertRegister::new();
reg.register(
"EURUSD",
1.0800,
"p1".into(),
Side::Buy,
AlertKind::PendingFill {
order_type: OrderType::Limit,
side: Side::Buy,
},
);
let q = quote("EURUSD", 1.0848, 1.0850);
let t = reg.check(&q, FillModel::BidAsk);
assert!(t.is_empty());
let q = quote("EURUSD", 1.0798, 1.0800);
let t = reg.check(&q, FillModel::BidAsk);
assert_eq!(t.len(), 1);
assert!(matches!(
t[0].kind,
AlertKind::PendingFill {
order_type: OrderType::Limit,
side: Side::Buy
}
));
}
#[test]
fn pending_fill_alert_stop_buy() {
let mut reg = PriceAlertRegister::new();
reg.register(
"EURUSD",
1.0900,
"p1".into(),
Side::Buy,
AlertKind::PendingFill {
order_type: OrderType::Stop,
side: Side::Buy,
},
);
let q = quote("EURUSD", 1.0848, 1.0850);
let t = reg.check(&q, FillModel::BidAsk);
assert!(t.is_empty());
let q = quote("EURUSD", 1.0898, 1.0900);
let t = reg.check(&q, FillModel::BidAsk);
assert_eq!(t.len(), 1);
}
#[test]
fn clear_all_removes_everything() {
let mut reg = PriceAlertRegister::new();
reg.register(
"EURUSD",
1.0800,
"p1".into(),
Side::Buy,
AlertKind::Stoploss,
);
reg.register(
"XAUUSD",
2300.00,
"p2".into(),
Side::Buy,
AlertKind::Stoploss,
);
reg.register_tick_eval("EURUSD", "p3".into());
reg.clear_all();
assert_eq!(reg.alert_count(), 0);
assert_eq!(reg.position_count(), 0);
assert!(reg.tick_eval_ids("EURUSD").is_empty());
let q = quote("EURUSD", 1.0700, 1.0702);
let t = reg.check(&q, FillModel::BidAsk);
assert!(t.is_empty());
}
#[test]
fn tick_eval_ids_returns_stateful_only() {
let mut reg = PriceAlertRegister::new();
reg.register_tick_eval("EURUSD", "p1".into());
reg.register(
"EURUSD",
1.0800,
"p2".into(),
Side::Buy,
AlertKind::Stoploss,
);
reg.register(
"EURUSD",
1.0900,
"p2".into(),
Side::Buy,
AlertKind::TakeProfit { close_ratio: 1.0 },
);
let tick_ids = reg.tick_eval_ids("EURUSD");
assert_eq!(tick_ids.len(), 1);
assert!(tick_ids.contains(&"p1".to_owned()));
}
#[test]
fn is_tick_eval_check() {
let mut reg = PriceAlertRegister::new();
reg.register_tick_eval("EURUSD", "p1".into());
assert!(reg.is_tick_eval("EURUSD", "p1"));
assert!(!reg.is_tick_eval("EURUSD", "p2"));
assert!(!reg.is_tick_eval("XAUUSD", "p1"));
}
#[test]
fn deregister_position_also_removes_tick_eval() {
let mut reg = PriceAlertRegister::new();
reg.register(
"EURUSD",
1.0800,
"p1".into(),
Side::Buy,
AlertKind::Stoploss,
);
reg.register_tick_eval("EURUSD", "p1".into());
assert!(reg.is_tick_eval("EURUSD", "p1"));
reg.deregister_position("p1");
assert!(!reg.is_tick_eval("EURUSD", "p1"));
assert_eq!(reg.alert_count(), 0);
}
#[test]
fn breakeven_trigger_buy() {
let mut reg = PriceAlertRegister::new();
reg.register(
"EURUSD",
1.0900,
"p1".into(),
Side::Buy,
AlertKind::BreakevenTrigger,
);
let q = quote("EURUSD", 1.0850, 1.0852);
let t = reg.check(&q, FillModel::BidAsk);
assert!(t.is_empty());
let q = quote("EURUSD", 1.0900, 1.0902);
let t = reg.check(&q, FillModel::BidAsk);
assert_eq!(t.len(), 1);
assert!(matches!(t[0].kind, AlertKind::BreakevenTrigger));
}
#[test]
fn breakeven_trigger_sell() {
let mut reg = PriceAlertRegister::new();
reg.register(
"EURUSD",
1.0750,
"p1".into(),
Side::Sell,
AlertKind::BreakevenTrigger,
);
let q = quote("EURUSD", 1.0800, 1.0802);
let t = reg.check(&q, FillModel::BidAsk);
assert!(t.is_empty());
let q = quote("EURUSD", 1.0748, 1.0750);
let t = reg.check(&q, FillModel::BidAsk);
assert_eq!(t.len(), 1);
assert!(matches!(t[0].kind, AlertKind::BreakevenTrigger));
}
#[test]
fn sell_take_profit_fires_on_drop() {
let mut reg = PriceAlertRegister::new();
reg.register(
"EURUSD",
1.0750,
"p1".into(),
Side::Sell,
AlertKind::TakeProfit { close_ratio: 1.0 },
);
let q = quote("EURUSD", 1.0800, 1.0802);
let t = reg.check(&q, FillModel::BidAsk);
assert!(t.is_empty());
let q = quote("EURUSD", 1.0748, 1.0750);
let t = reg.check(&q, FillModel::BidAsk);
assert_eq!(t.len(), 1);
}
#[test]
fn price_to_micros_precision() {
assert_eq!(price_to_micros(1.08500), 1_085_000);
assert_eq!(price_to_micros(2350.50), 2_350_500_000);
assert_eq!(price_to_micros(0.0), 0);
assert_eq!(price_to_micros(100_000.0), 100_000_000_000);
}
#[test]
fn micros_roundtrip() {
let prices = [1.08500, 2350.50, 154.325, 0.00001, 100000.0];
for p in prices {
let m = price_to_micros(p);
let back = micros_to_price(m);
assert!(
(back - p).abs() < 1e-6,
"roundtrip failed for {p}: got {back}"
);
}
}
#[test]
fn fill_model_ask_only_buy_sl() {
let mut reg = PriceAlertRegister::new();
reg.register(
"EURUSD",
1.0800,
"p1".into(),
Side::Buy,
AlertKind::Stoploss,
);
let q = quote("EURUSD", 1.0790, 1.0810);
let t = reg.check(&q, FillModel::AskOnly);
assert!(t.is_empty());
let q = quote("EURUSD", 1.0790, 1.0800);
let t = reg.check(&q, FillModel::AskOnly);
assert_eq!(t.len(), 1);
}
#[test]
fn multiple_positions_partial_trigger() {
let mut reg = PriceAlertRegister::new();
reg.register(
"EURUSD",
1.0800,
"p1".into(),
Side::Buy,
AlertKind::Stoploss,
);
reg.register(
"EURUSD",
1.0700,
"p2".into(),
Side::Buy,
AlertKind::Stoploss,
);
let q = quote("EURUSD", 1.0800, 1.0802);
let t = reg.check(&q, FillModel::BidAsk);
assert_eq!(t.len(), 1);
assert_eq!(t[0].position_id, "p1");
assert!(reg.has_alerts("p2"));
assert!(!reg.has_alerts("p1"));
}
#[test]
fn mixed_alert_types_same_position() {
let mut reg = PriceAlertRegister::new();
let pid: PositionId = "p1".into();
reg.register(
"EURUSD",
1.0800,
pid.clone(),
Side::Buy,
AlertKind::Stoploss,
);
reg.register(
"EURUSD",
1.0900,
pid.clone(),
Side::Buy,
AlertKind::TakeProfit { close_ratio: 0.5 },
);
reg.register(
"EURUSD",
1.0870,
pid.clone(),
Side::Buy,
AlertKind::BreakevenTrigger,
);
assert_eq!(reg.alert_count(), 3);
let q = quote("EURUSD", 1.0870, 1.0872);
let t = reg.check(&q, FillModel::BidAsk);
assert_eq!(t.len(), 1);
assert!(matches!(t[0].kind, AlertKind::BreakevenTrigger));
assert_eq!(reg.alert_count(), 2);
}
#[test]
fn unregister_tick_eval() {
let mut reg = PriceAlertRegister::new();
reg.register_tick_eval("EURUSD", "p1".into());
reg.register_tick_eval("EURUSD", "p2".into());
assert_eq!(reg.tick_eval_ids("EURUSD").len(), 2);
reg.unregister_tick_eval("EURUSD", "p1");
let ids = reg.tick_eval_ids("EURUSD");
assert_eq!(ids.len(), 1);
assert!(ids.contains(&"p2".to_owned()));
}
}