egui_charts/services/detectors/
big_trades.rs1use 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
12const WINDOW: usize = 10_000;
14const WARM_UP: u64 = 1_000;
16const CALIBRATE_EVERY: u64 = 100;
18
19#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
46#[non_exhaustive]
47pub struct BigTradeRule {
48 pub threshold: Threshold,
50 pub body_zones: bool,
52 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#[derive(Default)]
68pub(super) struct BigTrades {
69 tick: f64,
71 sizes_label: VolumeFormatter,
73 fills: Option<Trade>,
75 large: Option<u64>,
77 next: u64,
79 sizes: VecDeque<f64>,
81 seen: u64,
83 threshold: Option<f64>,
85 zones: Vec<Zone>,
87 clock: Option<BarClock>,
89 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 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 whole: bool,
121}
122
123impl BigTrades {
124 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 pub(super) fn set_clock(&mut self, clock: Option<BarClock>) {
138 self.clock = clock;
139 self.bar = None;
140 }
141
142 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 pub(super) fn trade(
154 &mut self,
155 fill: &Tick,
156 rule: &BigTradeRule,
157 enabled: bool,
158 out: &mut Vec<SignalEvent>,
159 ) {
160 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 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 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 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 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 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}