Skip to main content

kestrel_chartkit/indicator/
registry.rs

1use std::collections::HashMap;
2
3#[cfg(feature = "serde")]
4use serde::{Deserialize, Serialize};
5
6use super::adx::Adx;
7use super::alligator::AlligatorEngine;
8use super::anchored_vwap::{AnchoredVwapEngine, VwapAnchorKind, ZeroVolumePolicy};
9use super::atr::Atr;
10use super::bollinger::BollingerBands;
11use super::bop::BalanceOfPowerEngine;
12use super::bos_choch::BosChochEngine;
13use super::buy_sell_pressure::BuySellPressureEstimator;
14use super::candle_story::CandleStoryEngine;
15use super::cci::Cci;
16use super::chaikin_osc::ChaikinOscillatorEngine;
17use super::chandelier_exit::ChandelierExitEngine;
18use super::choppiness::ChoppinessIndexEngine;
19use super::connors_rsi::ConnorsRsiEngine;
20use super::coppock::CoppockCurveEngine;
21use super::dpo::DpoEngine;
22use super::efficiency::LegEfficiencyEngine;
23use super::envelope::EnvelopeEngine;
24use super::eom::EomEngine;
25use super::fisher_transform::FisherTransform;
26use super::kst::KstEngine;
27use super::liquidity_fvg::LiquidityFvgEngine;
28use super::liquidity_sweeps::LiquiditySweepEngine;
29use super::lsma::LsmaEngine;
30use super::macd::Macd;
31use super::market_structure_breaks::MarketStructureBreaksEngine;
32use super::mass_index::MassIndexEngine;
33use super::mcginley::McGinleyDynamicEngine;
34use super::mfi::Mfi;
35use super::midas::{MidasCurveEngine, MidasMode};
36use super::momentum_indicators::{
37    AwesomeOscillatorEngine, CmoEngine, ElderRayEngine, PpoEngine, RocEngine, StochasticEngine,
38    UltimateOscillatorEngine,
39};
40use super::moving_averages::{
41    DemaEngine, EmaEngine, HmaEngine, KamaEngine, SmaEngine, VwmaEngine, WmaEngine,
42};
43use super::multi_factor::MultiFactorMarketScore;
44use super::nvi_pvi::{NviEngine, PviEngine};
45use super::order_block::OrderBlockEngine;
46pub use super::params::{ParamValue, TypedParams};
47use super::pivot_sets::{PivotSetType, PivotSetsEngine};
48use super::pivots_structure::PivotStructureEngine;
49use super::rsi::Rsi;
50use super::rvi::RviEngine;
51use super::stoch_rsi::StochRsi;
52use super::tema::TemaEngine;
53use super::trend_quality::TrendQualityScoreEngine;
54use super::trend_structural::{
55    AroonEngine, DmiEngine, IchimokuEngine, ParabolicSarEngine, SupertrendEngine,
56};
57use super::tsi::Tsi;
58use super::vix_fix::WilliamsVixFix;
59use super::volatility_indicators::{
60    DonchianChannelEngine, GarmanKlassVolatilityEngine, HistoricalVolatilityEngine,
61    KeltnerChannelEngine, TrueRangeEngine,
62};
63use super::volatility_regime::VolatilityRegimeDetector;
64use super::volume_flow::{CvdEngine, KlingerVolumeForceEngine};
65use super::volume_flow_hires::HiResVolumeFlowEngine;
66use super::volume_indicators::{AccDistEngine, CmfEngine, ObvEngine, RvolEngine, VolumeEngine};
67use super::volume_profile::VolumeProfileEngine;
68use super::volume_profile_extended::ExtendedVolumeProfileEngine;
69use super::volume_profile_persistent::PersistentVolumeProfileEngine;
70use super::vortex::VortexEngine;
71use super::vwap::Vwap;
72use super::wavetrend::WaveTrendEngine;
73use super::williams_r::WilliamsR;
74use super::zigzag::ZigZagEngine;
75use super::zigzag_advanced::{AdvancedZigZagEngine, ZigZagDeviationMode};
76use super::zscore::ZScoreEngine;
77use super::Indicator;
78
79#[derive(Debug, Clone, PartialEq)]
80#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
81pub struct IndicatorCatalogEntry {
82    pub name: &'static str,
83    pub description: &'static str,
84    pub default_params: HashMap<String, f64>,
85}
86
87pub fn catalog() -> Vec<IndicatorCatalogEntry> {
88    vec![
89        IndicatorCatalogEntry {
90            name: "rsi",
91            description: "Relative Strength Index",
92            default_params: [
93                ("rsi_len".to_string(), 14.0),
94                ("avg_len".to_string(), 3.0),
95                ("sig_len".to_string(), 3.0),
96                ("overbought".to_string(), 70.0),
97                ("oversold".to_string(), 30.0),
98            ]
99            .into(),
100        },
101        IndicatorCatalogEntry {
102            name: "macd",
103            description: "Moving Average Convergence Divergence",
104            default_params: [
105                ("fast_len".to_string(), 12.0),
106                ("slow_len".to_string(), 26.0),
107                ("signal_len".to_string(), 9.0),
108            ]
109            .into(),
110        },
111        IndicatorCatalogEntry {
112            name: "bollinger",
113            description: "Bollinger Bands",
114            default_params: [("len".to_string(), 20.0), ("mult".to_string(), 2.0)].into(),
115        },
116        IndicatorCatalogEntry {
117            name: "adx",
118            description: "Average Directional Index",
119            default_params: [
120                ("di_len".to_string(), 14.0),
121                ("adx_smooth".to_string(), 14.0),
122                ("level_weak".to_string(), 20.0),
123            ]
124            .into(),
125        },
126        IndicatorCatalogEntry {
127            name: "stoch_rsi",
128            description: "Stochastic RSI",
129            default_params: [
130                ("rsi_len".to_string(), 14.0),
131                ("stoch_len".to_string(), 14.0),
132                ("k_len".to_string(), 3.0),
133                ("d_len".to_string(), 3.0),
134                ("overbought".to_string(), 80.0),
135                ("oversold".to_string(), 20.0),
136            ]
137            .into(),
138        },
139        IndicatorCatalogEntry {
140            name: "cci",
141            description: "Commodity Channel Index",
142            default_params: [
143                ("cci_len".to_string(), 20.0),
144                ("overbought".to_string(), 100.0),
145                ("oversold".to_string(), -100.0),
146            ]
147            .into(),
148        },
149        IndicatorCatalogEntry {
150            name: "mfi",
151            description: "Money Flow Index",
152            default_params: [
153                ("mfi_len".to_string(), 14.0),
154                ("overbought".to_string(), 80.0),
155                ("oversold".to_string(), 20.0),
156            ]
157            .into(),
158        },
159        IndicatorCatalogEntry {
160            name: "atr",
161            description: "Average True Range",
162            default_params: [("atr_len".to_string(), 14.0), ("sig_len".to_string(), 20.0)].into(),
163        },
164        IndicatorCatalogEntry {
165            name: "chandelier_exit",
166            description: "Chandelier Exit (ATR trailing stop with direction flip)",
167            default_params: [("length".to_string(), 22.0), ("atr_mult".to_string(), 3.0)].into(),
168        },
169        IndicatorCatalogEntry {
170            name: "midas",
171            description: "MIDAS launch-anchored curve with Topfinder/Bottomfinder projection (build_typed with mode=topfinder|bottomfinder)",
172            default_params: [("maturity_bars".to_string(), 20.0)].into(),
173        },
174        IndicatorCatalogEntry {
175            name: "hires_volume_flow",
176            description: "High-resolution volume flow with absorption detection (OHLC-estimated via this registry entry point; use HiResVolumeFlowEngine::on_bar_with_aggressor/on_intrabar_group directly for direct aggressor/intrabar-delta resolution)",
177            default_params: [("window_len".to_string(), 20.0)].into(),
178        },
179        IndicatorCatalogEntry {
180            name: "extended_volume_profile",
181            description: "Full-bin price/volume profile with HVN/LVN/AVN classification, a delta profile, and zone formation (use ExtendedVolumeProfileEngine::on_intrabar_group directly for intrabar-resolution distribution)",
182            default_params: [("lookback".to_string(), 70.0), ("num_bins".to_string(), 30.0)].into(),
183        },
184        IndicatorCatalogEntry {
185            name: "persistent_volume_profile",
186            description: "Fixed-price-grid volume profile with real bin lifecycle (birth/growth/expiry across updates) and a per-bin absorption profile",
187            default_params: [("lookback".to_string(), 70.0), ("bin_width".to_string(), 1.0)].into(),
188        },
189        IndicatorCatalogEntry {
190            name: "trend_relationship",
191            description: "Adaptive trend relationship between two configurable smoothers (build via build_typed with fast_kind/slow_kind params: ema|sma|rma|alma|jma)",
192            default_params: [("fast_len".to_string(), 9.0), ("slow_len".to_string(), 21.0)].into(),
193        },
194        IndicatorCatalogEntry {
195            name: "williams_r",
196            description: "Williams %R",
197            default_params: [
198                ("wpr_len".to_string(), 14.0),
199                ("overbought".to_string(), 80.0),
200                ("oversold".to_string(), 20.0),
201            ]
202            .into(),
203        },
204        IndicatorCatalogEntry {
205            name: "tsi",
206            description: "True Strength Index",
207            default_params: [
208                ("long_len".to_string(), 25.0),
209                ("short_len".to_string(), 13.0),
210                ("sig_len".to_string(), 7.0),
211            ]
212            .into(),
213        },
214        IndicatorCatalogEntry {
215            name: "fisher_transform",
216            description: "Fisher Transform",
217            default_params: [
218                ("fish_len".to_string(), 10.0),
219                ("overbought".to_string(), 1.5),
220                ("oversold".to_string(), -1.5),
221            ]
222            .into(),
223        },
224        IndicatorCatalogEntry {
225            name: "vwap",
226            description: "Rolling Volume Weighted Average Price with sigma bands and slope",
227            default_params: [
228                ("window".to_string(), 390.0),
229                ("slope_lookback".to_string(), 20.0),
230            ]
231            .into(),
232        },
233        IndicatorCatalogEntry {
234            name: "volume",
235            description: "Volume and Average Volume",
236            default_params: [("ma_period".to_string(), 20.0)].into(),
237        },
238        IndicatorCatalogEntry {
239            name: "rvol",
240            description: "Relative Volume vs Moving Average",
241            default_params: [("period".to_string(), 20.0)].into(),
242        },
243        IndicatorCatalogEntry {
244            name: "obv",
245            description: "On-Balance Volume",
246            default_params: HashMap::new(),
247        },
248        IndicatorCatalogEntry {
249            name: "cmf",
250            description: "Chaikin Money Flow",
251            default_params: [("period".to_string(), 20.0)].into(),
252        },
253        IndicatorCatalogEntry {
254            name: "acc_dist",
255            description: "Accumulation / Distribution Line",
256            default_params: HashMap::new(),
257        },
258        IndicatorCatalogEntry {
259            name: "true_range",
260            description: "True Range in price units",
261            default_params: HashMap::new(),
262        },
263        IndicatorCatalogEntry {
264            name: "keltner",
265            description: "Keltner Channels",
266            default_params: [
267                ("ema_period".to_string(), 20.0),
268                ("atr_period".to_string(), 10.0),
269                ("multiplier".to_string(), 2.0),
270            ]
271            .into(),
272        },
273        IndicatorCatalogEntry {
274            name: "donchian",
275            description: "Donchian Channels",
276            default_params: [("period".to_string(), 20.0)].into(),
277        },
278        IndicatorCatalogEntry {
279            name: "historical_volatility",
280            description: "Annualized Historical Volatility",
281            default_params: [("period".to_string(), 20.0)].into(),
282        },
283        IndicatorCatalogEntry {
284            name: "garman_klass",
285            description: "Garman-Klass Volatility Estimator",
286            default_params: [("period".to_string(), 20.0)].into(),
287        },
288        IndicatorCatalogEntry {
289            name: "sma",
290            description: "Simple Moving Average",
291            default_params: [("period".to_string(), 20.0)].into(),
292        },
293        IndicatorCatalogEntry {
294            name: "ema",
295            description: "Exponential Moving Average",
296            default_params: [("period".to_string(), 20.0)].into(),
297        },
298        IndicatorCatalogEntry {
299            name: "wma",
300            description: "Weighted Moving Average",
301            default_params: [("period".to_string(), 20.0)].into(),
302        },
303        IndicatorCatalogEntry {
304            name: "vwma",
305            description: "Volume-Weighted Moving Average",
306            default_params: [("period".to_string(), 20.0)].into(),
307        },
308        IndicatorCatalogEntry {
309            name: "hma",
310            description: "Hull Moving Average",
311            default_params: [("period".to_string(), 20.0)].into(),
312        },
313        IndicatorCatalogEntry {
314            name: "dema",
315            description: "Double Exponential Moving Average",
316            default_params: [("period".to_string(), 20.0)].into(),
317        },
318        IndicatorCatalogEntry {
319            name: "kama",
320            description: "Kaufman's Adaptive Moving Average",
321            default_params: [
322                ("period".to_string(), 10.0),
323                ("fast_period".to_string(), 2.0),
324                ("slow_period".to_string(), 30.0),
325            ]
326            .into(),
327        },
328        IndicatorCatalogEntry {
329            name: "dmi",
330            description: "Directional Movement Index (+DI / -DI)",
331            default_params: [("period".to_string(), 14.0)].into(),
332        },
333        IndicatorCatalogEntry {
334            name: "aroon",
335            description: "Aroon Indicator (Up, Down, Oscillator)",
336            default_params: [("period".to_string(), 14.0)].into(),
337        },
338        IndicatorCatalogEntry {
339            name: "parabolic_sar",
340            description: "Parabolic SAR",
341            default_params: [("step".to_string(), 0.02), ("max_step".to_string(), 0.20)].into(),
342        },
343        IndicatorCatalogEntry {
344            name: "supertrend",
345            description: "Supertrend ATR Trailing Stop",
346            default_params: [
347                ("period".to_string(), 10.0),
348                ("multiplier".to_string(), 3.0),
349            ]
350            .into(),
351        },
352        IndicatorCatalogEntry {
353            name: "ichimoku",
354            description: "Ichimoku Kinko Hyo Cloud",
355            default_params: [
356                ("tenkan_p".to_string(), 9.0),
357                ("kijun_p".to_string(), 26.0),
358                ("senkou_b_p".to_string(), 52.0),
359            ]
360            .into(),
361        },
362        IndicatorCatalogEntry {
363            name: "stochastic",
364            description: "Classic Stochastic Oscillator",
365            default_params: [
366                ("k_period".to_string(), 14.0),
367                ("d_period".to_string(), 3.0),
368            ]
369            .into(),
370        },
371        IndicatorCatalogEntry {
372            name: "roc",
373            description: "Rate of Change / Momentum",
374            default_params: [("period".to_string(), 12.0)].into(),
375        },
376        IndicatorCatalogEntry {
377            name: "ultimate_oscillator",
378            description: "Ultimate Oscillator",
379            default_params: [
380                ("period1".to_string(), 7.0),
381                ("period2".to_string(), 14.0),
382                ("period3".to_string(), 28.0),
383            ]
384            .into(),
385        },
386        IndicatorCatalogEntry {
387            name: "awesome_oscillator",
388            description: "Awesome Oscillator",
389            default_params: [
390                ("fast_period".to_string(), 5.0),
391                ("slow_period".to_string(), 34.0),
392            ]
393            .into(),
394        },
395        IndicatorCatalogEntry {
396            name: "ppo",
397            description: "Percentage Price Oscillator",
398            default_params: [
399                ("fast_period".to_string(), 12.0),
400                ("slow_period".to_string(), 26.0),
401                ("signal_period".to_string(), 9.0),
402            ]
403            .into(),
404        },
405        IndicatorCatalogEntry {
406            name: "wavetrend",
407            description: "WaveTrend Oscillator (wt1, wt2)",
408            default_params: [
409                ("n1".to_string(), 10.0),
410                ("n2".to_string(), 21.0),
411                ("ob_level".to_string(), 60.0),
412                ("os_level".to_string(), -60.0),
413            ]
414            .into(),
415        },
416        IndicatorCatalogEntry {
417            name: "cmo",
418            description: "Chande Momentum Oscillator",
419            default_params: [("period".to_string(), 14.0)].into(),
420        },
421        IndicatorCatalogEntry {
422            name: "elder_ray",
423            description: "Elder Ray Index (Bull/Bear Power)",
424            default_params: [("period".to_string(), 13.0)].into(),
425        },
426        IndicatorCatalogEntry {
427            name: "anchored_vwap",
428            description: "Anchored VWAP Engine",
429            default_params: [("mult1".to_string(), 1.0), ("mult2".to_string(), 2.0)].into(),
430        },
431        IndicatorCatalogEntry {
432            name: "cvd",
433            description: "Cumulative Volume Delta",
434            default_params: [].into(),
435        },
436        IndicatorCatalogEntry {
437            name: "klinger",
438            description: "Klinger Volume Force Oscillator",
439            default_params: [
440                ("fast_len".to_string(), 34.0),
441                ("slow_len".to_string(), 55.0),
442                ("signal_len".to_string(), 13.0),
443            ]
444            .into(),
445        },
446        IndicatorCatalogEntry {
447            name: "zigzag",
448            description: "ZigZag Swing Leg Engine",
449            default_params: [
450                ("depth".to_string(), 12.0),
451                ("deviation_pct".to_string(), 5.0),
452            ]
453            .into(),
454        },
455        IndicatorCatalogEntry {
456            name: "zigzag_advanced",
457            description: "ZigZag with backstep, running-leg/confirmation status, and ATR-mode deviation (build_typed with deviation_mode=percent|atr_multiple); AdvancedZigZagEngine::reduce/project_to_timeframe for recursive levels and HTF projection",
458            default_params: [
459                ("depth".to_string(), 3.0),
460                ("backstep".to_string(), 2.0),
461                ("deviation_pct".to_string(), 1.0),
462                ("atr_len".to_string(), 14.0),
463            ]
464            .into(),
465        },
466        IndicatorCatalogEntry {
467            name: "pivot_sets",
468            description: "Multi-Pivot Set Engine",
469            default_params: [].into(),
470        },
471        IndicatorCatalogEntry {
472            name: "tema",
473            description: "Triple Exponential Moving Average",
474            default_params: [("period".to_string(), 14.0)].into(),
475        },
476        IndicatorCatalogEntry {
477            name: "lsma",
478            description: "Least Squares Moving Average / Linear Regression",
479            default_params: [("period".to_string(), 25.0)].into(),
480        },
481        IndicatorCatalogEntry {
482            name: "mcginley",
483            description: "McGinley Dynamic Moving Average",
484            default_params: [("period".to_string(), 14.0)].into(),
485        },
486        IndicatorCatalogEntry {
487            name: "envelope",
488            description: "Moving Average Envelopes",
489            default_params: [("period".to_string(), 20.0), ("percent".to_string(), 2.5)].into(),
490        },
491        IndicatorCatalogEntry {
492            name: "choppiness",
493            description: "Choppiness Index",
494            default_params: [("period".to_string(), 14.0)].into(),
495        },
496        IndicatorCatalogEntry {
497            name: "vortex",
498            description: "Vortex Indicator (+VI, -VI)",
499            default_params: [("period".to_string(), 14.0)].into(),
500        },
501        IndicatorCatalogEntry {
502            name: "alligator",
503            description: "Williams Alligator (Jaw, Teeth, Lips)",
504            default_params: [].into(),
505        },
506        IndicatorCatalogEntry {
507            name: "connors_rsi",
508            description: "Connors RSI",
509            default_params: [
510                ("rsi_len".to_string(), 3.0),
511                ("streak_len".to_string(), 2.0),
512                ("rank_len".to_string(), 100.0),
513            ]
514            .into(),
515        },
516        IndicatorCatalogEntry {
517            name: "coppock",
518            description: "Coppock Curve",
519            default_params: [].into(),
520        },
521        IndicatorCatalogEntry {
522            name: "dpo",
523            description: "Detrended Price Oscillator",
524            default_params: [("period".to_string(), 21.0)].into(),
525        },
526        IndicatorCatalogEntry {
527            name: "kst",
528            description: "Know Sure Thing Oscillator",
529            default_params: [].into(),
530        },
531        IndicatorCatalogEntry {
532            name: "mass_index",
533            description: "Mass Index Reversal Detector",
534            default_params: [("period".to_string(), 25.0)].into(),
535        },
536        IndicatorCatalogEntry {
537            name: "rvi",
538            description: "Relative Vigor Index",
539            default_params: [("period".to_string(), 10.0)].into(),
540        },
541        IndicatorCatalogEntry {
542            name: "bop",
543            description: "Balance of Power",
544            default_params: [("period".to_string(), 14.0)].into(),
545        },
546        IndicatorCatalogEntry {
547            name: "eom",
548            description: "Ease of Movement",
549            default_params: [
550                ("period".to_string(), 14.0),
551                ("volume_divisor".to_string(), 10000.0),
552            ]
553            .into(),
554        },
555        IndicatorCatalogEntry {
556            name: "nvi",
557            description: "Negative Volume Index",
558            default_params: [].into(),
559        },
560        IndicatorCatalogEntry {
561            name: "pvi",
562            description: "Positive Volume Index",
563            default_params: [].into(),
564        },
565        IndicatorCatalogEntry {
566            name: "chaikin_oscillator",
567            description: "Chaikin Oscillator",
568            default_params: [
569                ("fast_len".to_string(), 3.0),
570                ("slow_len".to_string(), 10.0),
571            ]
572            .into(),
573        },
574        IndicatorCatalogEntry {
575            name: "bos_choch",
576            description: "BOS and CHoCH Market Structure Engine",
577            default_params: [("pivot_len".to_string(), 5.0)].into(),
578        },
579        IndicatorCatalogEntry {
580            name: "liquidity_sweeps",
581            description: "Liquidity Sweeps and EQH/EQL Detector",
582            default_params: [
583                ("pivot_len".to_string(), 5.0),
584                ("tolerance_pct".to_string(), 0.2),
585            ]
586            .into(),
587        },
588        IndicatorCatalogEntry {
589            name: "liquidity_pools",
590            description: "BSL/SSL liquidity pools with explicit stop-hunt/breakout/reclaim classification (see also FvgZoneTracker and SmartMoneyStructureLinker for FVG-fill tracking and cross-detector confluence)",
591            default_params: [
592                ("pivot_len".to_string(), 5.0),
593                ("tolerance_pct".to_string(), 0.2),
594            ]
595            .into(),
596        },
597        IndicatorCatalogEntry {
598            name: "wyckoff",
599            description: "Wyckoff accumulation/distribution state machine: range-lock, Phases A-E, Spring/UTAD, SOS/SOW/LPS/LPSY, sequence validation and Cause/Quality scoring",
600            default_params: [
601                ("range_lookback".to_string(), 20.0),
602                ("range_atr_max".to_string(), 3.0),
603                ("min_range_bars".to_string(), 6.0),
604            ]
605            .into(),
606        },
607        IndicatorCatalogEntry {
608            name: "trend_quality",
609            description: "Trend Quality Score Engine",
610            default_params: [("period".to_string(), 14.0)].into(),
611        },
612        IndicatorCatalogEntry {
613            name: "buy_sell_pressure",
614            description: "Buy/Sell Pressure Estimator",
615            default_params: [("period".to_string(), 14.0)].into(),
616        },
617        IndicatorCatalogEntry {
618            name: "volatility_regime",
619            description: "Volatility Regime & Squeeze Detector",
620            default_params: [
621                ("period".to_string(), 20.0),
622                ("bb_mult".to_string(), 2.0),
623                ("kc_mult".to_string(), 1.5),
624            ]
625            .into(),
626        },
627        IndicatorCatalogEntry {
628            name: "zscore",
629            description: "Rolling Z-Score Engine",
630            default_params: [("period".to_string(), 20.0)].into(),
631        },
632        IndicatorCatalogEntry {
633            name: "multi_factor",
634            description: "Multi-Factor Composite Market Score",
635            default_params: [("period".to_string(), 14.0)].into(),
636        },
637    ]
638}
639
640#[derive(Debug, Clone, PartialEq)]
641#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
642pub enum RegistryError {
643    UnknownIndicator(String),
644    InvalidParameter {
645        parameter: String,
646        value: f64,
647        reason: String,
648    },
649    /// A [`ParamValue`] with no numeric equivalent (`Enum`/`Text`/`Timeframe`/`Source`) was
650    /// passed to [`build_typed`] for an indicator whose current parameter surface is `f64`-only.
651    UnsupportedParameterType {
652        parameter: String,
653        type_name: String,
654    },
655    /// A [`ParamValue::Enum`] string did not match any variant accepted for this parameter.
656    InvalidEnumValue {
657        parameter: String,
658        value: String,
659        reason: String,
660    },
661    /// A parameter is a known, supported type but cannot be applied to this specific indicator
662    /// (e.g. a non-`Close` `source` on a range/OHLC-dependent indicator — see
663    /// [`super::source_mapped::SourceMapped`]'s doc comment for why).
664    IncompatibleParameter {
665        parameter: String,
666        indicator: String,
667        reason: String,
668    },
669}
670
671impl std::fmt::Display for RegistryError {
672    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
673        match self {
674            RegistryError::UnknownIndicator(name) => write!(f, "Unknown indicator: {}", name),
675            RegistryError::InvalidParameter {
676                parameter,
677                value,
678                reason,
679            } => write!(
680                f,
681                "Invalid parameter '{}' (value {}): {}",
682                parameter, value, reason
683            ),
684            RegistryError::UnsupportedParameterType {
685                parameter,
686                type_name,
687            } => write!(
688                f,
689                "Parameter '{}' has unsupported type '{}' for this indicator",
690                parameter, type_name
691            ),
692            RegistryError::InvalidEnumValue {
693                parameter,
694                value,
695                reason,
696            } => write!(
697                f,
698                "Invalid value '{}' for parameter '{}': {}",
699                value, parameter, reason
700            ),
701            RegistryError::IncompatibleParameter {
702                parameter,
703                indicator,
704                reason,
705            } => write!(
706                f,
707                "Parameter '{}' is not compatible with indicator '{}': {}",
708                parameter, indicator, reason
709            ),
710        }
711    }
712}
713
714impl std::error::Error for RegistryError {}
715
716fn get_usize_p(
717    params: &HashMap<String, f64>,
718    name: &str,
719    default: usize,
720    min: usize,
721    max: usize,
722) -> Result<usize, RegistryError> {
723    if let Some(&val) = params.get(name) {
724        if !val.is_finite() || val.fract() != 0.0 || val < (min as f64) || val > (max as f64) {
725            return Err(RegistryError::InvalidParameter {
726                parameter: name.to_string(),
727                value: val,
728                reason: format!(
729                    "Value must be a whole, finite number between {} and {}",
730                    min, max
731                ),
732            });
733        }
734        Ok(val as usize)
735    } else {
736        Ok(default)
737    }
738}
739
740fn get_f64_p(
741    params: &HashMap<String, f64>,
742    name: &str,
743    default: f64,
744    min: f64,
745    max: f64,
746) -> Result<f64, RegistryError> {
747    if let Some(&val) = params.get(name) {
748        if !val.is_finite() || val < min || val > max {
749            return Err(RegistryError::InvalidParameter {
750                parameter: name.to_string(),
751                value: val,
752                reason: format!("Value must be a finite number between {} and {}", min, max),
753            });
754        }
755        Ok(val)
756    } else {
757        Ok(default)
758    }
759}
760
761fn ensure_less(
762    parameter: &str,
763    value: f64,
764    upper_parameter: &str,
765    upper_value: f64,
766) -> Result<(), RegistryError> {
767    if value < upper_value {
768        return Ok(());
769    }
770
771    Err(RegistryError::InvalidParameter {
772        parameter: parameter.to_string(),
773        value,
774        reason: format!("{} must be smaller than {}", parameter, upper_parameter),
775    })
776}
777
778pub fn build_checked(
779    name: &str,
780    params: &HashMap<String, f64>,
781) -> Result<Box<dyn Indicator>, RegistryError> {
782    match name.to_lowercase().as_str() {
783        "rsi" => {
784            let rsi_len = get_usize_p(params, "rsi_len", 14, 1, 10000)?;
785            let avg_len = get_usize_p(params, "avg_len", 3, 1, 10000)?;
786            let sig_len = get_usize_p(params, "sig_len", 3, 1, 10000)?;
787            let overbought = get_f64_p(params, "overbought", 70.0, 0.0, 100.0)?;
788            let oversold = get_f64_p(params, "oversold", 30.0, 0.0, 100.0)?;
789            ensure_less("oversold", oversold, "overbought", overbought)?;
790            Ok(Box::new(Rsi::new(
791                rsi_len, avg_len, sig_len, 50.0, overbought, oversold, 5, true, 100, 4, 10.0,
792            )))
793        }
794        "macd" => {
795            let fast_len = get_usize_p(params, "fast_len", 12, 1, 10000)?;
796            let slow_len = get_usize_p(params, "slow_len", 26, 1, 10000)?;
797            let signal_len = get_usize_p(params, "signal_len", 9, 1, 10000)?;
798            ensure_less("fast_len", fast_len as f64, "slow_len", slow_len as f64)?;
799            Ok(Box::new(Macd::new(fast_len, slow_len, signal_len)))
800        }
801        "bollinger" | "bb" => {
802            let len = get_usize_p(params, "len", 20, 1, 10000)?;
803            let mult = get_f64_p(params, "mult", 2.0, 0.01, 100.0)?;
804            Ok(Box::new(BollingerBands::new(len, mult)))
805        }
806        "adx" => {
807            let di_len = get_usize_p(params, "di_len", 14, 1, 10000)?;
808            let adx_smooth = get_usize_p(params, "adx_smooth", 14, 1, 10000)?;
809            let level_weak = get_f64_p(params, "level_weak", 20.0, 0.0, 100.0)?;
810            Ok(Box::new(Adx::new(di_len, adx_smooth, 3, level_weak)))
811        }
812        "stoch_rsi" | "srsi" => {
813            let rsi_len = get_usize_p(params, "rsi_len", 14, 1, 10000)?;
814            let stoch_len = get_usize_p(params, "stoch_len", 14, 1, 10000)?;
815            let k_len = get_usize_p(params, "k_len", 3, 1, 10000)?;
816            let d_len = get_usize_p(params, "d_len", 3, 1, 10000)?;
817            let overbought = get_f64_p(params, "overbought", 80.0, 0.0, 100.0)?;
818            let oversold = get_f64_p(params, "oversold", 20.0, 0.0, 100.0)?;
819            ensure_less("oversold", oversold, "overbought", overbought)?;
820            Ok(Box::new(StochRsi::new(
821                rsi_len, stoch_len, k_len, d_len, 50.0, overbought, oversold, 5, true, 50, 50, 4,
822                10.0,
823            )))
824        }
825        "cci" => {
826            let cci_len = get_usize_p(params, "cci_len", 20, 1, 10000)?;
827            let overbought = get_f64_p(params, "overbought", 100.0, -1000.0, 1000.0)?;
828            let oversold = get_f64_p(params, "oversold", -100.0, -1000.0, 1000.0)?;
829            ensure_less("oversold", oversold, "overbought", overbought)?;
830            Ok(Box::new(Cci::new(
831                cci_len, 3, 3, 5, oversold, overbought, true, 100, 4, 25.0,
832            )))
833        }
834        "mfi" => {
835            let mfi_len = get_usize_p(params, "mfi_len", 14, 1, 10000)?;
836            let overbought = get_f64_p(params, "overbought", 80.0, 0.0, 100.0)?;
837            let oversold = get_f64_p(params, "oversold", 20.0, 0.0, 100.0)?;
838            ensure_less("oversold", oversold, "overbought", overbought)?;
839            Ok(Box::new(Mfi::new(
840                mfi_len, 3, 3, 50.0, overbought, oversold, 5, true,
841            )))
842        }
843        "atr" => {
844            let atr_len = get_usize_p(params, "atr_len", 14, 1, 10000)?;
845            let sig_len = get_usize_p(params, "sig_len", 20, 1, 10000)?;
846            Ok(Box::new(Atr::new(atr_len, sig_len)))
847        }
848        "chandelier_exit" | "ce" => {
849            let length = get_usize_p(params, "length", 22, 1, 10000)?;
850            let atr_mult = get_f64_p(params, "atr_mult", 3.0, 0.01, 100.0)?;
851            Ok(Box::new(ChandelierExitEngine::new(length, atr_mult)))
852        }
853        "midas" => {
854            // Fixed Topfinder/Hlc3 via this f64-only entry point; use `build_typed` with `mode`
855            // (ParamValue::Enum) and `source` to select Bottomfinder or another price source.
856            let maturity_bars = get_usize_p(params, "maturity_bars", 20, 1, 10000)?;
857            Ok(Box::new(MidasCurveEngine::new(
858                MidasMode::Topfinder,
859                crate::model::Source::Hlc3,
860                maturity_bars as u32,
861            )))
862        }
863        "trend_relationship" => {
864            // Fixed EMA/EMA via this f64-only entry point; use `build_typed` with
865            // `fast_kind`/`slow_kind` (ParamValue::Enum) to select other smoother kinds.
866            let fast_len = get_usize_p(params, "fast_len", 9, 1, 10000)?;
867            let slow_len = get_usize_p(params, "slow_len", 21, 1, 10000)?;
868            ensure_less("fast_len", fast_len as f64, "slow_len", slow_len as f64)?;
869            Ok(Box::new(
870                super::trend_relationship::AdaptiveTrendRelationship::new(
871                    super::smoothing::SmootherKind::Ema,
872                    fast_len,
873                    super::smoothing::SmootherKind::Ema,
874                    slow_len,
875                ),
876            ))
877        }
878        "williams_r" | "wpr" => {
879            let wpr_len = get_usize_p(params, "wpr_len", 14, 1, 10000)?;
880            let overbought = get_f64_p(params, "overbought", 80.0, 0.0, 100.0)?;
881            let oversold = get_f64_p(params, "oversold", 20.0, 0.0, 100.0)?;
882            ensure_less("oversold", oversold, "overbought", overbought)?;
883            Ok(Box::new(WilliamsR::new(
884                wpr_len, 3, 3, 50.0, overbought, oversold, 5, true, 50, 4, 10.0,
885            )))
886        }
887        "tsi" => {
888            let long_len = get_usize_p(params, "long_len", 25, 1, 10000)?;
889            let short_len = get_usize_p(params, "short_len", 13, 1, 10000)?;
890            let sig_len = get_usize_p(params, "sig_len", 7, 1, 10000)?;
891            ensure_less("short_len", short_len as f64, "long_len", long_len as f64)?;
892            Ok(Box::new(Tsi::new(
893                long_len, short_len, sig_len, 0.0, 25.0, -25.0, 5, true, 50, 25, 4, 5.0,
894            )))
895        }
896        "fisher_transform" | "fisher" => {
897            let fish_len = get_usize_p(params, "fish_len", 10, 1, 10000)?;
898            let overbought = get_f64_p(params, "overbought", 1.5, -100.0, 100.0)?;
899            let oversold = get_f64_p(params, "oversold", -1.5, -100.0, 100.0)?;
900            ensure_less("oversold", oversold, "overbought", overbought)?;
901            Ok(Box::new(FisherTransform::new(
902                fish_len, 2, 3, 0.0, overbought, oversold, 5, true, 40, 4, 0.5,
903            )))
904        }
905        "order_block" | "ob" => {
906            let atr_len = get_usize_p(params, "atr_len", 14, 1, 10000)?;
907            let min_disp = get_f64_p(params, "min_disp", 1.0, 0.01, 100.0)?;
908            Ok(Box::new(OrderBlockEngine::new(atr_len, min_disp)))
909        }
910        "liquidity_fvg" | "fvg" | "smc" => {
911            let lookback = get_usize_p(params, "lookback", 20, 1, 10000)?;
912            Ok(Box::new(LiquidityFvgEngine::new(lookback)))
913        }
914        "market_structure_breaks" | "bos" | "choch" => {
915            let lookback = get_usize_p(params, "lookback", 5, 1, 10000)?;
916            Ok(Box::new(MarketStructureBreaksEngine::new(lookback)))
917        }
918        "pivots_structure" | "pivots" => {
919            let left_bars = get_usize_p(params, "left_bars", 5, 1, 10000)?;
920            let right_bars = get_usize_p(params, "right_bars", 5, 1, 10000)?;
921            let score_window = get_usize_p(params, "score_window", 10, 1, 10000)?;
922            Ok(Box::new(PivotStructureEngine::new(
923                left_bars,
924                right_bars,
925                score_window,
926            )))
927        }
928        "volume_profile" | "vp" => {
929            let lookback = get_usize_p(params, "lookback", 70, 1, 10000)?;
930            let num_bins = get_usize_p(params, "num_bins", 30, 1, 1000)?;
931            Ok(Box::new(VolumeProfileEngine::new(lookback, num_bins)))
932        }
933        "extended_volume_profile" | "vp_extended" => {
934            let lookback = get_usize_p(params, "lookback", 70, 1, 10000)?;
935            let num_bins = get_usize_p(params, "num_bins", 30, 1, 1000)?;
936            Ok(Box::new(ExtendedVolumeProfileEngine::new(
937                lookback, num_bins,
938            )))
939        }
940        "persistent_volume_profile" | "vp_persistent" => {
941            let lookback = get_usize_p(params, "lookback", 70, 1, 10000)?;
942            let bin_width = get_f64_p(params, "bin_width", 1.0, 1e-6, 1_000_000.0)?;
943            Ok(Box::new(PersistentVolumeProfileEngine::new(
944                lookback, bin_width,
945            )))
946        }
947        "vwap" => {
948            let window = get_usize_p(params, "window", 390, 1, 10000)?;
949            let slope_lookback = get_usize_p(params, "slope_lookback", 20, 1, 10000)?;
950            Ok(Box::new(Vwap::new(window, slope_lookback)))
951        }
952        "vix_fix" | "wvf" => {
953            let pd = get_usize_p(params, "pd", 22, 1, 10000)?;
954            let bband_len = get_usize_p(params, "bband_len", 20, 1, 10000)?;
955            let mult = get_f64_p(params, "mult", 2.0, 0.01, 100.0)?;
956            Ok(Box::new(WilliamsVixFix::new(pd, bband_len, mult)))
957        }
958        "candle_story" | "pinbar" => Ok(Box::new(CandleStoryEngine::new())),
959        "efficiency" | "leg_efficiency" | "er" => {
960            let len = get_usize_p(params, "len", 14, 1, 10000)?;
961            Ok(Box::new(LegEfficiencyEngine::new(len)))
962        }
963        "volume" => {
964            let ma = get_usize_p(params, "ma_period", 20, 1, 10000)?;
965            Ok(Box::new(VolumeEngine::new(ma)))
966        }
967        "rvol" => {
968            let p = get_usize_p(params, "period", 20, 1, 10000)?;
969            Ok(Box::new(RvolEngine::new(p)))
970        }
971        "obv" => Ok(Box::new(ObvEngine::new())),
972        "cmf" => {
973            let p = get_usize_p(params, "period", 20, 1, 10000)?;
974            Ok(Box::new(CmfEngine::new(p)))
975        }
976        "acc_dist" => Ok(Box::new(AccDistEngine::new())),
977        "true_range" => Ok(Box::new(TrueRangeEngine::new())),
978        "keltner" => {
979            let ma = get_usize_p(params, "ma_period", 20, 1, 10000)?;
980            let atr = get_usize_p(params, "atr_period", 10, 1, 10000)?;
981            let mult = get_f64_p(params, "multiplier", 2.0, 0.01, 100.0)?;
982            Ok(Box::new(KeltnerChannelEngine::new(ma, atr, mult)))
983        }
984        "donchian" => {
985            let p = get_usize_p(params, "period", 20, 1, 10000)?;
986            Ok(Box::new(DonchianChannelEngine::new(p)))
987        }
988        "historical_volatility" => {
989            let p = get_usize_p(params, "period", 20, 1, 10000)?;
990            Ok(Box::new(HistoricalVolatilityEngine::new(p)))
991        }
992        "garman_klass" => {
993            let p = get_usize_p(params, "period", 20, 1, 10000)?;
994            Ok(Box::new(GarmanKlassVolatilityEngine::new(p)))
995        }
996        "sma" => {
997            let p = get_usize_p(params, "period", 20, 1, 10000)?;
998            Ok(Box::new(SmaEngine::new(p)))
999        }
1000        "ema" => {
1001            let p = get_usize_p(params, "period", 20, 1, 10000)?;
1002            Ok(Box::new(EmaEngine::new(p)))
1003        }
1004        "wma" => {
1005            let p = get_usize_p(params, "period", 20, 1, 10000)?;
1006            Ok(Box::new(WmaEngine::new(p)))
1007        }
1008        "vwma" => {
1009            let p = get_usize_p(params, "period", 20, 1, 10000)?;
1010            Ok(Box::new(VwmaEngine::new(p)))
1011        }
1012        "hma" => {
1013            let p = get_usize_p(params, "period", 20, 1, 10000)?;
1014            Ok(Box::new(HmaEngine::new(p)))
1015        }
1016        "dema" => {
1017            let p = get_usize_p(params, "period", 20, 1, 10000)?;
1018            Ok(Box::new(DemaEngine::new(p)))
1019        }
1020        "kama" => {
1021            let p = get_usize_p(params, "period", 10, 1, 10000)?;
1022            let fast = get_usize_p(params, "fast_period", 2, 1, 10000)?;
1023            let slow = get_usize_p(params, "slow_period", 30, 1, 10000)?;
1024            ensure_less("fast_period", fast as f64, "slow_period", slow as f64)?;
1025            Ok(Box::new(KamaEngine::new(p, fast, slow)))
1026        }
1027        "dmi" => {
1028            let p = get_usize_p(params, "period", 14, 1, 10000)?;
1029            Ok(Box::new(DmiEngine::new(p)))
1030        }
1031        "aroon" => {
1032            let p = get_usize_p(params, "period", 14, 1, 10000)?;
1033            Ok(Box::new(AroonEngine::new(p)))
1034        }
1035        "parabolic_sar" => {
1036            let step = get_f64_p(params, "step", 0.02, 0.001, 1.0)?;
1037            let max_step = get_f64_p(params, "max_step", 0.20, 0.001, 1.0)?;
1038            if step > max_step {
1039                return Err(RegistryError::InvalidParameter {
1040                    parameter: "step".to_string(),
1041                    value: step,
1042                    reason: "step must not exceed max_step".to_string(),
1043                });
1044            }
1045            Ok(Box::new(ParabolicSarEngine::new(step, max_step)))
1046        }
1047        "supertrend" => {
1048            let p = get_usize_p(params, "period", 10, 1, 10000)?;
1049            let mult = get_f64_p(params, "multiplier", 3.0, 0.01, 100.0)?;
1050            Ok(Box::new(SupertrendEngine::new(p, mult)))
1051        }
1052        "ichimoku" => {
1053            let tenkan = get_usize_p(params, "tenkan_p", 9, 1, 10000)?;
1054            let kijun = get_usize_p(params, "kijun_p", 26, 1, 10000)?;
1055            let senkou_b = get_usize_p(params, "senkou_b_p", 52, 1, 10000)?;
1056            ensure_less("tenkan_p", tenkan as f64, "kijun_p", kijun as f64)?;
1057            ensure_less("kijun_p", kijun as f64, "senkou_b_p", senkou_b as f64)?;
1058            Ok(Box::new(IchimokuEngine::new(tenkan, kijun, senkou_b)))
1059        }
1060        "stochastic" => {
1061            let k = get_usize_p(params, "k_period", 14, 1, 10000)?;
1062            let d = get_usize_p(params, "d_period", 3, 1, 10000)?;
1063            Ok(Box::new(StochasticEngine::new(k, d)))
1064        }
1065        "roc" => {
1066            let p = get_usize_p(params, "period", 12, 1, 10000)?;
1067            Ok(Box::new(RocEngine::new(p)))
1068        }
1069        "ultimate_oscillator" => {
1070            let p1 = get_usize_p(params, "period1", 7, 1, 10000)?;
1071            let p2 = get_usize_p(params, "period2", 14, 1, 10000)?;
1072            let p3 = get_usize_p(params, "period3", 28, 1, 10000)?;
1073            ensure_less("period1", p1 as f64, "period2", p2 as f64)?;
1074            ensure_less("period2", p2 as f64, "period3", p3 as f64)?;
1075            Ok(Box::new(UltimateOscillatorEngine::new(p1, p2, p3)))
1076        }
1077        "awesome_oscillator" => {
1078            let fast = get_usize_p(params, "fast_period", 5, 1, 10000)?;
1079            let slow = get_usize_p(params, "slow_period", 34, 1, 10000)?;
1080            ensure_less("fast_period", fast as f64, "slow_period", slow as f64)?;
1081            Ok(Box::new(AwesomeOscillatorEngine::new(fast, slow)))
1082        }
1083        "ppo" => {
1084            let fast = get_usize_p(params, "fast_period", 12, 1, 10000)?;
1085            let slow = get_usize_p(params, "slow_period", 26, 1, 10000)?;
1086            let signal = get_usize_p(params, "signal_period", 9, 1, 10000)?;
1087            ensure_less("fast_period", fast as f64, "slow_period", slow as f64)?;
1088            Ok(Box::new(PpoEngine::new(fast, slow, signal)))
1089        }
1090        "wavetrend" | "wt" => {
1091            let n1 = get_usize_p(params, "n1", 10, 1, 10000)?;
1092            let n2 = get_usize_p(params, "n2", 21, 1, 10000)?;
1093            let ob = get_f64_p(params, "ob_level", 60.0, -100.0, 100.0)?;
1094            let os = get_f64_p(params, "os_level", -60.0, -100.0, 100.0)?;
1095            Ok(Box::new(WaveTrendEngine::new(n1, n2, ob, os)))
1096        }
1097        "cmo" => {
1098            let p = get_usize_p(params, "period", 14, 1, 10000)?;
1099            Ok(Box::new(CmoEngine::new(p)))
1100        }
1101        "elder_ray" => {
1102            let p = get_usize_p(params, "period", 13, 1, 10000)?;
1103            Ok(Box::new(ElderRayEngine::new(p)))
1104        }
1105        "anchored_vwap" | "avwap" => {
1106            let m1 = get_f64_p(params, "mult1", 1.0, 0.01, 100.0)?;
1107            let m2 = get_f64_p(params, "mult2", 2.0, 0.01, 100.0)?;
1108            Ok(Box::new(AnchoredVwapEngine::new(
1109                VwapAnchorKind::Session,
1110                m1,
1111                m2,
1112            )))
1113        }
1114        "cvd" => Ok(Box::new(CvdEngine::new())),
1115        "hires_volume_flow" => {
1116            let window_len = get_usize_p(params, "window_len", 20, 2, 10000)?;
1117            Ok(Box::new(HiResVolumeFlowEngine::new(window_len)))
1118        }
1119        "klinger" | "kvo" => {
1120            let fast = get_usize_p(params, "fast_len", 34, 1, 10000)?;
1121            let slow = get_usize_p(params, "slow_len", 55, 1, 10000)?;
1122            let sig = get_usize_p(params, "signal_len", 13, 1, 10000)?;
1123            ensure_less("fast_len", fast as f64, "slow_len", slow as f64)?;
1124            Ok(Box::new(KlingerVolumeForceEngine::new(fast, slow, sig)))
1125        }
1126        "zigzag" => {
1127            let depth = get_usize_p(params, "depth", 12, 2, 10000)?;
1128            let dev = get_f64_p(params, "deviation_pct", 5.0, 0.01, 100.0)?;
1129            Ok(Box::new(ZigZagEngine::new(depth, dev)))
1130        }
1131        "zigzag_advanced" => {
1132            // Fixed Percent deviation via this f64-only entry point; use `build_typed` with
1133            // `deviation_mode` (ParamValue::Enum) to select AtrMultiple instead.
1134            let depth = get_usize_p(params, "depth", 3, 1, 10000)?;
1135            let backstep = get_usize_p(params, "backstep", 2, 0, 10000)?;
1136            let deviation_pct = get_f64_p(params, "deviation_pct", 1.0, 0.001, 100.0)?;
1137            let atr_len = get_usize_p(params, "atr_len", 14, 1, 10000)?;
1138            Ok(Box::new(AdvancedZigZagEngine::new(
1139                depth,
1140                backstep,
1141                ZigZagDeviationMode::Percent(deviation_pct),
1142                atr_len,
1143            )))
1144        }
1145        "pivot_sets" | "multi_pivots" => Ok(Box::new(PivotSetsEngine::new(PivotSetType::Classic))),
1146        "tema" => {
1147            let p = get_usize_p(params, "period", 14, 1, 10000)?;
1148            Ok(Box::new(TemaEngine::new(p)))
1149        }
1150        "lsma" => {
1151            let p = get_usize_p(params, "period", 25, 2, 10000)?;
1152            Ok(Box::new(LsmaEngine::new(p)))
1153        }
1154        "mcginley" => {
1155            let p = get_usize_p(params, "period", 14, 1, 10000)?;
1156            Ok(Box::new(McGinleyDynamicEngine::new(p)))
1157        }
1158        "envelope" => {
1159            let p = get_usize_p(params, "period", 20, 1, 10000)?;
1160            let pct = get_f64_p(params, "percent", 2.5, 0.01, 100.0)?;
1161            Ok(Box::new(EnvelopeEngine::new(p, pct)))
1162        }
1163        "choppiness" | "chop" => {
1164            let p = get_usize_p(params, "period", 14, 2, 10000)?;
1165            Ok(Box::new(ChoppinessIndexEngine::new(p)))
1166        }
1167        "vortex" | "vi" => {
1168            let p = get_usize_p(params, "period", 14, 1, 10000)?;
1169            Ok(Box::new(VortexEngine::new(p)))
1170        }
1171        "alligator" => Ok(Box::new(AlligatorEngine::new())),
1172        "connors_rsi" => {
1173            let rsi_len = get_usize_p(params, "rsi_len", 3, 1, 10000)?;
1174            let streak_len = get_usize_p(params, "streak_len", 2, 1, 10000)?;
1175            let rank_len = get_usize_p(params, "rank_len", 100, 1, 10000)?;
1176            Ok(Box::new(ConnorsRsiEngine::new(
1177                rsi_len, streak_len, rank_len,
1178            )))
1179        }
1180        "coppock" => Ok(Box::new(CoppockCurveEngine::new())),
1181        "dpo" => {
1182            let p = get_usize_p(params, "period", 21, 2, 10000)?;
1183            Ok(Box::new(DpoEngine::new(p)))
1184        }
1185        "kst" => Ok(Box::new(KstEngine::new())),
1186        "mass_index" => {
1187            let p = get_usize_p(params, "period", 25, 1, 10000)?;
1188            Ok(Box::new(MassIndexEngine::new(p)))
1189        }
1190        "rvi" => {
1191            let p = get_usize_p(params, "period", 10, 1, 10000)?;
1192            Ok(Box::new(RviEngine::new(p)))
1193        }
1194        "bop" => {
1195            let p = get_usize_p(params, "period", 14, 1, 10000)?;
1196            Ok(Box::new(BalanceOfPowerEngine::new(p)))
1197        }
1198        "eom" => {
1199            let p = get_usize_p(params, "period", 14, 1, 10000)?;
1200            let div = get_f64_p(params, "volume_divisor", 10000.0, 1.0, 1e9)?;
1201            Ok(Box::new(EomEngine::new(p, div)))
1202        }
1203        "nvi" => Ok(Box::new(NviEngine::new())),
1204        "pvi" => Ok(Box::new(PviEngine::new())),
1205        "chaikin_oscillator" | "cho" => {
1206            let fast = get_usize_p(params, "fast_len", 3, 1, 10000)?;
1207            let slow = get_usize_p(params, "slow_len", 10, 1, 10000)?;
1208            ensure_less("fast_len", fast as f64, "slow_len", slow as f64)?;
1209            Ok(Box::new(ChaikinOscillatorEngine::new(fast, slow)))
1210        }
1211        "bos_choch" => {
1212            let pivot_len = get_usize_p(params, "pivot_len", 5, 2, 10000)?;
1213            Ok(Box::new(BosChochEngine::new(pivot_len)))
1214        }
1215        "liquidity_sweeps" | "sweeps" => {
1216            let p = get_usize_p(params, "pivot_len", 5, 2, 10000)?;
1217            let tol = get_f64_p(params, "tolerance_pct", 0.2, 0.01, 100.0)?;
1218            Ok(Box::new(LiquiditySweepEngine::new(p, tol)))
1219        }
1220        "liquidity_pools" => {
1221            let p = get_usize_p(params, "pivot_len", 5, 2, 10000)?;
1222            let tol = get_f64_p(params, "tolerance_pct", 0.2, 0.001, 100.0)?;
1223            Ok(Box::new(
1224                super::smart_money_structure::LiquidityPoolEngine::new(p, tol),
1225            ))
1226        }
1227        "wyckoff" => {
1228            let lookback = get_usize_p(params, "range_lookback", 20, 3, 10000)?;
1229            let atr_max = get_f64_p(params, "range_atr_max", 3.0, 0.1, 1000.0)?;
1230            let min_bars = get_usize_p(params, "min_range_bars", 6, 2, 10000)?;
1231            Ok(Box::new(super::wyckoff::WyckoffStateMachine::new(
1232                lookback, atr_max, min_bars,
1233            )))
1234        }
1235        "trend_quality" => {
1236            let p = get_usize_p(params, "period", 14, 2, 10000)?;
1237            Ok(Box::new(TrendQualityScoreEngine::new(p)))
1238        }
1239        "buy_sell_pressure" | "pressure" => {
1240            let p = get_usize_p(params, "period", 14, 1, 10000)?;
1241            Ok(Box::new(BuySellPressureEstimator::new(p)))
1242        }
1243        "volatility_regime" => {
1244            let p = get_usize_p(params, "period", 20, 1, 10000)?;
1245            let bb_mult = get_f64_p(params, "bb_mult", 2.0, 0.01, 100.0)?;
1246            let kc_mult = get_f64_p(params, "kc_mult", 1.5, 0.01, 100.0)?;
1247            Ok(Box::new(VolatilityRegimeDetector::new(p, bb_mult, kc_mult)))
1248        }
1249        "zscore" => {
1250            let p = get_usize_p(params, "period", 20, 2, 10000)?;
1251            Ok(Box::new(ZScoreEngine::new(p)))
1252        }
1253        "multi_factor" => {
1254            let p = get_usize_p(params, "period", 14, 2, 10000)?;
1255            Ok(Box::new(MultiFactorMarketScore::new(p)))
1256        }
1257        _ => Err(RegistryError::UnknownIndicator(name.to_string())),
1258    }
1259}
1260
1261/// Dynamically builds an `Indicator` instance by its catalog name and parameters.
1262/// Delegates to `build_checked` and discards errors to return `Option`.
1263pub fn build(name: &str, params: &HashMap<String, f64>) -> Option<Box<dyn Indicator>> {
1264    build_checked(name, params).ok()
1265}
1266
1267/// Builds an `Indicator` instance from typed parameters (see [`ParamValue`]).
1268///
1269/// Numeric-compatible values (`Float`, `Int`, `Bool`, `Timestamp`) are flattened to `f64` and
1270/// forwarded to [`build_checked`], reusing its full per-indicator validation. Values with no
1271/// scalar form (`Enum`, `Text`, `Timeframe`, `Source`) are rejected with
1272/// [`RegistryError::UnsupportedParameterType`], since no indicator in this registry currently
1273/// consumes them through the `f64` parameter map.
1274/// Indicators known to depend on the genuine OHLC range (true range, high/low pivots, volume-at-
1275/// price, market-structure detection, ...), for which [`super::source_mapped::SourceMapped`]
1276/// would silently collapse `high == low == open == close` to the selected source and degenerate
1277/// their math. `build_typed` rejects a non-`Close` `source` for these rather than silently
1278/// applying it. Not necessarily exhaustive over the full catalog — extend as new range-dependent
1279/// indicators are added.
1280const RANGE_DEPENDENT_INDICATORS: &[&str] = &[
1281    "atr",
1282    "true_range",
1283    "adx",
1284    "dmi",
1285    "chandelier_exit",
1286    "wyckoff",
1287    "volume_profile",
1288    "vp",
1289    "extended_volume_profile",
1290    "vp_extended",
1291    "persistent_volume_profile",
1292    "vp_persistent",
1293    "pivots_structure",
1294    "pivots",
1295    "pivot_sets",
1296    "multi_pivots",
1297    "zigzag",
1298    "zigzag_advanced",
1299    "liquidity_pools",
1300    "liquidity_sweeps",
1301    "sweeps",
1302    "liquidity_fvg",
1303    "fvg",
1304    "smc",
1305    "order_block",
1306    "ob",
1307    "ce",
1308    "bos_choch",
1309    "market_structure_breaks",
1310    "bos",
1311    "choch",
1312    "vix_fix",
1313    "wvf",
1314    "keltner",
1315    "donchian",
1316    "vortex",
1317    "vi",
1318    "choppiness",
1319    "chop",
1320    "mass_index",
1321    "supertrend",
1322    "parabolic_sar",
1323    "ichimoku",
1324    "aroon",
1325    "garman_klass",
1326    "hires_volume_flow",
1327    "cvd",
1328    "klinger",
1329    "kvo",
1330    "volatility_regime",
1331    "swing_structure",
1332];
1333
1334pub fn build_typed(name: &str, params: &TypedParams) -> Result<Box<dyn Indicator>, RegistryError> {
1335    if name.to_lowercase() == "midas" {
1336        // Bypasses the generic `source` handling below; see `build_midas_typed`.
1337        return build_midas_typed(params);
1338    }
1339
1340    let source = match params.get("source") {
1341        None => None,
1342        Some(ParamValue::Source(s)) => Some(*s),
1343        Some(other) => {
1344            return Err(RegistryError::UnsupportedParameterType {
1345                parameter: "source".to_string(),
1346                type_name: other.type_name().to_string(),
1347            });
1348        }
1349    };
1350
1351    if let Some(s) = source {
1352        if s != crate::model::Source::Close
1353            && RANGE_DEPENDENT_INDICATORS.contains(&name.to_lowercase().as_str())
1354        {
1355            return Err(RegistryError::IncompatibleParameter {
1356                parameter: "source".to_string(),
1357                indicator: name.to_string(),
1358                reason: "range/OHLC-dependent indicator; SourceMapped would collapse its true range to zero"
1359                    .to_string(),
1360            });
1361        }
1362    }
1363
1364    // `source` is a cross-cutting concern applied uniformly via `SourceMapped` below, not an
1365    // indicator-specific parameter, so it is stripped before delegating to the per-indicator
1366    // builders.
1367    let mut remaining = params.clone();
1368    remaining.remove("source");
1369
1370    let built = match name.to_lowercase().as_str() {
1371        "anchored_vwap" | "avwap" => build_anchored_vwap_typed(&remaining)?,
1372        "pivot_sets" | "multi_pivots" => build_pivot_sets_typed(&remaining)?,
1373        "trend_relationship" => build_trend_relationship_typed(&remaining)?,
1374        "zigzag_advanced" => build_zigzag_advanced_typed(&remaining)?,
1375        _ => build_typed_by_flattening(name, &remaining)?,
1376    };
1377
1378    Ok(match source {
1379        Some(s) if s != crate::model::Source::Close => {
1380            Box::new(super::source_mapped::SourceMapped::new(built, s))
1381        }
1382        _ => built,
1383    })
1384}
1385
1386/// Default typed-build strategy for indicators whose full configuration surface is numeric:
1387/// flattens every value to `f64` via [`ParamValue::as_f64`] and delegates to [`build_checked`],
1388/// reusing its per-indicator validation. Rejects any value with no scalar form.
1389fn build_typed_by_flattening(
1390    name: &str,
1391    params: &TypedParams,
1392) -> Result<Box<dyn Indicator>, RegistryError> {
1393    let mut flat = HashMap::with_capacity(params.len());
1394    for (key, value) in params {
1395        match value.as_f64() {
1396            Some(v) => {
1397                flat.insert(key.clone(), v);
1398            }
1399            None => {
1400                return Err(RegistryError::UnsupportedParameterType {
1401                    parameter: key.clone(),
1402                    type_name: value.type_name().to_string(),
1403                });
1404            }
1405        }
1406    }
1407    build_checked(name, &flat)
1408}
1409
1410/// Reads a `ParamValue::Enum` parameter, lower-cased. Returns `Ok(None)` if the key is absent, and
1411/// [`RegistryError::UnsupportedParameterType`] if present with a non-`Enum` type.
1412fn get_enum_p(params: &TypedParams, name: &str) -> Result<Option<String>, RegistryError> {
1413    match params.get(name) {
1414        None => Ok(None),
1415        Some(ParamValue::Enum(value)) => Ok(Some(value.to_lowercase())),
1416        Some(other) => Err(RegistryError::UnsupportedParameterType {
1417            parameter: name.to_string(),
1418            type_name: other.type_name().to_string(),
1419        }),
1420    }
1421}
1422
1423/// Reads a `ParamValue::Timestamp` parameter. Returns `Ok(None)` if the key is absent, and
1424/// [`RegistryError::UnsupportedParameterType`] if present with a non-`Timestamp` type.
1425fn get_timestamp_p(params: &TypedParams, name: &str) -> Result<Option<i64>, RegistryError> {
1426    match params.get(name) {
1427        None => Ok(None),
1428        Some(ParamValue::Timestamp(value)) => Ok(Some(*value)),
1429        Some(other) => Err(RegistryError::UnsupportedParameterType {
1430            parameter: name.to_string(),
1431            type_name: other.type_name().to_string(),
1432        }),
1433    }
1434}
1435
1436/// Extracts the numeric-compatible entries of `keys` from `params` into a fresh `f64` map, for
1437/// forwarding to the existing `get_f64_p`/`get_usize_p` validators.
1438fn extract_numeric_subset(
1439    params: &TypedParams,
1440    keys: &[&str],
1441) -> Result<HashMap<String, f64>, RegistryError> {
1442    let mut numeric = HashMap::new();
1443    for key in keys {
1444        if let Some(value) = params.get(*key) {
1445            match value.as_f64() {
1446                Some(v) => {
1447                    numeric.insert((*key).to_string(), v);
1448                }
1449                None => {
1450                    return Err(RegistryError::UnsupportedParameterType {
1451                        parameter: (*key).to_string(),
1452                        type_name: value.type_name().to_string(),
1453                    });
1454                }
1455            }
1456        }
1457    }
1458    Ok(numeric)
1459}
1460
1461fn build_anchored_vwap_typed(params: &TypedParams) -> Result<Box<dyn Indicator>, RegistryError> {
1462    let anchor_kind = match get_enum_p(params, "anchor_kind")?.as_deref() {
1463        None | Some("session") => VwapAnchorKind::Session,
1464        Some("day") => VwapAnchorKind::Day,
1465        Some("week") => VwapAnchorKind::Week,
1466        Some("month") => VwapAnchorKind::Month,
1467        Some("external") => VwapAnchorKind::External,
1468        Some("manual_timestamp") => {
1469            let ts = get_timestamp_p(params, "anchor_timestamp")?.ok_or_else(|| {
1470                RegistryError::InvalidEnumValue {
1471                    parameter: "anchor_kind".to_string(),
1472                    value: "manual_timestamp".to_string(),
1473                    reason: "requires an accompanying 'anchor_timestamp' Timestamp parameter"
1474                        .to_string(),
1475                }
1476            })?;
1477            VwapAnchorKind::ManualTimestamp(ts)
1478        }
1479        Some(other) => {
1480            return Err(RegistryError::InvalidEnumValue {
1481                parameter: "anchor_kind".to_string(),
1482                value: other.to_string(),
1483                reason: "expected one of session|day|week|month|external|manual_timestamp"
1484                    .to_string(),
1485            });
1486        }
1487    };
1488
1489    let zero_volume_policy = match get_enum_p(params, "zero_volume_policy")?.as_deref() {
1490        None | Some("equal_weight") => ZeroVolumePolicy::EqualWeight,
1491        Some("skip") => ZeroVolumePolicy::Skip,
1492        Some(other) => {
1493            return Err(RegistryError::InvalidEnumValue {
1494                parameter: "zero_volume_policy".to_string(),
1495                value: other.to_string(),
1496                reason: "expected one of equal_weight|skip".to_string(),
1497            });
1498        }
1499    };
1500
1501    let numeric = extract_numeric_subset(params, &["mult1", "mult2"])?;
1502    let m1 = get_f64_p(&numeric, "mult1", 1.0, 0.01, 100.0)?;
1503    let m2 = get_f64_p(&numeric, "mult2", 2.0, 0.01, 100.0)?;
1504
1505    Ok(Box::new(
1506        AnchoredVwapEngine::new(anchor_kind, m1, m2).with_zero_volume_policy(zero_volume_policy),
1507    ))
1508}
1509
1510fn build_pivot_sets_typed(params: &TypedParams) -> Result<Box<dyn Indicator>, RegistryError> {
1511    let pivot_type = match get_enum_p(params, "pivot_type")?.as_deref() {
1512        None | Some("classic") => PivotSetType::Classic,
1513        Some("fibonacci") => PivotSetType::Fibonacci,
1514        Some("camarilla") => PivotSetType::Camarilla,
1515        Some("woodie") => PivotSetType::Woodie,
1516        Some("demark") => PivotSetType::DeMark,
1517        Some("cpr") => PivotSetType::Cpr,
1518        Some(other) => {
1519            return Err(RegistryError::InvalidEnumValue {
1520                parameter: "pivot_type".to_string(),
1521                value: other.to_string(),
1522                reason: "expected one of classic|fibonacci|camarilla|woodie|demark|cpr".to_string(),
1523            });
1524        }
1525    };
1526
1527    Ok(Box::new(PivotSetsEngine::new(pivot_type)))
1528}
1529
1530fn parse_smoother_kind(
1531    params: &TypedParams,
1532    parameter_name: &str,
1533    default: super::smoothing::SmootherKind,
1534) -> Result<super::smoothing::SmootherKind, RegistryError> {
1535    use super::smoothing::SmootherKind;
1536
1537    match get_enum_p(params, parameter_name)?.as_deref() {
1538        None => Ok(default),
1539        Some("ema") => Ok(SmootherKind::Ema),
1540        Some("sma") => Ok(SmootherKind::Sma),
1541        Some("rma") => Ok(SmootherKind::Rma),
1542        Some("alma") => Ok(SmootherKind::Alma),
1543        Some("jma") => Ok(SmootherKind::Jma),
1544        Some(other) => Err(RegistryError::InvalidEnumValue {
1545            parameter: parameter_name.to_string(),
1546            value: other.to_string(),
1547            reason: "expected one of ema|sma|rma|alma|jma".to_string(),
1548        }),
1549    }
1550}
1551
1552/// Builds a MIDAS engine directly from typed params, bypassing the generic `source`
1553/// strip-then-`SourceMapped`-wrap path in [`build_typed`]: MIDAS needs its own `Source` for the
1554/// cumulative curve while still reading genuine `bar.high`/`bar.low` for Topfinder/Bottomfinder
1555/// extreme tracking, which a bar flattened to a single OHLC value (what `SourceMapped` produces)
1556/// would break.
1557fn build_midas_typed(params: &TypedParams) -> Result<Box<dyn Indicator>, RegistryError> {
1558    let mode = match get_enum_p(params, "mode")?.as_deref() {
1559        None | Some("topfinder") => MidasMode::Topfinder,
1560        Some("bottomfinder") => MidasMode::Bottomfinder,
1561        Some(other) => {
1562            return Err(RegistryError::InvalidEnumValue {
1563                parameter: "mode".to_string(),
1564                value: other.to_string(),
1565                reason: "expected one of topfinder|bottomfinder".to_string(),
1566            });
1567        }
1568    };
1569
1570    let source = match params.get("source") {
1571        None => crate::model::Source::Hlc3,
1572        Some(ParamValue::Source(s)) => *s,
1573        Some(other) => {
1574            return Err(RegistryError::UnsupportedParameterType {
1575                parameter: "source".to_string(),
1576                type_name: other.type_name().to_string(),
1577            });
1578        }
1579    };
1580
1581    let numeric = extract_numeric_subset(params, &["maturity_bars"])?;
1582    let maturity_bars = get_usize_p(&numeric, "maturity_bars", 20, 1, 10000)?;
1583
1584    Ok(Box::new(MidasCurveEngine::new(
1585        mode,
1586        source,
1587        maturity_bars as u32,
1588    )))
1589}
1590
1591fn build_zigzag_advanced_typed(params: &TypedParams) -> Result<Box<dyn Indicator>, RegistryError> {
1592    let numeric =
1593        extract_numeric_subset(params, &["depth", "backstep", "deviation_value", "atr_len"])?;
1594    let depth = get_usize_p(&numeric, "depth", 3, 1, 10000)?;
1595    let backstep = get_usize_p(&numeric, "backstep", 2, 0, 10000)?;
1596    let atr_len = get_usize_p(&numeric, "atr_len", 14, 1, 10000)?;
1597
1598    let deviation = match get_enum_p(params, "deviation_mode")?.as_deref() {
1599        None | Some("percent") => {
1600            let value = get_f64_p(&numeric, "deviation_value", 1.0, 0.001, 100.0)?;
1601            ZigZagDeviationMode::Percent(value)
1602        }
1603        Some("atr_multiple") => {
1604            let value = get_f64_p(&numeric, "deviation_value", 1.0, 0.001, 1000.0)?;
1605            ZigZagDeviationMode::AtrMultiple(value)
1606        }
1607        Some(other) => {
1608            return Err(RegistryError::InvalidEnumValue {
1609                parameter: "deviation_mode".to_string(),
1610                value: other.to_string(),
1611                reason: "expected one of percent|atr_multiple".to_string(),
1612            });
1613        }
1614    };
1615
1616    Ok(Box::new(AdvancedZigZagEngine::new(
1617        depth, backstep, deviation, atr_len,
1618    )))
1619}
1620
1621fn build_trend_relationship_typed(
1622    params: &TypedParams,
1623) -> Result<Box<dyn Indicator>, RegistryError> {
1624    use super::smoothing::SmootherKind;
1625    use super::trend_relationship::AdaptiveTrendRelationship;
1626
1627    let fast_kind = parse_smoother_kind(params, "fast_kind", SmootherKind::Ema)?;
1628    let slow_kind = parse_smoother_kind(params, "slow_kind", SmootherKind::Ema)?;
1629
1630    let numeric = extract_numeric_subset(params, &["fast_len", "slow_len"])?;
1631    let fast_len = get_usize_p(&numeric, "fast_len", 9, 1, 10000)?;
1632    let slow_len = get_usize_p(&numeric, "slow_len", 21, 1, 10000)?;
1633    ensure_less("fast_len", fast_len as f64, "slow_len", slow_len as f64)?;
1634
1635    Ok(Box::new(AdaptiveTrendRelationship::new(
1636        fast_kind, fast_len, slow_kind, slow_len,
1637    )))
1638}
1639
1640// Note: `indicator::swing_structure::SwingStructureEngine` and
1641// `indicator::relative_strength::RelativeStrengthEngine` are intentionally not registered in the
1642// single-bar `build(name, params)` registry. `SwingStructureEngine` requires raw ATR injected per
1643// bar (`update(bar, atr)`), while `RelativeStrengthEngine` requires dual-series input (`update(own_bar, bench_bar)`).
1644// Both are exported directly from `kestrel_chartkit::indicator` for explicit use.
1645
1646#[cfg(test)]
1647mod tests {
1648    use super::*;
1649
1650    #[test]
1651    fn test_build_checked_valid_and_invalid_params() {
1652        let valid_params = HashMap::from([("period".to_string(), 14.0)]);
1653        assert!(build_checked("rsi", &valid_params).is_ok());
1654
1655        // Period 0 is invalid
1656        let zero_params = HashMap::from([("rsi_len".to_string(), 0.0)]);
1657        let err = match build_checked("rsi", &zero_params) {
1658            Err(e) => e,
1659            Ok(_) => panic!("Expected error for zero period"),
1660        };
1661        assert!(matches!(err, RegistryError::InvalidParameter { .. }));
1662
1663        // Negative period is invalid
1664        let neg_params = HashMap::from([("period".to_string(), -5.0)]);
1665        let err = match build_checked("sma", &neg_params) {
1666            Err(e) => e,
1667            Ok(_) => panic!("Expected error for negative period"),
1668        };
1669        assert!(matches!(err, RegistryError::InvalidParameter { .. }));
1670
1671        // Fractional periods are rejected instead of silently truncated
1672        let fractional_params = HashMap::from([("period".to_string(), 14.5)]);
1673        assert!(matches!(
1674            build_checked("sma", &fractional_params),
1675            Err(RegistryError::InvalidParameter { .. })
1676        ));
1677
1678        // Values above the allocation guard are rejected
1679        let huge_params = HashMap::from([("period".to_string(), 10_001.0)]);
1680        assert!(matches!(
1681            build_checked("sma", &huge_params),
1682            Err(RegistryError::InvalidParameter { .. })
1683        ));
1684
1685        // NaN is invalid
1686        let nan_params = HashMap::from([("period".to_string(), f64::NAN)]);
1687        let err = match build_checked("sma", &nan_params) {
1688            Err(e) => e,
1689            Ok(_) => panic!("Expected error for NaN period"),
1690        };
1691        assert!(matches!(err, RegistryError::InvalidParameter { .. }));
1692
1693        // Threshold and period relationships are validated
1694        let reversed_rsi = HashMap::from([
1695            ("oversold".to_string(), 80.0),
1696            ("overbought".to_string(), 20.0),
1697        ]);
1698        assert!(matches!(
1699            build_checked("rsi", &reversed_rsi),
1700            Err(RegistryError::InvalidParameter { .. })
1701        ));
1702
1703        let reversed_ultimate = HashMap::from([
1704            ("period1".to_string(), 28.0),
1705            ("period2".to_string(), 14.0),
1706            ("period3".to_string(), 7.0),
1707        ]);
1708        assert!(matches!(
1709            build_checked("ultimate_oscillator", &reversed_ultimate),
1710            Err(RegistryError::InvalidParameter { .. })
1711        ));
1712
1713        let oversized_bins = HashMap::from([("num_bins".to_string(), 1_001.0)]);
1714        assert!(matches!(
1715            build_checked("volume_profile", &oversized_bins),
1716            Err(RegistryError::InvalidParameter { .. })
1717        ));
1718
1719        // Infinity is invalid
1720        let inf_params = HashMap::from([("period".to_string(), f64::INFINITY)]);
1721        let err = match build_checked("sma", &inf_params) {
1722            Err(e) => e,
1723            Ok(_) => panic!("Expected error for Infinity period"),
1724        };
1725        assert!(matches!(err, RegistryError::InvalidParameter { .. }));
1726
1727        // MACD fast_len >= slow_len is invalid
1728        let macd_bad = HashMap::from([
1729            ("fast_len".to_string(), 30.0),
1730            ("slow_len".to_string(), 20.0),
1731        ]);
1732        let err = match build_checked("macd", &macd_bad) {
1733            Err(e) => e,
1734            Ok(_) => panic!("Expected error for fast >= slow"),
1735        };
1736        assert!(matches!(err, RegistryError::InvalidParameter { .. }));
1737
1738        // Unknown indicator
1739        let err = match build_checked("non_existent_ind", &HashMap::new()) {
1740            Err(e) => e,
1741            Ok(_) => panic!("Expected error for unknown indicator"),
1742        };
1743        assert_eq!(
1744            err,
1745            RegistryError::UnknownIndicator("non_existent_ind".to_string())
1746        );
1747    }
1748
1749    #[test]
1750    fn test_build_typed_flattens_numeric_params() {
1751        let params: TypedParams = HashMap::from([("period".to_string(), ParamValue::Int(14))]);
1752        assert!(build_typed("rsi", &params).is_ok());
1753
1754        let bool_params: TypedParams =
1755            HashMap::from([("period".to_string(), ParamValue::Bool(true))]);
1756        // Bool flattens to 1.0, which build_checked's own validation then accepts or rejects.
1757        assert!(build_typed("rsi", &bool_params).is_ok());
1758    }
1759
1760    #[test]
1761    fn test_build_typed_rejects_non_numeric_params() {
1762        let params: TypedParams =
1763            HashMap::from([("period".to_string(), ParamValue::Enum("fast".to_string()))]);
1764        let err = match build_typed("rsi", &params) {
1765            Err(e) => e,
1766            Ok(_) => panic!("Expected error for enum parameter"),
1767        };
1768        assert!(matches!(
1769            err,
1770            RegistryError::UnsupportedParameterType { .. }
1771        ));
1772    }
1773
1774    #[test]
1775    fn test_build_typed_propagates_indicator_validation_errors() {
1776        let params: TypedParams =
1777            HashMap::from([("period".to_string(), ParamValue::Float(f64::INFINITY))]);
1778        let err = match build_typed("sma", &params) {
1779            Err(e) => e,
1780            Ok(_) => panic!("Expected error for infinite period"),
1781        };
1782        assert!(matches!(err, RegistryError::InvalidParameter { .. }));
1783    }
1784
1785    #[test]
1786    fn test_build_typed_anchored_vwap_native_enum_selection() {
1787        // Default (no anchor_kind given) stays Session, matching build_checked's behavior.
1788        let defaulted = build_typed("anchored_vwap", &TypedParams::new());
1789        assert!(defaulted.is_ok());
1790
1791        let day_params: TypedParams = HashMap::from([(
1792            "anchor_kind".to_string(),
1793            ParamValue::Enum("day".to_string()),
1794        )]);
1795        assert!(build_typed("avwap", &day_params).is_ok());
1796
1797        let skip_zero_vol: TypedParams = HashMap::from([(
1798            "zero_volume_policy".to_string(),
1799            ParamValue::Enum("skip".to_string()),
1800        )]);
1801        assert!(build_typed("anchored_vwap", &skip_zero_vol).is_ok());
1802
1803        let manual_without_timestamp: TypedParams = HashMap::from([(
1804            "anchor_kind".to_string(),
1805            ParamValue::Enum("manual_timestamp".to_string()),
1806        )]);
1807        let err = match build_typed("anchored_vwap", &manual_without_timestamp) {
1808            Err(e) => e,
1809            Ok(_) => panic!("Expected error: manual_timestamp requires anchor_timestamp"),
1810        };
1811        assert!(matches!(err, RegistryError::InvalidEnumValue { .. }));
1812
1813        let manual_with_timestamp: TypedParams = HashMap::from([
1814            (
1815                "anchor_kind".to_string(),
1816                ParamValue::Enum("manual_timestamp".to_string()),
1817            ),
1818            (
1819                "anchor_timestamp".to_string(),
1820                ParamValue::Timestamp(1_700_000_000),
1821            ),
1822        ]);
1823        assert!(build_typed("anchored_vwap", &manual_with_timestamp).is_ok());
1824
1825        let unknown_kind: TypedParams = HashMap::from([(
1826            "anchor_kind".to_string(),
1827            ParamValue::Enum("bogus".to_string()),
1828        )]);
1829        let err = match build_typed("anchored_vwap", &unknown_kind) {
1830            Err(e) => e,
1831            Ok(_) => panic!("Expected error for unknown anchor_kind"),
1832        };
1833        assert!(matches!(err, RegistryError::InvalidEnumValue { .. }));
1834    }
1835
1836    #[test]
1837    fn test_build_typed_pivot_sets_native_enum_selection() {
1838        for kind in [
1839            "classic",
1840            "fibonacci",
1841            "camarilla",
1842            "woodie",
1843            "demark",
1844            "cpr",
1845        ] {
1846            let params: TypedParams =
1847                HashMap::from([("pivot_type".to_string(), ParamValue::Enum(kind.to_string()))]);
1848            assert!(
1849                build_typed("pivot_sets", &params).is_ok(),
1850                "expected {kind} to build"
1851            );
1852        }
1853
1854        let unknown: TypedParams = HashMap::from([(
1855            "pivot_type".to_string(),
1856            ParamValue::Enum("bogus".to_string()),
1857        )]);
1858        let err = match build_typed("multi_pivots", &unknown) {
1859            Err(e) => e,
1860            Ok(_) => panic!("Expected error for unknown pivot_type"),
1861        };
1862        assert!(matches!(err, RegistryError::InvalidEnumValue { .. }));
1863    }
1864
1865    #[test]
1866    fn test_build_typed_source_propagates_to_computation() {
1867        use crate::model::{Bar, Source};
1868
1869        let bars = [
1870            Bar::new(0, 10.0, 12.0, 8.0, 11.0, 100.0),
1871            Bar::new(60, 20.0, 22.0, 18.0, 21.0, 100.0),
1872        ];
1873
1874        let close_params: TypedParams = HashMap::from([("period".to_string(), ParamValue::Int(2))]);
1875        let mut close_sma = build_typed("sma", &close_params).unwrap();
1876
1877        let open_params: TypedParams = HashMap::from([
1878            ("period".to_string(), ParamValue::Int(2)),
1879            ("source".to_string(), ParamValue::Source(Source::Open)),
1880        ]);
1881        let mut open_sma = build_typed("sma", &open_params).unwrap();
1882
1883        let mut close_out = None;
1884        let mut open_out = None;
1885        for bar in &bars {
1886            close_out = close_sma.on_bar(bar);
1887            open_out = open_sma.on_bar(bar);
1888        }
1889
1890        assert_eq!(close_out.unwrap().value, (11.0 + 21.0) / 2.0);
1891        assert_eq!(open_out.unwrap().value, (10.0 + 20.0) / 2.0);
1892    }
1893
1894    #[test]
1895    fn test_build_typed_source_close_is_a_no_op() {
1896        let params: TypedParams = HashMap::from([(
1897            "source".to_string(),
1898            ParamValue::Source(crate::model::Source::Close),
1899        )]);
1900        assert!(build_typed("sma", &params).is_ok());
1901    }
1902
1903    #[test]
1904    fn test_adx_with_defaults_matches_registry_default() {
1905        let mut via_struct = Adx::with_defaults();
1906        let mut via_registry = build_checked("adx", &HashMap::new()).unwrap();
1907
1908        let bars = crate::model::Bar::new(0, 100.0, 101.0, 99.0, 100.5, 1000.0);
1909        let mut struct_out = None;
1910        let mut registry_out = None;
1911        for i in 0..60 {
1912            let price = 100.0 + (i as f64 * 0.3).sin() * 5.0;
1913            let bar =
1914                crate::model::Bar::new(i, price, price + 1.0, price - 1.0, price + 0.5, 1000.0);
1915            struct_out = via_struct.on_bar(&bar);
1916            registry_out = via_registry.on_bar(&bar);
1917        }
1918        let _ = bars;
1919        assert_eq!(
1920            struct_out.map(|o| o.value),
1921            registry_out.map(|o| o.value),
1922            "Adx::with_defaults() must produce identical output to the registry's \"adx\" default"
1923        );
1924    }
1925
1926    #[test]
1927    fn test_atr_with_defaults_matches_registry_default() {
1928        let mut via_struct = Atr::with_defaults();
1929        let mut via_registry = build_checked("atr", &HashMap::new()).unwrap();
1930
1931        let mut struct_out = None;
1932        let mut registry_out = None;
1933        for i in 0..40 {
1934            let price = 100.0 + (i as f64 * 0.3).sin() * 5.0;
1935            let bar =
1936                crate::model::Bar::new(i, price, price + 1.0, price - 1.0, price + 0.5, 1000.0);
1937            struct_out = via_struct.on_bar(&bar);
1938            registry_out = via_registry.on_bar(&bar);
1939        }
1940        assert_eq!(
1941            struct_out.map(|o| o.value),
1942            registry_out.map(|o| o.value),
1943            "Atr::with_defaults() must produce identical output to the registry's \"atr\" default"
1944        );
1945    }
1946
1947    #[test]
1948    fn test_build_typed_rejects_non_close_source_on_range_dependent_indicators() {
1949        let params: TypedParams = HashMap::from([(
1950            "source".to_string(),
1951            ParamValue::Source(crate::model::Source::Open),
1952        )]);
1953        for name in [
1954            "atr",
1955            "wyckoff",
1956            "chandelier_exit",
1957            "ce",
1958            "extended_volume_profile",
1959            "zigzag_advanced",
1960            "keltner",
1961            "donchian",
1962            "garman_klass",
1963        ] {
1964            let err = match build_typed(name, &params) {
1965                Err(e) => e,
1966                Ok(_) => panic!("expected '{name}' to reject a non-Close source"),
1967            };
1968            assert!(
1969                matches!(err, RegistryError::IncompatibleParameter { .. }),
1970                "'{name}' returned {err:?} instead of IncompatibleParameter"
1971            );
1972        }
1973
1974        // Close is always a no-op regardless of range-dependence, so it must still succeed.
1975        let close_params: TypedParams = HashMap::from([(
1976            "source".to_string(),
1977            ParamValue::Source(crate::model::Source::Close),
1978        )]);
1979        assert!(build_typed("atr", &close_params).is_ok());
1980    }
1981
1982    #[test]
1983    fn test_build_typed_trend_relationship_native_smoother_kind_selection() {
1984        let params: TypedParams = HashMap::from([
1985            ("fast_kind".to_string(), ParamValue::Enum("jma".to_string())),
1986            (
1987                "slow_kind".to_string(),
1988                ParamValue::Enum("alma".to_string()),
1989            ),
1990            ("fast_len".to_string(), ParamValue::Int(5)),
1991            ("slow_len".to_string(), ParamValue::Int(20)),
1992        ]);
1993        assert!(build_typed("trend_relationship", &params).is_ok());
1994
1995        let unknown: TypedParams = HashMap::from([(
1996            "fast_kind".to_string(),
1997            ParamValue::Enum("bogus".to_string()),
1998        )]);
1999        let err = match build_typed("trend_relationship", &unknown) {
2000            Err(e) => e,
2001            Ok(_) => panic!("Expected error for unknown fast_kind"),
2002        };
2003        assert!(matches!(err, RegistryError::InvalidEnumValue { .. }));
2004    }
2005
2006    #[test]
2007    fn test_build_checked_trend_relationship_defaults_to_ema() {
2008        assert!(build_checked("trend_relationship", &HashMap::new()).is_ok());
2009    }
2010
2011    #[test]
2012    fn test_build_typed_midas_native_mode_and_source_selection() {
2013        let params: TypedParams = HashMap::from([
2014            (
2015                "mode".to_string(),
2016                ParamValue::Enum("bottomfinder".to_string()),
2017            ),
2018            (
2019                "source".to_string(),
2020                ParamValue::Source(crate::model::Source::Close),
2021            ),
2022        ]);
2023        assert!(build_typed("midas", &params).is_ok());
2024
2025        let unknown: TypedParams =
2026            HashMap::from([("mode".to_string(), ParamValue::Enum("bogus".to_string()))]);
2027        let err = match build_typed("midas", &unknown) {
2028            Err(e) => e,
2029            Ok(_) => panic!("Expected error for unknown mode"),
2030        };
2031        assert!(matches!(err, RegistryError::InvalidEnumValue { .. }));
2032    }
2033
2034    #[test]
2035    fn test_build_checked_midas_defaults_to_topfinder() {
2036        assert!(build_checked("midas", &HashMap::new()).is_ok());
2037    }
2038}