1use crate::chart::coords::ChartMapping;
4use crate::chart::overlays::{FootprintConfig, FootprintMode};
5use crate::data::NodeSensitivity;
6use crate::model::Bar;
7use crate::studies::TradeProfile;
8use crate::theme::ThemeExt;
9use crate::tokens::DESIGN_TOKENS;
10use egui::{Align2, FontId, Painter, Rect, Stroke, pos2};
11
12#[derive(Clone, Debug)]
15pub struct TradeProfileSettings {
16 pub mode: FootprintMode,
18 pub developing_poc: bool,
20 pub nodes: Option<NodeSensitivity>,
22 pub value_area: Option<f64>,
24 pub value_area_guides: bool,
26}
27
28impl Default for TradeProfileSettings {
29 fn default() -> Self {
30 Self {
31 mode: FootprintMode::BidAsk,
32 developing_poc: false,
33 nodes: None,
34 value_area: Some(0.7),
35 value_area_guides: false,
36 }
37 }
38}
39
40pub fn render_trade_profiles(
49 painter: &Painter,
50 mapping: &ChartMapping,
51 bars: &[Bar],
52 profiles: &[TradeProfile],
53 step: f64,
54 tick: f64,
55 config: &FootprintConfig,
56) {
57 render_profiles(painter, mapping, bars, profiles, step, tick, config, None);
58}
59
60pub(crate) fn render_session_volume_profiles(
65 painter: &Painter,
66 mapping: &ChartMapping,
67 bars: &[Bar],
68 profiles: &[TradeProfile],
69 step: f64,
70 tick: f64,
71 settings: &TradeProfileSettings,
72) {
73 let theme = painter.ctx().active_theme();
74 let guide = settings
75 .value_area_guides
76 .then_some(Stroke::new(1.0, theme.chart.axis_text_secondary));
77 let config = FootprintConfig {
78 modes: vec![settings.mode],
79 value_area: settings.value_area,
80 node_sensitivity: settings.nodes.unwrap_or_default(),
81 ask_color: DESIGN_TOKENS.semantic.brand.accent,
82 bid_color: DESIGN_TOKENS.semantic.tpo.period_6,
83 neutral_color: theme.chart.axis_text_secondary,
84 text_color: DESIGN_TOKENS.semantic.chart.bearish,
85 value_area_high: guide,
86 value_area_low: guide,
87 min_text_height: if settings.nodes.is_some() {
88 12.0
89 } else {
90 f32::INFINITY
91 },
92 ..Default::default()
93 };
94 render_profiles(
95 painter,
96 mapping,
97 bars,
98 profiles,
99 step,
100 tick,
101 &config,
102 Some(settings),
103 );
104}
105
106fn render_profiles(
107 painter: &Painter,
108 mapping: &ChartMapping,
109 bars: &[Bar],
110 profiles: &[TradeProfile],
111 step: f64,
112 tick: f64,
113 config: &FootprintConfig,
114 settings: Option<&TradeProfileSettings>,
115) {
116 let session = settings.is_some();
117 if !step.is_finite() || step <= 0.0 || !tick.is_finite() || tick <= 0.0 || bars.is_empty() {
118 return;
119 }
120 let painter = painter.with_clip_rect(mapping.rect.intersect(painter.clip_rect()));
121 let theme = painter.ctx().active_theme();
122 let pixel = 1.0 / painter.pixels_per_point();
123 for profile in profiles {
124 let first = bars.partition_point(|b| b.time < profile.range.start);
125 let end = bars.partition_point(|b| b.time < profile.range.end);
126 if first >= end {
127 continue;
128 }
129 let x = mapping.idx_to_x(first) - mapping.bar_spacing * 0.45;
130 if mapping.idx_to_x(end - 1) < mapping.rect.left() || x > mapping.rect.right() {
131 continue;
132 }
133 let x = x.max(mapping.rect.left());
134 let session_width = (mapping.idx_to_x(end - 1) + mapping.bar_spacing * 0.5 - x).max(1.0);
135 let width = if session {
136 session_width
137 } else {
138 ((end - first) as f32 * mapping.bar_spacing * 0.7)
139 .clamp(1.0, 160.0)
140 .min(session_width)
141 };
142 let max = profile
143 .volume
144 .levels
145 .values()
146 .map(|r| r.volume())
147 .fold(0.0, f64::max);
148 if max <= 0.0 {
149 continue;
150 }
151 if session
152 && let (Some((&low, _)), Some((&high, _))) = (
153 profile.volume.levels.first_key_value(),
154 profile.volume.levels.last_key_value(),
155 )
156 {
157 let y0 = mapping.price_to_y(low as f64 * step - tick * 0.5);
158 let y1 = mapping.price_to_y((high as f64 + 1.0) * step - tick * 0.5);
159 if y0.is_finite() && y1.is_finite() {
160 let fill = theme
161 .chart
162 .bg
163 .lerp_to_gamma(theme.chart.axis_text, 0.035)
164 .lerp_to_gamma(config.ask_color, if theme.dark { 0.065 } else { 0.035 });
165 painter.rect_filled(
166 Rect::from_min_max(pos2(x, y0.min(y1)), pos2(x + width, y0.max(y1))),
167 0.0,
168 fill,
169 );
170 }
171 }
172 let area = config.value_area.and_then(|s| profile.volume.value_area(s));
173 let (high, low) = profile.nodes(config.node_sensitivity);
174 let poc = profile.volume.poc();
175 for row in profile.volume.levels.values() {
176 let y0 = mapping.price_to_y(row.level as f64 * step - tick * 0.5);
177 let y1 = mapping.price_to_y((row.level as f64 + 1.0) * step - tick * 0.5);
178 if !y0.is_finite() || !y1.is_finite() {
179 continue;
180 }
181 let mut top = y0.min(y1);
182 let mut bottom = y0.max(y1);
183 if bottom - top >= 2.0 * pixel {
185 top = (top / pixel).round() * pixel;
186 bottom = (bottom / pixel).round() * pixel - pixel;
187 }
188 let alpha = match (
189 area.is_some_and(|(a, b)| (a..=b).contains(&row.level)),
190 theme.dark,
191 ) {
192 (true, true) => 0.9,
193 (true, false) => 0.82,
194 (false, true) => 0.32,
195 (false, false) => 0.24,
196 };
197 let mut left = x;
198 let mode = config
199 .modes
200 .first()
201 .copied()
202 .unwrap_or(FootprintMode::BidAsk);
203 let segments = match mode {
204 FootprintMode::BidAsk if session => vec![
205 (row.ask, config.ask_color),
206 (row.bid, config.bid_color),
207 (row.unknown, config.neutral_color),
208 ],
209 FootprintMode::BidAsk => vec![
210 (row.bid, config.bid_color),
211 (row.ask, config.ask_color),
212 (row.unknown, config.neutral_color),
213 ],
214 FootprintMode::Volume => vec![(row.volume(), config.neutral_color)],
215 _ => {
216 left += width * 0.5;
217 vec![(
218 row.delta().abs(),
219 if row.delta() >= 0.0 {
220 config.ask_color
221 } else {
222 config.bid_color
223 },
224 )]
225 }
226 };
227 for (volume, color) in segments {
228 let w = width
229 * (volume / max) as f32
230 * if matches!(mode, FootprintMode::Delta | FootprintMode::DeltaVolume) {
231 0.5
232 } else {
233 1.0
234 };
235 let right = if row.delta() < 0.0
236 && matches!(mode, FootprintMode::Delta | FootprintMode::DeltaVolume)
237 {
238 left - w
239 } else {
240 left + w
241 };
242 painter.rect_filled(
243 Rect::from_min_max(pos2(left.min(right), top), pos2(left.max(right), bottom)),
244 0.0,
245 color.gamma_multiply(alpha),
246 );
247 left = right;
248 }
249 if config.show_poc && poc == Some(row.level) {
250 painter.line_segment(
251 [pos2(x, (y0 + y1) * 0.5), pos2(x + width, (y0 + y1) * 0.5)],
252 Stroke::new(DESIGN_TOKENS.stroke.hairline, config.text_color),
253 );
254 }
255 }
256 if !profile.complete
259 && width >= 60.0
260 && let Some(&high) = profile.volume.levels.keys().next_back()
261 {
262 let y = mapping.price_to_y((high as f64 + 1.0) * step - tick * 0.5);
263 let galley = painter.layout_no_wrap(
264 crate::localization::text(painter.ctx(), "Partial executions"),
265 FontId::proportional(crate::styles::typography::XS),
266 theme.chart.axis_text_secondary,
267 );
268 let size = galley.size();
269 if y.is_finite() && size.x <= width {
270 painter.galley(pos2(x, y - size.y), galley, theme.chart.axis_text_secondary);
271 }
272 }
273 for (label, nodes) in [("HVN", &high), ("LVN", &low)] {
275 for &level in nodes {
276 let y0 = mapping.price_to_y(level as f64 * step - tick * 0.5);
277 let y1 = mapping.price_to_y((level as f64 + 1.0) * step - tick * 0.5);
278 if y0.is_finite() && y1.is_finite() && (y0 - y1).abs() >= config.min_text_height {
279 let galley = painter.layout_no_wrap(
280 label.into(),
281 FontId::monospace(crate::styles::typography::TINY),
282 config.text_color,
283 );
284 if session && galley.size().x + 6.0 > width {
285 continue;
286 }
287 let anchor = if session {
288 Align2::RIGHT_CENTER
289 } else {
290 Align2::LEFT_CENTER
291 };
292 let pos = pos2(
293 x + width + if session { -3.0 } else { 3.0 },
294 (y0 + y1) * 0.5,
295 );
296 painter.galley(
297 anchor.anchor_size(pos, galley.size()).min,
298 galley,
299 config.text_color,
300 );
301 }
302 }
303 }
304 if let Some((low_level, high_level)) = area {
306 for (stroke, edge) in [
307 (config.value_area_high, (high_level as f64 + 1.0) * step),
308 (config.value_area_low, low_level as f64 * step),
309 ] {
310 let y = mapping.price_to_y(edge - tick * 0.5);
311 if let Some(stroke) = stroke
312 && y.is_finite()
313 {
314 painter.line_segment([pos2(x, y), pos2(x + width, y)], stroke);
315 }
316 }
317 }
318 if settings.is_some_and(|s| !s.developing_poc) {
319 continue;
320 }
321 let mut previous = None;
322 for (at, &(time, poc)) in profile.developing_poc.iter().enumerate() {
323 if time < bars[0].time
325 && profile
326 .developing_poc
327 .get(at + 1)
328 .is_some_and(|(next, _)| *next <= bars[0].time)
329 {
330 continue;
331 }
332 let index = bars.partition_point(|b| b.time < time);
333 let point = pos2(
334 mapping.idx_to_x(index),
335 mapping.price_to_y((poc as f64 + 0.5) * step - tick * 0.5),
336 );
337 if !point.is_finite() {
338 previous = None;
339 continue;
340 }
341 if let Some(old) = previous {
342 painter.line_segment([old, point], Stroke::new(1.0, config.text_color));
343 }
344 previous = Some(point);
345 }
346 }
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352 use crate::data::Footprint;
353 use crate::model::{BarClock, OrderSide, Tick, Timeframe};
354 use crate::scales::{PriceMap, PriceRange, PriceTransform};
355 use crate::studies::{TradeProfileRanges, trade_profiles};
356 use chrono::TimeDelta;
357 use egui::{Shape, vec2};
358
359 #[test]
362 fn partly_visible_sessions_keep_their_volume_profile_in_view() {
363 let time = "2026-01-05T10:00:00Z".parse().unwrap();
364 let mut flow = Footprint::new(BarClock::new(Timeframe::Min1), 1.0, 1).unwrap();
365 flow.push(&Tick::new(time, 100.0, 4.0, Some(OrderSide::Buy)))
366 .unwrap();
367 flow.push(&Tick::new(
368 time + TimeDelta::minutes(1),
369 101.0,
370 2.0,
371 Some(OrderSide::Sell),
372 ))
373 .unwrap();
374 let bars: Vec<_> = (0..4)
375 .map(|i| {
376 Bar::new(
377 time + TimeDelta::minutes(i),
378 100.0,
379 101.0,
380 100.0,
381 101.0,
382 0.0,
383 )
384 })
385 .collect();
386 let ranges = TradeProfileRanges::new(vec![time..time + TimeDelta::minutes(4)]).unwrap();
387 let profiles = trade_profiles(&flow, &ranges);
388 let rect = Rect::from_min_size(pos2(40.0, 20.0), vec2(240.0, 160.0));
389 let config = FootprintConfig {
390 modes: vec![FootprintMode::Volume],
391 value_area: None,
392 show_poc: false,
393 ..Default::default()
394 };
395 for (offset, column) in [
396 (0.0, Some((200.0, 56.0))),
397 (9.0, Some((40.0, 56.0))),
398 (11.0, Some((40.0, 19.0))),
399 (-5.0, None),
400 (20.0, None),
401 ] {
402 let mapping = ChartMapping::new(
403 rect,
404 20.0,
405 0,
406 3,
407 offset,
408 PriceMap::new(
409 PriceTransform::default(),
410 PriceRange::new(99.0, 103.0),
411 20.0,
412 180.0,
413 false,
414 ),
415 );
416 let ctx = egui::Context::default();
417 let mut output = ctx.run_ui(
418 egui::RawInput {
419 screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(320.0, 200.0))),
420 ..Default::default()
421 },
422 |ui| {
423 render_trade_profiles(
424 ui.painter(),
425 &mapping,
426 &bars,
427 &profiles,
428 1.0,
429 1.0,
430 &config,
431 )
432 },
433 );
434 output.textures_delta.clear();
435 let columns: Vec<_> = output
436 .shapes
437 .iter()
438 .filter_map(|shape| {
439 if let Shape::Rect(rect) = &shape.shape {
440 Some((rect.rect.left(), rect.rect.width()))
441 } else {
442 None
443 }
444 })
445 .collect();
446 let expected = column.map_or_else(Vec::new, |(left, width)| {
447 vec![(left, width), (left, width * 0.5)]
448 });
449 assert_eq!(columns, expected, "scroll offset {offset}");
450 }
451 }
452
453 #[test]
456 fn value_area_guides_follow_the_shaded_rows() {
457 let time = "2026-01-05T10:00:00Z".parse().unwrap();
458 let mut flow = Footprint::new(BarClock::new(Timeframe::Min1), 1.0, 1).unwrap();
459 for (price, size, side) in [
460 (100.0, 4.0, OrderSide::Buy),
461 (101.0, 1.0, OrderSide::Sell),
462 (102.0, 1.0, OrderSide::Sell),
463 ] {
464 flow.push(&Tick::new(time, price, size, Some(side)))
465 .unwrap();
466 }
467 let bars = [Bar::new(time, 100.0, 102.0, 100.0, 101.0, 0.0)];
468 let ranges = TradeProfileRanges::new(vec![time..time + TimeDelta::minutes(1)]).unwrap();
469 let profiles = trade_profiles(&flow, &ranges);
470 let rect = Rect::from_min_size(pos2(40.0, 20.0), vec2(240.0, 160.0));
471 let mapping = ChartMapping::new(
472 rect,
473 20.0,
474 0,
475 0,
476 0.0,
477 PriceMap::new(
478 PriceTransform::default(),
479 PriceRange::new(99.0, 104.0),
480 20.0,
481 180.0,
482 false,
483 ),
484 );
485 let (red, blue) = (
486 Stroke::new(1.0, egui::Color32::RED),
487 Stroke::new(2.0, egui::Color32::BLUE),
488 );
489 let guides = |high, low| {
490 let config = FootprintConfig {
491 value_area: Some(0.6),
492 value_area_high: high,
493 value_area_low: low,
494 ..Default::default()
495 };
496 let ctx = egui::Context::default();
497 let mut output = ctx.run_ui(
498 egui::RawInput {
499 screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(320.0, 200.0))),
500 ..Default::default()
501 },
502 |ui| {
503 render_trade_profiles(
504 ui.painter(),
505 &mapping,
506 &bars,
507 &profiles,
508 1.0,
509 1.0,
510 &config,
511 )
512 },
513 );
514 output.textures_delta.clear();
515 output
516 .shapes
517 .iter()
518 .filter_map(|shape| match &shape.shape {
519 Shape::LineSegment { points, stroke } if *stroke == red || *stroke == blue => {
520 Some((points[0].y, stroke.color))
521 }
522 _ => None,
523 })
524 .collect::<Vec<_>>()
525 };
526 let top = mapping.price_to_y(100.5);
528 let bottom = mapping.price_to_y(99.5);
529 assert_eq!(
530 guides(Some(red), Some(blue)),
531 [(top, egui::Color32::RED), (bottom, egui::Color32::BLUE)]
532 );
533 assert_eq!(guides(None, Some(blue)), [(bottom, egui::Color32::BLUE)]);
534 assert_eq!(guides(Some(red), None), [(top, egui::Color32::RED)]);
535 }
536}