1mod 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
34const RETAINED: usize = 5_000;
36const TRADE_IDS: usize = 100_000;
38
39pub struct OrderFlowDetectors {
90 state: Mutex<State>,
91}
92
93#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
95#[non_exhaustive]
96pub enum DetectionKind {
97 BigTrade,
99 BigTradeZone,
101 StopRun,
103 StopSweep,
105 Iceberg,
107 Absorption,
109 LargeLiquidity,
111 Wall,
113 LiquiditySweep,
115 PulledLiquidity,
117 Spoofing,
119 FreshLiquidity,
121}
122
123impl DetectionKind {
124 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 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#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
162#[non_exhaustive]
163pub struct DetectorSettings {
164 pub big_trades: Detector<BigTradeRule>,
166 pub stop_runs: Detector<StopRunRule>,
168 pub stop_sweeps: Detector<StopSweepRule>,
170 pub icebergs: Detector<IcebergRule>,
172 pub absorption: Detector<AbsorptionRule>,
174 pub liquidity: Detector<LiquidityRule>,
176 pub iceberg_count_alert: Option<CountAlert>,
178 pub stop_run_count_alert: Option<CountAlert>,
180}
181
182impl DetectorSettings {
183 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#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
220#[non_exhaustive]
221pub struct Detector<R> {
222 pub enabled: bool,
225 pub alert: bool,
227 pub sound: bool,
229 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#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
246#[non_exhaustive]
247pub enum Threshold {
248 Fixed(f64),
250 Auto,
252}
253
254#[derive(Default)]
255struct State {
256 settings: DetectorSettings,
257 symbols: HashMap<String, Tracked>,
258 subscriptions: HashMap<String, Subscription>,
260 delivery: Delivery,
261}
262
263struct Subscription {
264 timeframe: Timeframe,
266 streams: Vec<Events<SignalEvent>>,
267}
268
269struct 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 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 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 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 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 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 pub fn settings(&self) -> DetectorSettings {
349 self.lock().settings.clone()
350 }
351
352 pub fn set_settings(&self, settings: DetectorSettings) {
356 settings.validate().expect("invalid detector settings");
357 self.lock().settings = settings;
358 }
359
360 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 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 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 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
692fn 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
736fn 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}