1use crate::data::{FeedError, Footprint, FootprintBar, NodeSensitivity};
4use chrono::{DateTime, Utc};
5use std::ops::Range;
6
7#[derive(Clone, Debug)]
9pub struct TradeProfileRanges(Vec<Range<DateTime<Utc>>>);
10
11impl TradeProfileRanges {
12 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 pub fn ranges(&self) -> &[Range<DateTime<Utc>>] {
34 &self.0
35 }
36
37 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 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#[derive(Clone, Debug)]
63pub struct TradeProfile {
64 pub range: Range<DateTime<Utc>>,
66 pub volume: FootprintBar,
68 pub developing_poc: Vec<(DateTime<Utc>, i64)>,
70 pub complete: bool,
74}
75
76impl TradeProfile {
77 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 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
137pub 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 #[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}