Skip to main content

egui_charts/studies/builtin/
options_volume_profile.rs

1//! Options turnover supplied by a host, independent of the underlying's bar volume.
2
3use crate::data::{Datafeed, OptionsVolume, OptionsVolumeQuery, OptionsVolumeSource};
4use crate::data::{ProfileDisplay, StudyProfile, VolumeProfile, VolumeProfileConfig};
5use crate::model::{Bar, Instrument};
6use crate::studies::{Indicator, IndicatorValues};
7use egui::Color32;
8use std::cell::RefCell;
9use std::ops::Range;
10use std::sync::Arc;
11
12#[derive(Clone, Default)]
13struct CachedProfile {
14    values: Arc<[OptionsVolume]>,
15    profile: Option<Arc<VolumeProfile>>,
16}
17
18/// Call and put turnover at strike prices on the price pane, scaled to the largest row total.
19/// The host chooses an underlying and inclusive expiry range and supplies complete snapshots.
20/// Clones share the subscription; dropping its last clone makes the feed's sink inactive.
21#[derive(Clone)]
22pub struct OptionsVolumeProfile {
23    source: OptionsVolumeSource,
24    symbol: String,
25    matching_instrument: bool,
26    config: VolumeProfileConfig,
27    cache: RefCell<CachedProfile>,
28    values: IndicatorValues,
29    colors: Vec<Color32>,
30    visible: bool,
31}
32
33impl OptionsVolumeProfile {
34    /// Subscribe through `feed`, outside egui closures, and wake the host on each snapshot.
35    /// Pass a repaint callback, for example `move || ctx.request_repaint()` with a cloned
36    /// context. Rows are centered on multiples of `row_size`, in underlying price units.
37    /// Returns `None` for an empty symbol, reversed expiry range or nonpositive/nonfinite size.
38    /// Add the result with `Chart::add_indicator`. The query is host-owned: recreate the
39    /// study when changing the underlying, expiry range or row size. A chart with metadata
40    /// for another symbol hides the profile until its underlying is shown again.
41    ///
42    /// ```
43    /// # use egui_charts::data::{Datafeed, HistoryRequest, HistoryPage, Reply};
44    /// # struct Feed;
45    /// # impl Datafeed for Feed {
46    /// #     fn history(&self, _: HistoryRequest, reply: Reply<HistoryPage>) {
47    /// #         reply.send(Ok(HistoryPage::default()));
48    /// #     }
49    /// # }
50    /// use egui_charts::data::OptionsVolumeQuery;
51    /// use egui_charts::studies::OptionsVolumeProfile;
52    /// let expiry = chrono::NaiveDate::from_ymd_opt(2026, 12, 18).unwrap();
53    /// let query = OptionsVolumeQuery { symbol: "SAMPLE".into(), expiries: expiry..=expiry };
54    /// let profile = OptionsVolumeProfile::new(&Feed, query, 5.0, || {}).unwrap();
55    /// ```
56    pub fn new(
57        feed: &dyn Datafeed,
58        query: OptionsVolumeQuery,
59        row_size: f64,
60        wake: impl Fn() + Send + Sync + 'static,
61    ) -> Option<Self> {
62        if query.symbol.is_empty() || query.expiries.is_empty() {
63            return None;
64        }
65        let mut config = VolumeProfileConfig::default().with_row_size(Some(row_size))?;
66        config.display = ProfileDisplay::UpDown;
67        let (source, sink) = OptionsVolumeSource::new(wake);
68        let symbol = query.symbol.clone();
69        feed.options_volume(query, sink);
70        Some(Self {
71            source,
72            symbol,
73            matching_instrument: true,
74            config,
75            cache: RefCell::new(CachedProfile::default()),
76            values: IndicatorValues::new(),
77            colors: vec![
78                Color32::from_rgba_unmultiplied(38, 166, 154, 180),
79                Color32::from_rgba_unmultiplied(239, 83, 80, 180),
80            ],
81            visible: true,
82        })
83    }
84}
85
86impl Indicator for OptionsVolumeProfile {
87    fn name(&self) -> &str {
88        "Options Volume Profile"
89    }
90    fn desc(&self) -> &str {
91        "Call and put contract volume by strike, summed over the host's chosen expiries"
92    }
93    fn calculate(&mut self, data: &[Bar]) {
94        self.values = IndicatorValues::missing(data.len());
95    }
96    fn values(&self) -> &IndicatorValues {
97        &self.values
98    }
99    fn set_instrument(&mut self, instrument: Option<&Instrument>) -> bool {
100        self.matching_instrument = instrument.is_none_or(|i| i.symbol == self.symbol);
101        false
102    }
103    fn volume_profiles(&self, visible: Range<usize>) -> Vec<StudyProfile> {
104        if !self.visible || !self.matching_instrument || visible.is_empty() {
105            return Vec::new();
106        }
107        let values = self.source.values();
108        let mut cache = self.cache.borrow_mut();
109        if !Arc::ptr_eq(&values, &cache.values) {
110            cache.profile = VolumeProfile::from_options(
111                &values,
112                self.config.row_size().expect("fixed options row height"),
113            )
114            .map(Arc::new);
115            cache.values = values;
116        }
117        let Some(profile) = cache.profile.clone() else {
118            return Vec::new();
119        };
120        let mut config = self.config;
121        config.up_color = self.colors[0].to_srgba_unmultiplied();
122        config.down_color = self.colors[1].to_srgba_unmultiplied();
123        vec![StudyProfile {
124            bars: visible,
125            profile,
126            config,
127            color: self.colors[0].to_srgba_unmultiplied(),
128            right_aligned: true,
129        }]
130    }
131    fn colors(&self) -> Vec<Color32> {
132        self.colors.clone()
133    }
134    fn set_colors(&mut self, colors: Vec<Color32>) {
135        for (stored, color) in self.colors.iter_mut().zip(colors) {
136            *stored = color;
137        }
138    }
139    fn line_names(&self) -> Vec<String> {
140        Vec::new()
141    }
142    fn is_visible(&self) -> bool {
143        self.visible
144    }
145    fn set_visible(&mut self, visible: bool) {
146        self.visible = visible;
147    }
148    fn clone_box(&self) -> Box<dyn Indicator> {
149        Box::new(self.clone())
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    use crate::data::{HistoryPage, HistoryRequest, OptionsVolumeSink, Reply};
157    use std::sync::Mutex;
158    use std::sync::atomic::{AtomicUsize, Ordering};
159
160    #[derive(Default)]
161    struct Feed(Mutex<Option<(OptionsVolumeQuery, OptionsVolumeSink)>>);
162
163    impl Datafeed for Feed {
164        fn history(&self, _: HistoryRequest, reply: Reply<HistoryPage>) {
165            reply.send(Ok(HistoryPage::default()));
166        }
167        fn options_volume(&self, query: OptionsVolumeQuery, sink: OptionsVolumeSink) {
168            *self.0.lock().unwrap() = Some((query, sink));
169        }
170    }
171
172    /// Snapshot replacement must work without a new underlying bar, through clones, and old
173    /// sinks must stop once their last profile is gone. Empty snapshots remove the geometry.
174    #[test]
175    fn options_snapshots_replace_rows_and_end_with_the_last_consumer() {
176        let feed = Feed::default();
177        let start = chrono::NaiveDate::from_ymd_opt(2026, 12, 18).unwrap();
178        let end = chrono::NaiveDate::from_ymd_opt(2027, 1, 15).unwrap();
179        for end in [start, end] {
180            let query = OptionsVolumeQuery {
181                symbol: "SAMPLE".into(),
182                expiries: start..=end,
183            };
184            let wakes = Arc::new(AtomicUsize::new(0));
185            let counter = Arc::clone(&wakes);
186            let mut study = OptionsVolumeProfile::new(&feed, query.clone(), 4.0, move || {
187                counter.fetch_add(1, Ordering::Relaxed);
188            })
189            .unwrap();
190            let (received, sink) = feed.0.lock().unwrap().clone().unwrap();
191            assert_eq!(received, query);
192            assert!(study.volume_profiles(0..10).is_empty());
193            let clone = study.clone_box();
194            for (calls, puts) in [(90.0, 50.0), (30.0, 70.0)] {
195                // Intermediate snapshots coalesce before painting; totals are replaced, never added.
196                sink.push(vec![OptionsVolume {
197                    strike: 100.0,
198                    calls: 999.0,
199                    puts: 999.0,
200                }]);
201                sink.push(vec![OptionsVolume {
202                    strike: 100.0,
203                    calls,
204                    puts,
205                }]);
206                for consumer in [&study as &dyn Indicator, &*clone] {
207                    let plot = consumer.volume_profiles(2..7).remove(0);
208                    assert_eq!(plot.bars, 2..7);
209                    assert!(plot.right_aligned);
210                    assert_eq!(plot.profile.up(), [calls]);
211                    assert_eq!(plot.profile.down(), [puts]);
212                    assert_eq!(plot.profile.volume(), [calls + puts]);
213                }
214            }
215            for (symbol, shown) in [("SAMPLE", true), ("OTHER", false), ("SAMPLE", true)] {
216                study.set_instrument(Some(&Instrument::new(symbol)));
217                assert_eq!(study.volume_profiles(0..10).is_empty(), !shown, "{symbol}");
218            }
219            sink.push(Vec::new());
220            assert!(clone.volume_profiles(0..10).is_empty());
221            drop(study);
222            assert!(sink.is_active());
223            drop(clone);
224            assert!(!sink.is_active());
225            let before = wakes.load(Ordering::Relaxed);
226            sink.push(Vec::new());
227            assert_eq!(wakes.load(Ordering::Relaxed), before);
228        }
229    }
230}