use std::collections::HashSet;
use std::fmt;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use crate::indicator::IndicatorAlert;
use crate::timeframe::Timeframe;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(
feature = "serde",
derive(Serialize, Deserialize),
serde(rename_all = "snake_case")
)]
pub enum EventPhase {
Setup,
Watch,
Trigger,
Invalidation,
Expiry,
TargetHit,
}
impl fmt::Display for EventPhase {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
EventPhase::Setup => "setup",
EventPhase::Watch => "watch",
EventPhase::Trigger => "trigger",
EventPhase::Invalidation => "invalidation",
EventPhase::Expiry => "expiry",
EventPhase::TargetHit => "target_hit",
};
f.write_str(s)
}
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct AlertEvent {
pub alert: IndicatorAlert,
pub timestamp: i64,
pub phase: EventPhase,
instrument: Option<String>,
timeframe: Option<Timeframe>,
pub event_id: String,
}
fn encode_component(s: &str) -> String {
format!("{}#{}", s.len(), s)
}
fn compute_event_id(
kind: &str,
phase: EventPhase,
timestamp: i64,
instrument: Option<&str>,
timeframe: Option<Timeframe>,
) -> String {
let instrument_component = encode_component(instrument.unwrap_or(""));
let timeframe_component =
encode_component(&timeframe.map(|tf| tf.to_string()).unwrap_or_default());
let kind_component = encode_component(kind);
let phase_component = encode_component(&phase.to_string());
format!(
"{instrument_component}:{timeframe_component}:{kind_component}:{phase_component}:{timestamp}"
)
}
impl AlertEvent {
pub fn new(alert: IndicatorAlert, timestamp: i64, phase: EventPhase) -> Self {
let event_id = compute_event_id(&alert.kind, phase, timestamp, None, None);
Self {
alert,
timestamp,
phase,
instrument: None,
timeframe: None,
event_id,
}
}
pub fn instrument(&self) -> Option<&str> {
self.instrument.as_deref()
}
pub fn timeframe(&self) -> Option<Timeframe> {
self.timeframe
}
pub fn with_instrument(mut self, instrument: impl Into<String>) -> Self {
self.instrument = Some(instrument.into());
self.event_id = compute_event_id(
&self.alert.kind,
self.phase,
self.timestamp,
self.instrument.as_deref(),
self.timeframe,
);
self
}
pub fn with_timeframe(mut self, timeframe: Timeframe) -> Self {
self.timeframe = Some(timeframe);
self.event_id = compute_event_id(
&self.alert.kind,
self.phase,
self.timestamp,
self.instrument.as_deref(),
self.timeframe,
);
self
}
}
#[derive(Debug, Clone, Default)]
pub struct AlertDeduplicator {
seen: HashSet<String>,
}
impl AlertDeduplicator {
pub fn new() -> Self {
Self::default()
}
pub fn admit(&mut self, event: &AlertEvent) -> bool {
self.seen.insert(event.event_id.clone())
}
pub fn reset(&mut self) {
self.seen.clear();
}
pub fn len(&self) -> usize {
self.seen.len()
}
pub fn is_empty(&self) -> bool {
self.seen.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_alert_event_deterministic_id() {
let alert = IndicatorAlert::new("cross_up", "RSI crossed above 70", 0.8);
let event_a = AlertEvent::new(alert.clone(), 1_000, EventPhase::Trigger);
let event_b = AlertEvent::new(alert, 1_000, EventPhase::Trigger);
assert_eq!(event_a.event_id, event_b.event_id);
}
#[test]
fn test_alert_event_builders() {
let alert = IndicatorAlert::new("cross_up", "RSI crossed above 70", 0.8);
let event = AlertEvent::new(alert, 1_000, EventPhase::Watch)
.with_instrument("GENERIC")
.with_timeframe(Timeframe::Minute(5));
assert_eq!(event.instrument(), Some("GENERIC"));
assert_eq!(event.timeframe(), Some(Timeframe::Minute(5)));
}
#[test]
fn test_event_id_distinguishes_instrument() {
let alert = IndicatorAlert::new("cross_up", "crossed above trigger", 0.8);
let dax = AlertEvent::new(alert.clone(), 1_700_000_000, EventPhase::Trigger)
.with_instrument("DAX")
.with_timeframe(Timeframe::Minute(5));
let es = AlertEvent::new(alert, 1_700_000_000, EventPhase::Trigger)
.with_instrument("ES")
.with_timeframe(Timeframe::Minute(5));
assert_ne!(dax.event_id, es.event_id);
let mut dedup = AlertDeduplicator::new();
assert!(dedup.admit(&dax));
assert!(
dedup.admit(&es),
"same kind/phase/timestamp on a different instrument must not be treated as a duplicate"
);
}
#[test]
fn test_event_id_distinguishes_timeframe() {
let alert = IndicatorAlert::new("cross_up", "crossed above trigger", 0.8);
let m5 = AlertEvent::new(alert.clone(), 1_700_000_000, EventPhase::Trigger)
.with_instrument("DAX")
.with_timeframe(Timeframe::Minute(5));
let m15 = AlertEvent::new(alert, 1_700_000_000, EventPhase::Trigger)
.with_instrument("DAX")
.with_timeframe(Timeframe::Minute(15));
assert_ne!(m5.event_id, m15.event_id);
let mut dedup = AlertDeduplicator::new();
assert!(dedup.admit(&m5));
assert!(
dedup.admit(&m15),
"same instrument on a different timeframe must not be treated as a duplicate"
);
}
#[test]
fn test_event_id_same_full_context_is_duplicate() {
let alert = IndicatorAlert::new("cross_up", "crossed above trigger", 0.8);
let first = AlertEvent::new(alert.clone(), 1_700_000_000, EventPhase::Trigger)
.with_instrument("DAX")
.with_timeframe(Timeframe::Minute(5));
let second = AlertEvent::new(alert, 1_700_000_000, EventPhase::Trigger)
.with_instrument("DAX")
.with_timeframe(Timeframe::Minute(5));
assert_eq!(first.event_id, second.event_id);
let mut dedup = AlertDeduplicator::new();
assert!(dedup.admit(&first));
assert!(!dedup.admit(&second));
}
#[test]
fn test_event_id_independent_of_builder_call_order() {
let alert = IndicatorAlert::new("cross_up", "crossed above trigger", 0.8);
let instrument_then_timeframe =
AlertEvent::new(alert.clone(), 1_700_000_000, EventPhase::Trigger)
.with_instrument("DAX")
.with_timeframe(Timeframe::Minute(5));
let timeframe_then_instrument = AlertEvent::new(alert, 1_700_000_000, EventPhase::Trigger)
.with_timeframe(Timeframe::Minute(5))
.with_instrument("DAX");
assert_eq!(
instrument_then_timeframe.event_id,
timeframe_then_instrument.event_id
);
}
#[test]
fn test_event_id_special_characters_do_not_collide() {
let alert_ab_c = IndicatorAlert::new("C", "note", 0.5);
let event_1 =
AlertEvent::new(alert_ab_c, 1_000, EventPhase::Trigger).with_instrument("A:B");
let alert_b_c = IndicatorAlert::new("B:C", "note", 0.5);
let event_2 = AlertEvent::new(alert_b_c, 1_000, EventPhase::Trigger).with_instrument("A");
assert_ne!(event_1.event_id, event_2.event_id);
}
#[test]
fn test_alert_deduplicator() {
let alert = IndicatorAlert::new("cross_up", "RSI crossed above 70", 0.8);
let event = AlertEvent::new(alert.clone(), 1_000, EventPhase::Trigger);
let mut dedup = AlertDeduplicator::new();
assert!(dedup.admit(&event));
assert!(
!dedup.admit(&event),
"duplicate event must not be re-admitted"
);
assert_eq!(dedup.len(), 1);
let other_bar = AlertEvent::new(alert, 1_060, EventPhase::Trigger);
assert!(dedup.admit(&other_bar), "distinct timestamp is a new event");
assert_eq!(dedup.len(), 2);
dedup.reset();
assert!(dedup.is_empty());
}
}