Skip to main content

egui_charts/services/detectors/
mod.rs

1//! A signal service that finds order-flow events in the executions and depth the host streams.
2
3mod absorption;
4mod alerts;
5mod big_trades;
6mod depth;
7mod icebergs;
8mod liquidity;
9mod stops;
10
11use std::collections::{HashMap, HashSet, VecDeque};
12use std::sync::Mutex;
13
14use chrono::{DateTime, Utc};
15
16use super::{Events, SignalEvent, Signals};
17use crate::data::depth::Book;
18use crate::data::{FeedError, MarketEvent, OrderQueue};
19use crate::model::{BarClock, Instrument, Signal, Tick, Timeframe, TimezoneId};
20use absorption::Absorption;
21pub use absorption::AbsorptionRule;
22pub use alerts::{CountAlert, CountWindow, DetectionAlert, DetectionCounts};
23use alerts::{Counting, Delivery};
24pub use big_trades::BigTradeRule;
25use big_trades::BigTrades;
26use depth::Depth;
27pub use icebergs::IcebergRule;
28use icebergs::Icebergs;
29use liquidity::Liquidity;
30pub use liquidity::LiquidityRule;
31pub use stops::{StopRunRule, StopSweepRule};
32use stops::{StopRuns, StopSweeps};
33
34/// Detections of a symbol kept for the next subscription, most recent last.
35const RETAINED: usize = 5_000;
36/// Trade ids of a symbol remembered to drop repeated trades.
37const TRADE_IDS: usize = 100_000;
38
39/// A [`Signals`] service that finds order-flow events in the executions and depth a host already
40/// streams, as [`PaperAccount`](super::PaperAccount) is a ready-made
41/// [`Broker`](super::Broker). It needs no egui, so a server can run the same code.
42///
43/// The host [`track`](Self::track)s each symbol and feeds the service the events it pushes into
44/// the chart's [`Sink`](crate::data::Sink), as [`MarketEvent`] values tagged with the symbol and
45/// their feed time, so live feeds, recordings and replay share one input. Every window runs on
46/// that feed time, never the wall clock, so replayed data gives the signals live data gave.
47///
48/// *Trades.* A trade with an id already seen is dropped, so the trade an order execution carries
49/// counts once even when the feed also sends it as a trade. Fills group by the aggressor id on
50/// their first copy; later copies cannot identify it retroactively. A feed that prints the tape
51/// first must carry that id on the print or push the order execution before its tape copy.
52/// Trades that are not finite numbers or have a negative size are ignored.
53/// [`push_history`](Self::push_history) runs loaded trades through the same detection.
54/// Trade timestamps must be valid feed times, including in loaded
55/// history: the service trusts them without a tolerance against observation time. A far-future
56/// timestamp can end zones at that time and advance the forming bar past later prints, preventing
57/// body and wick classification until [`reset`](Self::reset). Validate times before pushing them.
58///
59/// *Book.* Depth snapshots and deltas, and order snapshots and events, keep the symbol's book
60/// with the sequencing rules of the `Sink`: a gap or an invalid event clears the book without a
61/// signal, and the next snapshot restores it.
62///
63/// *Signals.* Each [`DetectionKind`] has its own signal category, which the user can hide.
64/// [`subscribe`](Signals::subscribe) pushes the symbol's most recent 5,000 detections, then each
65/// new or changed one. Evicted detections are removed from existing subscriptions too.
66///
67/// ```
68/// use std::sync::Arc;
69///
70/// use egui_charts::data::{ReplayEvent, Sink};
71/// use egui_charts::model::Instrument;
72/// use egui_charts::services::OrderFlowDetectors;
73///
74/// let detectors = Arc::new(OrderFlowDetectors::new());
75/// let mut instrument = Instrument::new("ES");
76/// instrument.tick_size = Some(0.25);
77/// detectors.track(&instrument).unwrap();
78/// let mut settings = detectors.settings();
79/// settings.big_trades.enabled = true;
80/// detectors.set_settings(settings);
81/// // `Chart::set_signal_source(Some(detectors.clone()))` draws the detections.
82///
83/// /// Pushes one event of the feed into the chart and into the detectors.
84/// fn push(sink: &Sink, detectors: &OrderFlowDetectors, event: &ReplayEvent) {
85///     event.push_to(sink);
86///     detectors.push(&event.symbol, event.time, &event.event);
87/// }
88/// ```
89pub struct OrderFlowDetectors {
90    state: Mutex<State>,
91}
92
93/// The detections the service makes, each drawn in its own signal category.
94#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
95#[non_exhaustive]
96pub enum DetectionKind {
97    /// A large aggressive execution, as a marker: see [`BigTradeRule`].
98    BigTrade,
99    /// The prices a large aggressive execution traded at, as a zone: see [`BigTradeRule`].
100    BigTradeZone,
101    /// A rapid sequence of same-side aggressive fills.
102    StopRun,
103    /// A closed bar meeting the stop sweep rule.
104    StopSweep,
105    /// Repeated visible refills at one price, exact or estimated.
106    Iceberg,
107    /// Aggressive volume absorbed at an inside price.
108    Absorption,
109    /// A large resting level followed from its arrival.
110    LargeLiquidity,
111    /// A resting level rejecting aggression as the inside moves away.
112    Wall,
113    /// A large level traded away before price passes it.
114    LiquiditySweep,
115    /// A large level withdrawn from the book.
116    PulledLiquidity,
117    /// A large individual order quickly withdrawn with little execution.
118    Spoofing,
119    /// Size added at one nearby price in a single update.
120    FreshLiquidity,
121}
122
123impl DetectionKind {
124    /// Every detection category, in panel order.
125    pub const ALL: [Self; 12] = [
126        Self::BigTrade,
127        Self::BigTradeZone,
128        Self::StopRun,
129        Self::StopSweep,
130        Self::Iceberg,
131        Self::Absorption,
132        Self::LargeLiquidity,
133        Self::Wall,
134        Self::LiquiditySweep,
135        Self::PulledLiquidity,
136        Self::Spoofing,
137        Self::FreshLiquidity,
138    ];
139
140    /// The signal category of the kind's detections.
141    pub fn category(self) -> &'static str {
142        match self {
143            Self::BigTrade => "Big trades",
144            Self::BigTradeZone => "Big trade zones",
145            Self::StopRun => "Stop runs",
146            Self::StopSweep => "Stop sweeps",
147            Self::Iceberg => "Icebergs",
148            Self::Absorption => "Absorption",
149            Self::LargeLiquidity => "Large liquidity",
150            Self::Wall => "Walls",
151            Self::LiquiditySweep => "Liquidity sweeps",
152            Self::PulledLiquidity => "Pulled liquidity",
153            Self::Spoofing => "Spoofing",
154            Self::FreshLiquidity => "Fresh liquidity",
155        }
156    }
157}
158
159/// The settings of every detector, disabled by default, applied by
160/// [`OrderFlowDetectors::set_settings`].
161#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
162#[non_exhaustive]
163pub struct DetectorSettings {
164    /// Large aggressive executions and their zones.
165    pub big_trades: Detector<BigTradeRule>,
166    /// Rapid same-side execution bursts.
167    pub stop_runs: Detector<StopRunRule>,
168    /// Directional footprint bars with exceptional volume.
169    pub stop_sweeps: Detector<StopSweepRule>,
170    /// Exact order refills and estimates from aggregate depth.
171    pub icebergs: Detector<IcebergRule>,
172    /// Aggressive volume absorbed at a resting inside price.
173    pub absorption: Detector<AbsorptionRule>,
174    /// Large resting levels, walls, sweeps, pulls and fresh size.
175    pub liquidity: Detector<LiquidityRule>,
176    /// Optional rising-edge alert on the number of icebergs.
177    pub iceberg_count_alert: Option<CountAlert>,
178    /// Optional rising-edge alert on the number of stop runs.
179    pub stop_run_count_alert: Option<CountAlert>,
180}
181
182impl DetectorSettings {
183    /// Checks thresholds and minima before applying settings from storage or a user interface.
184    pub fn validate(&self) -> Result<(), FeedError> {
185        for threshold in [
186            self.big_trades.rule.threshold,
187            self.absorption.rule.threshold,
188            self.liquidity.rule.threshold,
189            self.liquidity.rule.fresh_threshold,
190        ] {
191            if let Threshold::Fixed(size) = threshold
192                && (!size.is_finite() || size <= 0.0)
193            {
194                return Err(FeedError::InvalidData(
195                    "fixed threshold must be positive and finite".into(),
196                ));
197            }
198        }
199        if self.icebergs.rule.refills == 0 {
200            return Err(FeedError::InvalidData(
201                "iceberg refills must be positive".into(),
202            ));
203        }
204        for alert in [self.iceberg_count_alert, self.stop_run_count_alert]
205            .into_iter()
206            .flatten()
207        {
208            if alert.minimum == 0 {
209                return Err(FeedError::InvalidData(
210                    "count alert minimum must be positive".into(),
211                ));
212            }
213        }
214        Ok(())
215    }
216}
217
218/// The settings of one detector, disabled by default with alerts and sounds off.
219#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
220#[non_exhaustive]
221pub struct Detector<R> {
222    /// Whether the detector detects. A disabled detector still calibrates its automatic
223    /// threshold.
224    pub enabled: bool,
225    /// Whether live detections should raise an alert. Detections from loaded history never do.
226    pub alert: bool,
227    /// Whether that alert should play a sound.
228    pub sound: bool,
229    /// What the detector detects.
230    pub rule: R,
231}
232
233impl<R: Default> Default for Detector<R> {
234    fn default() -> Self {
235        Self {
236            enabled: false,
237            alert: false,
238            sound: false,
239            rule: R::default(),
240        }
241    }
242}
243
244/// The size or count from which a detector detects.
245#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
246#[non_exhaustive]
247pub enum Threshold {
248    /// This positive and finite value, validated by [`OrderFlowDetectors::set_settings`].
249    Fixed(f64),
250    /// Calibrated on the symbol's own recent flow, as the detector's rule documents.
251    Auto,
252}
253
254#[derive(Default)]
255struct State {
256    settings: DetectorSettings,
257    symbols: HashMap<String, Tracked>,
258    /// Subscriptions by symbol, tracked or not.
259    subscriptions: HashMap<String, Subscription>,
260    delivery: Delivery,
261}
262
263struct Subscription {
264    /// The interval of the latest subscription.
265    timeframe: Timeframe,
266    streams: Vec<Events<SignalEvent>>,
267}
268
269/// The state of one tracked symbol.
270struct Tracked {
271    instrument: Instrument,
272    tick: f64,
273    book: Book,
274    orders: Option<OrderQueue>,
275    depth: Depth,
276    icebergs: Icebergs,
277    absorption: Absorption,
278    liquidity: Liquidity,
279    /// Ids of the latest trades, oldest first, and the same as a set.
280    trade_ids: VecDeque<u64>,
281    seen_ids: HashSet<u64>,
282    detections: VecDeque<Signal>,
283    big_trades: BigTrades,
284    stop_runs: StopRuns,
285    stop_sweeps: StopSweeps,
286    counting: Counting,
287}
288
289impl Default for OrderFlowDetectors {
290    fn default() -> Self {
291        Self::new()
292    }
293}
294
295impl OrderFlowDetectors {
296    /// A service tracking no symbol, with detectors, alerts and sounds disabled.
297    pub fn new() -> Self {
298        Self {
299            state: Mutex::new(State::default()),
300        }
301    }
302
303    fn lock(&self) -> std::sync::MutexGuard<'_, State> {
304        self.state
305            .lock()
306            .unwrap_or_else(|poisoned| poisoned.into_inner())
307    }
308
309    /// Starts detecting on `instrument`, which needs a positive and finite tick size. Tracking a
310    /// tracked symbol starts it afresh.
311    pub fn track(&self, instrument: &Instrument) -> Result<(), FeedError> {
312        let Some(tick) = instrument
313            .tick_size
314            .filter(|tick| tick.is_finite() && *tick > 0.0)
315        else {
316            return Err(FeedError::InvalidData(format!(
317                "{} has no positive finite tick size",
318                instrument.symbol
319            )));
320        };
321        self.lock().track(instrument, tick);
322        Ok(())
323    }
324
325    /// Stops detecting on `symbol` and drops its state and detections.
326    pub fn untrack(&self, symbol: &str) {
327        let mut state = self.lock();
328        if state.symbols.remove(symbol).is_some() {
329            deliver(
330                None,
331                state.subscriptions.get_mut(symbol),
332                vec![SignalEvent::Clear],
333            );
334        }
335    }
336
337    /// Drops the state and detections of `symbol` and starts it afresh, as after a session
338    /// change or a replay seek.
339    pub fn reset(&self, symbol: &str) {
340        let mut state = self.lock();
341        if let Some(tracked) = state.symbols.get(symbol) {
342            let (instrument, tick) = (tracked.instrument.clone(), tracked.tick);
343            state.track(&instrument, tick);
344        }
345    }
346
347    /// The settings of every detector.
348    pub fn settings(&self) -> DetectorSettings {
349        self.lock().settings.clone()
350    }
351
352    /// Replaces the settings of every detector from the next event on. Completed detections
353    /// stay; a disabled liquidity tracker drops its unfinished zones. Panics if a fixed
354    /// threshold is not positive and finite, or a refill or count-alert minimum is zero.
355    pub fn set_settings(&self, settings: DetectorSettings) {
356        settings.validate().expect("invalid detector settings");
357        self.lock().settings = settings;
358    }
359
360    /// Detects on `event` of `symbol`, observed at feed time `time`. Events of untracked symbols
361    /// are ignored.
362    pub fn push(&self, symbol: &str, time: DateTime<Utc>, event: &MarketEvent) {
363        let mut state = self.lock();
364        let State {
365            settings,
366            symbols,
367            subscriptions,
368            delivery,
369        } = &mut *state;
370        let Some(tracked) = symbols.get_mut(symbol) else {
371            return;
372        };
373        let mut out = Vec::new();
374        // A loss clears the book, which waits for the next snapshot, and is not a detection.
375        match event {
376            MarketEvent::Trade(trade) => tracked.trade(trade, settings, true, &mut out),
377            MarketEvent::Quote(_) => {}
378            MarketEvent::DepthSnapshot { sequence, book } => {
379                if tracked.orders.take().is_some() {
380                    tracked.book = Book::new(Some(tracked.tick));
381                    tracked.lose_depth(&mut out);
382                }
383                if tracked.book.snapshot(*sequence, book.clone()).is_ok() {
384                    tracked.update_depth(
385                        Depth::from_book(&tracked.book, tracked.tick),
386                        time,
387                        true,
388                        None,
389                        settings,
390                        &mut out,
391                    );
392                } else {
393                    tracked.lose_depth(&mut out);
394                }
395            }
396            MarketEvent::DepthDelta { sequence, book } => {
397                if tracked.orders.take().is_some() {
398                    tracked.book = Book::new(Some(tracked.tick));
399                    tracked.lose_depth(&mut out);
400                }
401                let replayed = tracked
402                    .book
403                    .sequence()
404                    .is_some_and(|last| *sequence <= last);
405                if tracked.book.delta(*sequence, book).is_err() {
406                    tracked.lose_depth(&mut out);
407                } else if !replayed {
408                    tracked.update_depth(
409                        Depth::from_book(&tracked.book, tracked.tick),
410                        time,
411                        false,
412                        Some(book),
413                        settings,
414                        &mut out,
415                    );
416                }
417            }
418            MarketEvent::OrderSnapshot { sequence, orders } => {
419                tracked.lose_depth(&mut out);
420                let queue = tracked
421                    .orders
422                    .get_or_insert_with(|| OrderQueue::new(Some(tracked.tick)));
423                let valid = queue.set_snapshot(*sequence, time, orders.clone()).is_ok();
424                let trades = queue.take_executions();
425                let depth = valid.then(|| Depth::from_orders(queue, tracked.tick));
426                for trade in trades {
427                    tracked.trade(&trade, settings, false, &mut out);
428                }
429                if let Some(depth) = depth {
430                    tracked.update_depth(depth, time, true, None, settings, &mut out);
431                }
432            }
433            MarketEvent::Order { sequence, event } => {
434                let queue = tracked
435                    .orders
436                    .get_or_insert_with(|| OrderQueue::new(Some(tracked.tick)));
437                let replayed = queue.sequence().is_some_and(|last| *sequence <= last);
438                let id = match event {
439                    crate::data::OrderEvent::Add(order) => order.id,
440                    crate::data::OrderEvent::Modify { id, .. }
441                    | crate::data::OrderEvent::Cancel { id }
442                    | crate::data::OrderEvent::Execute { id, .. } => *id,
443                };
444                let before = queue.orders().find(|order| order.id == id).cloned();
445                let valid = queue.event(*sequence, time, event.clone()).is_ok();
446                let trades = queue.take_executions();
447                if !valid {
448                    tracked.lose_depth(&mut out);
449                } else if !replayed {
450                    let depth = Depth::from_orders(queue, tracked.tick);
451                    for trade in trades {
452                        tracked.trade(&trade, settings, true, &mut out);
453                    }
454                    tracked.icebergs.order(
455                        event,
456                        before.as_ref(),
457                        time,
458                        tracked.tick,
459                        &settings.icebergs,
460                        &mut out,
461                    );
462                    tracked.liquidity.order(
463                        event,
464                        before.as_ref(),
465                        &depth,
466                        time,
467                        tracked.tick,
468                        &settings.liquidity,
469                        &mut out,
470                    );
471                    tracked.update_depth(depth, time, false, None, settings, &mut out);
472                }
473            }
474        }
475        tracked.absorption.advance(
476            time,
477            tracked.tick,
478            &tracked.depth,
479            &settings.absorption,
480            &mut out,
481        );
482        tracked
483            .stop_sweeps
484            .advance(time, settings.stop_sweeps.enabled, &mut out);
485        tracked
486            .counting
487            .advance(time, settings, true, symbol, delivery);
488        tracked.record(&out, time, true, settings, delivery, &mut HashSet::new());
489        deliver(Some(tracked), subscriptions.get_mut(symbol), out);
490    }
491
492    /// Detects on the loaded trade history of `symbol`, in time order, before the live trades
493    /// that follow it. Its detections are made and pushed like live ones and warm the automatic
494    /// thresholds and counts without raising alerts; trades with an id already seen are dropped.
495    pub fn push_history(&self, symbol: &str, trades: &[Tick]) {
496        let mut state = self.lock();
497        let State {
498            settings,
499            symbols,
500            subscriptions,
501            delivery,
502        } = &mut *state;
503        let Some(tracked) = symbols.get_mut(symbol) else {
504            return;
505        };
506        let mut out = Vec::new();
507        let mut added = HashSet::new();
508        for trade in trades {
509            let from = out.len();
510            tracked.trade(trade, settings, false, &mut out);
511            tracked
512                .counting
513                .advance(trade.ts, settings, false, symbol, delivery);
514            tracked.record(
515                &out[from..],
516                trade.ts,
517                false,
518                settings,
519                delivery,
520                &mut added,
521            );
522        }
523        deliver(Some(tracked), subscriptions.get_mut(symbol), out);
524    }
525}
526
527impl Signals for OrderFlowDetectors {
528    fn subscribe(&self, symbol: &str, timeframe: Timeframe, events: Events<SignalEvent>) {
529        let mut state = self.lock();
530        let State {
531            symbols,
532            subscriptions,
533            settings,
534            ..
535        } = &mut *state;
536        let new = !subscriptions.contains_key(symbol);
537        let subscription = subscriptions
538            .entry(symbol.to_owned())
539            .or_insert_with(|| Subscription {
540                timeframe,
541                streams: Vec::new(),
542            });
543        if let Some(tracked) = symbols.get_mut(symbol) {
544            if new || subscription.timeframe != timeframe {
545                let mut changes: Vec<_> = tracked
546                    .detections
547                    .iter()
548                    .filter(|signal| signal.category == DetectionKind::StopSweep.category())
549                    .map(|signal| SignalEvent::Remove(signal.id.clone()))
550                    .collect();
551                tracked.stop_sweeps.set_clock(
552                    clock(&tracked.instrument, timeframe),
553                    &settings.stop_sweeps,
554                    &mut changes,
555                );
556                deliver(Some(tracked), Some(subscription), changes);
557            }
558            for signal in &tracked.detections {
559                events.push(SignalEvent::Upsert(signal.clone()));
560            }
561            if new || subscription.timeframe != timeframe {
562                let clock = clock(&tracked.instrument, timeframe);
563                tracked.big_trades.set_clock(clock);
564            }
565        }
566        subscription.timeframe = timeframe;
567        subscription.streams.retain(Events::is_active);
568        subscription.streams.push(events);
569    }
570}
571
572impl State {
573    /// Starts tracking `instrument` of tick size `tick` afresh.
574    fn track(&mut self, instrument: &Instrument, tick: f64) {
575        let subscription = self.subscriptions.get_mut(&instrument.symbol);
576        let mut tracked = Tracked {
577            instrument: instrument.clone(),
578            tick,
579            book: Book::new(Some(tick)),
580            orders: None,
581            depth: Depth::default(),
582            icebergs: Icebergs::default(),
583            absorption: Absorption::default(),
584            liquidity: Liquidity::default(),
585            trade_ids: VecDeque::new(),
586            seen_ids: HashSet::new(),
587            detections: VecDeque::new(),
588            big_trades: BigTrades::new(tick, instrument.volume_decimals),
589            stop_runs: StopRuns::default(),
590            stop_sweeps: StopSweeps::new(tick),
591            counting: Counting::default(),
592        };
593        let timeframe = subscription.as_ref().map(|s| s.timeframe);
594        (tracked.big_trades).set_clock(timeframe.and_then(|tf| clock(instrument, tf)));
595        tracked.stop_sweeps.set_clock(
596            timeframe.and_then(|tf| clock(instrument, tf)),
597            &self.settings.stop_sweeps,
598            &mut Vec::new(),
599        );
600        if self
601            .symbols
602            .insert(instrument.symbol.clone(), tracked)
603            .is_some()
604        {
605            deliver(None, subscription, vec![SignalEvent::Clear]);
606        }
607    }
608}
609
610impl Tracked {
611    fn lose_depth(&mut self, out: &mut Vec<SignalEvent>) {
612        self.depth = Depth::default();
613        self.icebergs.clear();
614        self.absorption.clear();
615        self.liquidity.clear(out);
616    }
617
618    fn update_depth(
619        &mut self,
620        depth: Depth,
621        time: DateTime<Utc>,
622        snapshot: bool,
623        changes: Option<&crate::model::OrderBook>,
624        settings: &DetectorSettings,
625        out: &mut Vec<SignalEvent>,
626    ) {
627        self.absorption.advance(
628            time.checked_sub_signed(chrono::TimeDelta::nanoseconds(1))
629                .unwrap_or(time),
630            self.tick,
631            &self.depth,
632            &settings.absorption,
633            out,
634        );
635        self.absorption.update(&self.depth, &depth);
636        self.liquidity.update(
637            &self.depth,
638            &depth,
639            time,
640            self.tick,
641            snapshot,
642            changes,
643            &settings.liquidity,
644            out,
645        );
646        self.depth = depth;
647        self.icebergs
648            .update(&self.depth, time, self.tick, &settings.icebergs, out);
649    }
650
651    fn trade(
652        &mut self,
653        trade: &Tick,
654        settings: &DetectorSettings,
655        live: bool,
656        out: &mut Vec<SignalEvent>,
657    ) {
658        if !trade.price.is_finite() || !trade.size.is_finite() || trade.size < 0.0 {
659            return;
660        }
661        if let Some(id) = trade.id {
662            if !self.seen_ids.insert(id) {
663                return;
664            }
665            self.trade_ids.push_back(id);
666            if self.trade_ids.len() > TRADE_IDS
667                && let Some(old) = self.trade_ids.pop_front()
668            {
669                self.seen_ids.remove(&old);
670            }
671        }
672        if live {
673            self.icebergs.trade(trade, self.tick, &self.depth);
674            self.absorption
675                .trade(trade, self.tick, &self.depth, &settings.absorption, out);
676            self.liquidity.trade(
677                trade,
678                self.tick,
679                settings.liquidity.enabled,
680                &self.depth,
681                out,
682            );
683        }
684        self.stop_runs
685            .trade(trade, self.tick, settings.stop_runs.enabled, out);
686        self.stop_sweeps.trade(trade, &settings.stop_sweeps, out);
687        let big = &settings.big_trades;
688        (self.big_trades).trade(trade, &big.rule, big.enabled, out);
689    }
690}
691
692/// Keeps `events` in the detections of `tracked`, if any, and pushes them to `subscription`.
693fn deliver(
694    tracked: Option<&mut Tracked>,
695    subscription: Option<&mut Subscription>,
696    events: Vec<SignalEvent>,
697) {
698    let mut delivered = Vec::new();
699    if let Some(tracked) = tracked {
700        for event in events {
701            delivered.push(event.clone());
702            let detections = &mut tracked.detections;
703            match event {
704                SignalEvent::Upsert(signal) => {
705                    match detections.iter().rposition(|kept| kept.id == signal.id) {
706                        Some(at) => detections[at] = signal,
707                        None => {
708                            if detections.len() == RETAINED
709                                && let Some(old) = detections.pop_front()
710                            {
711                                tracked.big_trades.forget(&old.id);
712                                tracked.liquidity.forget(&old.id);
713                                delivered.push(SignalEvent::Remove(old.id));
714                            }
715                            detections.push_back(signal);
716                        }
717                    }
718                }
719                SignalEvent::Remove(id) => detections.retain(|kept| kept.id != id),
720                SignalEvent::Clear => detections.clear(),
721            }
722        }
723    } else {
724        delivered = events;
725    }
726    if let Some(subscription) = subscription {
727        subscription.streams.retain(Events::is_active);
728        for event in delivered {
729            for stream in &subscription.streams {
730                stream.push(event.clone());
731            }
732        }
733    }
734}
735
736/// The service bars of `timeframe` for `instrument`, or `None` for a trade-based interval.
737fn clock(instrument: &Instrument, timeframe: Timeframe) -> Option<BarClock> {
738    if timeframe.is_trade_based() {
739        return None;
740    }
741    let zone = (instrument.time_zone.clone()).unwrap_or_else(TimezoneId::utc);
742    let mut clock = BarClock::new(timeframe);
743    if timeframe.duration_ms() < Timeframe::Day1.duration_ms() {
744        clock = clock.with_timezone(zone.clone());
745    }
746    if let Some(session) = &instrument.session {
747        clock = clock.with_session(zone, session.clone(), false);
748    }
749    Some(clock)
750}
751
752#[cfg(test)]
753mod tests {
754    use super::*;
755    use crate::data::OrderEvent;
756    use crate::data::RestingOrder;
757    use crate::data::inbox::Inbox;
758    use crate::model::{Direction, OrderBook, OrderBookLevel, OrderSide, SignalId, SignalShape};
759    use crate::services::events::Listener;
760    use std::sync::Arc;
761
762    #[test]
763    fn detectors_start_disabled_and_can_be_enabled_without_reconnecting() {
764        let host = Host::new(Some(10.0));
765        let settings = DetectorSettings::default();
766        assert!(!settings.big_trades.enabled);
767        assert!(!settings.stop_runs.enabled);
768        assert!(!settings.stop_sweeps.enabled);
769        assert!(!settings.icebergs.enabled);
770        assert!(!settings.absorption.enabled);
771        assert!(!settings.liquidity.enabled);
772        host.detectors.set_settings(settings);
773        let trade =
774            |second, size| MarketEvent::Trade(fill(second, 100.0, size, OrderSide::Buy, None));
775        assert!(host.push(trade(0, 100.0)).is_empty());
776        assert!(host.detectors.take_alerts().is_empty());
777        let mut settings = host.detectors.settings();
778        settings.big_trades.enabled = true;
779        settings.big_trades.alert = true;
780        settings.big_trades.rule.threshold = Threshold::Fixed(10.0);
781        host.detectors.set_settings(settings);
782        assert!(!host.push(trade(1, 10.0)).is_empty());
783        let alerts = host.detectors.take_alerts();
784        assert_eq!(alerts.len(), 1);
785        assert!(!alerts[0].sound);
786    }
787
788    #[test]
789    fn detection_counts_follow_feed_windows_without_alerting_loaded_history() {
790        use crate::services::{Notification, Notifications};
791
792        let host = Host::new(Some(10.0));
793        let mut settings = host.detectors.settings();
794        settings.big_trades.alert = true;
795        settings.big_trades.sound = true;
796        host.detectors.set_settings(settings);
797        let inbox = Arc::new(Inbox::default());
798        let (_listener, events) = Listener::new::<Notification>(&inbox, 1);
799        host.detectors.watch(events);
800        host.detectors.push_history(
801            "ES",
802            &[
803                fill(0, 100.0, 10.0, OrderSide::Buy, None),
804                fill(270, 100.0, 10.0, OrderSide::Buy, None),
805            ],
806        );
807        assert!(host.detectors.take_alerts().is_empty());
808        assert!(inbox.drain().answers.is_empty());
809        // At 299 both are within five minutes; at 300 the first and the 30-second
810        // boundary expire. These feed times are unrelated to the test's wall clock.
811        for (now, minutes, seconds) in [(299, 2, 1), (300, 1, 0), (570, 0, 0)] {
812            assert_eq!(
813                host.detectors
814                    .counts("ES", DetectionKind::BigTrade, time(now)),
815                DetectionCounts {
816                    total: 2,
817                    last_five_minutes: minutes,
818                    last_thirty_seconds: seconds
819                }
820            );
821        }
822        host.detectors.push(
823            "ES",
824            time(571),
825            &MarketEvent::Trade(fill(571, 100.0, 10.0, OrderSide::Buy, None).with_id(7)),
826        );
827        host.detectors.push(
828            "ES",
829            time(572),
830            &MarketEvent::Trade(fill(571, 100.0, 10.0, OrderSide::Buy, None).with_id(7)),
831        );
832        let alerts = host.detectors.take_alerts();
833        assert_eq!(
834            alerts.iter().map(|alert| alert.kind).collect::<Vec<_>>(),
835            [DetectionKind::BigTrade],
836            "a big trade alerts once, not again for its zone"
837        );
838        assert!(alerts.iter().all(|alert| alert.sound
839            && alert.notification.symbol.as_deref() == Some("ES")
840            && !alert.notification.title.is_empty()
841            && !alert.notification.body.is_empty()));
842        let notifications: Vec<_> = inbox
843            .drain()
844            .answers
845            .into_iter()
846            .map(|(_, event)| *event.downcast::<Notification>().unwrap())
847            .collect();
848        assert_eq!(
849            notifications,
850            alerts
851                .iter()
852                .map(|alert| alert.notification.clone())
853                .collect::<Vec<_>>()
854        );
855        assert!(host.detectors.take_alerts().is_empty());
856        let (_replay_listener, replay) = Listener::new::<Notification>(&inbox, 2);
857        host.detectors.watch(replay);
858        assert_eq!(
859            inbox.drain().answers.len(),
860            1,
861            "late subscribers receive the live log only"
862        );
863        assert_eq!(
864            host.detectors
865                .counts("ES", DetectionKind::BigTrade, time(572))
866                .total,
867            3
868        );
869        host.detectors.reset("ES");
870        assert_eq!(
871            host.detectors
872                .counts("ES", DetectionKind::BigTrade, time(572)),
873            DetectionCounts::default()
874        );
875    }
876
877    #[test]
878    fn stop_count_alerts_rearm_only_below_the_minimum_and_ignore_signal_updates() {
879        for window in [
880            CountWindow::ThirtySeconds,
881            CountWindow::FiveMinutes,
882            CountWindow::Total,
883        ] {
884            for history_bursts in [1, 2] {
885                let host = Host::new(Some(1_000.0));
886                let mut settings = host.detectors.settings();
887                settings.stop_runs.enabled = true;
888                settings.stop_run_count_alert = Some(CountAlert {
889                    window,
890                    minimum: 2,
891                    sound: true,
892                });
893                host.detectors.set_settings(settings);
894                let burst = |second| {
895                    (0..6)
896                        .map(|i| {
897                            Tick::new(
898                                time(second) + chrono::TimeDelta::milliseconds(i * 10),
899                                100.0 + i as f64 * 0.25,
900                                1.0,
901                                Some(OrderSide::Buy),
902                            )
903                        })
904                        .collect::<Vec<_>>()
905                };
906                for second in 0..history_bursts {
907                    host.detectors.push_history("ES", &burst(second));
908                }
909                assert!(host.detectors.take_alerts().is_empty());
910                for second in [2, 3] {
911                    for trade in burst(second) {
912                        host.detectors
913                            .push("ES", trade.ts, &MarketEvent::Trade(trade));
914                    }
915                }
916                let alerts = host.detectors.take_alerts();
917                assert_eq!(alerts.len(), usize::from(history_bursts == 1), "{window:?}");
918                if let Some(alert) = alerts.first() {
919                    assert_eq!(alert.count, Some((window, 2)));
920                    assert!(alert.sound);
921                }
922                assert_eq!(
923                    host.detectors
924                        .counts("ES", DetectionKind::StopRun, time(4))
925                        .total,
926                    history_bursts as u64 + 2
927                );
928                // No events between the bursts: expiration must rearm before the next detection.
929                for second in [303, 304] {
930                    for trade in burst(second) {
931                        host.detectors
932                            .push("ES", trade.ts, &MarketEvent::Trade(trade));
933                    }
934                }
935                let alerts = host.detectors.take_alerts();
936                assert_eq!(
937                    alerts.len(),
938                    usize::from(window != CountWindow::Total),
939                    "{window:?}"
940                );
941                assert_eq!(
942                    host.detectors
943                        .counts("ES", DetectionKind::StopRun, time(305))
944                        .total,
945                    history_bursts as u64 + 4
946                );
947            }
948        }
949    }
950
951    fn time(seconds: i64) -> DateTime<Utc> {
952        DateTime::from_timestamp(seconds, 0).unwrap()
953    }
954
955    fn fill(seconds: i64, price: f64, size: f64, side: OrderSide, aggressor: Option<u64>) -> Tick {
956        Tick {
957            aggressor_order_id: aggressor,
958            ..Tick::new(time(seconds), price, size, Some(side))
959        }
960    }
961
962    fn marker(number: u64, at: i64, price: f64, direction: Direction, size: &str) -> SignalEvent {
963        SignalEvent::Upsert(Signal {
964            id: SignalId(format!("big-trade-{number}")),
965            shape: SignalShape::Marker {
966                time: time(at),
967                price: Some(price),
968                direction,
969            },
970            category: "Big trades".into(),
971            label: Some(size.into()),
972            tooltip: None,
973        })
974    }
975
976    fn zone(number: u64, from: i64, to: Option<i64>, low: f64, high: f64) -> SignalEvent {
977        SignalEvent::Upsert(Signal {
978            id: SignalId(format!("big-trade-zone-{number}")),
979            shape: SignalShape::Zone {
980                from: time(from),
981                to: to.map(time),
982                low,
983                high,
984            },
985            category: "Big trade zones".into(),
986            label: None,
987            tooltip: None,
988        })
989    }
990
991    /// A service tracking "ES", tick 0.25, subscribed at one minute, with a fixed big trade
992    /// threshold of `threshold` unless `None`.
993    struct Host {
994        detectors: OrderFlowDetectors,
995        inbox: Arc<Inbox>,
996        listeners: Vec<Listener>,
997    }
998
999    impl Host {
1000        fn new(threshold: Option<f64>) -> Self {
1001            let detectors = OrderFlowDetectors::new();
1002            let mut instrument = Instrument::new("ES").with_volume_decimals(0);
1003            instrument.tick_size = Some(0.25);
1004            detectors.track(&instrument).unwrap();
1005            let mut settings = DetectorSettings::default();
1006            settings.big_trades.enabled = true;
1007            if let Some(size) = threshold {
1008                settings.big_trades.rule.threshold = Threshold::Fixed(size);
1009            }
1010            detectors.set_settings(settings);
1011            let mut host = Self {
1012                detectors,
1013                inbox: Arc::new(Inbox::default()),
1014                listeners: Vec::new(),
1015            };
1016            host.subscribe();
1017            host
1018        }
1019
1020        fn subscribe(&mut self) {
1021            let (listener, events) = Listener::new(&self.inbox, 1);
1022            self.listeners.push(listener);
1023            self.detectors.subscribe("ES", Timeframe::Min1, events);
1024        }
1025
1026        /// Pushes `event` and returns the signal events pushed since the last call.
1027        fn push(&self, event: MarketEvent) -> Vec<SignalEvent> {
1028            self.detectors.push("ES", time(0), &event);
1029            self.events()
1030        }
1031
1032        fn events(&self) -> Vec<SignalEvent> {
1033            (self.inbox.drain().answers.into_iter())
1034                .map(|(_, event)| *event.downcast::<SignalEvent>().unwrap())
1035                .collect()
1036        }
1037    }
1038
1039    #[test]
1040    fn big_trades_are_marked_and_project_zones_until_traded_through() {
1041        use OrderSide::{Buy, Sell};
1042        let host = Host::new(Some(10.0));
1043        let trade = |fill| host.push(MarketEvent::Trade(fill));
1044        assert!(trade(self::fill(1, 100.0, 2.0, Buy, Some(7))).is_empty());
1045        assert!(trade(self::fill(1, 101.0, 6.0, Buy, Some(7))).is_empty());
1046        // 16 reaches 10: the marker sits at the first fill and the volume-weighted price.
1047        assert_eq!(
1048            trade(self::fill(2, 101.0, 8.0, Buy, Some(7))),
1049            [
1050                marker(1, 1, 100.875, Direction::Up, "16"),
1051                zone(1, 1, None, 100.0, 101.0)
1052            ]
1053        );
1054        assert_eq!(
1055            trade(self::fill(3, 101.0, 16.0, Buy, Some(7))),
1056            [
1057                marker(1, 1, 100.9375, Direction::Up, "32"),
1058                zone(1, 1, None, 100.0, 101.0)
1059            ]
1060        );
1061        // A sell below the buy zone ends it; its own zone is one tick around its single price.
1062        assert_eq!(
1063            trade(self::fill(4, 99.0, 12.0, Sell, None)),
1064            [
1065                zone(1, 1, Some(4), 100.0, 101.0),
1066                marker(2, 4, 99.0, Direction::Down, "12"),
1067                zone(2, 4, None, 98.875, 99.125)
1068            ]
1069        );
1070        // A late trade from before the sell zone began does not end it.
1071        assert!(trade(self::fill(3, 99.5, 1.0, Buy, None)).is_empty());
1072        assert_eq!(
1073            trade(self::fill(5, 99.25, 1.0, Buy, None)),
1074            [zone(2, 4, Some(5), 98.875, 99.125)]
1075        );
1076    }
1077
1078    #[test]
1079    fn zones_are_kept_by_where_they_traded_in_their_bar() {
1080        use OrderSide::{Buy, Sell};
1081        let mut host = Host::new(Some(10.0));
1082        let mut settings = host.detectors.settings();
1083        settings.big_trades.rule.wick_zones = false;
1084        host.detectors.set_settings(settings);
1085        let mut removed = Vec::new();
1086        let mut trade = |host: &Host, fill| {
1087            let events = host.push(MarketEvent::Trade(fill));
1088            removed.extend(
1089                events
1090                    .into_iter()
1091                    .filter(|event| matches!(event, SignalEvent::Remove(_))),
1092            );
1093        };
1094        // The first bar was not seen from its open, so its big buy in the upper wick stays.
1095        trade(&host, fill(-30, 100.0, 1.0, Buy, None));
1096        trade(&host, fill(-20, 103.0, 10.0, Buy, None));
1097        trade(&host, fill(-10, 101.0, 1.0, Buy, None));
1098        // The next bar opens at 100 and closes at 101: a big sell at 100.5 in the body and a big
1099        // buy at 103 in the upper wick.
1100        trade(&host, fill(0, 100.0, 1.0, Buy, None));
1101        trade(&host, fill(10, 100.5, 10.0, Sell, None));
1102        trade(&host, fill(20, 103.0, 10.0, Buy, None));
1103        // A new subscription at the same interval, once the old stream stopped, keeps the bar.
1104        host.listeners.clear();
1105        trade(&host, fill(25, 101.0, 1.0, Buy, None));
1106        host.subscribe();
1107        trade(&host, fill(30, 101.0, 1.0, Buy, None));
1108        trade(&host, fill(60, 101.0, 1.0, Buy, None));
1109        let mut settings = host.detectors.settings();
1110        settings.big_trades.rule.threshold = Threshold::Fixed(6.0);
1111        host.detectors.set_settings(settings);
1112        // The next bar closes on a six-lot at 99.99: (99.99 * (1 + 2 + 3)) / 6 = 99.99.
1113        trade(&host, fill(120, 99.98, 1.0, Buy, None));
1114        for (second, size) in [(130, 1.0), (131, 2.0), (132, 3.0)] {
1115            trade(&host, fill(second, 99.99, size, Buy, Some(5)));
1116        }
1117        trade(&host, fill(180, 99.99, 1.0, Buy, None));
1118        assert_eq!(
1119            removed,
1120            [SignalEvent::Remove(SignalId("big-trade-zone-3".into()))]
1121        );
1122    }
1123
1124    #[test]
1125    fn detections_past_the_retained_5000_leave_the_live_chart_too() {
1126        for kind in [
1127            DetectionKind::BigTrade,
1128            DetectionKind::StopRun,
1129            DetectionKind::Iceberg,
1130        ] {
1131            let host = Host::new(Some(10.0));
1132            let mut settings = host.detectors.settings();
1133            settings.stop_runs.enabled = kind == DetectionKind::StopRun;
1134            settings.stop_runs.alert = true;
1135            settings.icebergs.enabled = kind == DetectionKind::Iceberg;
1136            settings.icebergs.alert = true;
1137            settings.big_trades.alert = kind == DetectionKind::BigTrade;
1138            settings.liquidity.enabled = kind == DetectionKind::BigTrade;
1139            settings.liquidity.rule.threshold = Threshold::Fixed(f64::MAX);
1140            settings.liquidity.rule.fresh_threshold = Threshold::Fixed(1.0);
1141            host.detectors.set_settings(settings);
1142            let push = |ms, event| {
1143                host.detectors
1144                    .push("ES", time(0) + chrono::TimeDelta::milliseconds(ms), &event)
1145            };
1146            if kind == DetectionKind::BigTrade {
1147                push(
1148                    0,
1149                    MarketEvent::Trade(fill(0, 100.0, 10.0, OrderSide::Buy, Some(7))),
1150                );
1151            } else if kind == DetectionKind::StopRun {
1152                for i in 0..5 {
1153                    let ts = time(0) + chrono::TimeDelta::milliseconds(i * 10);
1154                    push(
1155                        i * 10,
1156                        MarketEvent::Trade(Tick::new(
1157                            ts,
1158                            100.0 + i as f64 * 0.25,
1159                            1.0,
1160                            Some(OrderSide::Buy),
1161                        )),
1162                    );
1163                }
1164            } else {
1165                push(
1166                    0,
1167                    MarketEvent::OrderSnapshot {
1168                        sequence: 1,
1169                        orders: vec![RestingOrder::new(1, OrderSide::Sell, 100.0, 10.0)],
1170                    },
1171                );
1172                for i in 0..2 {
1173                    push(
1174                        i * 20,
1175                        MarketEvent::Order {
1176                            sequence: 2 + i as u64 * 2,
1177                            event: OrderEvent::Execute {
1178                                id: 1 + i as u64,
1179                                size: 10.0,
1180                                trade: None,
1181                            },
1182                        },
1183                    );
1184                    push(
1185                        i * 20 + 10,
1186                        MarketEvent::Order {
1187                            sequence: 3 + i as u64 * 2,
1188                            event: OrderEvent::Add(RestingOrder::new(
1189                                2 + i as u64,
1190                                OrderSide::Sell,
1191                                100.0,
1192                                10.0,
1193                            )),
1194                        },
1195                    );
1196                }
1197            }
1198            assert_eq!(host.detectors.take_alerts().len(), 1, "{kind:?}");
1199            let ts = time(0) + chrono::TimeDelta::milliseconds(45);
1200            if kind == DetectionKind::BigTrade {
1201                // Depth detections evict the marker without ending its aggressor's fills.
1202                for sequence in 1..=5001 {
1203                    let book = OrderBook {
1204                        ts,
1205                        symbol: "ES".into(),
1206                        bids: vec![OrderBookLevel {
1207                            price: 99.0,
1208                            size: sequence as f64,
1209                            order_cnt: None,
1210                        }],
1211                        asks: Vec::new(),
1212                    };
1213                    push(
1214                        45,
1215                        if sequence == 1 {
1216                            MarketEvent::DepthSnapshot { sequence, book }
1217                        } else {
1218                            MarketEvent::DepthDelta { sequence, book }
1219                        },
1220                    );
1221                }
1222                push(
1223                    50,
1224                    MarketEvent::Trade(fill(0, 100.0, 5.0, OrderSide::Buy, Some(7))),
1225                );
1226            } else {
1227                host.detectors
1228                    .push_history("ES", &vec![Tick::new(ts, 100.0, 10.0, None); 2501]);
1229            }
1230            if kind == DetectionKind::StopRun {
1231                push(
1232                    50,
1233                    MarketEvent::Trade(Tick::new(
1234                        time(0) + chrono::TimeDelta::milliseconds(50),
1235                        101.25,
1236                        1.0,
1237                        Some(OrderSide::Buy),
1238                    )),
1239                );
1240            } else if kind == DetectionKind::Iceberg {
1241                push(
1242                    50,
1243                    MarketEvent::Order {
1244                        sequence: 6,
1245                        event: OrderEvent::Execute {
1246                            id: 3,
1247                            size: 10.0,
1248                            trade: None,
1249                        },
1250                    },
1251                );
1252                push(
1253                    60,
1254                    MarketEvent::Order {
1255                        sequence: 7,
1256                        event: OrderEvent::Add(RestingOrder::new(4, OrderSide::Sell, 100.0, 10.0)),
1257                    },
1258                );
1259            }
1260            assert_eq!(
1261                host.detectors.counts("ES", kind, time(1)).total,
1262                1,
1263                "an evicted {kind:?} keeps its detection identity"
1264            );
1265            assert!(
1266                host.detectors.take_alerts().is_empty(),
1267                "an update of an evicted {kind:?} stays silent"
1268            );
1269        }
1270        let mut host = Host::new(Some(10.0));
1271        let buy = |price| fill(1, price, 10.0, OrderSide::Buy, None);
1272        // One page evicts the first marker and zone, then ends all 2,501 zones.
1273        let history: Vec<Tick> = std::iter::once(buy(50.0))
1274            .chain(std::iter::repeat_n(buy(100.0), 2500))
1275            .chain(std::iter::once(fill(2, 49.0, 1.0, OrderSide::Sell, None)))
1276            .collect();
1277        host.detectors.push_history("ES", &history);
1278        let mut live = HashMap::new();
1279        for event in host.events() {
1280            match event {
1281                SignalEvent::Upsert(signal) => {
1282                    live.insert(signal.id.clone(), signal);
1283                }
1284                SignalEvent::Remove(id) => {
1285                    live.remove(&id);
1286                }
1287                SignalEvent::Clear => live.clear(),
1288            }
1289        }
1290        host.listeners.clear();
1291        host.subscribe();
1292        let replay = host.events();
1293        // 5,002 detections lose marker 1 and zone 1; restoring ended zone 1 loses marker 2.
1294        assert_eq!(replay.len(), 5000);
1295        assert!(replay.contains(&zone(1, 1, Some(2), 49.875, 50.125)));
1296        assert!(!replay.contains(&marker(1, 1, 50.0, Direction::Up, "10")));
1297        assert!(!replay.contains(&marker(2, 1, 100.0, Direction::Up, "10")));
1298        assert_eq!(live.len(), 5000, "live detections after one history page");
1299        let replay: HashMap<_, _> = replay
1300            .into_iter()
1301            .map(|event| match event {
1302                SignalEvent::Upsert(signal) => (signal.id.clone(), signal),
1303                _ => panic!("replay must contain only detections"),
1304            })
1305            .collect();
1306        assert_eq!(live, replay);
1307        for kind in [DetectionKind::BigTrade, DetectionKind::BigTradeZone] {
1308            assert_eq!(
1309                host.detectors.counts("ES", kind, time(2)).total,
1310                2501,
1311                "eviction and zone closure do not change running counts"
1312            );
1313        }
1314
1315        let mut host = Host::new(Some(10.0));
1316        let buy = |price| fill(1, price, 10.0, OrderSide::Buy, None);
1317        // A big buy at 50 and 2,499 at 100: a marker and a zone each, 5,000 detections.
1318        let history: Vec<Tick> = std::iter::once(buy(50.0))
1319            .chain(std::iter::repeat_n(buy(100.0), 2499))
1320            .collect();
1321        host.detectors.push_history("ES", &history);
1322        host.events();
1323        // The next one evicts the first marker and its zone, which is still open.
1324        assert_eq!(
1325            host.push(MarketEvent::Trade(buy(100.0))),
1326            [
1327                marker(2501, 1, 100.0, Direction::Up, "10"),
1328                SignalEvent::Remove(SignalId("big-trade-1".into())),
1329                zone(2501, 1, None, 99.875, 100.125),
1330                SignalEvent::Remove(SignalId("big-trade-zone-1".into()))
1331            ]
1332        );
1333        host.listeners.clear();
1334        host.subscribe();
1335        let replay = host.events();
1336        assert_eq!(replay.len(), 5000);
1337        assert!(!replay.contains(&zone(1, 1, None, 49.875, 50.125)));
1338        assert_eq!(
1339            host.push(MarketEvent::Trade(fill(
1340                2,
1341                49.0,
1342                1.0,
1343                OrderSide::Sell,
1344                None
1345            )))
1346            .len(),
1347            2500
1348        );
1349    }
1350
1351    #[test]
1352    fn fixed_thresholds_must_be_positive_and_finite() {
1353        let detectors = OrderFlowDetectors::new();
1354        for size in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 0.0, -1.0] {
1355            let mut settings = detectors.settings();
1356            settings.big_trades.rule.threshold = Threshold::Fixed(size);
1357            assert!(
1358                std::panic::catch_unwind(|| detectors.set_settings(settings)).is_err(),
1359                "accepted {size}"
1360            );
1361            assert_eq!(detectors.settings(), DetectorSettings::default());
1362        }
1363        for size in [f64::MIN_POSITIVE, 10.0, f64::MAX] {
1364            let mut settings = detectors.settings();
1365            settings.big_trades.rule.threshold = Threshold::Fixed(size);
1366            detectors.set_settings(settings.clone());
1367            assert_eq!(detectors.settings(), settings);
1368        }
1369    }
1370
1371    #[test]
1372    fn empty_and_invalid_trades_leave_the_aggregate_alone() {
1373        let host = Host::new(Some(10.0));
1374        host.detectors.push_history("ES", &[]);
1375        assert!(host.events().is_empty());
1376        let trade = |price, size| fill(1, price, size, OrderSide::Buy, Some(7)).with_id(1);
1377        for (price, size) in [(f64::NAN, 6.0), (100.0, f64::NAN), (100.0, -1.0)] {
1378            assert!(host.push(MarketEvent::Trade(trade(price, size))).is_empty());
1379        }
1380        assert!(host.push(MarketEvent::Trade(trade(100.0, 6.0))).is_empty());
1381        assert!(
1382            host.push(MarketEvent::Trade(fill(
1383                1,
1384                101.0,
1385                0.0,
1386                OrderSide::Buy,
1387                Some(7)
1388            )))
1389            .is_empty()
1390        );
1391        // A distinct fill with an earlier time still extends the first copy's aggressor.
1392        assert_eq!(
1393            host.push(MarketEvent::Trade(fill(
1394                0,
1395                100.0,
1396                4.0,
1397                OrderSide::Sell,
1398                Some(7)
1399            ))),
1400            [
1401                marker(1, 1, 100.0, Direction::Neutral, "10"),
1402                zone(1, 1, None, 100.0, 101.0)
1403            ]
1404        );
1405    }
1406
1407    #[test]
1408    fn the_automatic_threshold_needs_1000_trades_then_takes_their_99th_percentile() {
1409        let host = Host::new(None);
1410        let history: Vec<Tick> = (1..=1000)
1411            .map(|size| fill(size, 100.0, size as f64, OrderSide::Buy, None))
1412            .collect();
1413        host.detectors.push_history("ES", &history);
1414        assert!(host.events().is_empty());
1415        // The trade of 989 closes the 1,000th, setting the threshold to 990.
1416        let trade = |size| {
1417            host.push(MarketEvent::Trade(fill(
1418                2000,
1419                100.0,
1420                size,
1421                OrderSide::Buy,
1422                None,
1423            )))
1424        };
1425        assert!(trade(989.0).is_empty());
1426        assert_eq!(
1427            trade(990.0)[0],
1428            marker(1, 2000, 100.0, Direction::Up, "990")
1429        );
1430    }
1431
1432    #[test]
1433    fn a_depth_gap_clears_the_book_and_a_new_subscription_gets_the_detections() {
1434        let mut host = Host::new(Some(10.0));
1435        let execution = Tick {
1436            id: Some(9),
1437            ..fill(1, 100.0, 6.0, OrderSide::Buy, Some(5))
1438        };
1439        let resting = RestingOrder::new(1, OrderSide::Sell, 100.0, 20.0);
1440        host.push(MarketEvent::OrderSnapshot {
1441            sequence: 1,
1442            orders: vec![resting],
1443        });
1444        let event = OrderEvent::Execute {
1445            id: 1,
1446            size: 6.0,
1447            trade: Some(execution.clone()),
1448        };
1449        assert!(
1450            host.push(MarketEvent::Order { sequence: 2, event })
1451                .is_empty()
1452        );
1453        // The same trade from the trade feed is not counted again, or 12 would be big.
1454        assert!(host.push(MarketEvent::Trade(execution)).is_empty());
1455        let rest = Tick {
1456            id: Some(10),
1457            ..fill(1, 100.0, 4.0, OrderSide::Buy, Some(5))
1458        };
1459        let detections = host.push(MarketEvent::Trade(rest));
1460        assert_eq!(detections[0], marker(1, 1, 100.0, Direction::Up, "10"));
1461
1462        let book = |price| OrderBook {
1463            ts: time(2),
1464            symbol: "ES".into(),
1465            bids: vec![OrderBookLevel {
1466                price,
1467                size: 5.0,
1468                order_cnt: None,
1469            }],
1470            asks: Vec::new(),
1471        };
1472        let held = |host: &Host| host.detectors.lock().symbols["ES"].book.levels().is_some();
1473        let snapshot = |sequence| MarketEvent::DepthSnapshot {
1474            sequence,
1475            book: book(99.0),
1476        };
1477        assert!(host.push(snapshot(1)).is_empty() && held(&host));
1478        let delta = MarketEvent::DepthDelta {
1479            sequence: 3,
1480            book: book(99.25),
1481        };
1482        assert!(host.push(delta).is_empty() && !held(&host));
1483        assert!(host.push(snapshot(4)).is_empty() && held(&host));
1484
1485        host.subscribe();
1486        assert_eq!(host.events(), detections);
1487
1488        let host = Host::new(Some(10.0));
1489        let trade = fill(2, 100.0, 12.0, OrderSide::Buy, None).with_id(7);
1490        assert!(
1491            host.push(MarketEvent::Order {
1492                sequence: 2,
1493                event: OrderEvent::Execute {
1494                    id: 1,
1495                    size: 12.0,
1496                    trade: Some(trade.clone())
1497                }
1498            })
1499            .is_empty()
1500        );
1501        assert!(
1502            host.push(MarketEvent::Order {
1503                sequence: 3,
1504                event: OrderEvent::Cancel { id: 99 }
1505            })
1506            .is_empty()
1507        );
1508        let events = host.push(MarketEvent::OrderSnapshot {
1509            sequence: 1,
1510            orders: vec![RestingOrder::new(1, OrderSide::Sell, 100.0, 12.0)],
1511        });
1512        assert_eq!(
1513            events,
1514            [
1515                marker(1, 2, 100.0, Direction::Up, "12"),
1516                zone(1, 2, None, 99.875, 100.125)
1517            ],
1518            "the validated fill precedes the invalid cancel in the snapshot buffer"
1519        );
1520        assert!(
1521            host.push(MarketEvent::Trade(trade)).is_empty(),
1522            "the tape copy counts once"
1523        );
1524
1525        let host = Host::new(Some(1_000.0));
1526        let mut settings = host.detectors.settings();
1527        settings.icebergs.enabled = true;
1528        host.detectors.set_settings(settings);
1529        host.push(MarketEvent::DepthDelta {
1530            sequence: 2,
1531            book: depth_book(&[], &[(100.0, 10.0)]),
1532        });
1533        host.push(MarketEvent::DepthSnapshot {
1534            sequence: 1,
1535            book: depth_book(&[], &[(100.0, 10.0)]),
1536        });
1537        host.push(MarketEvent::Trade(fill(
1538            0,
1539            100.0,
1540            30.0,
1541            OrderSide::Buy,
1542            None,
1543        )));
1544        for sequence in [2, 3] {
1545            let signals = of_kind(
1546                host.push(MarketEvent::DepthDelta {
1547                    sequence,
1548                    book: depth_book(&[], &[(100.0, 10.0)]),
1549                }),
1550                "Icebergs",
1551            );
1552            assert_eq!(
1553                signals.len(),
1554                usize::from(sequence == 3),
1555                "only a new book update confirms the retained level"
1556            );
1557        }
1558    }
1559
1560    #[test]
1561    fn stop_runs_require_a_fast_monotone_burst_and_distinct_known_aggressors() {
1562        for (name, ids, gap, prices, known, expected) in [
1563            (
1564                "three aggressors",
1565                vec![1, 1, 2, 2, 3],
1566                20,
1567                vec![400, 401, 401, 402, 403],
1568                true,
1569                true,
1570            ),
1571            (
1572                "no ids",
1573                vec![],
1574                10,
1575                vec![400, 401, 401, 402, 403],
1576                true,
1577                true,
1578            ),
1579            (
1580                "one aggressor",
1581                vec![1; 5],
1582                10,
1583                vec![400, 401, 401, 402, 403],
1584                true,
1585                false,
1586            ),
1587            (
1588                "gap",
1589                vec![],
1590                21,
1591                vec![400, 401, 401, 402, 403],
1592                true,
1593                false,
1594            ),
1595            (
1596                "reversal",
1597                vec![],
1598                10,
1599                vec![400, 401, 400, 402, 403],
1600                true,
1601                false,
1602            ),
1603            (
1604                "unknown side",
1605                vec![],
1606                10,
1607                vec![400, 401, 401, 402, 403],
1608                false,
1609                false,
1610            ),
1611        ] {
1612            for side in [OrderSide::Buy, OrderSide::Sell] {
1613                let host = Host::new(Some(1_000.0));
1614                let mut settings = host.detectors.settings();
1615                settings.stop_runs.enabled = true;
1616                host.detectors.set_settings(settings);
1617                let mut events = Vec::new();
1618                for (i, price) in prices.iter().enumerate() {
1619                    let price = if side == OrderSide::Buy {
1620                        *price
1621                    } else {
1622                        800 - price
1623                    };
1624                    let mut fill = Tick::new(
1625                        time(0) + chrono::TimeDelta::milliseconds(i as i64 * gap),
1626                        price as f64 * 0.25,
1627                        2.0,
1628                        known.then_some(side),
1629                    );
1630                    fill.aggressor_order_id = ids.get(i).copied();
1631                    events.extend(host.push(MarketEvent::Trade(fill)));
1632                }
1633                let signals: Vec<_> = events
1634                    .into_iter()
1635                    .filter_map(|event| match event {
1636                        SignalEvent::Upsert(signal) if signal.category == "Stop runs" => {
1637                            Some(signal)
1638                        }
1639                        _ => None,
1640                    })
1641                    .collect();
1642                assert_eq!(signals.len(), usize::from(expected), "{name} {side:?}");
1643                if let Some(signal) = signals.first() {
1644                    assert_eq!(
1645                        signal.tooltip.as_deref(),
1646                        Some(format!("5 fills, 3 ticks, size 10, {} ms", gap * 4).as_str())
1647                    );
1648                    assert_eq!(
1649                        signal.shape,
1650                        SignalShape::Marker {
1651                            time: time(0) + chrono::TimeDelta::milliseconds(gap * 4),
1652                            price: Some(if side == OrderSide::Buy {
1653                                100.75
1654                            } else {
1655                                99.25
1656                            }),
1657                            direction: stops::direction(side),
1658                        }
1659                    );
1660                }
1661            }
1662        }
1663    }
1664
1665    #[test]
1666    fn stop_sweeps_use_closed_footprints_and_rebuild_on_interval_changes() {
1667        for (name, buy_size, close, extra_low, extra_sell, expected) in [
1668            ("qualifies", 10.0, 100.75, 0.0, 0.0, true),
1669            ("ratio below three", 2.0, 100.75, 30.0, 0.0, false),
1670            ("small body", 10.0, 100.25, 0.0, 0.0, false),
1671            ("low volume", 3.0, 100.75, 0.0, 0.0, false),
1672            ("opposite delta", 10.0, 100.75, 0.0, 40.0, false),
1673            ("truncated executions", 10.0, 100.75, 0.0, 0.0, false),
1674        ] {
1675            let mut host = Host::new(Some(1_000.0));
1676            let mut settings = host.detectors.settings();
1677            settings.stop_sweeps.enabled = true;
1678            host.detectors.set_settings(settings);
1679            // Eleven preceding bars: the first may be partial, the next ten each have volume 20.
1680            for minute in 0..11 {
1681                host.push(MarketEvent::Trade(fill(
1682                    minute * 60,
1683                    100.0,
1684                    20.0,
1685                    OrderSide::Buy,
1686                    None,
1687                )));
1688            }
1689            // Bid at 100, 100.25, 100.5 is 1 each. Ask at the next three ticks is 10 each:
1690            // diagonal ratios 10:1, volume 33, delta 27 (81.8%), full-range body, baseline 20.
1691            for (second, price, size, side) in [
1692                (660, 100.0, 1.0, OrderSide::Sell),
1693                (661, 100.25, 1.0, OrderSide::Sell),
1694                (662, 100.5, 1.0, OrderSide::Sell),
1695                (663, 100.25, buy_size, OrderSide::Buy),
1696                (664, 100.5, buy_size, OrderSide::Buy),
1697                (665, 100.75, buy_size, OrderSide::Buy),
1698                (665, 100.0, extra_low, OrderSide::Buy),
1699                (665, 100.75, extra_sell, OrderSide::Sell),
1700            ] {
1701                host.push(MarketEvent::Trade(fill(second, price, size, side, None)));
1702            }
1703            if name == "truncated executions" {
1704                let trades: Vec<_> =
1705                    std::iter::repeat_n(fill(665, 100.25, 0.001, OrderSide::Buy, None), 100_001)
1706                        .collect();
1707                host.detectors.push_history("ES", &trades);
1708                host.events();
1709            }
1710            host.push(MarketEvent::Trade(fill(
1711                666,
1712                close,
1713                0.1,
1714                OrderSide::Buy,
1715                None,
1716            )));
1717            // An unclassified print advances feed time without entering the footprint history.
1718            host.detectors.push(
1719                "ES",
1720                time(720),
1721                &MarketEvent::Trade(Tick::new(time(720), 100.75, 1.0, None)),
1722            );
1723            let events = host.events();
1724            let sweeps: Vec<_> = events
1725                .into_iter()
1726                .filter(|event| {
1727                    matches!(event,
1728                SignalEvent::Upsert(signal) if signal.category == "Stop sweeps")
1729                })
1730                .collect();
1731            assert_eq!(sweeps.len(), usize::from(expected), "{name}");
1732            let (listener, events) = Listener::new(&host.inbox, 1);
1733            host.listeners.push(listener);
1734            host.detectors.subscribe("ES", Timeframe::Min5, events);
1735            let events = host.events();
1736            assert!(
1737                !events.iter().any(|event| matches!(event,
1738                SignalEvent::Upsert(signal) if signal.category == "Stop sweeps")),
1739                "{name}: rebuilt five-minute bars"
1740            );
1741            if expected {
1742                assert!(events.iter().any(|event| matches!(event,
1743                    SignalEvent::Remove(id) if id.0 == "stop-sweep-660000")));
1744            }
1745            host.listeners.clear();
1746            host.subscribe();
1747            assert_eq!(
1748                of_kind(host.events(), "Stop sweeps").len(),
1749                usize::from(expected),
1750                "{name}: rebuilt back at one minute after the last sided execution"
1751            );
1752        }
1753    }
1754    fn depth_book(bids: &[(f64, f64)], asks: &[(f64, f64)]) -> OrderBook {
1755        let levels = |rows: &[(f64, f64)]| {
1756            rows.iter()
1757                .map(|&(price, size)| OrderBookLevel {
1758                    price,
1759                    size,
1760                    order_cnt: None,
1761                })
1762                .collect()
1763        };
1764        OrderBook {
1765            ts: time(0),
1766            symbol: "ES".into(),
1767            bids: levels(bids),
1768            asks: levels(asks),
1769        }
1770    }
1771
1772    fn of_kind(events: Vec<SignalEvent>, category: &str) -> Vec<Signal> {
1773        events
1774            .into_iter()
1775            .filter_map(|event| match event {
1776                SignalEvent::Upsert(signal) if signal.category == category => Some(signal),
1777                _ => None,
1778            })
1779            .collect()
1780    }
1781
1782    #[test]
1783    fn icebergs_count_exact_refills_and_confirm_aggregate_estimates_against_the_book() {
1784        for exact in [false, true] {
1785            for consumed in [false, true] {
1786                let host = Host::new(Some(1_000.0));
1787                let mut settings = host.detectors.settings();
1788                settings.icebergs.enabled = true;
1789                settings.icebergs.alert = true;
1790                settings.iceberg_count_alert = Some(CountAlert::default());
1791                host.detectors.set_settings(settings);
1792                let push = |ms, event| {
1793                    host.detectors.push(
1794                        "ES",
1795                        time(0) + chrono::TimeDelta::milliseconds(ms),
1796                        &event,
1797                    );
1798                    of_kind(host.events(), "Icebergs")
1799                };
1800                let initial = if exact {
1801                    MarketEvent::OrderSnapshot {
1802                        sequence: 1,
1803                        orders: vec![RestingOrder::new(1, OrderSide::Sell, 100.0, 10.0)],
1804                    }
1805                } else {
1806                    MarketEvent::DepthSnapshot {
1807                        sequence: 1,
1808                        book: depth_book(&[], &[(100.0, 10.0)]),
1809                    }
1810                };
1811                push(0, initial);
1812                let mut found = Vec::new();
1813                for index in 0..3 {
1814                    let mut trade = fill(0, 100.0, 10.0, OrderSide::Buy, None);
1815                    trade.ts += chrono::TimeDelta::milliseconds(index * 20 + 10);
1816                    trade.id = Some(index as u64 + 1);
1817                    if exact {
1818                        found.extend(push(
1819                            index * 20 + 10,
1820                            MarketEvent::Order {
1821                                sequence: index as u64 * 2 + 2,
1822                                event: OrderEvent::Execute {
1823                                    id: index as u64 + 1,
1824                                    size: 10.0,
1825                                    trade: Some(trade.clone()),
1826                                },
1827                            },
1828                        ));
1829                        // The tape repeats the carried execution; it must never count twice.
1830                        found.extend(push(index * 20 + 10, MarketEvent::Trade(trade)));
1831                        if !consumed || index < 1 {
1832                            found.extend(push(
1833                                index * 20 + 20,
1834                                MarketEvent::Order {
1835                                    sequence: index as u64 * 2 + 3,
1836                                    event: OrderEvent::Add(RestingOrder::new(
1837                                        index as u64 + 2,
1838                                        OrderSide::Sell,
1839                                        100.0,
1840                                        10.0,
1841                                    )),
1842                                },
1843                            ));
1844                        } else {
1845                            break;
1846                        }
1847                    } else {
1848                        assert!(
1849                            push(index * 20 + 10, MarketEvent::Trade(trade)).is_empty(),
1850                            "wait for depth confirmation"
1851                        );
1852                        found.extend(push(
1853                            index * 20 + 20,
1854                            MarketEvent::DepthDelta {
1855                                sequence: index as u64 + 2,
1856                                book: depth_book(
1857                                    &[],
1858                                    &[(100.0, if consumed && index == 2 { 0.0 } else { 10.0 })],
1859                                ),
1860                            },
1861                        ));
1862                    }
1863                }
1864                if consumed {
1865                    assert!(found.is_empty(), "consumed exact={exact}");
1866                } else {
1867                    assert_eq!(found.len(), if exact { 2 } else { 1 });
1868                    assert!(found.iter().all(|signal| signal.id == found[0].id));
1869                    assert_eq!(
1870                        found.last().unwrap().tooltip.as_deref(),
1871                        Some(if exact {
1872                            "3 refills, executed 30, shown 10; order-level refills"
1873                        } else {
1874                            "2 refills, executed 30, shown 10; estimate from aggregated depth"
1875                        })
1876                    );
1877                }
1878                assert_eq!(
1879                    host.detectors
1880                        .counts("ES", DetectionKind::Iceberg, time(1))
1881                        .total,
1882                    u64::from(!consumed),
1883                    "refill updates count one iceberg"
1884                );
1885                assert_eq!(
1886                    host.detectors.take_alerts().len(),
1887                    if consumed { 0 } else { 2 },
1888                    "one live alert and one count alert per iceberg"
1889                );
1890            }
1891        }
1892
1893        // 0.3 / 0.1 rounds just below three in binary floating point.
1894        let host = Host::new(Some(1_000.0));
1895        let mut settings = host.detectors.settings();
1896        settings.icebergs.enabled = true;
1897        host.detectors.set_settings(settings);
1898        host.push(MarketEvent::DepthSnapshot {
1899            sequence: 1,
1900            book: depth_book(&[], &[(100.0, 0.1)]),
1901        });
1902        host.push(MarketEvent::Trade(fill(
1903            0,
1904            100.0,
1905            0.3,
1906            OrderSide::Buy,
1907            None,
1908        )));
1909        let signals = of_kind(
1910            host.push(MarketEvent::DepthDelta {
1911                sequence: 2,
1912                book: depth_book(&[], &[(100.0, 0.1)]),
1913            }),
1914            "Icebergs",
1915        );
1916        assert_eq!(signals.len(), 1);
1917        assert!(
1918            signals[0]
1919                .tooltip
1920                .as_deref()
1921                .unwrap()
1922                .starts_with("2 refills")
1923        );
1924
1925        let host = Host::new(Some(1_000.0));
1926        let mut settings = host.detectors.settings();
1927        settings.icebergs.enabled = true;
1928        host.detectors.set_settings(settings);
1929        host.push(MarketEvent::OrderSnapshot {
1930            sequence: 1,
1931            orders: vec![
1932                RestingOrder::new(1, OrderSide::Sell, 100.0, 10.0),
1933                RestingOrder::new(2, OrderSide::Sell, 100.0, 10.0),
1934                RestingOrder::new(3, OrderSide::Sell, 100.0, 1.0),
1935            ],
1936        });
1937        for (sequence, event) in [
1938            (
1939                2,
1940                OrderEvent::Execute {
1941                    id: 1,
1942                    size: 10.0,
1943                    trade: None,
1944                },
1945            ),
1946            (
1947                3,
1948                OrderEvent::Execute {
1949                    id: 2,
1950                    size: 10.0,
1951                    trade: None,
1952                },
1953            ),
1954            (4, OrderEvent::Cancel { id: 3 }),
1955            (
1956                5,
1957                OrderEvent::Add(RestingOrder::new(4, OrderSide::Sell, 100.0, 7.0)),
1958            ),
1959            (
1960                6,
1961                OrderEvent::Add(RestingOrder::new(5, OrderSide::Sell, 100.0, 10.0)),
1962            ),
1963            (
1964                7,
1965                OrderEvent::Add(RestingOrder::new(6, OrderSide::Sell, 100.0, 10.0)),
1966            ),
1967        ] {
1968            host.detectors
1969                .push("ES", time(0), &MarketEvent::Order { sequence, event });
1970        }
1971        let signals = of_kind(host.events(), "Icebergs");
1972        assert_eq!(
1973            signals.len(),
1974            1,
1975            "both depletions survive unrelated orders at the same price"
1976        );
1977        assert_eq!(
1978            signals[0].tooltip.as_deref(),
1979            Some("2 refills, executed 20, shown 10; order-level refills")
1980        );
1981
1982        let host = Host::new(Some(1_000.0));
1983        let mut settings = host.detectors.settings();
1984        settings.icebergs.enabled = true;
1985        host.detectors.set_settings(settings);
1986        host.push(MarketEvent::OrderSnapshot {
1987            sequence: 1,
1988            orders: vec![RestingOrder::new(1, OrderSide::Sell, 100.0, 0.3)],
1989        });
1990        for i in 0..2 {
1991            for (offset, size) in [(0, 0.1), (1, 0.2)] {
1992                host.detectors.push(
1993                    "ES",
1994                    time(0),
1995                    &MarketEvent::Order {
1996                        sequence: 2 + i * 3 + offset,
1997                        event: OrderEvent::Execute {
1998                            id: 1 + i,
1999                            size,
2000                            trade: None,
2001                        },
2002                    },
2003                );
2004            }
2005            host.detectors.push(
2006                "ES",
2007                time(0),
2008                &MarketEvent::Order {
2009                    sequence: 4 + i * 3,
2010                    event: OrderEvent::Add(RestingOrder::new(2 + i, OrderSide::Sell, 100.0, 0.3)),
2011                },
2012            );
2013        }
2014        let signals = of_kind(host.events(), "Icebergs");
2015        assert_eq!(
2016            signals.len(),
2017            1,
2018            "decimal fills replenish twice without a false depth loss"
2019        );
2020        assert!(
2021            signals[0]
2022                .tooltip
2023                .as_deref()
2024                .unwrap()
2025                .starts_with("2 refills")
2026        );
2027    }
2028
2029    #[test]
2030    fn iceberg_refills_reject_late_replacements_priority_keeps_and_depth_gaps() {
2031        for (name, gap, delay, keep, expected) in [
2032            ("priority lost", false, 10, false, 1),
2033            ("changed displayed size", false, 10, false, 1),
2034            ("smaller refill clip", false, 10, false, 1),
2035            ("priority retained", false, 10, true, 0),
2036            ("gap", true, 10, false, 0),
2037            ("replacement too late", false, 101, false, 0),
2038            ("partial refill too late", false, 101, false, 0),
2039        ] {
2040            let host = Host::new(Some(1_000.0));
2041            let mut settings = host.detectors.settings();
2042            settings.icebergs.enabled = true;
2043            host.detectors.set_settings(settings);
2044            host.detectors.push(
2045                "ES",
2046                time(0),
2047                &MarketEvent::OrderSnapshot {
2048                    sequence: 1,
2049                    orders: vec![RestingOrder::new(1, OrderSide::Buy, 100.0, 10.0)],
2050                },
2051            );
2052            let mut found = Vec::new();
2053            for i in 0..2 {
2054                let seq = 2 + i * 2;
2055                let at = time(0) + chrono::TimeDelta::milliseconds(i as i64 * 200);
2056                host.detectors.push(
2057                    "ES",
2058                    at,
2059                    &MarketEvent::Order {
2060                        sequence: seq + u64::from(gap),
2061                        event: OrderEvent::Execute {
2062                            id: 1,
2063                            size: if name == "replacement too late" {
2064                                10.0
2065                            } else {
2066                                5.0
2067                            },
2068                            trade: None,
2069                        },
2070                    },
2071                );
2072                let event = if name == "replacement too late" {
2073                    OrderEvent::Add(RestingOrder::new(1, OrderSide::Buy, 100.0, 10.0))
2074                } else {
2075                    OrderEvent::Modify {
2076                        id: 1,
2077                        price: None,
2078                        size: Some(if name == "changed displayed size" {
2079                            12.0 + i as f64
2080                        } else if name == "smaller refill clip" {
2081                            8.0 + i as f64
2082                        } else {
2083                            10.0
2084                        }),
2085                        keep_priority: keep,
2086                    }
2087                };
2088                host.detectors.push(
2089                    "ES",
2090                    at + chrono::TimeDelta::milliseconds(delay),
2091                    &MarketEvent::Order {
2092                        sequence: seq + 1,
2093                        event,
2094                    },
2095                );
2096                found.extend(of_kind(host.events(), "Icebergs"));
2097            }
2098            assert_eq!(found.len(), expected, "{name}");
2099        }
2100    }
2101
2102    #[test]
2103    fn absorption_requires_a_held_inside_level_and_discards_broken_or_lost_episodes() {
2104        for side in [OrderSide::Buy, OrderSide::Sell] {
2105            for (name, remaining, broken, gap, expected) in [
2106                ("held", 5.0, false, false, true),
2107                ("decimal lots", 0.15, false, false, true),
2108                ("less than half remains", 4.0, false, false, false),
2109                ("traded through", 10.0, true, false, false),
2110                ("idle close before later break", 10.0, true, false, true),
2111                ("idle close before withdrawal", 0.0, false, false, true),
2112                ("gap", 10.0, false, true, false),
2113            ] {
2114                let host = Host::new(Some(1_000.0));
2115                let mut settings = host.detectors.settings();
2116                settings.absorption.enabled = true;
2117                settings.absorption.rule.threshold =
2118                    Threshold::Fixed(if name == "decimal lots" { 0.3 } else { 10.0 });
2119                host.detectors.set_settings(settings);
2120                let book = |size| {
2121                    if side == OrderSide::Buy {
2122                        depth_book(&[(100.0, size)], &[])
2123                    } else {
2124                        depth_book(&[], &[(100.0, size)])
2125                    }
2126                };
2127                host.push(MarketEvent::DepthSnapshot {
2128                    sequence: 1,
2129                    book: book(if name == "decimal lots" {
2130                        0.1 + 0.2
2131                    } else {
2132                        10.0
2133                    }),
2134                });
2135                let aggressor = if side == OrderSide::Buy {
2136                    OrderSide::Sell
2137                } else {
2138                    OrderSide::Buy
2139                };
2140                for (ms, size) in [
2141                    (0, if name == "decimal lots" { 0.15 } else { 4.0 }),
2142                    (200, if name == "decimal lots" { 0.15 } else { 6.0 }),
2143                ] {
2144                    let trade = Tick::new(
2145                        time(0) + chrono::TimeDelta::milliseconds(ms),
2146                        100.0,
2147                        size,
2148                        Some(aggressor),
2149                    );
2150                    host.detectors
2151                        .push("ES", trade.ts, &MarketEvent::Trade(trade));
2152                }
2153                if broken {
2154                    let price = if side == OrderSide::Buy {
2155                        99.75
2156                    } else {
2157                        100.25
2158                    };
2159                    let at = time(0)
2160                        + chrono::TimeDelta::milliseconds(
2161                            if name == "idle close before later break" {
2162                                1_500
2163                            } else {
2164                                500
2165                            },
2166                        );
2167                    host.detectors.push(
2168                        "ES",
2169                        at,
2170                        &MarketEvent::Trade(Tick::new(at, price, 1.0, None)),
2171                    );
2172                }
2173                host.detectors.push(
2174                    "ES",
2175                    if name.starts_with("idle close") {
2176                        time(2)
2177                    } else {
2178                        time(1) + chrono::TimeDelta::milliseconds(200)
2179                    },
2180                    &MarketEvent::DepthDelta {
2181                        sequence: if gap { 3 } else { 2 },
2182                        book: book(remaining),
2183                    },
2184                );
2185                if gap {
2186                    host.detectors.push(
2187                        "ES",
2188                        time(1) + chrono::TimeDelta::milliseconds(200),
2189                        &MarketEvent::DepthSnapshot {
2190                            sequence: 3,
2191                            book: book(10.0),
2192                        },
2193                    );
2194                }
2195                let signals = of_kind(host.events(), "Absorption");
2196                assert_eq!(signals.len(), usize::from(expected), "{name} {side:?}");
2197                if expected {
2198                    assert_eq!(
2199                        signals[0].shape,
2200                        SignalShape::PriceZone {
2201                            from: time(0),
2202                            to: Some(time(0) + chrono::TimeDelta::milliseconds(200)),
2203                            price: 100.0,
2204                            direction: stops::direction(side)
2205                        }
2206                    );
2207                }
2208            }
2209        }
2210    }
2211
2212    #[test]
2213    fn absorption_auto_threshold_uses_closed_episode_nearest_rank_and_order_adds_can_hold_a_level()
2214    {
2215        let host = Host::new(Some(1_000.0));
2216        let mut settings = host.detectors.settings();
2217        settings.absorption.enabled = true;
2218        host.detectors.set_settings(settings);
2219        host.push(MarketEvent::DepthSnapshot {
2220            sequence: 1,
2221            book: depth_book(&[], &[(100.0, 1.0)]),
2222        });
2223        for i in 0..100 {
2224            host.detectors.push(
2225                "ES",
2226                time(i * 2),
2227                &MarketEvent::Trade(fill(i * 2, 100.0, (i + 1) as f64, OrderSide::Buy, None)),
2228            );
2229        }
2230        host.detectors.push(
2231            "ES",
2232            time(199),
2233            &MarketEvent::DepthDelta {
2234                sequence: 2,
2235                book: depth_book(&[], &[]),
2236            },
2237        );
2238        assert!(
2239            of_kind(host.events(), "Absorption").is_empty(),
2240            "100-episode warm-up"
2241        );
2242        // Sorted 1..100 has nearest-rank 99th percentile 99. The next 98 fails, then 99 passes.
2243        for (at, size, expected) in [(200, 98.0, 0), (202, 99.0, 1)] {
2244            host.detectors.push(
2245                "ES",
2246                time(at),
2247                &MarketEvent::Trade(fill(at, 100.0, size, OrderSide::Buy, None)),
2248            );
2249            host.detectors.push(
2250                "ES",
2251                time(at + 1),
2252                &MarketEvent::DepthDelta {
2253                    sequence: (at / 2 - 97) as u64,
2254                    book: depth_book(&[], &[]),
2255                },
2256            );
2257            assert_eq!(of_kind(host.events(), "Absorption").len(), expected);
2258        }
2259        host.detectors.reset("ES");
2260        let mut settings = host.detectors.settings();
2261        settings.absorption.rule.threshold = Threshold::Fixed(10.0);
2262        host.detectors.set_settings(settings);
2263        host.detectors.push(
2264            "ES",
2265            time(300),
2266            &MarketEvent::OrderSnapshot {
2267                sequence: 1,
2268                orders: vec![RestingOrder::new(1, OrderSide::Sell, 100.0, 10.0)],
2269            },
2270        );
2271        host.detectors.push(
2272            "ES",
2273            time(300),
2274            &MarketEvent::Trade(fill(300, 100.0, 10.0, OrderSide::Buy, None)),
2275        );
2276        host.detectors.push(
2277            "ES",
2278            time(300),
2279            &MarketEvent::Order {
2280                sequence: 2,
2281                event: OrderEvent::Add(RestingOrder::new(2, OrderSide::Sell, 100.0, 1.0)),
2282            },
2283        );
2284        host.detectors.push(
2285            "ES",
2286            time(300),
2287            &MarketEvent::Order {
2288                sequence: 3,
2289                event: OrderEvent::Modify {
2290                    id: 1,
2291                    price: None,
2292                    size: Some(2.0),
2293                    keep_priority: true,
2294                },
2295            },
2296        );
2297        host.detectors.push(
2298            "ES",
2299            time(301),
2300            &MarketEvent::Order {
2301                sequence: 4,
2302                event: OrderEvent::Modify {
2303                    id: 1,
2304                    price: None,
2305                    size: Some(2.0),
2306                    keep_priority: true,
2307                },
2308            },
2309        );
2310        assert_eq!(
2311            of_kind(host.events(), "Absorption").len(),
2312            1,
2313            "added size holds despite less than half remaining"
2314        );
2315    }
2316
2317    #[test]
2318    fn liquidity_zones_end_at_the_pull_or_sweep_and_walls_need_execution_and_rejection() {
2319        for bid in [false, true] {
2320            for outcome in [
2321                "pull",
2322                "sweep",
2323                "sweep before depth",
2324                "sweep unrelated update",
2325                "pull after refill",
2326                "wall",
2327            ] {
2328                let mut host = Host::new(Some(1_000.0));
2329                let mut settings = host.detectors.settings();
2330                settings.liquidity.enabled = true;
2331                settings.liquidity.rule.threshold = Threshold::Fixed(50.0);
2332                settings.liquidity.rule.fresh_threshold = Threshold::Fixed(50.0);
2333                host.detectors.set_settings(settings);
2334                let book = |rows: &[(f64, f64)]| {
2335                    if bid {
2336                        depth_book(rows, &[])
2337                    } else {
2338                        depth_book(&[], rows)
2339                    }
2340                };
2341                let initial = host.push(MarketEvent::DepthSnapshot {
2342                    sequence: 1,
2343                    book: book(&[(100.0, 100.0)]),
2344                });
2345                let zones = of_kind(initial, "Large liquidity");
2346                assert_eq!(zones.len(), 1);
2347                let side = if bid { OrderSide::Sell } else { OrderSide::Buy };
2348                let traded = match outcome {
2349                    "sweep"
2350                    | "sweep before depth"
2351                    | "sweep unrelated update"
2352                    | "pull after refill" => 90.0,
2353                    "wall" => 10.0,
2354                    _ => 0.0,
2355                };
2356                if traded > 0.0 {
2357                    host.detectors.push(
2358                        "ES",
2359                        time(1),
2360                        &MarketEvent::Trade(fill(1, 100.0, traded, side, None)),
2361                    );
2362                }
2363                let interim = matches!(outcome, "sweep unrelated update" | "pull after refill");
2364                if interim {
2365                    host.detectors.push(
2366                        "ES",
2367                        time(1),
2368                        &MarketEvent::DepthDelta {
2369                            sequence: 2,
2370                            book: if outcome == "pull after refill" {
2371                                book(&[(100.0, 100.0)])
2372                            } else {
2373                                book(&[(if bid { 99.0 } else { 101.0 }, 1.0)])
2374                            },
2375                        },
2376                    );
2377                }
2378                let away = if bid { 100.75 } else { 99.25 };
2379                if outcome == "sweep before depth" {
2380                    let beyond = if bid { 99.75 } else { 100.25 };
2381                    host.detectors.push(
2382                        "ES",
2383                        time(1),
2384                        &MarketEvent::Trade(fill(1, beyond, 1.0, side, None)),
2385                    );
2386                }
2387                let rows = if outcome == "wall" {
2388                    vec![(100.0, 90.0), (away, 1.0)]
2389                } else {
2390                    vec![(100.0, 10.0)]
2391                };
2392                host.detectors.push(
2393                    "ES",
2394                    time(2),
2395                    &MarketEvent::DepthDelta {
2396                        sequence: if interim { 3 } else { 2 },
2397                        book: book(&rows),
2398                    },
2399                );
2400                if matches!(outcome, "sweep" | "sweep unrelated update") {
2401                    let beyond = if bid { 99.75 } else { 100.25 };
2402                    host.detectors.push(
2403                        "ES",
2404                        time(3),
2405                        &MarketEvent::Trade(fill(3, beyond, 1.0, side, None)),
2406                    );
2407                }
2408                let events = host.events();
2409                let category = match outcome {
2410                    "pull" | "pull after refill" => "Pulled liquidity",
2411                    "sweep" | "sweep before depth" | "sweep unrelated update" => "Liquidity sweeps",
2412                    _ => "Walls",
2413                };
2414                assert_eq!(
2415                    of_kind(events.clone(), category).len(),
2416                    1,
2417                    "{outcome} bid={bid}"
2418                );
2419                let ended = of_kind(events, "Large liquidity");
2420                if outcome == "wall" {
2421                    assert!(ended.is_empty());
2422                } else {
2423                    assert_eq!(ended.len(), 1);
2424                    assert_eq!(ended[0].id, zones[0].id);
2425                    assert_eq!(
2426                        ended[0].shape,
2427                        SignalShape::PriceZone {
2428                            from: time(0),
2429                            to: Some(time(
2430                                if matches!(outcome, "sweep" | "sweep unrelated update") {
2431                                    3
2432                                } else {
2433                                    2
2434                                }
2435                            )),
2436                            price: 100.0,
2437                            direction: if bid { Direction::Up } else { Direction::Down }
2438                        }
2439                    );
2440                }
2441                // Resubscribing retains the final zone and marker, including its end.
2442                host.listeners.clear();
2443                host.subscribe();
2444                assert_eq!(of_kind(host.events(), category).len(), 1);
2445            }
2446        }
2447
2448        let host = Host::new(Some(1_000.0));
2449        let mut settings = host.detectors.settings();
2450        settings.liquidity.enabled = true;
2451        settings.liquidity.rule.threshold = Threshold::Fixed(50.0);
2452        host.detectors.set_settings(settings);
2453        host.push(MarketEvent::OrderSnapshot {
2454            sequence: 1,
2455            orders: vec![RestingOrder::new(1, OrderSide::Buy, 100.0, 100.0)],
2456        });
2457        host.push(MarketEvent::Order {
2458            sequence: 2,
2459            event: OrderEvent::Execute {
2460                id: 1,
2461                size: 10.0,
2462                trade: Some(Tick::new(time(0), 100.0, 10.0, None)),
2463            },
2464        });
2465        let walls = of_kind(
2466            host.push(MarketEvent::Order {
2467                sequence: 3,
2468                event: OrderEvent::Add(RestingOrder::new(2, OrderSide::Buy, 100.75, 1.0)),
2469            }),
2470            "Walls",
2471        );
2472        assert_eq!(
2473            walls.len(),
2474            1,
2475            "order-level execution needs no tape aggressor inference"
2476        );
2477        assert!(walls[0].tooltip.as_deref().unwrap().contains("executed 10"));
2478    }
2479
2480    #[test]
2481    fn fresh_liquidity_uses_the_nearby_median_and_spoofing_requires_an_individual_order() {
2482        for (name, size, milliseconds, execute, expected) in [
2483            ("qualifies", 100.0, 2_000, 9.0, true),
2484            ("too old", 100.0, 2_001, 9.0, false),
2485            ("ten percent executed", 100.0, 1_000, 10.0, false),
2486            ("too small", 3.0, 1_000, 0.0, false),
2487        ] {
2488            let host = Host::new(Some(1_000.0));
2489            let mut settings = host.detectors.settings();
2490            settings.liquidity.enabled = true;
2491            host.detectors.set_settings(settings);
2492            host.detectors.push(
2493                "ES",
2494                time(0),
2495                &MarketEvent::OrderSnapshot {
2496                    sequence: 1,
2497                    orders: vec![
2498                        RestingOrder::new(1, OrderSide::Buy, 99.0, 1.0),
2499                        RestingOrder::new(2, OrderSide::Sell, 101.0, 1.0),
2500                    ],
2501                },
2502            );
2503            host.detectors.push(
2504                "ES",
2505                time(0),
2506                &MarketEvent::Order {
2507                    sequence: 2,
2508                    event: OrderEvent::Add(RestingOrder::new(3, OrderSide::Buy, 99.25, size)),
2509                },
2510            );
2511            let fresh = of_kind(host.events(), "Fresh liquidity");
2512            // Median [1, 1, size] is 1, threshold 4. The aggregate size cannot identify a spoof.
2513            assert_eq!(fresh.len(), usize::from(size >= 4.0), "{name}");
2514            if let Some(signal) = fresh.first() {
2515                assert!(
2516                    signal
2517                        .tooltip
2518                        .as_deref()
2519                        .unwrap()
2520                        .contains("largest resting order 3")
2521                );
2522            }
2523            let mut seq = 3;
2524            if execute > 0.0 {
2525                host.detectors.push(
2526                    "ES",
2527                    time(0),
2528                    &MarketEvent::Order {
2529                        sequence: seq,
2530                        event: OrderEvent::Execute {
2531                            id: 3,
2532                            size: execute,
2533                            trade: None,
2534                        },
2535                    },
2536                );
2537                seq += 1;
2538            }
2539            host.detectors.push(
2540                "ES",
2541                time(0) + chrono::TimeDelta::milliseconds(milliseconds),
2542                &MarketEvent::Order {
2543                    sequence: seq,
2544                    event: OrderEvent::Cancel { id: 3 },
2545                },
2546            );
2547            let events = host.events();
2548            assert_eq!(
2549                of_kind(events.clone(), "Spoofing").len(),
2550                usize::from(expected),
2551                "{name}"
2552            );
2553            if expected {
2554                let pulls = of_kind(events, "Pulled liquidity");
2555                assert_eq!(pulls.len(), 1);
2556                assert_eq!(
2557                    pulls[0].label, None,
2558                    "the coincident spoof supplies the label"
2559                );
2560            }
2561        }
2562        let host = Host::new(Some(1_000.0));
2563        let mut settings = host.detectors.settings();
2564        settings.liquidity.enabled = true;
2565        host.detectors.set_settings(settings);
2566        host.push(MarketEvent::DepthSnapshot {
2567            sequence: 1,
2568            book: depth_book(&[(99.0, 1.0)], &[(101.0, 1.0)]),
2569        });
2570        let added = host.push(MarketEvent::DepthDelta {
2571            sequence: 2,
2572            book: depth_book(&[(99.25, 100.0)], &[]),
2573        });
2574        assert_eq!(of_kind(added, "Fresh liquidity").len(), 1);
2575        let removed = host.push(MarketEvent::DepthDelta {
2576            sequence: 3,
2577            book: depth_book(&[(99.25, 0.0)], &[]),
2578        });
2579        assert!(of_kind(removed.clone(), "Spoofing").is_empty());
2580        assert_eq!(of_kind(removed, "Pulled liquidity").len(), 1);
2581    }
2582}