Skip to main content

quantwave_core/indicators/
conventions.rs

1//! Machine-readable convention notes for indicators whose name does not fully
2//! determine their formula (quantwave-xnaf / quantwave-l2n4).
3//!
4//! Some indicator slugs are ambiguous in the wild: "ATR" can mean Wilder's RMA
5//! (TA-Lib, TradingView, Wilder 1978) or a plain EMA of true range; "stddev" can
6//! be population (`ddof=0`) or sample (`ddof=1`); "roc" can be a percentage or a
7//! plain ratio. Prose in a docs page is easy to miss, so the convention actually
8//! implemented is recorded here as data and surfaced through the Python
9//! `metadata()` / `boundary_info()` discovery API.
10//!
11//! PROCESS RULE (AGENTS.md): the *source* of every calculation must be recorded
12//! and never assumed. `source` below is the recorded provenance for the
13//! convention that is actually implemented. Where no authoritative source has
14//! been found for the implemented variant, `source` says so explicitly rather
15//! than naming a plausible-looking one.
16
17/// One convention that a caller could plausibly get wrong.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
19pub struct ConventionNote {
20    /// Indicator slug this note applies to (matches `metadata_registry`).
21    pub slug: &'static str,
22    /// What aspect of the calculation is ambiguous, e.g. "smoothing", "scaling".
23    pub aspect: &'static str,
24    /// The convention this implementation actually follows.
25    pub convention: &'static str,
26    /// Widely used conventions this implementation does *not* follow.
27    pub differs_from: &'static str,
28    /// Recorded provenance for the implemented convention, or an explicit
29    /// statement that no source has been established.
30    pub source: &'static str,
31    /// What a caller should do about it.
32    pub guidance: &'static str,
33}
34
35/// Slugs whose implemented convention differs from what the bare name implies.
36///
37/// Keep this sorted by slug. Mirrored on the Python side in
38/// `quantwave/_metadata.py::_CONVENTION_NOTES`.
39pub const CONVENTION_NOTES: &[ConventionNote] = &[
40    ConventionNote {
41        slug: "atr",
42        aspect: "smoothing",
43        convention: "Exponential moving average of true range, alpha = 2/(period+1), \
44                     seeded from the first bar's true range (high - low, no prior close) \
45                     with no NaN warmup. Applies to the `Atr` streaming class and the \
46                     `atr(period, high, low, close)` native batch function.",
47        differs_from: "Wilder's RMA (alpha = 1/period, SMA-seeded over the first `period` \
48                       true ranges, NaN during warmup) as used by TA-Lib, TradingView's \
49                       Pine `ta.atr`, and pandas `ewm(alpha=1/period, adjust=False)`. \
50                       Also differs from a simple rolling mean of true range. At period=14 \
51                       the EMA variant typically runs a few tenths of a percent to several \
52                       percent away from Wilder on the same input.",
53        source: "NONE RECORDED for the EMA-smoothed variant. The metadata `formula_source` \
54                 for this indicator (Investopedia ATR) and its LaTeX formula both describe \
55                 Wilder's RMA, which is what `ta_atr` implements — not this one. Treated as \
56                 an undocumented divergence pending review (quantwave-xnaf).",
57        guidance: "For TA-Lib/TradingView-identical ATR use `ta_atr` (Rust `TaATR`, Polars \
58                   `.ta.ta_atr()` / `lf.ta().ta_atr()`). Note that the Polars plugin \
59                   `pl.col(close).ta.atr(high, low)` and `quantwave.talib.ATR` are ALREADY \
60                   Wilder — only the streaming class and native batch function are not.",
61    },
62    ConventionNote {
63        slug: "atr_ts",
64        aspect: "smoothing",
65        convention: "ATR trailing stop built on the EMA-smoothed `Atr` (alpha = 2/(period+1)).",
66        differs_from: "TradingView ATR trailing stops, which use Wilder's RMA.",
67        source: "NONE RECORDED for the EMA smoothing. Metadata `formula_source` points at \
68                 TradingView's ATR page, which specifies Wilder. Inherited from `atr`.",
69        guidance: "Stop distances differ from a TradingView-equivalent ATR trailing stop. \
70                   See quantwave-xnaf before using for live risk sizing.",
71    },
72    ConventionNote {
73        slug: "keltner",
74        aspect: "smoothing",
75        convention: "Channel width uses the EMA-smoothed `Atr` (alpha = 2/(period+1)); \
76                     the basis is a plain EMA of close.",
77        differs_from: "The Investopedia / Chester Keltner formulation and most charting \
78                       platforms, which use Wilder-smoothed ATR for the band width.",
79        source: "NONE RECORDED for the EMA smoothing. Inherited from `atr`.",
80        guidance: "Band widths differ from a Wilder-ATR Keltner channel.",
81    },
82    ConventionNote {
83        slug: "roc",
84        aspect: "scaling",
85        convention: "Percentage: (price / price[-period] - 1) * 100, matching TA-Lib `ROC`.",
86        differs_from: "pandas `pct_change(period)` and TA-Lib `ROCP`, which return the \
87                       plain ratio (no x100).",
88        source: "TA-Lib ROC/ROCP definitions.",
89        guidance: "Use `rocp` for the unscaled ratio. Mixing the two is a silent 100x error.",
90    },
91    ConventionNote {
92        slug: "stddev",
93        aspect: "degrees of freedom",
94        convention: "Population standard deviation, ddof = 0, matching TA-Lib `STDDEV`.",
95        differs_from: "pandas `Series.std()` and numpy `ndarray.std(ddof=1)` defaults, \
96                       which use the sample estimator ddof = 1.",
97        source: "TA-Lib STDDEV definition.",
98        guidance: "Values differ by a factor of sqrt(N / (N - 1)); rescale if you are \
99                   reconciling against pandas.",
100    },
101    ConventionNote {
102        slug: "supertrend",
103        aspect: "smoothing",
104        convention: "Bands are built on the EMA-smoothed `Atr` (alpha = 2/(period+1)).",
105        differs_from: "TradingView's SuperTrend, which uses Pine `ta.atr` — Wilder's RMA.",
106        source: "NONE RECORDED for the EMA smoothing. Metadata `formula_source` points at \
107                 a TradingView SuperTrend script, which uses Wilder. Inherited from `atr`.",
108        guidance: "Flip points can differ from a TradingView SuperTrend on the same inputs.",
109    },
110    ConventionNote {
111        slug: "ttm_squeeze",
112        aspect: "smoothing",
113        convention: "The Keltner leg of the squeeze test uses the EMA-smoothed `Atr`.",
114        differs_from: "Implementations that use Wilder-smoothed ATR for the Keltner leg.",
115        source: "NONE RECORDED for the EMA smoothing. Inherited from `atr`.",
116        guidance: "Squeeze on/off transitions can differ near the threshold.",
117    },
118    ConventionNote {
119        slug: "vpn",
120        aspect: "smoothing",
121        convention: "The volume-positive-negative threshold uses the EMA-smoothed `Atr`.",
122        differs_from: "Implementations that use Wilder-smoothed ATR for the threshold.",
123        source: "NONE RECORDED for the EMA smoothing. Inherited from `atr`.",
124        guidance: "Threshold crossings can differ from a Wilder-ATR VPN.",
125    },
126];
127
128/// Convention notes recorded for `slug`, if any.
129///
130/// ```
131/// use quantwave_core::indicators::conventions::convention_notes;
132/// let notes = convention_notes("atr");
133/// assert_eq!(notes.len(), 1);
134/// assert_eq!(notes[0].aspect, "smoothing");
135/// ```
136pub fn convention_notes(slug: &str) -> Vec<&'static ConventionNote> {
137    CONVENTION_NOTES.iter().filter(|n| n.slug == slug).collect()
138}
139
140/// Every slug that carries at least one convention note, sorted and deduplicated.
141pub fn slugs_with_conventions() -> Vec<&'static str> {
142    let mut slugs: Vec<&'static str> = CONVENTION_NOTES.iter().map(|n| n.slug).collect();
143    slugs.sort_unstable();
144    slugs.dedup();
145    slugs
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use crate::indicators::metadata_registry::ALL_REGISTERED;
152
153    #[test]
154    fn notes_are_sorted_by_slug() {
155        let slugs: Vec<&str> = CONVENTION_NOTES.iter().map(|n| n.slug).collect();
156        let mut sorted = slugs.clone();
157        sorted.sort_unstable();
158        assert_eq!(slugs, sorted, "CONVENTION_NOTES must be sorted by slug");
159    }
160
161    #[test]
162    fn every_note_targets_a_registered_slug() {
163        for note in CONVENTION_NOTES {
164            assert!(
165                ALL_REGISTERED.iter().any(|r| r.slug == note.slug),
166                "convention note for unregistered slug {:?}",
167                note.slug
168            );
169        }
170    }
171
172    #[test]
173    fn every_note_is_fully_populated() {
174        for note in CONVENTION_NOTES {
175            for (field, value) in [
176                ("aspect", note.aspect),
177                ("convention", note.convention),
178                ("differs_from", note.differs_from),
179                ("source", note.source),
180                ("guidance", note.guidance),
181            ] {
182                assert!(
183                    !value.trim().is_empty(),
184                    "convention note {:?} has empty {field}",
185                    note.slug
186                );
187            }
188        }
189    }
190
191    #[test]
192    fn atr_family_divergence_is_recorded() {
193        // The ATR-composed indicators all inherit the EMA smoothing; if one of
194        // them is ever switched to Wilder, this test forces the note to be updated.
195        for slug in [
196            "atr",
197            "atr_ts",
198            "keltner",
199            "supertrend",
200            "ttm_squeeze",
201            "vpn",
202        ] {
203            let notes = convention_notes(slug);
204            assert_eq!(notes.len(), 1, "expected exactly one note for {slug}");
205            assert_eq!(notes[0].aspect, "smoothing");
206        }
207    }
208
209    #[test]
210    fn lookup_of_unknown_slug_is_empty() {
211        assert!(convention_notes("rsi").is_empty());
212        assert!(convention_notes("not_a_real_slug").is_empty());
213    }
214}