Skip to main content

quantwave_core/indicators/
gap_momentum.rs

1use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
2use crate::indicators::smoothing::SMA;
3use crate::traits::Next;
4use crate::utils::RingBuffer as VecDeque;
5
6/// Gap Momentum
7///
8/// Introduced by Perry J. Kaufman in "Gap Momentum" (S&C January 2024).
9/// This indicator draws inspiration from J. Welles Wilder and Joseph Granville's On-Balance Volume (OBV).
10/// It accumulates positive and negative opening gaps over a specified period to derive a cumulative gap ratio.
11/// A signal line, computed as a moving average of this ratio, is used to identify momentum shifts.
12#[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    /// Non-zero gaps currently in the window. When a count hits 0 the matching
20    /// running total is reset to exactly 0.0, so add/subtract round-off can
21    /// never leave a residue (quantwave-nkwp: flat bars after movement gave
22    /// `100 * residue / residue` instead of the formula's `DnGaps = 0 -> 1`).
23    nonzero_up: usize,
24    nonzero_dn: usize,
25    sma: SMA,
26    prev_close: Option<f64>,
27}
28
29impl GapMomentum {
30    pub fn new(period: usize, signal_period: usize) -> Self {
31        Self {
32            period,
33            up_gaps: VecDeque::with_capacity(period),
34            dn_gaps: VecDeque::with_capacity(period),
35            total_up_gaps: 0.0,
36            total_dn_gaps: 0.0,
37            nonzero_up: 0,
38            nonzero_dn: 0,
39            sma: SMA::new(signal_period),
40            prev_close: None,
41        }
42    }
43}
44
45impl Next<(f64, f64)> for GapMomentum {
46    type Output = (f64, f64); // (GapRatio, Signal)
47
48    fn next(&mut self, (open, close): (f64, f64)) -> Self::Output {
49        let gap = match self.prev_close {
50            Some(pc) => open - pc,
51            None => 0.0,
52        };
53        self.prev_close = Some(close);
54
55        let up_gap = if gap > 0.0 { gap } else { 0.0 };
56        let dn_gap = if gap < 0.0 { -gap } else { 0.0 };
57
58        self.up_gaps.push_back(up_gap);
59        self.dn_gaps.push_back(dn_gap);
60        self.total_up_gaps += up_gap;
61        self.total_dn_gaps += dn_gap;
62        self.nonzero_up += usize::from(up_gap != 0.0);
63        self.nonzero_dn += usize::from(dn_gap != 0.0);
64
65        if self.up_gaps.len() > self.period {
66            if let Some(old_up) = self.up_gaps.pop_front() {
67                self.total_up_gaps -= old_up;
68                self.nonzero_up -= usize::from(old_up != 0.0);
69            }
70            if let Some(old_dn) = self.dn_gaps.pop_front() {
71                self.total_dn_gaps -= old_dn;
72                self.nonzero_dn -= usize::from(old_dn != 0.0);
73            }
74        }
75        if self.nonzero_up == 0 {
76            self.total_up_gaps = 0.0;
77        }
78        if self.nonzero_dn == 0 {
79            self.total_dn_gaps = 0.0;
80        }
81
82        let gap_ratio = if self.total_dn_gaps == 0.0 {
83            1.0
84        } else {
85            100.0 * self.total_up_gaps / self.total_dn_gaps
86        };
87
88        let signal = self.sma.next(gap_ratio);
89
90        (gap_ratio, signal)
91    }
92}
93
94pub const GAP_MOMENTUM_METADATA: IndicatorMetadata = IndicatorMetadata {
95    name: "Gap Momentum",
96    description: "Accumulates positive and negative opening gaps to derive a cumulative gap ratio, smoothed by a signal line.",
97    usage: "Used to identify momentum shifts based on price gaps. Buy when the signal line is rising and sell when it is falling.",
98    keywords: &["momentum", "gap", "kaufman", "oscillator"],
99    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",
100    params: &[
101        ParamDef {
102            name: "period",
103            default: "40",
104            description: "Rolling window for gap accumulation",
105        },
106        ParamDef {
107            name: "signal_period",
108            default: "20",
109            description: "Smoothing period for the gap ratio",
110        },
111    ],
112    formula_source: "https://github.com/lavs9/quantwave/blob/main/references/traderstipsreference/TRADERS%E2%80%99%20TIPS%20-%20JANUARY%202024.html",
113    formula_latex: r#"
114\[
115Gap = Open_t - Close_{t-1}
116\]
117\[
118UpGaps = \sum_{i=0}^{Period-1} \max(0, Gap_{t-i})
119\]
120\[
121DnGaps = \sum_{i=0}^{Period-1} \max(0, -Gap_{t-i})
122\]
123\[
124GapRatio = \begin{cases} 1 & \text{if } DnGaps = 0 \\ 100 \times \frac{UpGaps}{DnGaps} & \text{otherwise} \end{cases}
125\]
126\[
127Signal = SMA(GapRatio, SignalPeriod)
128\]
129"#,
130    gold_standard_file: "gap_momentum.json",
131    category: "Momentum",
132};
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use proptest::prelude::*;
138
139    #[test]
140    fn test_gap_momentum_basic() {
141        let mut gm = GapMomentum::new(10, 5);
142        // Bar 1: Open 10, Close 10. Gap = 0.
143        // Bar 2: Open 11, Close 11. Gap = 11 - 10 = 1. UpGap = 1, DnGap = 0. GapRatio = 100 * 1 / 0 -> 1.0 (wait, DnGap is 0)
144        // Wait, if DnGaps is 0, GapRatio is 1.0.
145        let (gr, sig) = gm.next((10.0, 10.0));
146        assert_eq!(gr, 1.0);
147        assert_eq!(sig, 1.0);
148
149        let (gr, sig) = gm.next((11.0, 11.0));
150        assert_eq!(gr, 1.0); // total_up=1, total_dn=0 -> 1.0
151        assert_eq!(sig, 1.0);
152
153        let (gr, _) = gm.next((10.0, 10.0)); // gap = 10 - 11 = -1. total_up=1, total_dn=1 -> 100.0
154        assert_eq!(gr, 100.0);
155    }
156
157    /// Exact-window batch reference (Kaufman, S&C Jan 2024): sums recomputed per bar.
158    fn batch_reference(
159        inputs: &[(f64, f64)],
160        period: usize,
161        signal_period: usize,
162    ) -> Vec<(f64, f64)> {
163        let mut gaps = Vec::with_capacity(inputs.len());
164        let mut prev_close = None;
165        for &(open, close) in inputs {
166            gaps.push(prev_close.map(|pc| open - pc).unwrap_or(0.0));
167            prev_close = Some(close);
168        }
169        let mut sma = SMA::new(signal_period);
170        (0..inputs.len())
171            .map(|i| {
172                let window = &gaps[i.saturating_sub(period - 1)..=i];
173                let up: f64 = window.iter().filter(|&&g| g > 0.0).sum();
174                let dn: f64 = window.iter().filter(|&&g| g < 0.0).map(|&g| -g).sum();
175                let ratio = if dn == 0.0 { 1.0 } else { 100.0 * up / dn };
176                (ratio, sma.next(ratio))
177            })
178            .collect()
179    }
180
181    /// quantwave-nkwp: all-flat input has no gaps, so GapRatio is exactly 1.
182    #[test]
183    fn test_gap_momentum_flat_input_is_one() {
184        let mut gm = GapMomentum::new(20, 10);
185        for _ in 0..100 {
186            assert_eq!(gm.next((10.0, 10.0)), (1.0, 1.0));
187        }
188    }
189
190    /// quantwave-nkwp: once every gap has left the window after movement,
191    /// DnGaps is exactly 0 and GapRatio must be exactly 1, not
192    /// `100 * residue / residue` from running-sum cancellation.
193    #[test]
194    fn test_gap_momentum_flat_after_movement() {
195        let (period, signal_period) = (20, 10);
196        // Movement with awkward decimal gaps (cancellation-prone), then flat bars.
197        let mut inputs: Vec<(f64, f64)> = (0..40)
198            .map(|i| {
199                let x = 10.0 + ((i * 7919) % 97) as f64 * 0.1037;
200                (x + 0.0131 * (i % 5) as f64, x)
201            })
202            .collect();
203        let last = inputs[inputs.len() - 1].1;
204        inputs.extend(std::iter::repeat_n((last, last), 60));
205
206        let mut gm = GapMomentum::new(period, signal_period);
207        let streaming: Vec<(f64, f64)> = inputs.iter().map(|&b| gm.next(b)).collect();
208        let batch = batch_reference(&inputs, period, signal_period);
209        for (i, (s, b)) in streaming.iter().zip(&batch).enumerate() {
210            approx::assert_relative_eq!(s.0, b.0, max_relative = 1e-9);
211            approx::assert_relative_eq!(s.1, b.1, max_relative = 1e-9);
212            if i >= 40 + period {
213                assert_eq!(s.0, 1.0, "bar {i}: flat window must give GapRatio 1");
214            }
215        }
216    }
217
218    proptest! {
219        #[test]
220        fn test_gap_momentum_parity(
221            inputs in prop::collection::vec((10.0..20.0, 10.0..20.0), 50..100),
222        ) {
223            let period = 20;
224            let signal_period = 10;
225            let mut gm = GapMomentum::new(period, signal_period);
226
227            let mut streaming_results = Vec::with_capacity(inputs.len());
228            for &val in &inputs {
229                streaming_results.push(gm.next(val));
230            }
231
232            // Batch implementation
233            let mut batch_results = Vec::with_capacity(inputs.len());
234            let mut gaps = Vec::with_capacity(inputs.len());
235            let mut prev_close = None;
236            for &(open, close) in &inputs {
237                let gap = prev_close.map(|pc| open - pc).unwrap_or(0.0);
238                gaps.push(gap);
239                prev_close = Some(close);
240            }
241
242            let mut gap_ratios = Vec::with_capacity(inputs.len());
243            for i in 0..inputs.len() {
244                let start = if i >= period { i - period + 1 } else { 0 };
245                let window = &gaps[start..=i];
246                let up_gaps: f64 = window.iter().filter(|&&g| g > 0.0).sum();
247                let dn_gaps: f64 = window.iter().filter(|&&g| g < 0.0).map(|&g| -g).sum();
248                let ratio = if dn_gaps == 0.0 { 1.0 } else { 100.0 * up_gaps / dn_gaps };
249                gap_ratios.push(ratio);
250            }
251
252            let mut sma = SMA::new(signal_period);
253            for ratio in gap_ratios {
254                let signal = sma.next(ratio);
255                batch_results.push((ratio, signal));
256            }
257
258            for (s, b) in streaming_results.iter().zip(batch_results.iter()) {
259                approx::assert_relative_eq!(s.0, b.0, max_relative = 1e-9);
260                approx::assert_relative_eq!(s.1, b.1, max_relative = 1e-9);
261            }
262        }
263    }
264}