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