Skip to main content

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}