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: "alma",
42        aspect: "warmup",
43        convention: "During the initial warmup window (< period bars), ALMA normalizes a partial slice of Gaussian weights over available bars, returning finite values immediately from bar 0.",
44        differs_from: "Implementations that emit NaN until the full period of bars has accumulated.",
45        source: "ProRealTime Arnaud Legoux Moving Average specification.",
46        guidance: "To match NaN-warmup implementations, use WarmupPolicy::Nan or mask the first period-1 bars.",
47    },
48    ConventionNote {
49        slug: "atr",
50        aspect: "smoothing",
51        convention: "Wilder's RMA (alpha = 1/period, SMA-seeded over the first `period` \
52                     true ranges, NaN during warmup) by default across all layers (`atr()`, \
53                     `Atr` streaming, and Polars `pl.col().ta.atr()`). Both layers support \
54                     `smoothing='ema'` for exponential smoothing (alpha = 2/(period+1)).",
55        differs_from: "EMA-smoothed ATR variants (alpha = 2/(period+1)) without NaN warmup \
56                       when using default smoothing='wilder'.",
57        source: "Wilder, J. W. (1978). New Concepts in Technical Trading Systems.",
58        guidance: "Default `atr()` matches TA-Lib and TradingView bit for bit across plain and \
59                   Polars APIs. Use `smoothing='ema'` if EMA smoothing is explicitly desired.",
60    },
61    ConventionNote {
62        slug: "atr",
63        aspect: "seed",
64        convention: "Default Wilder RMA uses TA-Lib standard seeding: True Range is undefined on bar 0, \
65                     accumulating `period` valid TRs from bars 1..period, emitting first ATR at bar `period` \
66                     (period leading NaNs). An explicit `seed='wilder1978'` (or `'classic'`) option implements \
67                     Wilder's original 1978 textbook formula with TR[0]=H[0]-L[0] and seeds at bar `period-1` \
68                     (period-1 leading NaNs).",
69        differs_from: "Classic 1978 textbook Wilder ATR seeds 1 bar earlier (at index period-1 vs period) \
70                       with TR[0]=H-L. The relative difference between seeds decays below 1e-9 by bar 400.",
71        source: "Wilder, J. W. (1978). New Concepts in Technical Trading Systems; TA-Lib C implementation.",
72        guidance: "Use seed='talib' (default) to match TA-Lib and TradingView, or seed='wilder1978' ('classic') \
73                   to match the 1978 book formula.",
74    },
75    ConventionNote {
76        slug: "atr_ts",
77        aspect: "smoothing",
78        convention: "ATR trailing stop built on the EMA-smoothed `Atr` (alpha = 2/(period+1)).",
79        differs_from: "TradingView ATR trailing stops, which use Wilder's RMA.",
80        source: "NONE RECORDED for the EMA smoothing. Metadata `formula_source` points at \
81                 TradingView's ATR page, which specifies Wilder. Inherited from `atr`.",
82        guidance: "Stop distances differ from a TradingView-equivalent ATR trailing stop. \
83                   See quantwave-xnaf before using for live risk sizing.",
84    },
85    ConventionNote {
86        slug: "keltner",
87        aspect: "smoothing",
88        convention: "Channel width uses the EMA-smoothed `Atr` (alpha = 2/(period+1)); \
89                     the basis is a plain EMA of close.",
90        differs_from: "The Investopedia / Chester Keltner formulation and most charting \
91                       platforms, which use Wilder-smoothed ATR for the band width.",
92        source: "NONE RECORDED for the EMA smoothing. Inherited from `atr`.",
93        guidance: "Band widths differ from a Wilder-ATR Keltner channel.",
94    },
95    ConventionNote {
96        slug: "roc",
97        aspect: "scaling",
98        convention: "Percentage: (price / price[-period] - 1) * 100, matching TA-Lib `ROC`.",
99        differs_from: "pandas `pct_change(period)` and TA-Lib `ROCP`, which return the \
100                       plain ratio (no x100).",
101        source: "TA-Lib ROC/ROCP definitions.",
102        guidance: "Use `rocp` for the unscaled ratio. Mixing the two is a silent 100x error.",
103    },
104    ConventionNote {
105        slug: "stddev",
106        aspect: "degrees of freedom",
107        convention: "Population standard deviation, ddof = 0, matching TA-Lib `STDDEV`.",
108        differs_from: "pandas `Series.std()` and numpy `ndarray.std(ddof=1)` defaults, \
109                       which use the sample estimator ddof = 1.",
110        source: "TA-Lib STDDEV definition.",
111        guidance: "Values differ by a factor of sqrt(N / (N - 1)); rescale if you are \
112                   reconciling against pandas.",
113    },
114    ConventionNote {
115        slug: "supertrend",
116        aspect: "smoothing",
117        convention: "Bands are built on the EMA-smoothed `Atr` (alpha = 2/(period+1)).",
118        differs_from: "TradingView's SuperTrend, which uses Pine `ta.atr` — Wilder's RMA.",
119        source: "NONE RECORDED for the EMA smoothing. Metadata `formula_source` points at \
120                 a TradingView SuperTrend script, which uses Wilder. Inherited from `atr`.",
121        guidance: "Flip points can differ from a TradingView SuperTrend on the same inputs.",
122    },
123    ConventionNote {
124        slug: "ttm_squeeze",
125        aspect: "smoothing",
126        convention: "The Keltner leg of the squeeze test uses the EMA-smoothed `Atr`.",
127        differs_from: "Implementations that use Wilder-smoothed ATR for the Keltner leg.",
128        source: "NONE RECORDED for the EMA smoothing. Inherited from `atr`.",
129        guidance: "Squeeze on/off transitions can differ near the threshold.",
130    },
131    ConventionNote {
132        slug: "vpn",
133        aspect: "smoothing",
134        convention: "The volume-positive-negative threshold uses the EMA-smoothed `Atr`.",
135        differs_from: "Implementations that use Wilder-smoothed ATR for the threshold.",
136        source: "NONE RECORDED for the EMA smoothing. Inherited from `atr`.",
137        guidance: "Threshold crossings can differ from a Wilder-ATR VPN.",
138    },
139];
140
141/// Convention notes recorded for `slug`, if any.
142///
143/// ```
144/// use quantwave_core::indicators::conventions::convention_notes;
145/// let notes = convention_notes("atr");
146/// assert_eq!(notes.len(), 1);
147/// assert_eq!(notes[0].aspect, "smoothing");
148/// ```
149pub fn convention_notes(slug: &str) -> Vec<&'static ConventionNote> {
150    CONVENTION_NOTES.iter().filter(|n| n.slug == slug).collect()
151}
152
153/// Every slug that carries at least one convention note, sorted and deduplicated.
154pub fn slugs_with_conventions() -> Vec<&'static str> {
155    let mut slugs: Vec<&'static str> = CONVENTION_NOTES.iter().map(|n| n.slug).collect();
156    slugs.sort_unstable();
157    slugs.dedup();
158    slugs
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use crate::indicators::metadata_registry::ALL_REGISTERED;
165
166    #[test]
167    fn notes_are_sorted_by_slug() {
168        let slugs: Vec<&str> = CONVENTION_NOTES.iter().map(|n| n.slug).collect();
169        let mut sorted = slugs.clone();
170        sorted.sort_unstable();
171        assert_eq!(slugs, sorted, "CONVENTION_NOTES must be sorted by slug");
172    }
173
174    #[test]
175    fn every_note_targets_a_registered_slug() {
176        for note in CONVENTION_NOTES {
177            assert!(
178                ALL_REGISTERED.iter().any(|r| r.slug == note.slug),
179                "convention note for unregistered slug {:?}",
180                note.slug
181            );
182        }
183    }
184
185    #[test]
186    fn every_note_is_fully_populated() {
187        for note in CONVENTION_NOTES {
188            for (field, value) in [
189                ("aspect", note.aspect),
190                ("convention", note.convention),
191                ("differs_from", note.differs_from),
192                ("source", note.source),
193                ("guidance", note.guidance),
194            ] {
195                assert!(
196                    !value.trim().is_empty(),
197                    "convention note {:?} has empty {field}",
198                    note.slug
199                );
200            }
201        }
202    }
203
204    #[test]
205    fn atr_family_divergence_is_recorded() {
206        // The ATR-composed indicators all inherit the EMA smoothing; if one of
207        // them is ever switched to Wilder, this test forces the note to be updated.
208        for slug in ["atr_ts", "keltner", "supertrend", "ttm_squeeze", "vpn"] {
209            let notes = convention_notes(slug);
210            assert_eq!(notes.len(), 1, "expected exactly one note for {slug}");
211            assert_eq!(notes[0].aspect, "smoothing");
212        }
213        let atr_notes = convention_notes("atr");
214        assert!(atr_notes.iter().any(|n| n.aspect == "smoothing"));
215        assert!(atr_notes.iter().any(|n| n.aspect == "seed"));
216    }
217
218    #[test]
219    fn lookup_of_unknown_slug_is_empty() {
220        assert!(convention_notes("rsi").is_empty());
221        assert!(convention_notes("not_a_real_slug").is_empty());
222    }
223}