Skip to main content

kestrel_chartkit/indicator/
midas.rs

1//! MIDAS curves: a launch-anchored cumulative volume-weighted curve (the same accumulation as
2//! [`super::anchored_vwap::AnchoredVwapEngine`]), plus Levine's Topfinder/Bottomfinder projection
3//! — the part a plain anchored VWAP does not cover. After price sets a new extreme (high for a
4//! Topfinder, low for a Bottomfinder) since launch, the projection curve estimates where price
5//! will meet resistance/support going forward using the classic square-root volume-decay formula:
6//!
7//! ```text
8//! midas(t)      = cum(price * volume) / cum(volume)              since launch
9//! projection(t) = extreme - (extreme - midas(t_extreme)) * sqrt(cum_v(t_extreme) / cum_v(t))
10//! ```
11//!
12//! `projection` converges back toward `extreme` as cumulative volume grows, modeling the curve's
13//! resistance/support decay — the qualitative behavior Topfinder/Bottomfinder curves are known
14//! for, not a parameter variant of a rolling VWAP.
15
16use std::collections::HashMap;
17
18use crate::model::{Bar, Source};
19
20use super::{Indicator, IndicatorAlert, IndicatorOutput};
21
22/// Lifecycle state of a MIDAS Topfinder/Bottomfinder projection.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum MidasState {
25    /// No extreme (high for Topfinder, low for Bottomfinder) has been set since launch yet;
26    /// only the base MIDAS curve is meaningful.
27    Launch,
28    /// An extreme has been set and the projection curve is actively converging toward it.
29    Projecting,
30    /// `maturity_bars` have passed since the extreme with no new one set: the projection is
31    /// considered to have played out.
32    Exhausted,
33}
34
35/// Which extreme this engine tracks: highs (Topfinder, projecting resistance after an up-move) or
36/// lows (Bottomfinder, projecting support after a down-move).
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum MidasMode {
39    Topfinder,
40    Bottomfinder,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq)]
44pub struct MidasOutput {
45    pub curve: f64,
46    pub projection: Option<f64>,
47    pub state: MidasState,
48}
49
50pub struct MidasCurveEngine {
51    mode: MidasMode,
52    source: Source,
53    maturity_bars: u32,
54    cum_pv: f64,
55    cum_v: f64,
56    extreme_price: Option<f64>,
57    extreme_cum_v: f64,
58    extreme_curve_value: f64,
59    bars_since_extreme: u32,
60    alerts: Vec<IndicatorAlert>,
61}
62
63impl MidasCurveEngine {
64    pub fn new(mode: MidasMode, source: Source, maturity_bars: u32) -> Self {
65        Self {
66            mode,
67            source,
68            maturity_bars: maturity_bars.max(1),
69            cum_pv: 0.0,
70            cum_v: 0.0,
71            extreme_price: None,
72            extreme_cum_v: 0.0,
73            extreme_curve_value: 0.0,
74            bars_since_extreme: 0,
75            alerts: Vec::new(),
76        }
77    }
78
79    pub fn with_defaults(mode: MidasMode) -> Self {
80        Self::new(mode, Source::Hlc3, 20)
81    }
82
83    fn is_new_extreme(&self, bar: &Bar) -> bool {
84        match (self.mode, self.extreme_price) {
85            (MidasMode::Topfinder, None) => true,
86            (MidasMode::Topfinder, Some(extreme)) => bar.high > extreme,
87            (MidasMode::Bottomfinder, None) => true,
88            (MidasMode::Bottomfinder, Some(extreme)) => bar.low < extreme,
89        }
90    }
91}
92
93impl Indicator for MidasCurveEngine {
94    fn name(&self) -> &str {
95        // Fixed regardless of `mode`, matching the established convention for other
96        // enum-configured indicators (e.g. `AnchoredVwapEngine`, `PivotSetsEngine`): the mode is
97        // exposed through output data (`IndicatorOutput::state`), not the indicator's identity.
98        "midas"
99    }
100
101    fn reset(&mut self) {
102        self.cum_pv = 0.0;
103        self.cum_v = 0.0;
104        self.extreme_price = None;
105        self.extreme_cum_v = 0.0;
106        self.extreme_curve_value = 0.0;
107        self.bars_since_extreme = 0;
108        self.alerts.clear();
109    }
110
111    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
112        self.alerts.clear();
113
114        let price = self.source.extract(bar);
115        self.cum_pv += price * bar.volume;
116        self.cum_v += bar.volume;
117        if self.cum_v <= 0.0 {
118            return None;
119        }
120        let curve = self.cum_pv / self.cum_v;
121
122        // A pullback (at least one bar since the running extreme was last set) had already begun
123        // before this bar iff we were in Projecting/Exhausted territory, i.e. `bars_since_extreme
124        // > 0`. Only a *new* extreme arriving after such a pullback genuinely invalidates an
125        // in-progress projection; a new extreme while still climbing every bar (Launch) does not.
126        let had_pullback = self.bars_since_extreme > 0;
127
128        if self.is_new_extreme(bar) {
129            let extreme_price = match self.mode {
130                MidasMode::Topfinder => bar.high,
131                MidasMode::Bottomfinder => bar.low,
132            };
133            self.extreme_price = Some(extreme_price);
134            self.extreme_cum_v = self.cum_v;
135            self.extreme_curve_value = curve;
136            self.bars_since_extreme = 0;
137            if had_pullback {
138                self.alerts.push(IndicatorAlert::new(
139                    "midas_extreme_reset",
140                    "MIDAS projection restarted from a new extreme",
141                    0.6,
142                ));
143            }
144        } else if self.extreme_price.is_some() {
145            self.bars_since_extreme += 1;
146        }
147
148        // `bars_since_extreme == 0` means either no extreme exists yet, or a new one was just set
149        // this very bar (still climbing) — both are "Launch": the projection only begins once a
150        // bar has passed *without* extending the extreme.
151        let (projection, state) = match self.extreme_price {
152            Some(extreme) if self.bars_since_extreme > 0 => {
153                let decay = if self.cum_v > 0.0 {
154                    (self.extreme_cum_v / self.cum_v).sqrt()
155                } else {
156                    1.0
157                };
158                let projected = extreme - (extreme - self.extreme_curve_value) * decay;
159                let state = if self.bars_since_extreme >= self.maturity_bars {
160                    MidasState::Exhausted
161                } else {
162                    MidasState::Projecting
163                };
164                (Some(projected), state)
165            }
166            _ => (None, MidasState::Launch),
167        };
168
169        if state == MidasState::Exhausted && self.bars_since_extreme == self.maturity_bars {
170            self.alerts.push(IndicatorAlert::new(
171                "midas_exhausted",
172                "MIDAS projection reached maturity without a new extreme",
173                0.5,
174            ));
175        }
176
177        let mut extra = HashMap::new();
178        if let Some(p) = projection {
179            extra.insert("projection".to_string(), p);
180        }
181        extra.insert(
182            "bars_since_extreme".to_string(),
183            self.bars_since_extreme as f64,
184        );
185
186        let state_label = match state {
187            MidasState::Launch => "launch",
188            MidasState::Projecting => "projecting",
189            MidasState::Exhausted => "exhausted",
190        };
191
192        Some(
193            IndicatorOutput::with_extra(curve, extra)
194                .with_secondary(projection.unwrap_or(curve))
195                .with_state(state_label),
196        )
197    }
198
199    fn alerts(&self) -> Vec<IndicatorAlert> {
200        self.alerts.clone()
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    fn up_move_bars() -> Vec<Bar> {
209        // Rally to a peak at bar 4, then pull back.
210        vec![
211            Bar::new(0, 100.0, 101.0, 99.0, 100.5, 100.0),
212            Bar::new(60, 100.5, 103.0, 100.0, 102.5, 120.0),
213            Bar::new(120, 102.5, 106.0, 102.0, 105.5, 150.0),
214            Bar::new(180, 105.5, 110.0, 105.0, 109.0, 200.0),
215            Bar::new(240, 109.0, 115.0, 108.5, 113.0, 250.0), // peak: high = 115.0
216            Bar::new(300, 113.0, 114.0, 108.0, 109.0, 180.0),
217            Bar::new(360, 109.0, 111.0, 105.0, 106.0, 160.0),
218        ]
219    }
220
221    #[test]
222    fn test_curve_matches_manual_vwap_accumulation() {
223        let mut engine = MidasCurveEngine::new(MidasMode::Topfinder, Source::Close, 20);
224        let bars = [
225            Bar::new(0, 100.0, 101.0, 99.0, 100.0, 10.0),
226            Bar::new(60, 101.0, 102.0, 100.0, 102.0, 20.0),
227        ];
228        let mut last = None;
229        for bar in &bars {
230            last = engine.on_bar(bar);
231        }
232        let expected = (100.0 * 10.0 + 102.0 * 20.0) / 30.0;
233        assert!((last.unwrap().value - expected).abs() < 1e-9);
234    }
235
236    #[test]
237    fn test_topfinder_projection_converges_toward_extreme() {
238        let mut engine = MidasCurveEngine::new(MidasMode::Topfinder, Source::Hlc3, 20);
239        let mut projections = Vec::new();
240        for bar in up_move_bars() {
241            if let Some(out) = engine.on_bar(&bar) {
242                if out.state.as_deref() == Some("projecting") {
243                    projections.push(out.extra["projection"]);
244                }
245            }
246        }
247        assert!(projections.len() >= 2);
248        // As cumulative volume grows past the extreme, sqrt(extreme_v / v) shrinks toward 0, so
249        // successive projections must move monotonically closer to the 115.0 extreme.
250        for pair in projections.windows(2) {
251            let dist_a = (115.0f64 - pair[0]).abs();
252            let dist_b = (115.0f64 - pair[1]).abs();
253            assert!(dist_b <= dist_a + 1e-9);
254        }
255    }
256
257    #[test]
258    fn test_state_transitions_launch_projecting_exhausted() {
259        let mut engine = MidasCurveEngine::new(MidasMode::Topfinder, Source::Hlc3, 2);
260        let bars = up_move_bars();
261
262        // The first bar always sets the running extreme (still "climbing"), so it is Launch.
263        assert_eq!(
264            engine.on_bar(&bars[0]).unwrap().state.as_deref(),
265            Some("launch")
266        );
267
268        for bar in &bars[1..5] {
269            engine.on_bar(bar);
270        }
271        // Bar 5 and 6 are two bars past the peak at bar 4 with maturity_bars = 2.
272        let out5 = engine.on_bar(&bars[5]).unwrap();
273        assert_eq!(out5.state.as_deref(), Some("projecting"));
274        let out6 = engine.on_bar(&bars[6]).unwrap();
275        assert_eq!(out6.state.as_deref(), Some("exhausted"));
276    }
277
278    #[test]
279    fn test_bottomfinder_tracks_lows_not_highs() {
280        let mut engine = MidasCurveEngine::new(MidasMode::Bottomfinder, Source::Hlc3, 20);
281        let down_bars: Vec<Bar> = up_move_bars()
282            .into_iter()
283            .map(|b| {
284                Bar::new(
285                    b.timestamp,
286                    220.0 - b.close,
287                    220.0 - b.low,
288                    220.0 - b.high,
289                    220.0 - b.open,
290                    b.volume,
291                )
292            })
293            .collect();
294        let mut saw_projecting = false;
295        for bar in &down_bars {
296            if let Some(out) = engine.on_bar(bar) {
297                if out.state.as_deref() == Some("projecting") {
298                    saw_projecting = true;
299                }
300            }
301        }
302        assert!(
303            saw_projecting,
304            "a clear down-move must eventually start a Bottomfinder projection"
305        );
306    }
307}