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