//! Session-only price crossings. No I/O: accepted quotes drive rules; //! the runtime consumes the notification clock and the bell effect. use std::collections::{HashMap, VecDeque}; use std::time::{Duration, Instant}; use chrono::{DateTime, Utc}; use crate::domain::{PriceFeed, Quote}; const BANNER_TIME: Duration = Duration::from_secs(12); const BELL_INTERVAL: Duration = Duration::from_secs(1); #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Direction { Up, Down, } #[derive(Clone, Debug)] pub struct Level { pub direction: Direction, pub price: f64, pub label: String, } impl Level { pub fn parse(input: &str) -> Result { let input = input.trim(); let (direction, rest) = if let Some(rest) = input.strip_prefix('>') { (Direction::Up, rest) } else if let Some(rest) = input.strip_prefix('<') { (Direction::Down, rest) } else { return Err("Use >= 150 or <= 140".into()); }; let value = rest.strip_prefix('=').unwrap_or(rest).trim(); let mut parts = value.split('.'); let whole = parts.next().unwrap_or_default(); let fraction = parts.next(); if whole.is_empty() || !whole.bytes().all(|b| b.is_ascii_digit()) || fraction.is_some_and(|f| f.is_empty() || !f.bytes().all(|b| b.is_ascii_digit())) || parts.next().is_some() || input.len() > 32 { return Err("Use a positive decimal price, e.g. >= 150.25".into()); } let price = value.parse::().unwrap_or(f64::NAN); if !price.is_finite() || price <= 0.0 { return Err("Price must be finite and greater than zero".into()); } let whole = whole.trim_start_matches('0'); let whole = if whole.is_empty() { "0" } else { whole }; let amount = fraction.map_or_else(|| whole.to_string(), |f| format!("{whole}.{f}")); let operator = match direction { Direction::Up => ">=", Direction::Down => "<=", }; let label = format!("{operator} {amount}"); // The saved expression is also the next prompt's input: an alias // must not normalize into a line that the prompt cannot rearm. if label.len() > 32 { return Err("Price is too long".into()); } Ok(Self { direction, price, label, }) } pub fn reached(&self, price: f64) -> bool { match self.direction { Direction::Up => price >= self.price, Direction::Down => price <= self.price, } } } #[derive(Clone, Debug)] pub struct Sample { pub price: f64, pub timestamp: i64, pub feed: PriceFeed, pub currency: Option, } /// Use the newest timestamped price, never the chart's historical OHLC. /// A missing regular timestamp is normal for an Alpaca overnight close. pub fn quote_sample(quote: &Quote, now: DateTime) -> Option { let candidate = |price: f64, time: Option, feed| { let timestamp = time.filter(|t| *t > 0 && *t <= now.timestamp())?; (price.is_finite() && price > 0.0).then(|| Sample { price, timestamp, feed, currency: quote .currency .as_deref() .map(str::trim) .filter(|s| !s.is_empty()) .map(str::to_ascii_uppercase), }) }; let regular = candidate(quote.price, quote.timing.regular, quote.timing.regular_feed); let extended = quote .extended_price_at(now) .and_then(|price| candidate(price, quote.timing.extended, quote.timing.extended_feed)); match (regular, extended) { (Some(r), Some(e)) => Some(if e.timestamp > r.timestamp { e } else { r }), (r, e) => r.or(e), } } #[derive(Clone, Debug)] pub struct Trigger { pub sample: Sample, pub detected_at: DateTime, } #[derive(Clone, Debug)] pub struct Rule { pub level: Level, pub currency: Option, pub previous: Option, pub fired: Option, pub currency_paused: bool, latest_timestamp: Option, } impl Rule { pub fn new(level: Level, previous: Option) -> Self { Self { currency: previous.as_ref().and_then(|s| s.currency.clone()), latest_timestamp: previous.as_ref().map(|s| s.timestamp), level, previous, fired: None, currency_paused: false, } } fn observe(&mut self, sample: Sample, detected_at: DateTime) -> Option { if self.fired.is_some() || self.latest_timestamp.is_some_and(|t| sample.timestamp < t) { return None; } self.latest_timestamp = Some(sample.timestamp); if matches!((&self.currency, &sample.currency), (Some(a), Some(b)) if a != b) { self.currency_paused = true; self.previous = None; return None; } if self.currency.is_none() { self.currency = sample.currency.clone(); } // Returning from a currency mismatch establishes a new baseline. // Missing currency is compatible with a known one (e.g. Finnhub). self.currency_paused = false; let crossed = self.previous.as_ref().is_some_and(|previous| { !self.level.reached(previous.price) && self.level.reached(sample.price) }); self.previous = Some(sample.clone()); if crossed { let trigger = Trigger { sample, detected_at, }; self.fired = Some(trigger.clone()); Some(trigger) } else { None } } pub fn marker(&self) -> char { if self.fired.is_some() { '!' } else if self.currency_paused || self.previous.is_none() { '?' } else { match self.level.direction { Direction::Up => '↑', Direction::Down => '↓', } } } pub fn status(&self) -> &'static str { if self.fired.is_some() { "Fired; Enter arms again" } else if self.currency_paused { "Paused: currency changed; Enter accepts it" } else if let Some(previous) = &self.previous { if self.level.reached(previous.price) { match self.level.direction { Direction::Up => "Waiting for a move below, then back to the level", Direction::Down => "Waiting for a move above, then back to the level", } } else { "Armed; waiting for the crossing" } } else { "Waiting for a timestamped quote" } } } #[derive(Clone, Debug)] pub struct Notification { pub symbol: String, pub level: String, pub trigger: Trigger, needs_bell: bool, } #[derive(Default)] pub struct Alerts { rules: HashMap, queue: VecDeque, shown_since: Option, last_bell: Option, } impl Alerts { pub fn get(&self, symbol: &str) -> Option<&Rule> { self.rules.get(symbol) } pub fn arm(&mut self, symbol: String, level: Level, previous: Option) { self.remove(&symbol); self.rules.insert(symbol, Rule::new(level, previous)); } pub fn remove(&mut self, symbol: &str) { self.rules.remove(symbol); if self.queue.front().is_some_and(|n| n.symbol == symbol) { self.shown_since = None; } self.queue.retain(|n| n.symbol != symbol); } pub fn retain_symbols(&mut self, symbols: &[String]) { self.rules.retain(|s, _| symbols.contains(s)); if self .queue .front() .is_some_and(|n| !symbols.contains(&n.symbol)) { self.shown_since = None; } self.queue.retain(|n| symbols.contains(&n.symbol)); } pub fn observe(&mut self, symbol: &str, sample: Sample, now: DateTime) { let Some(rule) = self.rules.get_mut(symbol) else { return; }; if let Some(trigger) = rule.observe(sample, now) { self.queue.push_back(Notification { symbol: symbol.into(), level: rule.level.label.clone(), trigger, needs_bell: true, }); } } /// Called immediately before a frame. Returns an effect, never writes /// to the terminal; closely spaced events share a single bell. pub fn tick(&mut self, now: Instant) -> bool { if self .shown_since .is_some_and(|at| now.saturating_duration_since(at) >= BANNER_TIME) { self.queue.pop_front(); self.shown_since = None; } if !self.queue.is_empty() && self.shown_since.is_none() { self.shown_since = Some(now); } let pending = self.queue.iter().any(|n| n.needs_bell); for notice in &mut self.queue { notice.needs_bell = false; } let bell = pending && self .last_bell .is_none_or(|at| now.saturating_duration_since(at) >= BELL_INTERVAL); if bell { self.last_bell = Some(now); } bell } pub fn notification(&self) -> Option<&Notification> { self.queue.front() } pub fn waiting_count(&self) -> usize { self.queue.len().saturating_sub(1) } } #[cfg(test)] mod tests { use super::*; use crate::domain::QuoteTiming; use chrono::TimeZone; fn now() -> DateTime { Utc.with_ymd_and_hms(2026, 11, 6, 21, 10, 0).unwrap() } fn sample(price: f64, ts: i64) -> Sample { Sample { price, timestamp: ts, feed: PriceFeed::Iex, currency: Some("USD".into()), } } #[test] fn grammar_keeps_precision_and_normalizes_aliases() { assert_eq!( Level::parse(" > 000.0000100 ").unwrap().label, ">= 0.0000100" ); assert_eq!(Level::parse("<= 0140.50").unwrap().label, "<= 140.50"); let long = format!(">{}", "1".repeat(31)); assert!(Level::parse(&long).is_err()); let limit = Level::parse(&format!(">{}", "1".repeat(29))).unwrap(); assert_eq!(Level::parse(&limit.label).unwrap().label, limit.label); for bad in [ "150", "=150", ">=NaN", ">=inf", ">=0", "<-1", ">1e2", ">1,200", ">1.2.3", ">.5", ">1.", ] { assert!(Level::parse(bad).is_err(), "{bad}"); } } #[test] fn crossings_are_inclusive_one_shot_and_require_a_baseline() { for (level, before, at, beyond) in [(">150", 149., 150., 151.), ("<150", 151., 150., 149.)] { let mut r = Rule::new(Level::parse(level).unwrap(), None); assert!(r.observe(sample(before, 1), now()).is_none()); assert!(r.observe(sample(at, 2), now()).is_some()); assert!(r.observe(sample(beyond, 3), now()).is_none()); assert_eq!(r.fired.as_ref().unwrap().sample.price, at); } let mut r = Rule::new(Level::parse(">150").unwrap(), Some(sample(151., 1))); assert!(r.observe(sample(152., 2), now()).is_none()); assert!(r.observe(sample(149., 3), now()).is_none()); assert!(r.observe(sample(150., 4), now()).is_some()); } #[test] fn timestamp_order_survives_feed_changes_and_open_bar_updates() { let mut r = Rule::new(Level::parse(">150").unwrap(), Some(sample(149., 10))); assert!(r.observe(sample(160., 9), now()).is_none()); assert!(r.observe(sample(149., 10), now()).is_none()); let mut next = sample(150., 10); next.feed = PriceFeed::DelayedSip; assert!(r.observe(next, now()).is_some()); } #[test] fn missing_currency_is_compatible_but_different_known_currency_pauses() { let mut r = Rule::new(Level::parse(">150").unwrap(), Some(sample(149., 1))); let mut next = sample(151., 2); next.currency = None; next.feed = PriceFeed::Finnhub; assert!(r.observe(next, now()).is_some()); let mut r = Rule::new(Level::parse(">150").unwrap(), Some(sample(149., 1))); let mut next = sample(151., 2); next.currency = Some("EUR".into()); assert!(r.observe(next, now()).is_none()); assert!(r.currency_paused); assert!(r.observe(sample(151., 3), now()).is_none()); assert!(!r.currency_paused); assert!(r.observe(sample(149., 4), now()).is_none()); assert!(r.observe(sample(150., 5), now()).is_some()); } #[test] fn samples_use_timestamped_prices_with_source_specific_gaps() { let at = now(); // US after hours; today's close precedes AH. let mut q = Quote { price: 149., extended: Some(151.), timing: QuoteTiming { regular: Some(at.timestamp() - 600), regular_feed: PriceFeed::Yahoo, extended: Some(at.timestamp() - 1), extended_feed: PriceFeed::Yahoo, ..Default::default() }, ..Default::default() }; assert_eq!(quote_sample(&q, at).unwrap().price, 151.); q.timing.regular = None; // Alpaca: regular close has no trade time. assert_eq!(quote_sample(&q, at).unwrap().price, 151.); q.extended = None; // Tiingo before open, without daily history. assert!(quote_sample(&q, at).is_none()); q.timing.regular = Some(at.timestamp()); q.timing.regular_feed = PriceFeed::Finnhub; assert_eq!(quote_sample(&q, at).unwrap().currency, None); for price in [0., -1., f64::NAN, f64::INFINITY] { q.price = price; assert!(quote_sample(&q, at).is_none()); } q.price = 149.; q.timing.regular = Some(at.timestamp() + 1); assert!(quote_sample(&q, at).is_none()); } #[test] fn queue_coalesces_bells_times_each_banner_and_prunes_rearmed_rules() { let mut alerts = Alerts::default(); for s in ["AAPL", "MSFT"] { alerts.arm( s.into(), Level::parse(">150").unwrap(), Some(sample(149., 1)), ); alerts.observe(s, sample(150., 2), now()); } let clock = Instant::now(); assert!(alerts.tick(clock)); assert!(!alerts.tick(clock + Duration::from_secs(1))); assert_eq!(alerts.notification().unwrap().symbol, "AAPL"); assert!(!alerts.tick(clock + BANNER_TIME)); assert_eq!(alerts.notification().unwrap().symbol, "MSFT"); alerts.arm( "MSFT".into(), Level::parse(">160").unwrap(), Some(sample(150., 2)), ); assert!(alerts.notification().is_none()); assert!(alerts.get("AAPL").unwrap().fired.is_some()); alerts.retain_symbols(&["MSFT".into()]); assert!(alerts.get("AAPL").is_none()); } #[test] fn samples_cover_premarket_night_crypto_and_equal_timestamps() { let pre = Utc.with_ymd_and_hms(2026, 11, 6, 13, 0, 0).unwrap(); let mut q = Quote { symbol: "AAPL".into(), price: 149., extended: Some(151.), timing: QuoteTiming { regular: Some(pre.timestamp() - 16 * 3600), regular_feed: PriceFeed::Yahoo, extended: Some(pre.timestamp() - 60), extended_feed: PriceFeed::Yahoo, ..Default::default() }, ..Default::default() }; assert_eq!(quote_sample(&q, pre).unwrap().price, 151.); // A stale extended price must not outrank a newer regular print. q.timing.regular = Some(pre.timestamp()); assert_eq!(quote_sample(&q, pre).unwrap().price, 149.); q.timing.extended = q.timing.regular; assert_eq!(quote_sample(&q, pre).unwrap().price, 149.); let night = Utc.with_ymd_and_hms(2026, 11, 7, 2, 0, 0).unwrap(); q.timing.regular = None; // Last AH print remains valid overnight; a PRE from this morning does not. assert!(quote_sample(&q, night).is_none()); q.timing.extended = Some(now().timestamp()); assert_eq!(quote_sample(&q, night).unwrap().price, 151.); let monday_pre = Utc.with_ymd_and_hms(2026, 11, 9, 13, 0, 0).unwrap(); assert!(quote_sample(&q, monday_pre).is_none()); q.symbol = "BTC-USD".into(); q.extended = None; q.timing.regular = Some(night.timestamp()); q.timing.regular_feed = PriceFeed::Crypto; assert_eq!(quote_sample(&q, night).unwrap().feed, PriceFeed::Crypto); } #[test] fn banner_timer_starts_on_tick_and_a_suppressed_bell_never_replays() { let mut alerts = Alerts::default(); let clock = Instant::now(); for s in ["AAPL", "MSFT", "NVDA"] { alerts.arm( s.into(), Level::parse(">150").unwrap(), Some(sample(149., 1)), ); } alerts.observe("AAPL", sample(150., 2), now()); assert!(alerts.tick(clock)); alerts.observe("MSFT", sample(150., 2), now()); assert!(!alerts.tick(clock + Duration::from_millis(30))); assert!( !alerts.tick(clock + Duration::from_secs(1)), "suppressed sound replayed" ); alerts.observe("NVDA", sample(150., 2), now()); assert!(alerts.tick(clock + Duration::from_secs(2))); assert_eq!(alerts.waiting_count(), 2); // Even after a long idle interval, the next banner gets its full time. assert!(!alerts.tick(clock + Duration::from_secs(60))); assert_eq!(alerts.notification().unwrap().symbol, "MSFT"); alerts.tick(clock + Duration::from_secs(71)); assert_eq!(alerts.notification().unwrap().symbol, "MSFT"); alerts.tick(clock + Duration::from_secs(72)); assert_eq!(alerts.notification().unwrap().symbol, "NVDA"); alerts.tick(clock + Duration::from_secs(84)); assert!(alerts.notification().is_none()); assert!(alerts.get("MSFT").unwrap().fired.is_some()); } }