Skip to main content

egui_charts/studies/builtin/
trade_profile.rs

1//! Exact bid, ask and delta profiles over host-selected session or composite ranges.
2
3use crate::data::{FeedError, Footprint, FootprintBar, NodeSensitivity};
4use chrono::{DateTime, Utc};
5use std::ops::Range;
6
7/// Half-open session ranges. Adjacent sessions can be merged and split again by the user.
8#[derive(Clone, Debug)]
9pub struct TradeProfileRanges(Vec<Range<DateTime<Utc>>>);
10
11impl TradeProfileRanges {
12    /// Creates ordered, non-overlapping ranges. The host supplies exchange session boundaries,
13    /// including any extended-hours splits, rather than inferring sessions from missing trades.
14    pub fn new(ranges: Vec<Range<DateTime<Utc>>>) -> Result<Self, FeedError> {
15        if let Some(i) = ranges.iter().position(|r| r.start >= r.end) {
16            return Err(FeedError::InvalidData(format!(
17                "trade profile range {i} must end after it starts, got {} to {}",
18                ranges[i].start, ranges[i].end
19            )));
20        }
21        if let Some(i) = ranges.windows(2).position(|r| r[0].end > r[1].start) {
22            return Err(FeedError::InvalidData(format!(
23                "trade profile range {} starts at {} before range {i} ends at {}",
24                i + 1,
25                ranges[i + 1].start,
26                ranges[i].end
27            )));
28        }
29        Ok(Self(ranges))
30    }
31
32    /// The current ranges in time order.
33    pub fn ranges(&self) -> &[Range<DateTime<Utc>>] {
34        &self.0
35    }
36
37    /// Combines this profile with its next neighbour, including any intervening gap.
38    pub fn merge_next(&mut self, index: usize) -> bool {
39        if index >= self.0.len().saturating_sub(1) {
40            return false;
41        }
42        self.0[index].end = self.0.remove(index + 1).end;
43        true
44    }
45
46    /// Splits a profile at an interior bar boundary. An endpoint leaves it unchanged.
47    pub fn split(&mut self, index: usize, time: DateTime<Utc>) -> bool {
48        let Some(range) = self.0.get_mut(index) else {
49            return false;
50        };
51        if time <= range.start || time >= range.end {
52            return false;
53        }
54        let end = range.end;
55        range.end = time;
56        self.0.insert(index + 1, time..end);
57        true
58    }
59}
60
61/// Executed volume over one session or composite, with the POC after each contributing bar.
62#[derive(Clone, Debug)]
63pub struct TradeProfile {
64    /// Half-open range of bar opening times.
65    pub range: Range<DateTime<Utc>>,
66    /// Per-price classified and unclassified volume. POC and value area use total volume.
67    pub volume: FootprintBar,
68    /// Developing POC as `(bar open, grouped price level)`, including lower-price ties.
69    pub developing_poc: Vec<(DateTime<Utc>, i64)>,
70    /// Whether the executions of every bar in `range` are held. False when `range` starts
71    /// before [`Footprint::complete_from`], or no bar is complete: `volume` and
72    /// `developing_poc` then leave out the bars before it, and the profile is partial.
73    pub complete: bool,
74}
75
76impl TradeProfile {
77    /// Local volume maxima and minima, respectively, between the first and last traded rows.
78    /// Untraded rows have zero volume, so a gap between traded rows is a valley; it is
79    /// evaluated as one span, however wide. Rows of equal smoothed volume form one plateau,
80    /// reported once at its lowest row, and the outer traded rows are never nodes. See
81    /// [`NodeSensitivity`] for the smoothing and prominence applied.
82    pub fn nodes(&self, sensitivity: NodeSensitivity) -> (Vec<i64>, Vec<i64>) {
83        let mut high = Vec::new();
84        let mut low = Vec::new();
85        let levels = &self.volume.levels;
86        let (Some(&first), Some(&last)) = (levels.keys().next(), levels.keys().next_back()) else {
87            return (high, low);
88        };
89        let reach = i64::from(sensitivity.smoothing);
90        let smoothed = |at: i64| {
91            levels
92                .range(at.saturating_sub(reach)..=at.saturating_add(reach))
93                .map(|(_, row)| row.volume())
94                .sum::<f64>()
95                / (2 * reach + 1) as f64
96        };
97        // The smoothed volume changes only where a traded row enters or leaves the window.
98        let mut starts = vec![first];
99        for &level in levels.keys() {
100            for start in [level.saturating_sub(reach), level.saturating_add(reach + 1)] {
101                if first < start && start <= last {
102                    starts.push(start);
103                }
104            }
105        }
106        starts.sort_unstable();
107        starts.dedup();
108        let mut plateaus: Vec<(i64, f64)> = Vec::new();
109        for start in starts {
110            let volume = smoothed(start);
111            if plateaus.last().is_none_or(|&(_, v)| v != volume) {
112                plateaus.push((start, volume));
113            }
114        }
115        let largest = plateaus.iter().map(|p| p.1).fold(0.0, f64::max);
116        let share = sensitivity.prominence;
117        let margin = if share.is_nan() {
118            0.0
119        } else {
120            share.clamp(0.0, 1.0)
121        } * largest;
122        for pair in plateaus.windows(3) {
123            let [(_, before), (level, volume), (_, after)] = [pair[0], pair[1], pair[2]];
124            let rise = volume - before.max(after);
125            let drop = before.min(after) - volume;
126            if rise > 0.0 && rise >= margin {
127                high.push(level);
128            }
129            if drop > 0.0 && drop >= margin {
130                low.push(level);
131            }
132        }
133        (high, low)
134    }
135}
136
137/// Builds exact profiles from footprint rows. No OHLC volume is distributed across prices.
138/// Ranges select whole bars, so session and split boundaries must align with the footprint's
139/// bar clock. Unknown aggressor volume remains separate and contributes only to total volume.
140/// Bars before [`Footprint::complete_from`], whose executions are not all held, are left
141/// out; [`TradeProfile::complete`] tells whether a range starts before it.
142pub fn trade_profiles(flow: &Footprint, ranges: &TradeProfileRanges) -> Vec<TradeProfile> {
143    let complete_from = flow.complete_from();
144    ranges
145        .ranges()
146        .iter()
147        .map(|range| {
148            let mut volume = FootprintBar::empty(range.start);
149            let mut developing_poc = Vec::new();
150            for bar in flow.complete_bars(range.clone()) {
151                volume.absorb(bar);
152                if let Some(poc) = volume.poc() {
153                    developing_poc.push((bar.time, poc));
154                }
155            }
156            TradeProfile {
157                range: range.clone(),
158                volume,
159                developing_poc,
160                complete: complete_from.is_some_and(|from| from <= range.start),
161            }
162        })
163        .collect()
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169    use crate::data::FootprintLevel;
170    use crate::model::{BarClock, OrderSide, Tick, Timeframe};
171    use chrono::TimeDelta;
172
173    #[test]
174    fn sessions_merge_and_split_without_estimating_side_or_developing_poc() {
175        let start: DateTime<Utc> = "2026-01-05T10:00:00Z".parse().unwrap();
176        let mut flow = Footprint::new(BarClock::new(Timeframe::Min1), 1.0, 1).unwrap();
177        for (second, price, size, side) in [
178            (0, 99.0, 2.0, Some(OrderSide::Sell)),
179            (1, 100.0, 8.0, Some(OrderSide::Buy)),
180            (2, 101.0, 1.0, None),
181            (3, 102.0, 5.0, Some(OrderSide::Sell)),
182            (60, 101.0, 10.0, Some(OrderSide::Buy)),
183        ] {
184            flow.push(&Tick::new(
185                start + TimeDelta::seconds(second),
186                price,
187                size,
188                side,
189            ))
190            .unwrap();
191        }
192        let middle = start + TimeDelta::minutes(1);
193        let end = start + TimeDelta::minutes(2);
194        let mut ranges = TradeProfileRanges::new(vec![start..middle, middle..end]).unwrap();
195        let split = trade_profiles(&flow, &ranges);
196        assert_eq!(split[0].volume.poc(), Some(100));
197        assert_eq!(
198            split[0].nodes(NodeSensitivity::default()),
199            (vec![100], vec![101])
200        );
201        assert!(ranges.merge_next(0));
202        let merged = trade_profiles(&flow, &ranges);
203        assert_eq!(merged[0].developing_poc, [(start, 100), (middle, 101)]);
204        assert_eq!(
205            (
206                merged[0].volume.bid,
207                merged[0].volume.ask,
208                merged[0].volume.unknown,
209                merged[0].volume.delta()
210            ),
211            (7.0, 18.0, 1.0, 11.0)
212        );
213        assert!(ranges.split(0, middle));
214        let restored = trade_profiles(&flow, &ranges);
215        assert_eq!(restored[0].volume, split[0].volume);
216        assert_eq!(restored[1].volume, split[1].volume);
217        assert!(!ranges.split(0, start));
218        for (ranges, expected) in [
219            (
220                vec![start..start],
221                "trade profile range 0 must end after it starts, got 2026-01-05 10:00:00 UTC to 2026-01-05 10:00:00 UTC",
222            ),
223            (
224                vec![start..end, middle..end],
225                "trade profile range 1 starts at 2026-01-05 10:01:00 UTC before range 0 ends at 2026-01-05 10:02:00 UTC",
226            ),
227        ] {
228            assert_eq!(
229                TradeProfileRanges::new(ranges).unwrap_err().to_string(),
230                format!("invalid data: {expected}")
231            );
232        }
233    }
234
235    /// Expected nodes were worked out by hand from the volumes by level, with untraded levels
236    /// at zero: a smoothed volume is the mean over `2 * smoothing + 1` levels.
237    #[test]
238    fn nodes_follow_the_sensitivity_across_steps_plateaus_and_gaps() {
239        let nodes = |volumes: &[(i64, f64)], smoothing, prominence| {
240            let levels = volumes
241                .iter()
242                .map(|&(level, ask)| {
243                    (
244                        level,
245                        FootprintLevel {
246                            level,
247                            ask,
248                            ..Default::default()
249                        },
250                    )
251                })
252                .collect();
253            let time = "2026-01-05T10:00:00Z".parse().unwrap();
254            TradeProfile {
255                range: time..time,
256                volume: FootprintBar {
257                    time,
258                    levels,
259                    bid: 0.0,
260                    ask: 0.0,
261                    unknown: 0.0,
262                    min_delta: 0.0,
263                    max_delta: 0.0,
264                },
265                developing_poc: Vec::new(),
266                complete: true,
267            }
268            .nodes(NodeSensitivity {
269                smoothing,
270                prominence,
271            })
272        };
273        let steps = [(1, 1.0), (2, 2.0), (3, 3.0), (4, 4.0)];
274        let flat = [(1, 5.0), (2, 5.0), (3, 5.0)];
275        let plateau = [(1, 1.0), (2, 5.0), (3, 5.0), (4, 1.0)];
276        let gaps = [(1, 4.0), (5, 6.0), (9, 4.0)];
277        let wide_gap = [(-1_000_000_000_000, 2.0), (1_000_000_000_000, 2.0)];
278        let noisy = [(1, 3.0), (2, 1.0), (3, 3.0), (4, 1.0), (5, 3.0)];
279        let ragged = [(1, 10.0), (2, 4.0), (3, 6.0), (4, 2.0), (5, 10.0)];
280        for (volumes, smoothing, prominence, high, low) in [
281            (&steps[..], 0, 0.0, vec![], vec![]),
282            (&flat[..], 0, 0.0, vec![], vec![]),
283            (&plateau[..], 0, 0.0, vec![2], vec![]),
284            (&gaps[..], 0, 0.0, vec![5], vec![2, 6]),
285            (&wide_gap[..], 0, 0.0, vec![], vec![-999_999_999_999]),
286            (&noisy[..], 0, 0.0, vec![3], vec![2, 4]),
287            (&noisy[..], 2, 0.0, vec![3], vec![]),
288            (&ragged[..], 0, 0.0, vec![3], vec![2, 4]),
289            (&ragged[..], 0, 0.3, vec![], vec![4]),
290            (&ragged[..], 0, f64::NAN, vec![3], vec![2, 4]),
291        ] {
292            assert_eq!(
293                nodes(volumes, smoothing, prominence),
294                (high, low),
295                "{volumes:?}, smoothing {smoothing}, prominence {prominence}"
296            );
297        }
298    }
299}