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 {
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 "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 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 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 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), 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 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 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 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}