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: "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
141pub fn convention_notes(slug: &str) -> Vec<&'static ConventionNote> {
150 CONVENTION_NOTES.iter().filter(|n| n.slug == slug).collect()
151}
152
153pub 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 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}