Skip to main content

egui_charts/studies/builtin/
commodity_selection.rs

1use super::clamp_param;
2use crate::model::Bar;
3use crate::studies::palette;
4use crate::studies::stepper::{Stepper, update_window};
5use crate::studies::{ADX, ATR, Indicator, IndicatorValue, IndicatorValues};
6use egui::Color32;
7
8/// Commodity Selection Index (CSI), J. Welles Wilder, New Concepts in Technical Trading Systems (1978).
9///
10/// `CSI = ADXR * ATR(n) * (V / sqrt(M)) * (1 / (150 + C)) * 100`, where `ADXR = (ADX + ADX n bars
11/// earlier) / 2`, `V` is the money value of a one-unit price move (the point value), `M` the margin
12/// and `C` the commission, both in money. With the defaults (V = 1, M = 1, C = 0) values are not
13/// comparable across instruments; set the contract with [`Self::with_point_value`],
14/// [`Self::with_margin`] and [`Self::with_commission`]. The first value is on bar `3n - 1`; earlier
15/// bars are `None`.
16#[derive(Clone)]
17pub struct CommoditySelectionIndex {
18    /// Period for calculation
19    period: usize,
20    point_value: f64,
21    margin: f64,
22    commission: f64,
23    values: IndicatorValues,
24    adx: ADX,
25    atr: ATR,
26    stepper: Stepper<(usize, std::collections::VecDeque<f64>)>,
27    color: Color32,
28    visible: bool,
29}
30
31impl CommoditySelectionIndex {
32    /// Create with a lookback of `period` bars, clamped to at least 1.
33    pub fn new(period: usize) -> Self {
34        Self {
35            period: period.max(1),
36            point_value: 1.0,
37            margin: 1.0,
38            commission: 0.0,
39            values: IndicatorValues::new(),
40            adx: ADX::new(period),
41            atr: ATR::new(period),
42            stepper: Stepper::default(),
43            color: palette::BROWN, // Brown
44            visible: true,
45        }
46    }
47
48    /// Set `V`, the money value of a one-unit price move, 1 by default, clamped into 0 to `f64::MAX`, NaN to 0.
49    pub fn with_point_value(mut self, point_value: f64) -> Self {
50        self.point_value = clamp_param(point_value, 0.0, f64::MAX);
51        self
52    }
53
54    /// Set `M`, the margin in money, 1 by default, clamped into `f64::EPSILON` to `f64::MAX`, NaN to `f64::EPSILON`.
55    pub fn with_margin(mut self, margin: f64) -> Self {
56        self.margin = clamp_param(margin, f64::EPSILON, f64::MAX);
57        self
58    }
59
60    /// Set `C`, the commission in money, 0 by default, clamped into 0 to `f64::MAX`, NaN to 0.
61    pub fn with_commission(mut self, commission: f64) -> Self {
62        self.commission = clamp_param(commission, 0.0, f64::MAX);
63        self
64    }
65
66    /// Set the plot color.
67    pub fn with_color(mut self, color: Color32) -> Self {
68        self.color = color;
69        self
70    }
71}
72
73impl Default for CommoditySelectionIndex {
74    fn default() -> Self {
75        Self::new(14)
76    }
77}
78
79impl Indicator for CommoditySelectionIndex {
80    crate::studies::inputs::study_inputs!(self;
81        period: length("Period", 1, usize::MAX),
82        point_value: number("Point value", 0.0, f64::MAX),
83        margin: number("Margin", f64::EPSILON, f64::MAX),
84        commission: number("Commission", 0.0, f64::MAX)
85    ; {});
86
87    fn name(&self) -> &str {
88        "CSI"
89    }
90
91    fn desc(&self) -> &str {
92        "Commodity Selection Index - Pos sizing metric"
93    }
94
95    fn calculate(&mut self, data: &[Bar]) {
96        self.update(data, 0);
97    }
98
99    fn update(&mut self, data: &[Bar], from: usize) {
100        if from == 0 {
101            self.adx = ADX::new(self.period);
102        }
103        self.adx.update(data, from);
104        if from == 0 {
105            self.atr = ATR::new(self.period);
106        }
107        self.atr.update(data, from);
108        self.stepper
109            .run(data, from, &mut self.values, |state, bar| {
110                let index = state.0;
111                state.0 += 1;
112                if ![bar.high, bar.low, bar.close].iter().all(|x| x.is_finite()) {
113                    return IndicatorValue::None;
114                }
115                let adx = self.adx.values().value(0, index).unwrap_or(f64::NAN);
116                state.1.push_back(adx);
117                let past = if state.1.len() > self.period {
118                    state.1.pop_front().unwrap()
119                } else {
120                    f64::NAN
121                };
122                let atr = self.atr.values().value(0, index);
123                match atr.filter(|_| adx.is_finite() && past.is_finite()) {
124                    Some(atr) => {
125                        let adxr = (adx + past) / 2.0;
126                        let scale =
127                            self.point_value / self.margin.sqrt() / (150.0 + self.commission)
128                                * 100.0;
129                        IndicatorValue::Single(adxr * atr * scale)
130                    }
131                    None => IndicatorValue::None,
132                }
133            });
134    }
135
136    fn is_incremental(&self) -> bool {
137        true
138    }
139
140    fn values(&self) -> &IndicatorValues {
141        &self.values
142    }
143
144    fn colors(&self) -> Vec<Color32> {
145        vec![self.color]
146    }
147
148    fn set_colors(&mut self, colors: Vec<Color32>) {
149        if !colors.is_empty() {
150            self.color = colors[0];
151        }
152    }
153
154    fn is_overlay(&self) -> bool {
155        false
156    }
157
158    fn is_visible(&self) -> bool {
159        self.visible
160    }
161
162    fn set_visible(&mut self, visible: bool) {
163        self.visible = visible;
164    }
165
166    fn clone_box(&self) -> Box<dyn Indicator> {
167        Box::new(self.clone())
168    }
169
170    fn line_names(&self) -> Vec<String> {
171        vec![format!("CSI({})", self.period)]
172    }
173}
174
175/// Trend Detection Index (TDI), M. H. Pee, Technical Analysis of Stocks & Commodities, October 2001.
176///
177/// With `mom = close - close[n]`, `TDI = |sum(mom, n)| - (sum(|mom|, 2n) - sum(|mom|, n))`.
178/// Positive values mean the market is trending, negative values mean it is consolidating.
179/// The first value is on bar `3n - 1`; earlier bars are `None`.
180#[derive(Clone)]
181pub struct TrendDetectionIndex {
182    period: usize,
183    values: IndicatorValues,
184    color: Color32,
185    visible: bool,
186}
187
188impl TrendDetectionIndex {
189    /// Create with a lookback of `period` bars, clamped to at least 1.
190    pub fn new(period: usize) -> Self {
191        Self {
192            period: period.max(1),
193            values: IndicatorValues::new(),
194            color: palette::BLUE, // Indigo (using INFO blue as closest match)
195            visible: true,
196        }
197    }
198
199    /// Set the plot color.
200    pub fn with_color(mut self, color: Color32) -> Self {
201        self.color = color;
202        self
203    }
204}
205
206impl Default for TrendDetectionIndex {
207    fn default() -> Self {
208        Self::new(20)
209    }
210}
211
212impl TrendDetectionIndex {
213    fn compute(&self, data: &[Bar]) -> IndicatorValues {
214        let mut values = IndicatorValues::new();
215
216        let n = self.period;
217        // mom[i] = close[i] - close[i - n], defined from bar n.
218        let mom: Vec<f64> = (0..data.len())
219            .map(|i| {
220                if i >= n {
221                    data[i].close - data[i - n].close
222                } else {
223                    0.0
224                }
225            })
226            .collect();
227        // The 2n-bar window of momentum starts at bar n, so the first value is on bar 3n - 1.
228        let first = n.saturating_mul(3) - 1;
229
230        for i in 0..data.len() {
231            if i < first {
232                values.push(IndicatorValue::None);
233                continue;
234            }
235            let net: f64 = mom[i + 1 - n..=i].iter().sum();
236            let older_abs: f64 = mom[i + 1 - 2 * n..=i - n].iter().map(|m| m.abs()).sum();
237            values.push(IndicatorValue::Single(net.abs() - older_abs));
238        }
239        values
240    }
241}
242
243impl Indicator for TrendDetectionIndex {
244    crate::studies::inputs::study_inputs!(self;
245        period: length("Period", 1, usize::MAX)
246    ; {});
247
248    fn name(&self) -> &str {
249        "TDI"
250    }
251
252    fn desc(&self) -> &str {
253        "Trend Detection Index - Positive when trending, negative when consolidating"
254    }
255
256    fn calculate(&mut self, data: &[Bar]) {
257        self.update(data, 0);
258    }
259
260    fn update(&mut self, data: &[Bar], from: usize) {
261        let mut values = std::mem::take(&mut self.values);
262        update_window(
263            &mut values,
264            data,
265            from,
266            |_data, from| from.saturating_sub(self.period.saturating_mul(3) - 1),
267            |data| self.compute(data),
268        );
269        self.values = values;
270    }
271
272    fn is_incremental(&self) -> bool {
273        true
274    }
275
276    fn values(&self) -> &IndicatorValues {
277        &self.values
278    }
279
280    fn colors(&self) -> Vec<Color32> {
281        vec![self.color]
282    }
283
284    fn set_colors(&mut self, colors: Vec<Color32>) {
285        if !colors.is_empty() {
286            self.color = colors[0];
287        }
288    }
289
290    fn is_overlay(&self) -> bool {
291        false
292    }
293
294    fn is_visible(&self) -> bool {
295        self.visible
296    }
297
298    fn set_visible(&mut self, visible: bool) {
299        self.visible = visible;
300    }
301
302    fn clone_box(&self) -> Box<dyn Indicator> {
303        Box::new(self.clone())
304    }
305
306    fn line_names(&self) -> Vec<String> {
307        vec![format!("TDI({})", self.period)]
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314    use chrono::Utc;
315
316    fn make_bar(close: f64) -> Bar {
317        Bar {
318            time: Utc::now(),
319            open: close,
320            high: close + 1.0,
321            low: close - 1.0,
322            close,
323            volume: 1000.0,
324            colors: None,
325            payload: None,
326        }
327    }
328
329    /// Reference values from M. H. Pee, "Trend Detection Index", Technical Analysis of Stocks &
330    /// Commodities, October 2001: mom = close - close[n], TDI = |sum(mom, n)| - (sum(|mom|, 2n) - sum(|mom|, n)).
331    #[test]
332    fn tdi_matches_published_definition() {
333        let flat_then_rising: Vec<f64> = (0..60)
334            .map(|i| 100.0 + (i as f64 - 39.0).max(0.0))
335            .collect();
336        let reversal = [
337            10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 14.0, 13.0, 12.0, 11.0, 12.0, 13.0,
338        ];
339        let cases: [(usize, &[f64], &[f64]); 2] = [
340            (20, &flat_then_rising, &[210.0]),
341            (3, &reversal, &[-6.0, 0.0, 2.0, -2.0]),
342        ];
343        for (period, closes, expected) in cases {
344            let data: Vec<Bar> = closes.iter().map(|&c| make_bar(c)).collect();
345            let mut tdi = TrendDetectionIndex::new(period);
346            tdi.calculate(&data);
347            let first = 3 * period - 1;
348            assert_eq!(tdi.values.len(), closes.len());
349            for (k, want) in expected.iter().enumerate() {
350                match tdi.values.to_rows()[first + k] {
351                    IndicatorValue::Single(got) => {
352                        assert!(
353                            (got - want).abs() < 1e-12,
354                            "period {period}, bar {}: {got} != {want}",
355                            first + k
356                        )
357                    }
358                    _ => panic!("period {period}, bar {}: no value", first + k),
359                }
360            }
361            assert!(
362                tdi.values.to_rows()[..first]
363                    .iter()
364                    .all(|v| matches!(v, IndicatorValue::None))
365            );
366        }
367    }
368}