Skip to main content

egui_charts/services/detectors/
big_trades.rs

1//! Large aggressive executions, as markers and as the price zones they leave.
2
3use std::collections::VecDeque;
4
5use chrono::{DateTime, Utc};
6
7use super::Threshold;
8use crate::model::{BarClock, Direction, OrderSide, Signal, SignalId, SignalShape, Tick};
9use crate::scales::{PriceFormatter, VolumeFormatter};
10use crate::services::SignalEvent;
11
12/// Combined trade sizes the automatic threshold is calibrated on.
13const WINDOW: usize = 10_000;
14/// Combined trades seen before the automatic threshold applies.
15const WARM_UP: u64 = 1_000;
16/// Combined trades between two calibrations of the automatic threshold.
17const CALIBRATE_EVERY: u64 = 100;
18
19/// What makes a trade large, and which of the zones of large trades are kept.
20///
21/// Fills sharing an explicit aggressor order id, in a row, form one trade; fills without one
22/// stand alone. A trade is large once its combined size reaches the threshold. With
23/// [`Threshold::Auto`] that is the 99th percentile, by nearest rank, of the combined sizes of
24/// the symbol's last 10,000 trades, recomputed every 100 trades and inactive until 1,000 trades
25/// have been seen; trades from loaded history count.
26///
27/// A large trade shows as a marker at its first fill time and volume-weighted price, coloured by
28/// its aggressor side and labelled with its size, and as a zone from its first fill onward over
29/// the prices it filled at, at least one tick high, centred on them. Both follow the later fills
30/// of the same aggressor. A zone ends at the first trade at or after its start strictly beyond
31/// its far side: below it after a buy, above it after a sell, and on either side when the side is
32/// unknown.
33///
34/// The service uses one interval per symbol, chosen by its latest subscription, for every chart
35/// of that symbol. Intraday bars use the instrument's exchange time zone (UTC when absent) and
36/// regular session; daily and longer bars use UTC calendar boundaries. The chart's display time
37/// zone and extended-hours setting are not inputs to the service.
38///
39/// When the bar of the subscribed time interval that contains the first fill closes, the trade
40/// is in the body of that bar if its volume-weighted price lies between the bar's open and close,
41/// both included, and in a wick otherwise. Zones of the kinds not kept are then removed. Zones
42/// in a bar the service did not see open, the first one after tracking starts or the interval
43/// changes, or under a trade-based interval, stay. A trade that first becomes large after its
44/// first fill's bar has closed also keeps its zone: that bar is no longer held for classification.
45#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
46#[non_exhaustive]
47pub struct BigTradeRule {
48    /// Combined size from which a trade is large.
49    pub threshold: Threshold,
50    /// Keep the zones of large trades in the body of their bar.
51    pub body_zones: bool,
52    /// Keep the zones of large trades in a wick of their bar.
53    pub wick_zones: bool,
54}
55
56impl Default for BigTradeRule {
57    fn default() -> Self {
58        Self {
59            threshold: Threshold::Auto,
60            body_zones: true,
61            wick_zones: true,
62        }
63    }
64}
65
66/// The big trade detector of one symbol.
67#[derive(Default)]
68pub(super) struct BigTrades {
69    /// The symbol's tick size, positive and finite.
70    tick: f64,
71    /// Writes the size labels.
72    sizes_label: VolumeFormatter,
73    /// Running aggregate of the open trade's fills.
74    fills: Option<Trade>,
75    /// Number of the open trade's detection, once it is large.
76    large: Option<u64>,
77    /// Number of the next detection.
78    next: u64,
79    /// Combined sizes of the last trades, oldest first.
80    sizes: VecDeque<f64>,
81    /// Trades seen since tracking began.
82    seen: u64,
83    /// The automatic threshold, once calibrated.
84    threshold: Option<f64>,
85    /// Zones still to end or to classify.
86    zones: Vec<Zone>,
87    /// Bars of the subscribed interval, if it is time-based.
88    clock: Option<BarClock>,
89    /// The forming bar.
90    bar: Option<Bar>,
91}
92
93struct Trade {
94    aggressor_order_id: Option<u64>,
95    first_time: DateTime<Utc>,
96    side: Option<OrderSide>,
97    size: f64,
98    price: f64,
99    low: f64,
100    high: f64,
101}
102
103struct Zone {
104    number: u64,
105    side: Option<OrderSide>,
106    /// Volume-weighted price of the trade.
107    price: f64,
108    from: DateTime<Utc>,
109    to: Option<DateTime<Utc>>,
110    low: f64,
111    high: f64,
112    classified: bool,
113}
114
115struct Bar {
116    open_time: DateTime<Utc>,
117    open: f64,
118    close: f64,
119    /// Whether the service saw the bar's first trade.
120    whole: bool,
121}
122
123impl BigTrades {
124    /// The detector of a symbol of tick size `tick`, labelling sizes with `volume_decimals`
125    /// decimals, or 2 like the chart's volume readouts.
126    pub(super) fn new(tick: f64, volume_decimals: Option<u8>) -> Self {
127        Self {
128            tick,
129            sizes_label: VolumeFormatter {
130                precision: volume_decimals.map_or(2, usize::from),
131            },
132            ..Self::default()
133        }
134    }
135
136    /// Classifies zones by the bars of `clock` from the next bar on.
137    pub(super) fn set_clock(&mut self, clock: Option<BarClock>) {
138        self.clock = clock;
139        self.bar = None;
140    }
141
142    /// Stops following the zone `id`, which is no longer retained.
143    pub(super) fn forget(&mut self, id: &SignalId) {
144        if let Some(number) =
145            id.0.strip_prefix("big-trade-zone-")
146                .and_then(|number| number.parse::<u64>().ok())
147        {
148            self.zones.retain(|zone| zone.number != number);
149        }
150    }
151
152    /// Detects on `fill`, a valid trade, pushing the changes to `out`.
153    pub(super) fn trade(
154        &mut self,
155        fill: &Tick,
156        rule: &BigTradeRule,
157        enabled: bool,
158        out: &mut Vec<SignalEvent>,
159    ) {
160        // shortcut: history has no observation clock, add a feed-time policy before rejecting jumps.
161        let open = self.clock.as_ref().map(|clock| clock.bar_open(fill.ts));
162        if let (Some(open), Some(bar)) = (open, &self.bar)
163            && open > bar.open_time
164        {
165            self.classify(rule, out);
166        }
167
168        let extends = fill.aggressor_order_id.is_some()
169            && self.fills.as_ref().map(|trade| trade.aggressor_order_id)
170                == Some(fill.aggressor_order_id);
171        if !extends {
172            if let Some(trade) = self.fills.take() {
173                self.calibrate(trade.size);
174            }
175            self.large = None;
176        }
177
178        for zone in self.zones.iter_mut() {
179            let beyond = match zone.side {
180                Some(OrderSide::Buy) => fill.price < zone.low,
181                Some(OrderSide::Sell) => fill.price > zone.high,
182                None => fill.price < zone.low || fill.price > zone.high,
183            };
184            if zone.to.is_none()
185                && fill.ts >= zone.from
186                && Some(zone.number) != self.large
187                && beyond
188            {
189                zone.to = Some(fill.ts);
190                out.push(SignalEvent::Upsert(zone.signal()));
191            }
192        }
193        self.zones
194            .retain(|zone| zone.to.is_none() || !zone.classified);
195
196        let trade = self.fills.get_or_insert(Trade {
197            aggressor_order_id: fill.aggressor_order_id,
198            first_time: fill.ts,
199            side: fill.side,
200            size: 0.0,
201            price: fill.price,
202            low: fill.price,
203            high: fill.price,
204        });
205        let scale = trade.size.max(fill.size);
206        if scale > 0.0 {
207            let weight = (fill.size / scale) / (trade.size / scale + fill.size / scale);
208            trade.price += (fill.price - trade.price) * weight;
209        }
210        trade.size = (trade.size + fill.size).min(f64::MAX);
211        if trade.side != fill.side {
212            trade.side = None;
213        }
214        trade.low = trade.low.min(fill.price);
215        trade.high = trade.high.max(fill.price);
216        let threshold = match rule.threshold {
217            Threshold::Fixed(size) => Some(size),
218            Threshold::Auto => self.threshold,
219        };
220        if enabled && threshold.is_some_and(|threshold| trade.size >= threshold) {
221            let (low, high) = (trade.low, trade.high);
222            let tick = self.tick;
223            let (low, high) = if high - low < tick {
224                let middle = (low + high) / 2.0;
225                (middle - tick / 2.0, middle + tick / 2.0)
226            } else {
227                (low, high)
228            };
229            let number = match self.large {
230                Some(number) => number,
231                None => {
232                    self.next += 1;
233                    self.zones.push(Zone {
234                        number: self.next,
235                        side: trade.side,
236                        price: trade.price,
237                        from: trade.first_time,
238                        to: None,
239                        low,
240                        high,
241                        // Without bars there is nothing to classify by.
242                        classified: self.clock.is_none(),
243                    });
244                    *self.large.insert(self.next)
245                }
246            };
247            out.push(SignalEvent::Upsert(Signal {
248                id: SignalId(format!("big-trade-{number}")),
249                shape: SignalShape::Marker {
250                    time: trade.first_time,
251                    price: Some(trade.price),
252                    direction: match trade.side {
253                        Some(OrderSide::Buy) => Direction::Up,
254                        Some(OrderSide::Sell) => Direction::Down,
255                        None => Direction::Neutral,
256                    },
257                },
258                category: super::DetectionKind::BigTrade.category().into(),
259                label: Some(self.sizes_label.format(trade.size)),
260                tooltip: None,
261            }));
262            if let Some(zone) = self.zones.iter_mut().find(|zone| zone.number == number) {
263                zone.side = trade.side;
264                zone.price = trade.price;
265                zone.low = low;
266                zone.high = high;
267                out.push(SignalEvent::Upsert(zone.signal()));
268            }
269        }
270
271        match (open, &mut self.bar) {
272            (Some(open), Some(bar)) if open == bar.open_time => bar.close = fill.price,
273            (Some(open), bar) if bar.as_ref().is_none_or(|bar| open > bar.open_time) => {
274                *bar = Some(Bar {
275                    open_time: open,
276                    open: fill.price,
277                    close: fill.price,
278                    whole: bar.is_some(),
279                });
280            }
281            _ => {}
282        }
283    }
284
285    /// Classifies the zones of the forming bar, which closed, removing those not kept.
286    fn classify(&mut self, rule: &BigTradeRule, out: &mut Vec<SignalEvent>) {
287        let Some(bar) = &self.bar else {
288            return;
289        };
290        let (bottom, top) = (bar.open.min(bar.close), bar.open.max(bar.close));
291        self.zones.retain_mut(|zone| {
292            if zone.classified {
293                return true;
294            }
295            zone.classified = true;
296            // shortcut: late threshold crossings have no saved first bar,
297            // retain bars if classification is needed.
298            let body = (bottom..=top).contains(&zone.price);
299            if !bar.whole
300                || zone.from < bar.open_time
301                || if body {
302                    rule.body_zones
303                } else {
304                    rule.wick_zones
305                }
306            {
307                return zone.to.is_none();
308            }
309            out.push(SignalEvent::Remove(zone_id(zone.number)));
310            false
311        });
312    }
313
314    /// Adds a trade's combined size to the window of the automatic threshold.
315    fn calibrate(&mut self, size: f64) {
316        if self.sizes.len() == WINDOW {
317            self.sizes.pop_front();
318        }
319        self.sizes.push_back(size);
320        self.seen += 1;
321        if self.seen >= WARM_UP && self.seen.is_multiple_of(CALIBRATE_EVERY) {
322            let mut sizes: Vec<f64> = self.sizes.iter().copied().collect();
323            // Nearest rank: the smallest size that 99% of the window does not exceed.
324            let rank = (sizes.len() * 99).div_ceil(100);
325            self.threshold = Some(*sizes.select_nth_unstable_by(rank - 1, f64::total_cmp).1);
326        }
327    }
328}
329
330impl Zone {
331    fn signal(&self) -> Signal {
332        Signal {
333            id: zone_id(self.number),
334            shape: SignalShape::Zone {
335                from: self.from,
336                to: self.to,
337                low: self.low,
338                high: self.high,
339            },
340            category: super::DetectionKind::BigTradeZone.category().into(),
341            label: None,
342            tooltip: None,
343        }
344    }
345}
346
347fn zone_id(number: u64) -> SignalId {
348    SignalId(format!("big-trade-zone-{number}"))
349}