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 std::collections::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    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); // (GapRatio, Signal)
39
40    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        // Bar 1: Open 10, Close 10. Gap = 0.
125        // Bar 2: Open 11, Close 11. Gap = 11 - 10 = 1. UpGap = 1, DnGap = 0. GapRatio = 100 * 1 / 0 -> 1.0 (wait, DnGap is 0)
126        // Wait, if DnGaps is 0, GapRatio is 1.0.
127        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); // total_up=1, total_dn=0 -> 1.0
133        assert_eq!(sig, 1.0);
134
135        let (gr, _) = gm.next((10.0, 10.0)); // gap = 10 - 11 = -1. total_up=1, total_dn=1 -> 100.0
136        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            // Batch implementation
154            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}