kestrel_chartkit/indicator/
midas.rs1use std::collections::HashMap;
17
18use crate::model::{Bar, Source};
19
20use super::{Indicator, IndicatorAlert, IndicatorOutput};
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum MidasState {
25 Launch,
28 Projecting,
30 Exhausted,
33}
34
35#[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 {
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 "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 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 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 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), 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 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 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 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}