egui_charts/studies/builtin/
options_volume_profile.rs1use 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#[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 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 #[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 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}