Skip to main content

egui_charts/services/detectors/
absorption.rs

1//! Aggressive volume executed into a resting inside level that holds.
2
3use std::collections::{BTreeMap, VecDeque};
4
5use chrono::{DateTime, TimeDelta, Utc};
6
7use super::{
8    DetectionKind, Detector, Threshold,
9    depth::{self, Depth, Price},
10    stops::direction,
11};
12use crate::data::meets_threshold;
13use crate::model::{Signal, SignalId, SignalShape, Tick};
14use crate::services::SignalEvent;
15
16/// An inside-price episode closes after one second without an execution there. It qualifies
17/// when aggressive volume reaches both the threshold and opening displayed size, no trade
18/// printed beyond it, and half the displayed size remains or order-level depth received adds.
19#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
20#[non_exhaustive]
21pub struct AbsorptionRule {
22    /// Fixed size, or nearest-rank 99th percentile of the last 1,000 closed episode volumes,
23    /// inactive until 100 episodes have closed. Gaps discard episodes without calibrating.
24    pub threshold: Threshold,
25}
26
27impl Default for AbsorptionRule {
28    fn default() -> Self {
29        Self {
30            threshold: Threshold::Auto,
31        }
32    }
33}
34
35#[derive(Default)]
36pub(super) struct Absorption {
37    episodes: BTreeMap<Price, Episode>,
38    volumes: VecDeque<f64>,
39    next: u64,
40}
41
42struct Episode {
43    first: DateTime<Utc>,
44    last: DateTime<Utc>,
45    shown: f64,
46    volume: f64,
47    added: bool,
48}
49
50impl Absorption {
51    pub(super) fn clear(&mut self) {
52        self.episodes.clear();
53    }
54
55    pub(super) fn trade(
56        &mut self,
57        fill: &Tick,
58        tick: f64,
59        book: &Depth,
60        settings: &Detector<AbsorptionRule>,
61        out: &mut Vec<SignalEvent>,
62    ) {
63        self.advance(
64            fill.ts
65                .checked_sub_signed(TimeDelta::nanoseconds(1))
66                .unwrap_or(fill.ts),
67            tick,
68            book,
69            settings,
70            out,
71        );
72        let traded = (fill.price / tick).round() as i64;
73        let broken: Vec<_> = self
74            .episodes
75            .iter()
76            .filter(|(price, episode)| fill.ts >= episode.first && depth::beyond(**price, traded))
77            .map(|(&price, _)| price)
78            .collect();
79        for price in broken {
80            if let Some(episode) = self.episodes.remove(&price) {
81                self.calibrate(episode.volume);
82            }
83        }
84        self.advance(fill.ts, tick, book, settings, out);
85        let Some(price) = depth::resting(fill, tick).filter(|price| {
86            book.inside(price.0) == Some(price.1) && book.size(*price) > 0.0 && fill.size > 0.0
87        }) else {
88            return;
89        };
90        let episode = self.episodes.entry(price).or_insert(Episode {
91            first: fill.ts,
92            last: fill.ts,
93            shown: book.size(price),
94            volume: 0.0,
95            added: false,
96        });
97        if fill.ts < episode.last {
98            return;
99        }
100        episode.last = fill.ts;
101        episode.volume = (episode.volume + fill.size).min(f64::MAX);
102    }
103
104    pub(super) fn update(&mut self, before: &Depth, after: &Depth) {
105        if after.exact {
106            for (&price, episode) in &mut self.episodes {
107                episode.added |= after.size(price) > before.size(price);
108            }
109        }
110    }
111
112    pub(super) fn advance(
113        &mut self,
114        time: DateTime<Utc>,
115        tick: f64,
116        book: &Depth,
117        settings: &Detector<AbsorptionRule>,
118        out: &mut Vec<SignalEvent>,
119    ) {
120        let closed: Vec<_> = self
121            .episodes
122            .iter()
123            .filter(|(_, episode)| time - episode.last >= TimeDelta::seconds(1))
124            .map(|(&price, _)| price)
125            .collect();
126        for price in closed {
127            let episode = self.episodes.remove(&price).unwrap();
128            let threshold = match settings.rule.threshold {
129                Threshold::Fixed(size) => Some(size),
130                Threshold::Auto if self.volumes.len() >= 100 => {
131                    let mut sorted: Vec<_> = self.volumes.iter().copied().collect();
132                    sorted.sort_unstable_by(f64::total_cmp);
133                    Some(sorted[(sorted.len() * 99).div_ceil(100) - 1])
134                }
135                Threshold::Auto => None,
136            };
137            if settings.enabled
138                && threshold.is_some_and(|threshold| meets_threshold(episode.volume, threshold))
139                && meets_threshold(episode.volume, episode.shown)
140                && (meets_threshold(book.size(price), episode.shown * 0.5) || episode.added)
141            {
142                self.next += 1;
143                out.push(SignalEvent::Upsert(Signal {
144                    id: SignalId(format!("absorption-{}", self.next)),
145                    shape: SignalShape::PriceZone {
146                        from: episode.first,
147                        to: Some(episode.last),
148                        price: price.1 as f64 * tick,
149                        direction: direction(depth::side(price)),
150                    },
151                    category: DetectionKind::Absorption.category().into(),
152                    label: Some("Absorption".into()),
153                    tooltip: Some(format!(
154                        "Absorbed {}, opening shown {}",
155                        episode.volume, episode.shown
156                    )),
157                }));
158            }
159            self.calibrate(episode.volume);
160        }
161    }
162
163    fn calibrate(&mut self, volume: f64) {
164        self.volumes.push_back(volume);
165        if self.volumes.len() > 1_000 {
166            self.volumes.pop_front();
167        }
168    }
169}