Skip to main content

egui_charts/data/
volume_profile.rs

1//! Price-row volume distributions shared by studies and drawings.
2
3use super::FootprintBar;
4use crate::model::Bar;
5use serde::{Deserialize, Serialize};
6use std::ops::{Range, RangeInclusive};
7use std::sync::Arc;
8
9/// One price-row profile placed over a half-open span of chart bars.
10#[derive(Clone, Debug)]
11pub struct StudyProfile {
12    /// Full source span for bar profiles, including offscreen bars in fixed periods,
13    /// or the visible span for an options overlay.
14    pub bars: Range<usize>,
15    /// Immutable row volumes and price boundaries.
16    pub profile: Arc<VolumeProfile>,
17    /// Row layout, value area and display settings.
18    pub config: VolumeProfileConfig,
19    /// Colour of total volume and value-area lines.
20    pub color: [u8; 4],
21    /// Draw at the right of the viewport instead of beside the first source bar.
22    pub right_aligned: bool,
23}
24
25/// Volume shown in each price row of a profile built from bars.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
27pub enum ProfileDisplay {
28    /// Total volume, with the point of control and value area highlighted.
29    #[default]
30    Total,
31    /// Up-bar and down-bar volume in adjacent segments.
32    UpDown,
33    /// Absolute up minus down volume, coloured by its sign.
34    Delta,
35}
36
37fn profile_up_color() -> [u8; 4] {
38    [38, 166, 154, 180]
39}
40fn profile_down_color() -> [u8; 4] {
41    [239, 83, 80, 180]
42}
43
44/// Row layout and display settings shared by volume profile studies and drawings.
45#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
46#[serde(try_from = "VolumeProfileFields")]
47pub struct VolumeProfileConfig {
48    row_size: Option<f64>,
49    rows: usize,
50    value_area: f64,
51    /// Volume displayed in each row. Totals still determine POC and value area.
52    pub display: ProfileDisplay,
53    /// Colour of rising bars, including an unchanged close, in split and delta modes.
54    pub up_color: [u8; 4],
55    /// Colour of falling bars in split and delta modes.
56    pub down_color: [u8; 4],
57    /// Optional colours inside the value area; absent uses the ordinary side colours.
58    pub value_area_colors: Option<[[u8; 4]; 2]>,
59    /// Draw the high and low boundaries of the value area.
60    pub show_value_area_lines: bool,
61}
62
63/// Stored settings of a volume profile, checked by [`VolumeProfileConfig::new`] on load.
64#[derive(Deserialize)]
65struct VolumeProfileFields {
66    #[serde(default)]
67    row_size: Option<f64>,
68    rows: usize,
69    value_area: f64,
70    #[serde(default)]
71    display: ProfileDisplay,
72    #[serde(default = "profile_up_color")]
73    up_color: [u8; 4],
74    #[serde(default = "profile_down_color")]
75    down_color: [u8; 4],
76    #[serde(default)]
77    value_area_colors: Option<[[u8; 4]; 2]>,
78    #[serde(default)]
79    show_value_area_lines: bool,
80}
81
82impl TryFrom<VolumeProfileFields> for VolumeProfileConfig {
83    type Error = &'static str;
84
85    fn try_from(fields: VolumeProfileFields) -> Result<Self, Self::Error> {
86        let mut config = Self::new(fields.rows, fields.value_area)
87            .ok_or("volume profile settings out of bounds")?;
88        config.display = fields.display;
89        config.up_color = fields.up_color;
90        config.down_color = fields.down_color;
91        config.value_area_colors = fields.value_area_colors;
92        config.show_value_area_lines = fields.show_value_area_lines;
93        config
94            .with_row_size(fields.row_size)
95            .ok_or("volume profile row height must be finite and positive")
96    }
97}
98
99impl VolumeProfileConfig {
100    /// Price rows a profile may have.
101    pub const ROWS: RangeInclusive<usize> = 1..=1000;
102
103    /// A profile of `rows` equal price rows whose value area holds the
104    /// `value_area` share of its volume (above 0, at most 1); `None` when
105    /// either is out of bounds.
106    pub fn new(rows: usize, value_area: f64) -> Option<Self> {
107        (Self::ROWS.contains(&rows) && value_area > 0.0 && value_area <= 1.0).then_some(Self {
108            rows,
109            value_area,
110            ..Self::default()
111        })
112    }
113
114    /// Fix each row to a price height, aligned to multiples of that height.
115    /// `None` restores equal rows over the span. Rejects nonpositive or nonfinite heights.
116    /// Profiles spanning more than 10,000 fixed rows are not drawn.
117    pub fn with_row_size(mut self, height: Option<f64>) -> Option<Self> {
118        if height.is_some_and(|h| !h.is_finite() || h <= 0.0) {
119            return None;
120        }
121        self.row_size = height;
122        Some(self)
123    }
124
125    /// Fixed price height per row, or equal rows over the span when absent.
126    pub fn row_size(&self) -> Option<f64> {
127        self.row_size
128    }
129
130    /// Number of price rows.
131    pub fn rows(&self) -> usize {
132        self.rows
133    }
134
135    /// Share of the volume inside the value area.
136    pub fn value_area(&self) -> f64 {
137        self.value_area
138    }
139
140    /// Settings a new volume profile drawing starts with: up and down volume side by side in
141    /// cyan and pink, drawn at half opacity outside the value area. Studies keep
142    /// [`Self::default`].
143    pub fn drawing_default() -> Self {
144        Self {
145            display: ProfileDisplay::UpDown,
146            up_color: [124, 210, 225, 180],
147            down_color: [222, 119, 155, 180],
148            ..Self::default()
149        }
150    }
151}
152
153impl Default for VolumeProfileConfig {
154    /// 24 rows and a value area of 70% of the volume.
155    fn default() -> Self {
156        Self {
157            row_size: None,
158            rows: 24,
159            value_area: 0.7,
160            display: ProfileDisplay::Total,
161            up_color: profile_up_color(),
162            down_color: profile_down_color(),
163            value_area_colors: None,
164            show_value_area_lines: false,
165        }
166    }
167}
168
169/// Where a [`VolumeProfile`]'s volumes come from.
170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
171pub enum ProfileSource {
172    /// Bar volume spread over each bar's high-low range, split by candle direction, from the
173    /// chart's bars or from finer bars where they cover the chart bars.
174    Estimated,
175    /// Executed volume at each price, split by aggressor side, with unclassified volume
176    /// apart. `complete` is false when the span starts before the first bar whose executions
177    /// are all held, [`Footprint::complete_from`](super::Footprint::complete_from), so the
178    /// earliest rows may be missing.
179    Executions {
180        /// Whether the executions of every bar in the span are held.
181        complete: bool,
182    },
183    /// Call and put contract volume at each strike, from [`VolumeProfile::from_options`].
184    Options,
185}
186
187/// The executions behind an exact profile, whose POC and value area follow [`FootprintBar`].
188#[derive(Clone, Debug, PartialEq)]
189struct Executions {
190    first_level: i64,
191    bar: FootprintBar,
192    complete: bool,
193}
194
195/// Volume traded at each price row of bars or host-supplied option strikes.
196#[derive(Clone, Debug, PartialEq)]
197pub struct VolumeProfile {
198    /// Price at the bottom of row 0.
199    pub(crate) low: f64,
200    /// Highest price of the bars.
201    pub(crate) high: f64,
202    pub(crate) row_height: f64,
203    fixed_height: bool,
204    /// Original price of an all-flat equal-row span, before padding its grid.
205    flat_price: Option<f64>,
206    /// Volume per row, lowest prices first.
207    pub(crate) volume: Vec<f64>,
208    pub(crate) up: Vec<f64>,
209    pub(crate) down: Vec<f64>,
210    executions: Option<Executions>,
211    /// Built from option strikes by [`Self::from_options`].
212    options: bool,
213}
214
215fn valid_bar(bar: &Bar) -> bool {
216    [bar.open, bar.high, bar.low, bar.close, bar.volume]
217        .iter()
218        .all(|v| v.is_finite())
219        && (bar.high - bar.low).is_finite()
220        && bar.high >= bar.low
221        && bar.volume >= 0.0
222}
223
224impl VolumeProfile {
225    /// Group host options turnover into rows centered on multiples of `row_size`.
226    /// A row at strike K includes [K - row_size/2, K + row_size/2); a boundary
227    /// goes to the higher row. Calls occupy [`Self::up`], puts [`Self::down`].
228    /// Duplicate strikes are summed. Empty, invalid, overflowing or unrepresentable
229    /// snapshots, including grids exceeding 10,000 rows, produce no profile.
230    pub fn from_options(data: &[crate::data::OptionsVolume], row_size: f64) -> Option<Self> {
231        if data.is_empty()
232            || !row_size.is_finite()
233            || row_size <= 0.0
234            || data.iter().any(|v| {
235                !v.strike.is_finite()
236                    || !v.calls.is_finite()
237                    || !v.puts.is_finite()
238                    || v.calls < 0.0
239                    || v.puts < 0.0
240            })
241        {
242            return None;
243        }
244        let key = |strike| grid_floor(strike / row_size + 0.5);
245        let first = data
246            .iter()
247            .map(|v| key(v.strike))
248            .fold(f64::INFINITY, f64::min);
249        let last = data
250            .iter()
251            .map(|v| key(v.strike))
252            .fold(f64::NEG_INFINITY, f64::max);
253        let count = last - first + 1.0;
254        if !first.is_finite()
255            || !last.is_finite()
256            || first.abs() >= 2_f64.powi(52)
257            || last.abs() >= 2_f64.powi(52)
258            || !(1.0..=10_000.0).contains(&count)
259        {
260            return None;
261        }
262        let low = (first - 0.5) * row_size;
263        let high = low + count * row_size;
264        if !low.is_finite() || !high.is_finite() || high <= low {
265            return None;
266        }
267        let mut profile = Self {
268            low,
269            high,
270            row_height: row_size,
271            fixed_height: false,
272            flat_price: None,
273            volume: vec![0.0; count as usize],
274            up: vec![0.0; count as usize],
275            down: vec![0.0; count as usize],
276            executions: None,
277            options: true,
278        };
279        let mut total = 0.0;
280        for v in data {
281            let row = (key(v.strike) - first) as usize;
282            profile.up[row] += v.calls;
283            profile.down[row] += v.puts;
284            profile.volume[row] = profile.up[row] + profile.down[row];
285            total += v.calls + v.puts;
286        }
287        total.is_finite().then_some(profile)
288    }
289
290    /// Distribute finite, nonnegative-volume bars over their high-low ranges.
291    /// Invalid bars are ignored. Returns no profile for an empty or unrepresentable grid.
292    pub fn from_bars(bars: &[Bar], config: VolumeProfileConfig) -> Option<Self> {
293        Self::of(bars, config.rows(), config.row_size())
294    }
295
296    /// Group the executions of one span into a row per traded price step, from the lowest
297    /// traded row to the highest, so untraded rows between them hold zero. `step` is the
298    /// footprint's [`price_step`](super::Footprint::price_step), `tick_size` its
299    /// [`tick_size`](super::Footprint::tick_size), and `complete` whether executions are held
300    /// from the start of the span. A row spans from half a tick below its first tick to half
301    /// a tick below the next row's, so a trade at 100 with a tick of 1 spans 99.5 to 100.5.
302    /// Buys occupy [`Self::up`] and sells [`Self::down`]; unclassified volume is in
303    /// [`Self::volume`] only. Returns no profile without executed volume, or for more than
304    /// 10,000 rows.
305    pub fn from_executions(
306        bar: &FootprintBar,
307        step: f64,
308        tick_size: f64,
309        complete: bool,
310    ) -> Option<Self> {
311        let (&first, &last) = (bar.levels.keys().next()?, bar.levels.keys().next_back()?);
312        let count = last as i128 - first as i128 + 1;
313        let half_tick = tick_size * 0.5;
314        let (low, high) = (
315            first as f64 * step - half_tick,
316            (last as f64 + 1.0) * step - half_tick,
317        );
318        if bar.volume() <= 0.0
319            || !step.is_finite()
320            || step <= 0.0
321            || !tick_size.is_finite()
322            || !(1..=10_000).contains(&count)
323            || !low.is_finite()
324            || !high.is_finite()
325        {
326            return None;
327        }
328        let mut profile = Self {
329            low,
330            high,
331            row_height: step,
332            fixed_height: true,
333            flat_price: None,
334            volume: vec![0.0; count as usize],
335            up: vec![0.0; count as usize],
336            down: vec![0.0; count as usize],
337            executions: Some(Executions {
338                first_level: first,
339                bar: bar.clone(),
340                complete,
341            }),
342            options: false,
343        };
344        for (&level, row) in &bar.levels {
345            let i = (level - first) as usize;
346            profile.volume[i] = row.volume();
347            profile.up[i] = row.ask;
348            profile.down[i] = row.bid;
349        }
350        Some(profile)
351    }
352
353    /// Whether the volumes are estimated from bars, executed or option turnover.
354    pub fn source(&self) -> ProfileSource {
355        if self.options {
356            return ProfileSource::Options;
357        }
358        self.executions
359            .as_ref()
360            .map_or(ProfileSource::Estimated, |e| ProfileSource::Executions {
361                complete: e.complete,
362            })
363    }
364
365    /// Bottom edge of the first row.
366    pub fn low(&self) -> f64 {
367        self.low
368    }
369    /// Top edge of the last row.
370    pub fn high(&self) -> f64 {
371        self.low + self.volume.len() as f64 * self.row_height
372    }
373    /// Price height of a row.
374    pub fn row_height(&self) -> f64 {
375        self.row_height
376    }
377    /// Total volume per row, lowest price first.
378    pub fn volume(&self) -> &[f64] {
379        &self.volume
380    }
381    /// Up-bar volume per row (unchanged closes count as up), or calls for options.
382    pub fn up(&self) -> &[f64] {
383        &self.up
384    }
385    /// Down-bar volume per row, or puts for options.
386    pub fn down(&self) -> &[f64] {
387        &self.down
388    }
389
390    /// Spreads each bar's volume evenly over its high-low range across
391    /// `rows` equal price rows (a bar without range puts it in the row of its
392    /// price). A flat span uses one narrow row; `None` for an unrepresentable grid.
393    pub(crate) fn of(bars: &[Bar], rows: usize, row_size: Option<f64>) -> Option<Self> {
394        let low = bars
395            .iter()
396            .filter(|b| valid_bar(b))
397            .map(|b| b.low)
398            .fold(f64::INFINITY, f64::min);
399        let high = bars
400            .iter()
401            .filter(|b| valid_bar(b))
402            .map(|b| b.high)
403            .fold(f64::NEG_INFINITY, f64::max);
404        if rows == 0 || rows > 10_000 {
405            return None;
406        }
407        let flat_price = (row_size.is_none() && low == high).then_some(low);
408        let (low, high, row_height, rows) = if let Some(height) = row_size {
409            let (low, high, rows) = fixed_grid(low, high, height)?;
410            (low, high, height, rows)
411        } else if low == high && low.is_finite() {
412            let height = (low.abs() * 1e-6).max(1e-6);
413            (low - height * 0.5, high + height * 0.5, height, 1)
414        } else {
415            (low, high, (high - low) / rows as f64, rows)
416        };
417        if !low.is_finite() || !high.is_finite() || !row_height.is_finite() || row_height <= 0.0 {
418            return None;
419        }
420        let mut profile = Self {
421            low,
422            high,
423            row_height,
424            fixed_height: row_size.is_some(),
425            flat_price,
426            volume: vec![0.0; rows],
427            up: vec![0.0; rows],
428            down: vec![0.0; rows],
429            executions: None,
430            options: false,
431        };
432        bars.iter().for_each(|bar| profile.add(bar));
433        Some(profile)
434    }
435
436    /// Replaces each of `bars` by the sorted `finer` bars opening inside it, up to the next
437    /// chart bar's open or `end` after the last. Bars without finer inputs keep their estimate.
438    /// Returns the profile and the number of chart bars with finer inputs.
439    #[cfg(any(feature = "studies", feature = "drawings"))]
440    pub(crate) fn of_finer(
441        bars: &[Bar],
442        end: chrono::DateTime<chrono::Utc>,
443        finer: &[Bar],
444        rows: usize,
445        row_size: Option<f64>,
446    ) -> (Option<Self>, usize) {
447        let mut inputs = Vec::new();
448        let mut covered = 0;
449        for (i, bar) in bars.iter().enumerate() {
450            let next = bars.get(i + 1).map_or(end, |next| next.time);
451            let inside = &finer[finer.partition_point(|f| f.time < bar.time)
452                ..finer.partition_point(|f| f.time < next)];
453            if inside.is_empty() {
454                inputs.push(bar.clone());
455            } else {
456                covered += 1;
457                inputs.extend_from_slice(inside);
458            }
459        }
460        (Self::of(&inputs, rows, row_size), covered)
461    }
462
463    /// Add one completed bar without redistributing earlier bars. Fixed-height grids grow
464    /// at the edges; equal-row grids return `None` when the bar moves a boundary.
465    /// A changed forming bar must be added to the profile before that bar, not added twice.
466    pub fn with_bar(&self, bar: &Bar) -> Option<Self> {
467        let mut profile = self.clone();
468        if !valid_bar(bar) {
469            return Some(profile);
470        }
471        if self.fixed_height {
472            let (low, high, _) = fixed_grid(bar.low, bar.high, self.row_height)?;
473            let low = low.min(self.low);
474            let high = high.max(self.high);
475            let count = ((high - low) / self.row_height).round();
476            if count > 10_000.0 {
477                return None;
478            }
479            let prepend = ((self.low - low) / self.row_height).round() as usize;
480            for rows in [&mut profile.volume, &mut profile.up, &mut profile.down] {
481                let old_len = rows.len();
482                rows.resize(count as usize, 0.0);
483                if prepend > 0 {
484                    rows.copy_within(..old_len, prepend);
485                    rows[..prepend].fill(0.0);
486                }
487            }
488            profile.low = low;
489            profile.high = high;
490        } else if self
491            .flat_price
492            .is_some_and(|price| bar.low != price || bar.high != price)
493            || bar.low < self.low
494            || bar.high > self.high
495        {
496            return None;
497        }
498        profile.add(bar);
499        Some(profile)
500    }
501
502    /// Adds the volume of `bar` to the rows that its high-low range covers,
503    /// in proportion to the overlap.
504    fn add(&mut self, bar: &Bar) {
505        if !valid_bar(bar) {
506            return;
507        }
508        let (low, row_height) = (self.low, self.row_height);
509        let direction = if bar.close >= bar.open {
510            &mut self.up
511        } else {
512            &mut self.down
513        };
514        let top_row = self.volume.len() - 1;
515        let row_of = |price: f64| (grid_floor((price - low) / row_height) as usize).min(top_row);
516        if bar.high > bar.low {
517            let (first, last) = (row_of(bar.low), row_of(bar.high));
518            for (row, row_volume) in self
519                .volume
520                .iter_mut()
521                .enumerate()
522                .take(last + 1)
523                .skip(first)
524            {
525                let row_low = low + row as f64 * row_height;
526                let overlap = bar.high.min(row_low + row_height) - bar.low.max(row_low);
527                let amount = bar.volume * (overlap.max(0.0) / (bar.high - bar.low));
528                *row_volume += amount;
529                direction[row] += amount;
530            }
531        } else {
532            self.volume[row_of(bar.low)] += bar.volume;
533            direction[row_of(bar.low)] += bar.volume;
534        }
535    }
536
537    /// The row with the most volume (point of control), and the lowest and
538    /// highest row of the value area, which holds `share` of the volume;
539    /// `None` when no volume traded. POC ties take the lower row; value-area
540    /// ties add one row on each side.
541    pub fn poc_and_value_area(&self, share: f64) -> Option<(usize, (usize, usize))> {
542        if !share.is_finite() || share <= 0.0 || share > 1.0 {
543            return None;
544        }
545        let volume = &self.volume;
546        let total: f64 = volume.iter().sum();
547        if !total.is_finite() || total <= 0.0 {
548            return None;
549        }
550        if let Some(e) = &self.executions {
551            let row = |level: i64| (level - e.first_level) as usize;
552            let (low, high) = e.bar.value_area(share)?;
553            return Some((row(e.bar.poc()?), (row(low), row(high))));
554        }
555        let poc = (0..volume.len())
556            .max_by(|&a, &b| volume[a].total_cmp(&volume[b]).then_with(|| b.cmp(&a)))?;
557        Some((poc, value_area(volume, poc, total * share)))
558    }
559}
560
561// Decimal tick heights can put an exact boundary one rounding step below its integer row.
562fn grid_floor(value: f64) -> f64 {
563    let nearest = value.round();
564    if (value - nearest).abs() <= 4.0 * f64::EPSILON * value.abs() {
565        nearest
566    } else {
567        value.floor()
568    }
569}
570
571/// A zero-aligned grid, including the row of a zero-range bar on an edge.
572fn fixed_grid(low: f64, high: f64, height: f64) -> Option<(f64, f64, usize)> {
573    if !height.is_finite() || height <= 0.0 {
574        return None;
575    }
576    let first = grid_floor(low / height);
577    let last = grid_floor(high / height);
578    let count = last - first + 1.0;
579    if !first.is_finite()
580        || !last.is_finite()
581        || first.abs() >= 2_f64.powi(52)
582        || last.abs() >= 2_f64.powi(52)
583        || !(1.0..=10_000.0).contains(&count)
584    {
585        return None;
586    }
587    let bounds = (first * height, (last + 1.0) * height);
588    (bounds.0.is_finite() && bounds.1.is_finite() && bounds.1 > bounds.0).then_some((
589        bounds.0,
590        bounds.1,
591        count as usize,
592    ))
593}
594
595/// Rows around the POC that hold `target` volume. Market Profile rule: grow
596/// toward whichever of the next two rows above or the next two rows below
597/// holds more volume, one row at a time. Equal sums add one row on each side.
598fn value_area(volume: &[f64], poc: usize, target: f64) -> (usize, usize) {
599    let (mut low, mut high, mut sum) = (poc, poc, volume[poc]);
600    while sum < target && (low > 0 || high + 1 < volume.len()) {
601        let above: f64 = volume[high + 1..].iter().take(2).sum();
602        let below: f64 = volume[..low].iter().rev().take(2).sum();
603        if low > 0 && high + 1 < volume.len() && above == below {
604            low -= 1;
605            high += 1;
606            sum += volume[low] + volume[high];
607            continue;
608        }
609        let up = high + 1 < volume.len() && (low == 0 || above >= below);
610        for _ in 0..2 {
611            if up && high + 1 < volume.len() {
612                high += 1;
613                sum += volume[high];
614            } else if !up && low > 0 {
615                low -= 1;
616                sum += volume[low];
617            }
618            if sum >= target {
619                break;
620            }
621        }
622    }
623    (low, high)
624}
625
626#[cfg(test)]
627mod tests {
628    use super::*;
629    fn sample_bars() -> Vec<Bar> {
630        [
631            (10.0, 12.0, 9.0, 11.0, 100.0),
632            (11.0, 13.0, 10.0, 12.0, 200.0),
633            (12.0, 12.5, 11.0, 11.0, 150.0),
634            (11.5, 14.0, 11.0, 13.5, 300.0),
635            (13.5, 15.0, 13.0, 14.0, 250.0),
636            (14.0, 14.5, 12.0, 12.5, 400.0),
637            (13.0, 13.0, 13.0, 13.0, 50.0),
638        ]
639        .into_iter()
640        .enumerate()
641        .map(|(i, (o, h, l, c, v))| {
642            let time = chrono::DateTime::from_timestamp(i as i64 * 60, 0).unwrap();
643            Bar::new(time, o, h, l, c, v)
644        })
645        .collect()
646    }
647    fn assert_close(got: f64, want: f64) {
648        assert!((got - want).abs() < 1e-9, "{got} != {want}");
649    }
650    /// Reference: rows of 1.5 from 9.0, each bar's volume split by how much of
651    /// its range falls in the row, the zero-range bar whole in the row of 13.0.
652    /// For 70% the value area takes the two rows below the POC (433.3) over
653    /// the one row above it (397.5); comparing single rows would take the row
654    /// above.
655    #[test]
656    fn volume_profile_matches_reference() {
657        let profile = VolumeProfile::of(&sample_bars(), 4, None).unwrap();
658        for (price, representable) in [(13.0, true), (f64::MAX, false), (-f64::MAX, false)] {
659            let flat = Bar::new(
660                chrono::DateTime::UNIX_EPOCH,
661                price,
662                price,
663                price,
664                price,
665                50.0,
666            );
667            let flat_profile = VolumeProfile::of(&[flat], 4, None);
668            assert_eq!(flat_profile.is_some(), representable, "flat price {price}");
669            if let Some(flat_profile) = flat_profile {
670                assert_eq!(flat_profile.volume(), [50.0]);
671                assert_eq!(flat_profile.up(), [50.0]);
672            }
673        }
674        assert_close(profile.low, 9.0);
675        assert_close(profile.row_height, 1.5);
676        for (got, want) in
677            profile
678                .volume
679                .iter()
680                .zip([83.33333333333334, 350.0, 619.1666666666667, 397.5])
681        {
682            assert_close(*got, want);
683        }
684        // The down bars are 150 over 11..12.5 and 400 over 12..14.5.
685        for (row, (up, down)) in [
686            (250.0 / 3.0, 0.0),
687            (250.0, 100.0),
688            (3950.0 / 12.0, 290.0),
689            (237.5, 160.0),
690        ]
691        .into_iter()
692        .enumerate()
693        {
694            assert_close(profile.up[row], up);
695            assert_close(profile.down[row], down);
696            assert_close(profile.up[row] + profile.down[row], profile.volume[row]);
697        }
698        for (share, value_area) in [(0.5, (1, 2)), (0.7, (0, 2)), (1.0, (0, 3))] {
699            assert_eq!(
700                profile.poc_and_value_area(share),
701                Some((2, value_area)),
702                "share {share}"
703            );
704        }
705    }
706
707    /// Equal-volume POCs take the lowest row; tied expansion adds a row on each side. Executed
708    /// rows are centred on their price: the trade at 100 with a tick of 1 spans 99.5 to 100.5.
709    #[test]
710    fn estimated_and_executed_profiles_agree_on_ties() {
711        use crate::data::Footprint;
712        use crate::model::{BarClock, OrderSide, Tick, Timeframe};
713        let time = chrono::DateTime::UNIX_EPOCH;
714        // One bar of 24 over 24 rows, and five unit-high bars holding 1, 1, 2, 1 and 1.
715        let single = vec![Bar::new(time, 100.0, 124.0, 100.0, 124.0, 24.0)];
716        let peak_rows = vec![1.0, 1.0, 2.0, 1.0, 1.0];
717        let peak: Vec<_> = peak_rows
718            .iter()
719            .enumerate()
720            .map(|(i, &volume)| {
721                let low = 100.0 + i as f64;
722                Bar::new(time, low, low + 1.0, low, low + 1.0, volume)
723            })
724            .collect();
725        for (bars, volumes, share, expected) in [
726            (single, vec![1.0; 24], 0.7, (0, (0, 16))),
727            (peak, peak_rows, 0.6, (2, (1, 3))),
728        ] {
729            let estimated = VolumeProfile::of(&bars, volumes.len(), None).unwrap();
730            let mut flow = Footprint::new(BarClock::new(Timeframe::Min1), 1.0, 1).unwrap();
731            for (i, &volume) in volumes.iter().enumerate() {
732                flow.push(&Tick::new(
733                    time,
734                    100.0 + i as f64,
735                    volume,
736                    Some(OrderSide::Buy),
737                ))
738                .unwrap();
739            }
740            let executed =
741                VolumeProfile::from_executions(flow.bar_at(time).unwrap(), 1.0, 1.0, true).unwrap();
742            assert_eq!(estimated.volume(), executed.volume());
743            assert_eq!(
744                (executed.low(), executed.high()),
745                (99.5, 99.5 + volumes.len() as f64)
746            );
747            // 70% of 24 needs 17 unit rows. 60% of 6 needs the POC's 2 plus both tied 1s.
748            assert_eq!(
749                (
750                    estimated.poc_and_value_area(share),
751                    executed.poc_and_value_area(share)
752                ),
753                (Some(expected), Some(expected)),
754                "rows {volumes:?}, share {share}"
755            );
756        }
757    }
758
759    /// A flat span's padding is not its traded range: even a bar inside it needs a new grid.
760    #[test]
761    fn with_bar_rebuilds_when_a_flat_span_gains_range() {
762        let time = chrono::DateTime::UNIX_EPOCH;
763        for price in [0.000_02, 0.5, 2.0, 100.0] {
764            for rows in [1, 24] {
765                let flat = Bar::new(time, price, price, price, price, 100.0);
766                let profile = VolumeProfile::of(std::slice::from_ref(&flat), rows, None).unwrap();
767                assert_eq!(
768                    profile.with_bar(&flat),
769                    VolumeProfile::of(&[flat.clone(), flat.clone()], rows, None)
770                );
771                let range = (price.abs() * 1e-6).max(1e-6) * 0.4;
772                let next = Bar::new(time, price, price + range, price - range, price, 100.0);
773                assert!(next.low > profile.low() && next.high < profile.high());
774                assert!(
775                    profile.with_bar(&next).is_none(),
776                    "price {price}, rows {rows}"
777                );
778                // A different flat price also changes the span, even by one representable step.
779                let moved = Bar::new(time, price, price.next_up(), price, price, 100.0);
780                assert!(profile.with_bar(&moved).is_none());
781                let bars = [flat.clone(), next.clone()];
782                let rebuilt = VolumeProfile::of(&bars, rows, None).unwrap();
783                assert_eq!(rebuilt.volume().len(), rows);
784                assert_eq!((rebuilt.low(), rebuilt.high()), (next.low, next.high));
785                assert_close(rebuilt.row_height(), 2.0 * range / rows as f64);
786                // The flat bar lies in the middle row; the second bar spreads 100 evenly over all rows.
787                assert!((rebuilt.volume()[rows / 2] - (100.0 + 100.0 / rows as f64)).abs() < 1e-6);
788                let third = Bar::new(
789                    time,
790                    price,
791                    price + range / 2.0,
792                    price - range / 2.0,
793                    price,
794                    100.0,
795                );
796                let updated = rebuilt.with_bar(&third).unwrap();
797                assert_eq!(
798                    Some(updated.clone()),
799                    VolumeProfile::of(&[flat, next, third], rows, None)
800                );
801                assert!(
802                    (updated.volume()[rows / 2] - (100.0 + if rows == 1 { 200.0 } else { 12.5 }))
803                        .abs()
804                        < 1e-6
805                );
806            }
807        }
808    }
809
810    /// Existing rows keep their prices and volume when a fixed grid grows at either edge.
811    #[test]
812    fn fixed_profile_grows_without_rebinning() {
813        let time = chrono::DateTime::UNIX_EPOCH;
814        let first = Bar::new(time, -0.5, 1.0, -1.0, 0.5, 20.0);
815        let mut bars = vec![first];
816        let mut profile = VolumeProfile::of(&bars, 24, Some(1.0)).unwrap();
817        assert_eq!(profile.volume, [10.0, 10.0, 0.0]);
818        for (low, high, volume, expected) in [
819            (-2.0, -1.0, 6.0, vec![6.0, 10.0, 10.0, 0.0]),
820            (1.0, 2.0, 8.0, vec![6.0, 10.0, 10.0, 8.0, 0.0]),
821            (2.0, 2.0, 3.0, vec![6.0, 10.0, 10.0, 8.0, 3.0]),
822        ] {
823            let bar = Bar::new(time, low, high, low, high, volume);
824            // Extension must succeed: a fallback rebuild would conceal the regression.
825            profile = profile.with_bar(&bar).expect("extend fixed grid");
826            bars.push(bar);
827            assert_eq!(profile.volume, expected);
828            assert_eq!(
829                Some(profile.clone()),
830                VolumeProfile::of(&bars, 24, Some(1.0))
831            );
832        }
833        assert!(VolumeProfile::of(&bars, 24, Some(1e-12)).is_none());
834        let boundary = Bar::new(time, 0.3, 0.3, 0.3, 0.3, 7.0);
835        let decimal = VolumeProfile::of(&[boundary], 24, Some(0.1)).unwrap();
836        assert_close(decimal.low(), 0.3);
837        assert_eq!(decimal.volume(), [7.0]);
838    }
839
840    /// Four-unit rows centered at 100, 104 and 108 collect [98,102), [102,106)
841    /// and [106,110). Totals are 90+50=140, 60+140=200 and 75+25=100.
842    #[test]
843    fn options_profile_groups_strikes_and_scales_by_largest_total() {
844        use crate::data::OptionsVolume;
845        let data: Vec<_> = [
846            (99.0, 20.0, 10.0),
847            (100.0, 10.0, 5.0),
848            (100.0, 20.0, 15.0),
849            (101.0, 40.0, 20.0),
850            (102.0, 10.0, 40.0),
851            (103.0, 20.0, 50.0),
852            (104.0, 30.0, 50.0),
853            (107.0, 75.0, 25.0),
854        ]
855        .into_iter()
856        .map(|(strike, calls, puts)| OptionsVolume {
857            strike,
858            calls,
859            puts,
860        })
861        .collect();
862        let profile = VolumeProfile::from_options(&data, 4.0).unwrap();
863        assert_eq!(profile.source(), ProfileSource::Options);
864        assert_eq!(
865            (profile.low(), profile.high(), profile.row_height()),
866            (98.0, 110.0, 4.0)
867        );
868        let max = profile.volume().iter().copied().fold(0.0, f64::max);
869        for (row, (calls, puts, share)) in
870            [(90.0, 50.0, 0.7), (60.0, 140.0, 1.0), (75.0, 25.0, 0.5)]
871                .into_iter()
872                .enumerate()
873        {
874            assert_close(profile.up()[row], calls);
875            assert_close(profile.down()[row], puts);
876            assert_close(profile.volume()[row] / max, share);
877        }
878        #[cfg(feature = "widget")]
879        for right_aligned in [false, true] {
880            for direction in [-1.0, 1.0] {
881                let config = VolumeProfileConfig {
882                    display: ProfileDisplay::UpDown,
883                    ..VolumeProfileConfig::default()
884                };
885                let rgba = |c: [u8; 4]| {
886                    egui::Color32::from_rgba_unmultiplied(c[0], c[1], c[2], c[3])
887                        .gamma_multiply(0.35)
888                };
889                let mut output = egui::Context::default().run_ui(egui::RawInput::default(), |ui| {
890                    let painter = egui::Painter::new(
891                        ui.ctx().clone(),
892                        egui::LayerId::background(),
893                        egui::Rect::EVERYTHING,
894                    );
895                    crate::painting::volume_profile::paint_profile(
896                        &painter,
897                        &profile,
898                        config,
899                        [0, 0, 255, 255],
900                        100.0..300.0,
901                        crate::painting::volume_profile::ProfileStyle::Study { right_aligned },
902                        &|price| price as f32 * direction,
903                    );
904                });
905                let shapes = std::mem::take(&mut output.shapes);
906                output.drop_without_applying_deltas();
907                let rows: Vec<_> = shapes
908                    .iter()
909                    .filter_map(|shape| match &shape.shape {
910                        egui::Shape::Rect(r)
911                            if r.fill == rgba(config.up_color)
912                                || r.fill == rgba(config.down_color) =>
913                        {
914                            Some(r)
915                        }
916                        _ => None,
917                    })
918                    .collect();
919                assert_eq!(rows.len(), 6);
920                // Of 180 available points, the rows use 126, 180 and 90; each segment
921                // uses its own contract volume divided by the largest combined total, 200.
922                for (row, (call_width, put_width, strike)) in [
923                    (81.0, 45.0, 100.0),
924                    (54.0, 126.0, 104.0),
925                    (67.5, 22.5, 108.0),
926                ]
927                .into_iter()
928                .enumerate()
929                {
930                    let (calls, puts) = (rows[2 * row], rows[2 * row + 1]);
931                    assert!((calls.rect.width() - call_width).abs() < 1e-4);
932                    assert!((puts.rect.width() - put_width).abs() < 1e-4);
933                    assert!((calls.rect.top() - (strike * direction - 2.0)).abs() < 1e-4);
934                    assert_eq!(calls.fill, rgba(config.up_color));
935                    assert_eq!(puts.fill, rgba(config.down_color));
936                    if right_aligned {
937                        assert!((calls.rect.right() - 300.0).abs() < 1e-4);
938                        assert!((puts.rect.right() - calls.rect.left()).abs() < 1e-4);
939                    } else {
940                        assert!((calls.rect.left() - 100.0).abs() < 1e-4);
941                        assert!((puts.rect.left() - calls.rect.right()).abs() < 1e-4);
942                    }
943                }
944            }
945        }
946        // Exact and decimal strikes stay at row centers; half-row boundaries go up.
947        for (strike, size, center) in [
948            (100.0, 1.0, 100.0),
949            (0.3, 0.1, 0.3),
950            (0.35, 0.1, 0.4),
951            (-0.15, 0.1, -0.1),
952        ] {
953            let point = OptionsVolume {
954                strike,
955                calls: 3.0,
956                puts: 7.0,
957            };
958            let p = VolumeProfile::from_options(&[point], size).unwrap();
959            assert_close(p.low() + p.row_height() / 2.0, center);
960            assert_eq!(p.volume(), [10.0]);
961        }
962        for bad in [f64::NAN, f64::INFINITY, -1.0] {
963            let invalid = OptionsVolume {
964                strike: 100.0,
965                calls: bad,
966                puts: 0.0,
967            };
968            assert!(VolumeProfile::from_options(&[invalid], 1.0).is_none());
969            assert!(VolumeProfile::from_options(&data, bad).is_none());
970        }
971        assert!(VolumeProfile::from_options(&data, 0.0).is_none());
972        assert!(VolumeProfile::from_options(&data, 1e-12).is_none());
973        assert!(VolumeProfile::from_options(&[], 4.0).is_none());
974        assert!(
975            VolumeProfile::from_options(
976                &[-1e308, 1e308].map(|strike| OptionsVolume {
977                    strike,
978                    calls: 1.0,
979                    puts: 0.0,
980                }),
981                1e308,
982            )
983            .is_none()
984        );
985        assert!(
986            VolumeProfile::from_options(
987                &[OptionsVolume {
988                    strike: 100.0,
989                    calls: f64::MAX,
990                    puts: f64::MAX
991                }],
992                1.0
993            )
994            .is_none()
995        );
996    }
997}