quantwave_core/indicators/
gap_momentum.rs1use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
2use crate::indicators::smoothing::SMA;
3use crate::traits::Next;
4use std::collections::VecDeque;
5
6#[derive(Debug, Clone)]
13pub struct GapMomentum {
14 period: usize,
15 up_gaps: VecDeque<f64>,
16 dn_gaps: VecDeque<f64>,
17 total_up_gaps: f64,
18 total_dn_gaps: f64,
19 sma: SMA,
20 prev_close: Option<f64>,
21}
22
23impl GapMomentum {
24 pub fn new(period: usize, signal_period: usize) -> Self {
25 Self {
26 period,
27 up_gaps: VecDeque::with_capacity(period),
28 dn_gaps: VecDeque::with_capacity(period),
29 total_up_gaps: 0.0,
30 total_dn_gaps: 0.0,
31 sma: SMA::new(signal_period),
32 prev_close: None,
33 }
34 }
35}
36
37impl Next<(f64, f64)> for GapMomentum {
38 type Output = (f64, f64); fn next(&mut self, (open, close): (f64, f64)) -> Self::Output {
41 let gap = match self.prev_close {
42 Some(pc) => open - pc,
43 None => 0.0,
44 };
45 self.prev_close = Some(close);
46
47 let up_gap = if gap > 0.0 { gap } else { 0.0 };
48 let dn_gap = if gap < 0.0 { -gap } else { 0.0 };
49
50 self.up_gaps.push_back(up_gap);
51 self.dn_gaps.push_back(dn_gap);
52 self.total_up_gaps += up_gap;
53 self.total_dn_gaps += dn_gap;
54
55 if self.up_gaps.len() > self.period {
56 if let Some(old_up) = self.up_gaps.pop_front() {
57 self.total_up_gaps -= old_up;
58 }
59 if let Some(old_dn) = self.dn_gaps.pop_front() {
60 self.total_dn_gaps -= old_dn;
61 }
62 }
63
64 let gap_ratio = if self.total_dn_gaps == 0.0 {
65 1.0
66 } else {
67 100.0 * self.total_up_gaps / self.total_dn_gaps
68 };
69
70 let signal = self.sma.next(gap_ratio);
71
72 (gap_ratio, signal)
73 }
74}
75
76pub const GAP_MOMENTUM_METADATA: IndicatorMetadata = IndicatorMetadata {
77 name: "Gap Momentum",
78 description: "Accumulates positive and negative opening gaps to derive a cumulative gap ratio, smoothed by a signal line.",
79 usage: "Used to identify momentum shifts based on price gaps. Buy when the signal line is rising and sell when it is falling.",
80 keywords: &["momentum", "gap", "kaufman", "oscillator"],
81 ehlers_summary: "Perry J. Kaufman introduced Gap Momentum as a way to quantify price gaps relative to their cumulative volatility, similar to an On-Balance Volume (OBV) logic applied to opening gaps. It helps traders identify if gap-driven momentum is increasing or decreasing by comparing the sum of upward gaps against downward gaps over a rolling window. — Perry Kaufman, S&C 2024",
82 params: &[
83 ParamDef {
84 name: "period",
85 default: "40",
86 description: "Rolling window for gap accumulation",
87 },
88 ParamDef {
89 name: "signal_period",
90 default: "20",
91 description: "Smoothing period for the gap ratio",
92 },
93 ],
94 formula_source: "https://github.com/lavs9/quantwave/blob/main/references/traderstipsreference/TRADERS%E2%80%99%20TIPS%20-%20JANUARY%202024.html",
95 formula_latex: r#"
96\[
97Gap = Open_t - Close_{t-1}
98\]
99\[
100UpGaps = \sum_{i=0}^{Period-1} \max(0, Gap_{t-i})
101\]
102\[
103DnGaps = \sum_{i=0}^{Period-1} \max(0, -Gap_{t-i})
104\]
105\[
106GapRatio = \begin{cases} 1 & \text{if } DnGaps = 0 \\ 100 \times \frac{UpGaps}{DnGaps} & \text{otherwise} \end{cases}
107\]
108\[
109Signal = SMA(GapRatio, SignalPeriod)
110\]
111"#,
112 gold_standard_file: "gap_momentum.json",
113 category: "Momentum",
114};
115
116#[cfg(test)]
117mod tests {
118 use super::*;
119 use proptest::prelude::*;
120
121 #[test]
122 fn test_gap_momentum_basic() {
123 let mut gm = GapMomentum::new(10, 5);
124 let (gr, sig) = gm.next((10.0, 10.0));
128 assert_eq!(gr, 1.0);
129 assert_eq!(sig, 1.0);
130
131 let (gr, sig) = gm.next((11.0, 11.0));
132 assert_eq!(gr, 1.0); assert_eq!(sig, 1.0);
134
135 let (gr, _) = gm.next((10.0, 10.0)); assert_eq!(gr, 100.0);
137 }
138
139 proptest! {
140 #[test]
141 fn test_gap_momentum_parity(
142 inputs in prop::collection::vec((10.0..20.0, 10.0..20.0), 50..100),
143 ) {
144 let period = 20;
145 let signal_period = 10;
146 let mut gm = GapMomentum::new(period, signal_period);
147
148 let mut streaming_results = Vec::with_capacity(inputs.len());
149 for &val in &inputs {
150 streaming_results.push(gm.next(val));
151 }
152
153 let mut batch_results = Vec::with_capacity(inputs.len());
155 let mut gaps = Vec::with_capacity(inputs.len());
156 let mut prev_close = None;
157 for &(open, close) in &inputs {
158 let gap = prev_close.map(|pc| open - pc).unwrap_or(0.0);
159 gaps.push(gap);
160 prev_close = Some(close);
161 }
162
163 let mut gap_ratios = Vec::with_capacity(inputs.len());
164 for i in 0..inputs.len() {
165 let start = if i >= period { i - period + 1 } else { 0 };
166 let window = &gaps[start..=i];
167 let up_gaps: f64 = window.iter().filter(|&&g| g > 0.0).sum();
168 let dn_gaps: f64 = window.iter().filter(|&&g| g < 0.0).map(|&g| -g).sum();
169 let ratio = if dn_gaps == 0.0 { 1.0 } else { 100.0 * up_gaps / dn_gaps };
170 gap_ratios.push(ratio);
171 }
172
173 let mut sma = SMA::new(signal_period);
174 for ratio in gap_ratios {
175 let signal = sma.next(ratio);
176 batch_results.push((ratio, signal));
177 }
178
179 for (s, b) in streaming_results.iter().zip(batch_results.iter()) {
180 approx::assert_relative_eq!(s.0, b.0, epsilon = 1e-10);
181 approx::assert_relative_eq!(s.1, b.1, epsilon = 1e-10);
182 }
183 }
184 }
185}