indicators/descriptor.rs
1//! Indicator metadata — the descriptor layer for chart-page auto-discovery.
2//!
3//! This module is *purely additive metadata*. It does not touch the compute
4//! API (`ema()`, `rsi()`, the `Indicator` trait, the runtime `IndicatorRegistry`)
5//! — existing consumers keep working unchanged. Its job is to describe, in a
6//! machine-readable and JSON-serializable form, *what parameters each indicator
7//! takes* so a front-end (janus catalog API → chart page) can render controls
8//! for every indicator without hard-coding them.
9//!
10//! # The one-obvious-place pattern
11//!
12//! [`catalog()`] is a **hand-maintained registry**: one entry per indicator,
13//! co-located in this file. When you add a new Rust indicator to the crate, you
14//! add exactly one thing here — a [`IndicatorDescriptor`] with honest
15//! [`ParamSpec`]s that mirror its `factory()` constructor params (the same keys
16//! and defaults the factory reads from the params map). That single edit is what
17//! makes the new indicator show up in the UI. There is no codegen and no derive
18//! magic on purpose: the descriptor is the source of truth for the UI, and
19//! keeping it explicit keeps it honest.
20//!
21//! Rule of thumb: the `default`/`min`/`max` of each [`ParamSpec`] must match the
22//! `param_usize`/`param_f64` default the corresponding `factory()` uses, and the
23//! `id` must match the lowercased name the indicator is registered under in the
24//! runtime [`crate::registry::IndicatorRegistry`].
25
26use serde::{Deserialize, Serialize};
27
28/// Where an indicator is drawn on a chart.
29///
30/// - `Overlay` — plotted on the price pane, sharing the price scale
31/// (moving averages, bands, VWAP, parabolic SAR).
32/// - `Oscillator` — plotted in a separate pane with its own scale
33/// (RSI, MACD, stochastics, volatility/volume oscillators).
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35pub enum IndicatorCategory {
36 /// Drawn on the price pane, on the price scale.
37 Overlay,
38 /// Drawn in its own pane, on its own scale.
39 Oscillator,
40}
41
42/// The numeric type of a tunable parameter.
43///
44/// Both variants carry their bounds as `f64` in [`ParamSpec`]; `Integer` simply
45/// tells the UI to render a stepper/whole-number control and round on input.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
47pub enum ParamKind {
48 /// A whole-number parameter (a period, a smoothing length, ...).
49 Integer,
50 /// A real-valued parameter (a multiplier, a standard-deviation count, ...).
51 Float,
52}
53
54/// A single tunable parameter of an indicator.
55///
56/// `default`, `min` and `max` are all `f64` regardless of [`ParamKind`] so the
57/// serialized JSON has a uniform numeric shape; for `Integer` params the values
58/// are whole numbers stored as `f64` (e.g. `14.0`).
59#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
60pub struct ParamSpec {
61 /// Parameter key — matches the key the indicator's `factory()` reads
62 /// (e.g. `"period"`, `"fast_period"`, `"std_dev"`).
63 pub name: &'static str,
64 /// Whether the UI should treat this as an integer or a float control.
65 pub kind: ParamKind,
66 /// Default value — must equal the factory's default and lie in `[min, max]`.
67 pub default: f64,
68 /// Inclusive lower bound for the UI control.
69 pub min: f64,
70 /// Inclusive upper bound for the UI control.
71 pub max: f64,
72}
73
74impl ParamSpec {
75 /// Construct an [`ParamKind::Integer`] parameter spec.
76 #[must_use]
77 pub const fn int(name: &'static str, default: f64, min: f64, max: f64) -> Self {
78 Self {
79 name,
80 kind: ParamKind::Integer,
81 default,
82 min,
83 max,
84 }
85 }
86
87 /// Construct a [`ParamKind::Float`] parameter spec.
88 #[must_use]
89 pub const fn float(name: &'static str, default: f64, min: f64, max: f64) -> Self {
90 Self {
91 name,
92 kind: ParamKind::Float,
93 default,
94 min,
95 max,
96 }
97 }
98}
99
100/// Machine-readable description of one indicator, for UI auto-discovery.
101///
102/// This is the JSON payload the janus catalog API emits. Example (RSI):
103///
104/// ```json
105/// {
106/// "id": "rsi",
107/// "display_name": "RSI",
108/// "category": "Oscillator",
109/// "params": [
110/// { "name": "period", "kind": "Integer", "default": 14.0, "min": 2.0, "max": 500.0 }
111/// ]
112/// }
113/// ```
114#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
115pub struct IndicatorDescriptor {
116 /// Stable identifier — the lowercased name the indicator is registered
117 /// under in the runtime registry (e.g. `"rsi"`, `"bollingerbands"`).
118 pub id: &'static str,
119 /// Human-readable name for the UI (e.g. `"RSI"`, `"Bollinger Bands"`).
120 pub display_name: &'static str,
121 /// Which chart pane the indicator belongs to.
122 pub category: IndicatorCategory,
123 /// The indicator's tunable parameters, in display order.
124 pub params: Vec<ParamSpec>,
125}
126
127impl IndicatorDescriptor {
128 /// Convenience constructor keeping [`catalog()`] entries terse.
129 fn new(
130 id: &'static str,
131 display_name: &'static str,
132 category: IndicatorCategory,
133 params: Vec<ParamSpec>,
134 ) -> Self {
135 Self {
136 id,
137 display_name,
138 category,
139 params,
140 }
141 }
142}
143
144/// The hand-maintained catalog of every indicator in this crate.
145///
146/// **This is the one place to edit when adding an indicator.** Each entry's
147/// `id` matches the runtime registry name, `display_name` matches the
148/// indicator's `name()`, and the [`ParamSpec`]s mirror the defaults its
149/// `factory()` reads. See the module docs for the full contract.
150///
151/// Ordering: trend → momentum → volatility → volume, matching
152/// `registry::registry()`'s `register_all` call order.
153#[must_use]
154pub fn catalog() -> Vec<IndicatorDescriptor> {
155 use IndicatorCategory::{Oscillator, Overlay};
156 use ParamSpec as P;
157
158 // Shared bound: periods are whole numbers in a sane charting range.
159 const PMIN: f64 = 1.0;
160 const PMAX: f64 = 500.0;
161
162 vec![
163 // ── trend ────────────────────────────────────────────────────────────
164 IndicatorDescriptor::new(
165 "ema",
166 "EMA",
167 Overlay,
168 vec![P::int("period", 20.0, PMIN, PMAX)],
169 ),
170 IndicatorDescriptor::new(
171 "sma",
172 "SMA",
173 Overlay,
174 vec![P::int("period", 20.0, PMIN, PMAX)],
175 ),
176 IndicatorDescriptor::new(
177 "wma",
178 "WMA",
179 Overlay,
180 vec![P::int("period", 14.0, PMIN, PMAX)],
181 ),
182 IndicatorDescriptor::new(
183 "macd",
184 "MACD",
185 Oscillator,
186 vec![
187 P::int("fast_period", 12.0, PMIN, PMAX),
188 P::int("slow_period", 26.0, PMIN, PMAX),
189 P::int("signal_period", 9.0, PMIN, PMAX),
190 ],
191 ),
192 IndicatorDescriptor::new(
193 "atr",
194 "ATR",
195 Oscillator,
196 vec![P::int("period", 14.0, PMIN, PMAX)],
197 ),
198 IndicatorDescriptor::new(
199 "linearregression",
200 "LinearRegression",
201 Overlay,
202 vec![P::int("period", 14.0, PMIN, PMAX)],
203 ),
204 IndicatorDescriptor::new(
205 "parabolicsar",
206 "ParabolicSAR",
207 Overlay,
208 vec![
209 P::float("step", 0.02, 0.001, 0.5),
210 P::float("max_step", 0.2, 0.01, 1.0),
211 ],
212 ),
213 // ── momentum ─────────────────────────────────────────────────────────
214 IndicatorDescriptor::new(
215 "rsi",
216 "RSI",
217 Oscillator,
218 vec![P::int("period", 14.0, 2.0, PMAX)],
219 ),
220 IndicatorDescriptor::new(
221 "schafftrendcycle",
222 "SchaffTrendCycle",
223 Oscillator,
224 vec![
225 P::int("short_ema", 12.0, PMIN, PMAX),
226 P::int("long_ema", 26.0, PMIN, PMAX),
227 P::int("stoch_period", 10.0, PMIN, PMAX),
228 P::int("signal_period", 3.0, PMIN, PMAX),
229 ],
230 ),
231 IndicatorDescriptor::new(
232 "stochastic",
233 "Stochastic",
234 Oscillator,
235 vec![
236 P::int("k_period", 14.0, PMIN, PMAX),
237 P::int("smooth_k", 3.0, PMIN, PMAX),
238 P::int("d_period", 3.0, PMIN, PMAX),
239 ],
240 ),
241 IndicatorDescriptor::new(
242 "stochasticrsi",
243 "StochasticRSI",
244 Oscillator,
245 vec![
246 P::int("rsi_period", 14.0, 2.0, PMAX),
247 P::int("stoch_period", 14.0, PMIN, PMAX),
248 P::int("k_smooth", 3.0, PMIN, PMAX),
249 P::int("d_period", 3.0, PMIN, PMAX),
250 ],
251 ),
252 IndicatorDescriptor::new(
253 "williamsr",
254 "WilliamsR",
255 Oscillator,
256 vec![P::int("period", 14.0, PMIN, PMAX)],
257 ),
258 // ── volatility ───────────────────────────────────────────────────────
259 IndicatorDescriptor::new(
260 "bollingerbands",
261 "BollingerBands",
262 Overlay,
263 vec![
264 P::int("period", 20.0, PMIN, PMAX),
265 P::float("std_dev", 2.0, 0.1, 10.0),
266 ],
267 ),
268 IndicatorDescriptor::new(
269 "choppinessindex",
270 "ChoppinessIndex",
271 Oscillator,
272 vec![P::int("period", 14.0, PMIN, PMAX)],
273 ),
274 IndicatorDescriptor::new(
275 "elderrayindex",
276 "ElderRayIndex",
277 Oscillator,
278 vec![P::int("fast_period", 14.0, PMIN, PMAX)],
279 ),
280 IndicatorDescriptor::new(
281 "keltnerchannels",
282 "KeltnerChannels",
283 Overlay,
284 vec![
285 P::int("period", 20.0, PMIN, PMAX),
286 P::float("multiplier", 2.0, 0.1, 10.0),
287 ],
288 ),
289 IndicatorDescriptor::new(
290 "marketcycle",
291 "MarketCycle",
292 Oscillator,
293 vec![P::int("momentum_period", 1.0, PMIN, PMAX)],
294 ),
295 // ── volume ───────────────────────────────────────────────────────────
296 IndicatorDescriptor::new("adl", "ADL", Oscillator, vec![]),
297 IndicatorDescriptor::new(
298 "chaikinmoneyflow",
299 "ChaikinMoneyFlow",
300 Oscillator,
301 vec![P::int("period", 20.0, PMIN, PMAX)],
302 ),
303 // VWAP: period 0 means cumulative (session) VWAP; > 0 is a rolling window.
304 IndicatorDescriptor::new(
305 "vwap",
306 "VWAP",
307 Overlay,
308 vec![P::int("period", 0.0, 0.0, PMAX)],
309 ),
310 IndicatorDescriptor::new(
311 "vzo",
312 "VZO",
313 Oscillator,
314 vec![P::int("period", 14.0, PMIN, PMAX)],
315 ),
316 ]
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322 use std::collections::HashSet;
323
324 #[test]
325 fn catalog_is_non_empty() {
326 assert!(!catalog().is_empty());
327 }
328
329 #[test]
330 fn ids_are_unique() {
331 let cat = catalog();
332 let mut seen = HashSet::new();
333 for d in &cat {
334 assert!(seen.insert(d.id), "duplicate indicator id: {}", d.id);
335 }
336 }
337
338 #[test]
339 fn defaults_within_bounds() {
340 for d in catalog() {
341 for p in &d.params {
342 assert!(
343 p.min <= p.max,
344 "{}::{} has min {} > max {}",
345 d.id,
346 p.name,
347 p.min,
348 p.max
349 );
350 assert!(
351 p.default >= p.min && p.default <= p.max,
352 "{}::{} default {} out of [{}, {}]",
353 d.id,
354 p.name,
355 p.default,
356 p.min,
357 p.max
358 );
359 }
360 }
361 }
362
363 #[test]
364 fn ids_match_runtime_registry() {
365 // The descriptor catalog must not drift from the runtime registry:
366 // every descriptor id should be a creatable indicator name.
367 let reg = crate::registry::registry();
368 for d in catalog() {
369 assert!(
370 reg.contains(d.id),
371 "descriptor id `{}` is not registered in the runtime registry",
372 d.id
373 );
374 }
375 }
376
377 #[test]
378 fn serializes_to_expected_json_shape() {
379 let rsi = catalog()
380 .into_iter()
381 .find(|d| d.id == "rsi")
382 .expect("rsi in catalog");
383 let json = serde_json::to_value(&rsi).unwrap();
384 assert_eq!(json["id"], "rsi");
385 assert_eq!(json["display_name"], "RSI");
386 assert_eq!(json["category"], "Oscillator");
387 assert_eq!(json["params"][0]["name"], "period");
388 assert_eq!(json["params"][0]["kind"], "Integer");
389 assert_eq!(json["params"][0]["default"], 14.0);
390 }
391}