quantwave_core/indicators/
conventions.rs1#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
19pub struct ConventionNote {
20 pub slug: &'static str,
22 pub aspect: &'static str,
24 pub convention: &'static str,
26 pub differs_from: &'static str,
28 pub source: &'static str,
31 pub guidance: &'static str,
33}
34
35pub 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
128pub fn convention_notes(slug: &str) -> Vec<&'static ConventionNote> {
137 CONVENTION_NOTES.iter().filter(|n| n.slug == slug).collect()
138}
139
140pub 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 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}