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, TrueRangeSmoothing};
10use super::bbtrend::BbTrend;
11use super::bollinger::{BollingerBands, VarianceConvention};
12use super::bop::BalanceOfPowerEngine;
13use super::bos_choch::BosChochEngine;
14use super::buy_sell_pressure::BuySellPressureEstimator;
15use super::candle_story::{CandleStoryConfig, CandleStoryEngine};
16use super::cci::Cci;
17use super::chaikin_osc::ChaikinOscillatorEngine;
18use super::chande_kroll::ChandeKrollStop;
19use super::chandelier_exit::ChandelierExitEngine;
20use super::chandelier_flip_radar::ChandelierFlipRadarEngine;
21use super::choppiness::ChoppinessIndexEngine;
22use super::connors_rsi::ConnorsRsiEngine;
23use super::coppock::CoppockCurveEngine;
24use super::dpo::DpoEngine;
25use super::efficiency::LegEfficiencyEngine;
26use super::envelope::EnvelopeEngine;
27use super::eom::EomEngine;
28use super::fisher_transform::FisherTransform;
29use super::force_index::ElderForceIndex;
30use super::kst::KstEngine;
31use super::liquidity_fvg::LiquidityFvgEngine;
32use super::liquidity_sweeps::LiquiditySweepEngine;
33use super::lsma::LsmaEngine;
34use super::macd::Macd;
35use super::market_structure_breaks::MarketStructureBreaksEngine;
36use super::mass_index::MassIndexEngine;
37use super::mcginley::McGinleyDynamicEngine;
38use super::mfi::Mfi;
39use super::midas::{MidasCurveEngine, MidasMode};
40use super::momentum_indicators::{
41    AwesomeOscillatorEngine, CmoEngine, ElderRayEngine, PpoEngine, RocEngine, StochasticEngine,
42    UltimateOscillatorEngine,
43};
44use super::money_flow_profile::MoneyFlowProfileEngine;
45use super::moving_averages::{
46    DemaEngine, EmaEngine, HmaEngine, KamaEngine, SmaEngine, VwmaEngine, WmaEngine,
47};
48use super::multi_factor::MultiFactorMarketScore;
49use super::nvi_pvi::{NviEngine, PviEngine};
50use super::order_block::OrderBlockEngine;
51pub use super::params::{ParamValue, TypedParams};
52use super::pivot_sets::{PivotSetType, PivotSetsEngine};
53use super::pivots_structure::PivotStructureEngine;
54use super::pmo::PriceMomentumOscillator;
55use super::pvt::PriceVolumeTrend;
56use super::rci::RciEngine;
57use super::relative_volatility::{RelativeVolatilityIndex, RelativeVolatilityVariant};
58use super::rsi::{Rsi, RsiSmoothing};
59use super::rvat::RelativeVolumeAtTime;
60use super::rvi::RviEngine;
61use super::smi::StochasticMomentumIndex;
62use super::smoothing::EmaInit;
63use super::stoch_rsi::StochRsi;
64use super::t3::T3;
65use super::tema::TemaEngine;
66use super::trend_quality::TrendQualityScoreEngine;
67use super::trend_structural::{
68    AroonEngine, DmiEngine, IchimokuEngine, ParabolicSarEngine, SupertrendEngine,
69};
70use super::trix::Trix;
71use super::tsi::Tsi;
72use super::twap::{AnchoredTwap, TwapAnchor, TwapWeighting};
73use super::ulcer::UlcerIndexEngine;
74use super::vidya::Vidya;
75use super::vix_fix::WilliamsVixFix;
76use super::volatility_indicators::{
77    DonchianChannelEngine, GarmanKlassVolatilityEngine, HistoricalVolatilityEngine,
78    KeltnerChannelEngine, TrueRangeEngine,
79};
80use super::volatility_regime::VolatilityRegimeDetector;
81use super::volume_flow::{CvdEngine, KlingerVolumeForceEngine};
82use super::volume_flow_hires::HiResVolumeFlowEngine;
83use super::volume_indicators::{AccDistEngine, CmfEngine, ObvEngine, RvolEngine, VolumeEngine};
84use super::volume_profile::VolumeProfileEngine;
85use super::volume_profile_extended::ExtendedVolumeProfileEngine;
86use super::volume_profile_persistent::PersistentVolumeProfileEngine;
87use super::vortex::VortexEngine;
88use super::vwap::Vwap;
89use super::wavetrend::WaveTrendEngine;
90use super::williams_r::WilliamsR;
91use super::zigzag::ZigZagEngine;
92use super::zigzag_advanced::{AdvancedZigZagEngine, ZigZagDeviationMode};
93use super::zscore::ZScoreEngine;
94use super::Indicator;
95
96#[derive(Debug, Clone, PartialEq)]
97#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
98pub struct IndicatorCatalogEntry {
99    pub name: &'static str,
100    pub description: &'static str,
101    pub default_params: HashMap<String, f64>,
102}
103
104pub fn catalog() -> Vec<IndicatorCatalogEntry> {
105    vec![
106        IndicatorCatalogEntry {
107            name: "rsi",
108            description: "Relative Strength Index (build_typed accepts smoothing=wilder|ema for the up/down averages; wilder is the default)",
109            default_params: [
110                ("rsi_len".to_string(), 14.0),
111                ("avg_len".to_string(), 3.0),
112                ("sig_len".to_string(), 3.0),
113                ("overbought".to_string(), 70.0),
114                ("oversold".to_string(), 30.0),
115            ]
116            .into(),
117        },
118        IndicatorCatalogEntry {
119            name: "macd",
120            description: "Moving Average Convergence Divergence",
121            default_params: [
122                ("fast_len".to_string(), 12.0),
123                ("slow_len".to_string(), 26.0),
124                ("signal_len".to_string(), 9.0),
125            ]
126            .into(),
127        },
128        IndicatorCatalogEntry {
129            name: "bollinger",
130            description: "Bollinger Bands (build_typed accepts variance=population|sample for the standard-deviation divisor; population is the default)",
131            default_params: [("len".to_string(), 20.0), ("mult".to_string(), 2.0)].into(),
132        },
133        IndicatorCatalogEntry {
134            name: "adx",
135            description: "Average Directional Index",
136            default_params: [
137                ("di_len".to_string(), 14.0),
138                ("adx_smooth".to_string(), 14.0),
139                ("level_weak".to_string(), 20.0),
140            ]
141            .into(),
142        },
143        IndicatorCatalogEntry {
144            name: "stoch_rsi",
145            description: "Stochastic RSI",
146            default_params: [
147                ("rsi_len".to_string(), 14.0),
148                ("stoch_len".to_string(), 14.0),
149                ("k_len".to_string(), 3.0),
150                ("d_len".to_string(), 3.0),
151                ("overbought".to_string(), 80.0),
152                ("oversold".to_string(), 20.0),
153            ]
154            .into(),
155        },
156        IndicatorCatalogEntry {
157            name: "cci",
158            description: "Commodity Channel Index",
159            default_params: [
160                ("cci_len".to_string(), 20.0),
161                ("overbought".to_string(), 100.0),
162                ("oversold".to_string(), -100.0),
163            ]
164            .into(),
165        },
166        IndicatorCatalogEntry {
167            name: "mfi",
168            description: "Money Flow Index",
169            default_params: [
170                ("mfi_len".to_string(), 14.0),
171                ("overbought".to_string(), 80.0),
172                ("oversold".to_string(), 20.0),
173            ]
174            .into(),
175        },
176        IndicatorCatalogEntry {
177            name: "twap",
178            description: "Anchored Time Weighted Average Price (no volume; build_typed accepts weighting=per_bar|by_duration and anchor=continuous|daily|manual_timestamp)",
179            default_params: [("day_start_offset".to_string(), 0.0)].into(),
180        },
181        IndicatorCatalogEntry {
182            name: "relative_volatility",
183            description: "Relative Volatility Index (the RSI construction applied to the standard deviation; build_typed accepts variant=close|high_low). Unrelated to \"rvi\", the Relative Vigor Index",
184            default_params: [
185                ("stdev_len".to_string(), 10.0),
186                ("smooth_len".to_string(), 14.0),
187            ]
188            .into(),
189        },
190        IndicatorCatalogEntry {
191            name: "bbtrend",
192            description: "BBTrend (how far a short Bollinger set has moved out of a long one, in percent of the short basis; build_typed accepts variance=population|sample for both sets)",
193            default_params: [
194                ("short_len".to_string(), 20.0),
195                ("long_len".to_string(), 50.0),
196                ("mult".to_string(), 2.0),
197            ]
198            .into(),
199        },
200        IndicatorCatalogEntry {
201            name: "pvt",
202            description: "Price Volume Trend (volume weighted by the relative price change, accumulated)",
203            default_params: [].into(),
204        },
205        IndicatorCatalogEntry {
206            name: "pmo",
207            description: "Price Momentum Oscillator (twice-smoothed one-bar return with alpha = 2/length, scaled by ten)",
208            default_params: [
209                ("length_1".to_string(), 35.0),
210                ("length_2".to_string(), 20.0),
211                ("signal_len".to_string(), 10.0),
212            ]
213            .into(),
214        },
215        IndicatorCatalogEntry {
216            name: "rvat",
217            description: "Relative Volume at Time (volume against the same time of day on previous days, regular and cumulative)",
218            default_params: [
219                ("days".to_string(), 10.0),
220                ("day_start_offset".to_string(), 0.0),
221                ("bar_seconds".to_string(), 60.0),
222            ]
223            .into(),
224        },
225        IndicatorCatalogEntry {
226            name: "smi",
227            description: "Stochastic Momentum Index (double-smoothed position of the close relative to the midpoint of the high-low range)",
228            default_params: [
229                ("len".to_string(), 10.0),
230                ("smooth_1".to_string(), 3.0),
231                ("smooth_2".to_string(), 3.0),
232                ("signal_len".to_string(), 3.0),
233            ]
234            .into(),
235        },
236        IndicatorCatalogEntry {
237            name: "rci",
238            description: "Rank Correlation Index (rank of prices against rank of time, scaled by 100)",
239            default_params: [("len".to_string(), 9.0)].into(),
240        },
241        IndicatorCatalogEntry {
242            name: "ulcer_index",
243            description: "Ulcer Index (root mean square of the percentage drawdowns below the running high)",
244            default_params: [("len".to_string(), 14.0)].into(),
245        },
246        IndicatorCatalogEntry {
247            name: "t3",
248            description: "Tillson T3 (weighted combination of six chained EMAs; v is a shape factor, not volume)",
249            default_params: [("period".to_string(), 5.0), ("v".to_string(), 0.7)].into(),
250        },
251        IndicatorCatalogEntry {
252            name: "vidya",
253            description: "Variable Index Dynamic Average (CMO-scaled exponential smoothing)",
254            default_params: [("cmo_len".to_string(), 9.0), ("ema_len".to_string(), 12.0)].into(),
255        },
256        IndicatorCatalogEntry {
257            name: "trix",
258            description: "TRIX (rate of change of a triple-smoothed price series, in percent)",
259            default_params: [("len".to_string(), 15.0), ("signal_len".to_string(), 9.0)].into(),
260        },
261        IndicatorCatalogEntry {
262            name: "efi",
263            description: "Elder's Force Index (price change times volume, EMA-smoothed)",
264            default_params: [("ema_len".to_string(), 13.0)].into(),
265        },
266        IndicatorCatalogEntry {
267            name: "atr",
268            description: "Average True Range (build_typed accepts smoothing=rma|sma|ema|wma for the true-range average; rma is the default and the signal line stays Wilder-smoothed)",
269            default_params: [("atr_len".to_string(), 14.0), ("sig_len".to_string(), 20.0)].into(),
270        },
271        IndicatorCatalogEntry {
272            name: "chande_kroll",
273            description: "Chande Kroll Stop (window extremes minus/plus a Wilder-smoothed ATR, passed through a second extreme window)",
274            default_params: [
275                ("atr_len".to_string(), 10.0),
276                ("stop_len".to_string(), 9.0),
277                ("mult".to_string(), 3.0),
278            ]
279            .into(),
280        },
281        IndicatorCatalogEntry {
282            name: "chandelier_exit",
283            description: "Chandelier Exit (ATR trailing stop with direction flip)",
284            default_params: [("length".to_string(), 22.0), ("atr_mult".to_string(), 3.0)].into(),
285        },
286        IndicatorCatalogEntry {
287            name: "chandelier_flip_radar",
288            description: "Chandelier Exit ratchet extended with adaptive multiplier, body-filtered weak flips, and bull/bear trap detection (use ChandelierFlipRadarEngine::new directly for use_close_extremes=false or simple_adaptive=true; this f64-only entry uses the documented defaults for both)",
289            default_params: [
290                ("length".to_string(), 30.0),
291                ("atr_mult".to_string(), 4.5),
292                ("body_filter_atr".to_string(), 0.80),
293                ("danger_dist_atr".to_string(), 0.35),
294                ("warn_dist_atr".to_string(), 0.75),
295            ]
296            .into(),
297        },
298        IndicatorCatalogEntry {
299            name: "midas",
300            description: "MIDAS launch-anchored curve with Topfinder/Bottomfinder projection (build_typed with mode=topfinder|bottomfinder)",
301            default_params: [("maturity_bars".to_string(), 20.0)].into(),
302        },
303        IndicatorCatalogEntry {
304            name: "hires_volume_flow",
305            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)",
306            default_params: [("window_len".to_string(), 20.0)].into(),
307        },
308        IndicatorCatalogEntry {
309            name: "extended_volume_profile",
310            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)",
311            default_params: [("lookback".to_string(), 70.0), ("num_bins".to_string(), 30.0)].into(),
312        },
313        IndicatorCatalogEntry {
314            name: "persistent_volume_profile",
315            description: "Fixed-price-grid volume profile with real bin lifecycle (birth/growth/expiry across updates) and a per-bin absorption profile",
316            default_params: [("lookback".to_string(), 70.0), ("bin_width".to_string(), 1.0)].into(),
317        },
318        IndicatorCatalogEntry {
319            name: "trend_relationship",
320            description: "Adaptive trend relationship between two configurable smoothers (build via build_typed with fast_kind/slow_kind params: ema|sma|rma|alma|jma)",
321            default_params: [("fast_len".to_string(), 9.0), ("slow_len".to_string(), 21.0)].into(),
322        },
323        IndicatorCatalogEntry {
324            name: "williams_r",
325            description: "Williams %R",
326            default_params: [
327                ("wpr_len".to_string(), 14.0),
328                ("overbought".to_string(), 80.0),
329                ("oversold".to_string(), 20.0),
330            ]
331            .into(),
332        },
333        IndicatorCatalogEntry {
334            name: "tsi",
335            description: "True Strength Index",
336            default_params: [
337                ("long_len".to_string(), 25.0),
338                ("short_len".to_string(), 13.0),
339                ("sig_len".to_string(), 7.0),
340            ]
341            .into(),
342        },
343        IndicatorCatalogEntry {
344            name: "fisher_transform",
345            description: "Fisher Transform",
346            default_params: [
347                ("fish_len".to_string(), 10.0),
348                ("overbought".to_string(), 1.5),
349                ("oversold".to_string(), -1.5),
350            ]
351            .into(),
352        },
353        IndicatorCatalogEntry {
354            name: "order_block",
355            description: "Order block detection from displacement candles (ATR-filtered)",
356            default_params: [
357                ("atr_len".to_string(), 14.0),
358                ("min_disp".to_string(), 1.0),
359            ]
360            .into(),
361        },
362        IndicatorCatalogEntry {
363            name: "liquidity_fvg",
364            description: "Fair value gap (imbalance) detection",
365            default_params: [("lookback".to_string(), 20.0)].into(),
366        },
367        IndicatorCatalogEntry {
368            name: "market_structure_breaks",
369            description: "Break of structure / change of character (BOS/CHoCH) detection",
370            default_params: [("lookback".to_string(), 5.0)].into(),
371        },
372        IndicatorCatalogEntry {
373            name: "pivots_structure",
374            description: "Swing pivot detection with a rolling structure score",
375            default_params: [
376                ("left_bars".to_string(), 5.0),
377                ("right_bars".to_string(), 5.0),
378                ("score_window".to_string(), 10.0),
379            ]
380            .into(),
381        },
382        IndicatorCatalogEntry {
383            name: "volume_profile",
384            description: "Price/volume distribution profile with point-of-control",
385            default_params: [
386                ("lookback".to_string(), 70.0),
387                ("num_bins".to_string(), 30.0),
388            ]
389            .into(),
390        },
391        IndicatorCatalogEntry {
392            name: "money_flow_profile",
393            description: "Volume-by-price profile binned by dollar volume (volume x price) instead of raw volume, plus an aggregate bull/bear flow-bias percentage",
394            default_params: [
395                ("lookback".to_string(), 200.0),
396                ("rows".to_string(), 25.0),
397                ("va_pct".to_string(), 0.70),
398            ]
399            .into(),
400        },
401        IndicatorCatalogEntry {
402            name: "vwap",
403            description: "Rolling Volume Weighted Average Price with sigma bands and slope",
404            default_params: [
405                ("window".to_string(), 390.0),
406                ("slope_lookback".to_string(), 20.0),
407            ]
408            .into(),
409        },
410        IndicatorCatalogEntry {
411            name: "vix_fix",
412            description: "Williams Vix Fix volatility spike detector",
413            default_params: [
414                ("pd".to_string(), 22.0),
415                ("bband_len".to_string(), 20.0),
416                ("mult".to_string(), 2.0),
417            ]
418            .into(),
419        },
420        IndicatorCatalogEntry {
421            name: "candle_story",
422            description: "Normalised candle classification — reports every pattern it recognises",
423            default_params: [
424                ("pin_wick_min".to_string(), 0.55),
425                ("pin_close_pos".to_string(), 0.65),
426                ("marubozu_body_min".to_string(), 0.82),
427                ("doji_body_max".to_string(), 0.08),
428                ("hammer_wick_body_min".to_string(), 2.0),
429                ("min_range_atr".to_string(), 0.5),
430                ("atr_len".to_string(), 14.0),
431                ("trend_lookback".to_string(), 10.0),
432            ]
433            .into(),
434        },
435        IndicatorCatalogEntry {
436            name: "efficiency",
437            description: "Kaufman-style leg efficiency ratio",
438            default_params: [("len".to_string(), 14.0)].into(),
439        },
440        IndicatorCatalogEntry {
441            name: "volume",
442            description: "Volume and Average Volume",
443            default_params: [("ma_period".to_string(), 20.0)].into(),
444        },
445        IndicatorCatalogEntry {
446            name: "rvol",
447            description: "Relative Volume vs Moving Average",
448            default_params: [("period".to_string(), 20.0)].into(),
449        },
450        IndicatorCatalogEntry {
451            name: "obv",
452            description: "On-Balance Volume",
453            default_params: HashMap::new(),
454        },
455        IndicatorCatalogEntry {
456            name: "cmf",
457            description: "Chaikin Money Flow",
458            default_params: [("period".to_string(), 20.0)].into(),
459        },
460        IndicatorCatalogEntry {
461            name: "acc_dist",
462            description: "Accumulation / Distribution Line",
463            default_params: HashMap::new(),
464        },
465        IndicatorCatalogEntry {
466            name: "true_range",
467            description: "True Range in price units",
468            default_params: HashMap::new(),
469        },
470        IndicatorCatalogEntry {
471            name: "keltner",
472            description: "Keltner Channels",
473            default_params: [
474                ("ema_period".to_string(), 20.0),
475                ("atr_period".to_string(), 10.0),
476                ("multiplier".to_string(), 2.0),
477            ]
478            .into(),
479        },
480        IndicatorCatalogEntry {
481            name: "donchian",
482            description: "Donchian Channels",
483            default_params: [("period".to_string(), 20.0)].into(),
484        },
485        IndicatorCatalogEntry {
486            name: "historical_volatility",
487            description: "Annualized Historical Volatility",
488            default_params: [("period".to_string(), 20.0)].into(),
489        },
490        IndicatorCatalogEntry {
491            name: "garman_klass",
492            description: "Garman-Klass Volatility Estimator",
493            default_params: [("period".to_string(), 20.0)].into(),
494        },
495        IndicatorCatalogEntry {
496            name: "sma",
497            description: "Simple Moving Average",
498            default_params: [("period".to_string(), 20.0)].into(),
499        },
500        IndicatorCatalogEntry {
501            name: "ema",
502            description: "Exponential Moving Average (build_typed accepts init=first_sample|sma for the seed; first_sample is the default)",
503            default_params: [("period".to_string(), 20.0)].into(),
504        },
505        IndicatorCatalogEntry {
506            name: "wma",
507            description: "Weighted Moving Average",
508            default_params: [("period".to_string(), 20.0)].into(),
509        },
510        IndicatorCatalogEntry {
511            name: "vwma",
512            description: "Volume-Weighted Moving Average",
513            default_params: [("period".to_string(), 20.0)].into(),
514        },
515        IndicatorCatalogEntry {
516            name: "hma",
517            description: "Hull Moving Average",
518            default_params: [("period".to_string(), 20.0)].into(),
519        },
520        IndicatorCatalogEntry {
521            name: "dema",
522            description: "Double Exponential Moving Average",
523            default_params: [("period".to_string(), 20.0)].into(),
524        },
525        IndicatorCatalogEntry {
526            name: "kama",
527            description: "Kaufman's Adaptive Moving Average",
528            default_params: [
529                ("period".to_string(), 10.0),
530                ("fast_period".to_string(), 2.0),
531                ("slow_period".to_string(), 30.0),
532            ]
533            .into(),
534        },
535        IndicatorCatalogEntry {
536            name: "dmi",
537            description: "Directional Movement Index (+DI / -DI)",
538            default_params: [("period".to_string(), 14.0)].into(),
539        },
540        IndicatorCatalogEntry {
541            name: "aroon",
542            description: "Aroon Indicator (Up, Down, Oscillator)",
543            default_params: [("period".to_string(), 14.0)].into(),
544        },
545        IndicatorCatalogEntry {
546            name: "parabolic_sar",
547            description: "Parabolic SAR",
548            default_params: [("step".to_string(), 0.02), ("max_step".to_string(), 0.20)].into(),
549        },
550        IndicatorCatalogEntry {
551            name: "supertrend",
552            description: "Supertrend ATR Trailing Stop",
553            default_params: [
554                ("period".to_string(), 10.0),
555                ("multiplier".to_string(), 3.0),
556            ]
557            .into(),
558        },
559        IndicatorCatalogEntry {
560            name: "ichimoku",
561            description: "Ichimoku Kinko Hyo Cloud",
562            default_params: [
563                ("tenkan_p".to_string(), 9.0),
564                ("kijun_p".to_string(), 26.0),
565                ("senkou_b_p".to_string(), 52.0),
566            ]
567            .into(),
568        },
569        IndicatorCatalogEntry {
570            name: "stochastic",
571            description: "Classic Stochastic Oscillator",
572            default_params: [
573                ("k_period".to_string(), 14.0),
574                ("d_period".to_string(), 3.0),
575            ]
576            .into(),
577        },
578        IndicatorCatalogEntry {
579            name: "roc",
580            description: "Rate of Change / Momentum",
581            default_params: [("period".to_string(), 12.0)].into(),
582        },
583        IndicatorCatalogEntry {
584            name: "ultimate_oscillator",
585            description: "Ultimate Oscillator",
586            default_params: [
587                ("period1".to_string(), 7.0),
588                ("period2".to_string(), 14.0),
589                ("period3".to_string(), 28.0),
590            ]
591            .into(),
592        },
593        IndicatorCatalogEntry {
594            name: "awesome_oscillator",
595            description: "Awesome Oscillator",
596            default_params: [
597                ("fast_period".to_string(), 5.0),
598                ("slow_period".to_string(), 34.0),
599            ]
600            .into(),
601        },
602        IndicatorCatalogEntry {
603            name: "ppo",
604            description: "Percentage Price Oscillator",
605            default_params: [
606                ("fast_period".to_string(), 12.0),
607                ("slow_period".to_string(), 26.0),
608                ("signal_period".to_string(), 9.0),
609            ]
610            .into(),
611        },
612        IndicatorCatalogEntry {
613            name: "wavetrend",
614            description: "WaveTrend Oscillator (wt1, wt2)",
615            default_params: [
616                ("n1".to_string(), 10.0),
617                ("n2".to_string(), 21.0),
618                ("ob_level".to_string(), 60.0),
619                ("os_level".to_string(), -60.0),
620            ]
621            .into(),
622        },
623        IndicatorCatalogEntry {
624            name: "cmo",
625            description: "Chande Momentum Oscillator",
626            default_params: [("period".to_string(), 14.0)].into(),
627        },
628        IndicatorCatalogEntry {
629            name: "elder_ray",
630            description: "Elder Ray Index (Bull/Bear Power)",
631            default_params: [("period".to_string(), 13.0)].into(),
632        },
633        IndicatorCatalogEntry {
634            name: "anchored_vwap",
635            description: "Anchored VWAP Engine",
636            default_params: [("mult1".to_string(), 1.0), ("mult2".to_string(), 2.0)].into(),
637        },
638        IndicatorCatalogEntry {
639            name: "cvd",
640            // Der Name verspricht Kauf- minus Verkaufsvolumen, also die
641            // Aggressorseite. Diese Fassung schaetzt sie aus der Bar-Form
642            // (`(close - low) / (high - low)`) und sieht keinen einzigen
643            // Abschluss an — deshalb `needs_trade_direction: false`, was
644            // korrekt ist, aber ohne diesen Zusatz irrefuehrend waere.
645            description: "Cumulative Volume Delta (Aufteilung aus der Bar-Form geschätzt, \
646                          nicht aus klassifizierten Abschlüssen)",
647            default_params: [].into(),
648        },
649        IndicatorCatalogEntry {
650            name: "klinger",
651            description: "Klinger Volume Force Oscillator",
652            default_params: [
653                ("fast_len".to_string(), 34.0),
654                ("slow_len".to_string(), 55.0),
655                ("signal_len".to_string(), 13.0),
656            ]
657            .into(),
658        },
659        IndicatorCatalogEntry {
660            name: "zigzag",
661            description: "ZigZag Swing Leg Engine",
662            default_params: [
663                ("depth".to_string(), 12.0),
664                ("deviation_pct".to_string(), 5.0),
665            ]
666            .into(),
667        },
668        IndicatorCatalogEntry {
669            name: "zigzag_advanced",
670            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",
671            default_params: [
672                ("depth".to_string(), 3.0),
673                ("backstep".to_string(), 2.0),
674                ("deviation_pct".to_string(), 1.0),
675                ("atr_len".to_string(), 14.0),
676            ]
677            .into(),
678        },
679        IndicatorCatalogEntry {
680            name: "pivot_sets",
681            description: "Multi-Pivot Set Engine",
682            default_params: [].into(),
683        },
684        IndicatorCatalogEntry {
685            name: "tema",
686            description: "Triple Exponential Moving Average",
687            default_params: [("period".to_string(), 14.0)].into(),
688        },
689        IndicatorCatalogEntry {
690            name: "lsma",
691            description: "Least Squares Moving Average / Linear Regression",
692            default_params: [("period".to_string(), 25.0)].into(),
693        },
694        IndicatorCatalogEntry {
695            name: "mcginley",
696            description: "McGinley Dynamic Moving Average",
697            default_params: [("period".to_string(), 14.0)].into(),
698        },
699        IndicatorCatalogEntry {
700            name: "envelope",
701            description: "Moving Average Envelopes",
702            default_params: [("period".to_string(), 20.0), ("percent".to_string(), 2.5)].into(),
703        },
704        IndicatorCatalogEntry {
705            name: "choppiness",
706            description: "Choppiness Index",
707            default_params: [("period".to_string(), 14.0)].into(),
708        },
709        IndicatorCatalogEntry {
710            name: "vortex",
711            description: "Vortex Indicator (+VI, -VI)",
712            default_params: [("period".to_string(), 14.0)].into(),
713        },
714        IndicatorCatalogEntry {
715            name: "alligator",
716            description: "Williams Alligator (Jaw, Teeth, Lips)",
717            default_params: [].into(),
718        },
719        IndicatorCatalogEntry {
720            name: "connors_rsi",
721            description: "Connors RSI",
722            default_params: [
723                ("rsi_len".to_string(), 3.0),
724                ("streak_len".to_string(), 2.0),
725                ("rank_len".to_string(), 100.0),
726            ]
727            .into(),
728        },
729        IndicatorCatalogEntry {
730            name: "coppock",
731            description: "Coppock Curve",
732            default_params: [].into(),
733        },
734        IndicatorCatalogEntry {
735            name: "dpo",
736            description: "Detrended Price Oscillator",
737            default_params: [("period".to_string(), 21.0)].into(),
738        },
739        IndicatorCatalogEntry {
740            name: "kst",
741            description: "Know Sure Thing Oscillator",
742            default_params: [].into(),
743        },
744        IndicatorCatalogEntry {
745            name: "mass_index",
746            description: "Mass Index Reversal Detector",
747            default_params: [("period".to_string(), 25.0)].into(),
748        },
749        IndicatorCatalogEntry {
750            name: "rvi",
751            description: "Relative Vigor Index",
752            default_params: [("period".to_string(), 10.0)].into(),
753        },
754        IndicatorCatalogEntry {
755            name: "bop",
756            description: "Balance of Power",
757            default_params: [("period".to_string(), 14.0)].into(),
758        },
759        IndicatorCatalogEntry {
760            name: "eom",
761            description: "Ease of Movement",
762            default_params: [
763                ("period".to_string(), 14.0),
764                ("volume_divisor".to_string(), 10000.0),
765            ]
766            .into(),
767        },
768        IndicatorCatalogEntry {
769            name: "nvi",
770            description: "Negative Volume Index",
771            default_params: [].into(),
772        },
773        IndicatorCatalogEntry {
774            name: "pvi",
775            description: "Positive Volume Index",
776            default_params: [].into(),
777        },
778        IndicatorCatalogEntry {
779            name: "chaikin_oscillator",
780            description: "Chaikin Oscillator",
781            default_params: [
782                ("fast_len".to_string(), 3.0),
783                ("slow_len".to_string(), 10.0),
784            ]
785            .into(),
786        },
787        IndicatorCatalogEntry {
788            name: "bos_choch",
789            description: "BOS and CHoCH Market Structure Engine",
790            default_params: [("pivot_len".to_string(), 5.0)].into(),
791        },
792        IndicatorCatalogEntry {
793            name: "liquidity_sweeps",
794            description: "Liquidity Sweeps and EQH/EQL Detector",
795            default_params: [
796                ("pivot_len".to_string(), 5.0),
797                ("tolerance_pct".to_string(), 0.2),
798            ]
799            .into(),
800        },
801        IndicatorCatalogEntry {
802            name: "liquidity_pools",
803            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)",
804            default_params: [
805                ("pivot_len".to_string(), 5.0),
806                ("tolerance_pct".to_string(), 0.2),
807            ]
808            .into(),
809        },
810        IndicatorCatalogEntry {
811            name: "wyckoff",
812            description: "Wyckoff accumulation/distribution state machine: range-lock, Phases A-E, Spring/UTAD, SOS/SOW/LPS/LPSY, sequence validation and Cause/Quality scoring",
813            default_params: [
814                ("range_lookback".to_string(), 20.0),
815                ("range_atr_max".to_string(), 3.0),
816                ("min_range_bars".to_string(), 6.0),
817            ]
818            .into(),
819        },
820        IndicatorCatalogEntry {
821            name: "trend_quality",
822            description: "Trend Quality Score Engine",
823            default_params: [("period".to_string(), 14.0)].into(),
824        },
825        IndicatorCatalogEntry {
826            name: "buy_sell_pressure",
827            description: "Buy/Sell Pressure Estimator",
828            default_params: [("period".to_string(), 14.0)].into(),
829        },
830        IndicatorCatalogEntry {
831            name: "volatility_regime",
832            description: "Volatility Regime & Squeeze Detector",
833            default_params: [
834                ("period".to_string(), 20.0),
835                ("bb_mult".to_string(), 2.0),
836                ("kc_mult".to_string(), 1.5),
837            ]
838            .into(),
839        },
840        IndicatorCatalogEntry {
841            name: "zscore",
842            description: "Rolling Z-Score Engine",
843            default_params: [("period".to_string(), 20.0)].into(),
844        },
845        IndicatorCatalogEntry {
846            name: "multi_factor",
847            description: "Multi-Factor Composite Market Score",
848            default_params: [("period".to_string(), 14.0)].into(),
849        },
850    ]
851}
852
853/// Der Wertebereich, in dem die Ausgabe eines Indikators liegt.
854///
855/// Eine Eigenschaft des Indikators, keine Darstellungsvorliebe: Ein RSI ist per Konstruktion auf
856/// `0..=100` begrenzt, ein MACD schwankt unbegrenzt um null. Ein Konsument, der eine Achse
857/// auslegt, braucht diese Angabe — ohne sie bildet er die Achse aus den zufälligen Extrema des
858/// gerade verwendeten Datensatzes und beschriftet Werte, die nichts bedeuten.
859///
860/// [`OutputRange::Unbounded`] ist die ehrliche Voreinstellung: Sie sagt, dass über den Bereich
861/// hier nichts erklärt wird — nicht, dass er unbegrenzt wäre.
862#[derive(Debug, Clone, Copy, PartialEq)]
863#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
864pub enum OutputRange {
865    /// Fest begrenzt: Der Wert kann den Bereich konstruktionsbedingt nicht verlassen.
866    Bounded { min: f64, max: f64 },
867    /// Nach beiden Seiten offen, aber um eine ausgezeichnete Mitte schwankend. Die Mittellinie
868    /// trägt die Aussage — ein MACD über null bedeutet etwas anderes als einer darunter.
869    Centered { center: f64 },
870    /// Nie negativ, nach oben offen: Spannen, Mengen, Verhältnisse.
871    NonNegative,
872    /// Über den Bereich wird nichts erklärt.
873    Unbounded,
874}
875
876/// Der Wertebereich der Ausgabe von `name`.
877///
878/// Deklariert wird nur, was sich aus der Konstruktion des Indikators ergibt. Der Rest bleibt
879/// [`OutputRange::Unbounded`] — eine Deklaration auf Verdacht wäre schlechter als keine, weil ein
880/// Konsument sie für bare Münze nimmt. `tests/output_range_invariants.rs` prüft jede Angabe hier
881/// gegen tatsächliche Läufe, damit sie nicht still veralten kann.
882pub fn output_range(name: &str) -> OutputRange {
883    // Auf 0..100 normierte Anteilsmaße. `williams_r` gehört hierher und nicht nach -100..0: die
884    // Implementierung rechnet `100 * (close - lowest_low) / range`, also in der aufsteigenden
885    // Konvention — was auch die Voreinstellungen `oversold: 20` / `overbought: 80` erklärt.
886    const PROZENT: OutputRange = OutputRange::Bounded {
887        min: 0.0,
888        max: 100.0,
889    };
890
891    match name {
892        "adx"
893        | "choppiness"
894        | "connors_rsi"
895        | "efficiency"
896        | "mfi"
897        | "relative_volatility"
898        | "rsi"
899        | "stoch_rsi"
900        | "stochastic"
901        | "ultimate_oscillator"
902        | "williams_r" => PROZENT,
903
904        // Auf -100..100 normierte Differenzmaße. `dmi` gehört hierher und nicht zu den
905        // Prozentmaßen: seine Hauptreihe ist `plus_di - minus_di`, die Differenz zweier
906        // 0..100-Werte — die einzelnen DI liegen als Nebenreihen darin. `aroon` ebenso: die
907        // Hauptreihe ist der Oszillator `up - down`, nicht eine der beiden Linien.
908        "aroon" | "cmo" | "dmi" | "rci" | "tsi" => OutputRange::Bounded {
909            min: -100.0,
910            max: 100.0,
911        },
912
913        // Anteilsmaße mit Vorzeichen.
914        "bop" | "cmf" => OutputRange::Bounded {
915            min: -1.0,
916            max: 1.0,
917        },
918
919        // Um null schwankend und unbegrenzt: Differenzen, Abweichungen, Transformationen.
920        "awesome_oscillator" | "cci" | "chaikin_oscillator" | "coppock" | "dpo" | "efi"
921        | "elder_ray" | "eom" | "fisher_transform" | "klinger" | "kst" | "macd" | "ppo" | "roc"
922        | "bbtrend" | "pmo" | "smi" | "trix" | "wavetrend" | "zscore" => {
923            OutputRange::Centered { center: 0.0 }
924        }
925
926        // Spannen, Mengen und Verhältnisse — nie negativ, nach oben offen.
927        "atr"
928        | "garman_klass"
929        | "historical_volatility"
930        | "mass_index"
931        | "rvat"
932        | "rvol"
933        | "true_range"
934        | "ulcer_index"
935        | "vix_fix"
936        | "volume"
937        | "vortex" => OutputRange::NonNegative,
938
939        _ => OutputRange::Unbounded,
940    }
941}
942
943/// Die Parameter von `name`, die eine Schwelle im Wertebereich der Ausgabe bezeichnen.
944///
945/// Als Parameternamen statt als Zahlen: Die Schwelle folgt damit dem, was der Aufrufer eingestellt
946/// hat, statt eine zweite Wahrheit daneben zu führen. Ein Konsument, der die Ausgabe zeichnet,
947/// findet so die Linien, ohne die Bedeutung einzelner Parameter kennen zu müssen.
948pub fn threshold_params(name: &str) -> &'static [&'static str] {
949    match name {
950        "cci" | "fisher_transform" | "mfi" | "rsi" | "stoch_rsi" | "williams_r" => {
951            &["oversold", "overbought"]
952        }
953        "adx" => &["level_weak"],
954        "wavetrend" => &["os_level", "ob_level"],
955        _ => &[],
956    }
957}
958
959impl IndicatorCatalogEntry {
960    /// Siehe [`output_range`].
961    pub fn output_range(&self) -> OutputRange {
962        output_range(self.name)
963    }
964
965    /// Die Schwellenwerte dieses Eintrags, aufgelöst über seine Voreinstellungen.
966    ///
967    /// Aufsteigend sortiert, damit ein Zeichner sie ohne weitere Annahme von unten nach oben
968    /// abarbeiten kann.
969    pub fn thresholds(&self) -> Vec<f64> {
970        let mut werte: Vec<f64> = threshold_params(self.name)
971            .iter()
972            .filter_map(|p| self.default_params.get(*p).copied())
973            .filter(|v| v.is_finite())
974            .collect();
975        werte.sort_by(|a, b| a.partial_cmp(b).expect("filtered to finite values"));
976        werte
977    }
978}
979
980#[derive(Debug, Clone, PartialEq)]
981#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
982pub enum RegistryError {
983    UnknownIndicator(String),
984    InvalidParameter {
985        parameter: String,
986        value: f64,
987        reason: String,
988    },
989    /// A [`ParamValue`] with no numeric equivalent (`Enum`/`Text`/`Timeframe`/`Source`) was
990    /// passed to [`build_typed`] for an indicator whose current parameter surface is `f64`-only.
991    UnsupportedParameterType {
992        parameter: String,
993        type_name: String,
994    },
995    /// A [`ParamValue::Enum`] string did not match any variant accepted for this parameter.
996    InvalidEnumValue {
997        parameter: String,
998        value: String,
999        reason: String,
1000    },
1001    /// A parameter is a known, supported type but cannot be applied to this specific indicator
1002    /// (e.g. a non-`Close` `source` on a range/OHLC-dependent indicator — see
1003    /// [`super::source_mapped::SourceMapped`]'s doc comment for why).
1004    IncompatibleParameter {
1005        parameter: String,
1006        indicator: String,
1007        reason: String,
1008    },
1009}
1010
1011impl std::fmt::Display for RegistryError {
1012    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1013        match self {
1014            RegistryError::UnknownIndicator(name) => write!(f, "Unknown indicator: {}", name),
1015            RegistryError::InvalidParameter {
1016                parameter,
1017                value,
1018                reason,
1019            } => write!(
1020                f,
1021                "Invalid parameter '{}' (value {}): {}",
1022                parameter, value, reason
1023            ),
1024            RegistryError::UnsupportedParameterType {
1025                parameter,
1026                type_name,
1027            } => write!(
1028                f,
1029                "Parameter '{}' has unsupported type '{}' for this indicator",
1030                parameter, type_name
1031            ),
1032            RegistryError::InvalidEnumValue {
1033                parameter,
1034                value,
1035                reason,
1036            } => write!(
1037                f,
1038                "Invalid value '{}' for parameter '{}': {}",
1039                value, parameter, reason
1040            ),
1041            RegistryError::IncompatibleParameter {
1042                parameter,
1043                indicator,
1044                reason,
1045            } => write!(
1046                f,
1047                "Parameter '{}' is not compatible with indicator '{}': {}",
1048                parameter, indicator, reason
1049            ),
1050        }
1051    }
1052}
1053
1054impl std::error::Error for RegistryError {}
1055
1056fn get_usize_p(
1057    params: &HashMap<String, f64>,
1058    name: &str,
1059    default: usize,
1060    min: usize,
1061    max: usize,
1062) -> Result<usize, RegistryError> {
1063    if let Some(&val) = params.get(name) {
1064        if !val.is_finite() || val.fract() != 0.0 || val < (min as f64) || val > (max as f64) {
1065            return Err(RegistryError::InvalidParameter {
1066                parameter: name.to_string(),
1067                value: val,
1068                reason: format!(
1069                    "Value must be a whole, finite number between {} and {}",
1070                    min, max
1071                ),
1072            });
1073        }
1074        Ok(val as usize)
1075    } else {
1076        Ok(default)
1077    }
1078}
1079
1080fn get_f64_p(
1081    params: &HashMap<String, f64>,
1082    name: &str,
1083    default: f64,
1084    min: f64,
1085    max: f64,
1086) -> Result<f64, RegistryError> {
1087    if let Some(&val) = params.get(name) {
1088        if !val.is_finite() || val < min || val > max {
1089            return Err(RegistryError::InvalidParameter {
1090                parameter: name.to_string(),
1091                value: val,
1092                reason: format!("Value must be a finite number between {} and {}", min, max),
1093            });
1094        }
1095        Ok(val)
1096    } else {
1097        Ok(default)
1098    }
1099}
1100
1101/// Reads a `usize` parameter that has a canonical name and a deprecated legacy alias (finding
1102/// 05): if both are present with different values, that is treated as an ambiguous configuration
1103/// and rejected rather than silently preferring one; if only one is present, it is validated and
1104/// used under the canonical name's semantics; if neither is present, `default` applies.
1105fn get_usize_p_aliased(
1106    params: &HashMap<String, f64>,
1107    canonical: &str,
1108    legacy_alias: &str,
1109    default: usize,
1110    min: usize,
1111    max: usize,
1112) -> Result<usize, RegistryError> {
1113    match (params.get(canonical), params.get(legacy_alias)) {
1114        (Some(&canonical_val), Some(&alias_val)) if canonical_val != alias_val => {
1115            Err(RegistryError::InvalidParameter {
1116                parameter: canonical.to_string(),
1117                value: canonical_val,
1118                reason: format!(
1119                    "conflicting values for '{canonical}' ({canonical_val}) and legacy alias \
1120                     '{legacy_alias}' ({alias_val}); set only one"
1121                ),
1122            })
1123        }
1124        (Some(_), _) => get_usize_p(params, canonical, default, min, max),
1125        (None, Some(_)) => get_usize_p(params, legacy_alias, default, min, max),
1126        (None, None) => Ok(default),
1127    }
1128}
1129
1130fn ensure_less(
1131    parameter: &str,
1132    value: f64,
1133    upper_parameter: &str,
1134    upper_value: f64,
1135) -> Result<(), RegistryError> {
1136    if value < upper_value {
1137        return Ok(());
1138    }
1139
1140    Err(RegistryError::InvalidParameter {
1141        parameter: parameter.to_string(),
1142        value,
1143        reason: format!("{} must be smaller than {}", parameter, upper_parameter),
1144    })
1145}
1146
1147pub fn build_checked(
1148    name: &str,
1149    params: &HashMap<String, f64>,
1150) -> Result<Box<dyn Indicator>, RegistryError> {
1151    match name.to_lowercase().as_str() {
1152        "rsi" => Ok(Box::new(build_rsi(params, RsiSmoothing::Wilder)?)),
1153        "macd" => {
1154            let fast_len = get_usize_p(params, "fast_len", 12, 1, 10000)?;
1155            let slow_len = get_usize_p(params, "slow_len", 26, 1, 10000)?;
1156            let signal_len = get_usize_p(params, "signal_len", 9, 1, 10000)?;
1157            ensure_less("fast_len", fast_len as f64, "slow_len", slow_len as f64)?;
1158            Ok(Box::new(Macd::new(fast_len, slow_len, signal_len)))
1159        }
1160        "bollinger" | "bb" => Ok(Box::new(build_bollinger(
1161            params,
1162            VarianceConvention::Population,
1163        )?)),
1164        "adx" => {
1165            let di_len = get_usize_p(params, "di_len", 14, 1, 10000)?;
1166            let adx_smooth = get_usize_p(params, "adx_smooth", 14, 1, 10000)?;
1167            let level_weak = get_f64_p(params, "level_weak", 20.0, 0.0, 100.0)?;
1168            Ok(Box::new(Adx::new(di_len, adx_smooth, 3, level_weak)))
1169        }
1170        "stoch_rsi" | "srsi" => {
1171            let rsi_len = get_usize_p(params, "rsi_len", 14, 1, 10000)?;
1172            let stoch_len = get_usize_p(params, "stoch_len", 14, 1, 10000)?;
1173            let k_len = get_usize_p(params, "k_len", 3, 1, 10000)?;
1174            let d_len = get_usize_p(params, "d_len", 3, 1, 10000)?;
1175            let overbought = get_f64_p(params, "overbought", 80.0, 0.0, 100.0)?;
1176            let oversold = get_f64_p(params, "oversold", 20.0, 0.0, 100.0)?;
1177            ensure_less("oversold", oversold, "overbought", overbought)?;
1178            Ok(Box::new(StochRsi::new(
1179                rsi_len, stoch_len, k_len, d_len, 50.0, overbought, oversold, 5, true, 50, 50, 4,
1180                10.0,
1181            )))
1182        }
1183        "cci" => {
1184            let cci_len = get_usize_p(params, "cci_len", 20, 1, 10000)?;
1185            let overbought = get_f64_p(params, "overbought", 100.0, -1000.0, 1000.0)?;
1186            let oversold = get_f64_p(params, "oversold", -100.0, -1000.0, 1000.0)?;
1187            ensure_less("oversold", oversold, "overbought", overbought)?;
1188            Ok(Box::new(Cci::new(
1189                cci_len, 3, 3, 5, oversold, overbought, true, 100, 4, 25.0,
1190            )))
1191        }
1192        "mfi" => {
1193            let mfi_len = get_usize_p(params, "mfi_len", 14, 1, 10000)?;
1194            let overbought = get_f64_p(params, "overbought", 80.0, 0.0, 100.0)?;
1195            let oversold = get_f64_p(params, "oversold", 20.0, 0.0, 100.0)?;
1196            ensure_less("oversold", oversold, "overbought", overbought)?;
1197            Ok(Box::new(Mfi::new(
1198                mfi_len, 3, 3, 50.0, overbought, oversold, 5, true,
1199            )))
1200        }
1201        "twap" => {
1202            let offset = get_f64_p(params, "day_start_offset", 0.0, -86_400.0, 86_400.0)?;
1203            Ok(Box::new(AnchoredTwap::new(
1204                TwapAnchor::Daily {
1205                    start_offset_seconds: offset as i64,
1206                },
1207                crate::model::Source::Close,
1208                TwapWeighting::PerBar,
1209            )))
1210        }
1211        "relative_volatility" => Ok(Box::new(build_relative_volatility(
1212            params,
1213            RelativeVolatilityVariant::Close,
1214        )?)),
1215        "bbtrend" => Ok(Box::new(build_bbtrend(
1216            params,
1217            VarianceConvention::Population,
1218        )?)),
1219        "pvt" => Ok(Box::new(PriceVolumeTrend::new())),
1220        "pmo" => {
1221            let length_1 = get_usize_p(params, "length_1", 35, 1, 10000)?;
1222            let length_2 = get_usize_p(params, "length_2", 20, 1, 10000)?;
1223            let signal_len = get_usize_p(params, "signal_len", 10, 1, 10000)?;
1224            Ok(Box::new(PriceMomentumOscillator::new(
1225                length_1, length_2, signal_len,
1226            )))
1227        }
1228        "rvat" => {
1229            let days = get_usize_p(params, "days", 10, 1, 1000)?;
1230            let day_start_offset = get_f64_p(params, "day_start_offset", 0.0, -86_400.0, 86_400.0)?;
1231            let bar_seconds = get_f64_p(params, "bar_seconds", 60.0, 1.0, 86_400.0)?;
1232            Ok(Box::new(RelativeVolumeAtTime::new(
1233                days,
1234                day_start_offset as i64,
1235                bar_seconds as i64,
1236            )))
1237        }
1238        "smi" => {
1239            let len = get_usize_p(params, "len", 10, 1, 10000)?;
1240            let smooth_1 = get_usize_p(params, "smooth_1", 3, 1, 10000)?;
1241            let smooth_2 = get_usize_p(params, "smooth_2", 3, 1, 10000)?;
1242            let signal_len = get_usize_p(params, "signal_len", 3, 1, 10000)?;
1243            Ok(Box::new(StochasticMomentumIndex::new(
1244                len, smooth_1, smooth_2, signal_len,
1245            )))
1246        }
1247        "rci" => {
1248            let len = get_usize_p(params, "len", 9, 2, 10000)?;
1249            Ok(Box::new(RciEngine::new(len)))
1250        }
1251        "ulcer_index" => {
1252            let len = get_usize_p(params, "len", 14, 1, 10000)?;
1253            Ok(Box::new(UlcerIndexEngine::new(len)))
1254        }
1255        "t3" => {
1256            let period = get_usize_p(params, "period", 5, 1, 10000)?;
1257            let v = get_f64_p(params, "v", 0.7, 0.0, 1.0)?;
1258            Ok(Box::new(T3::new(period, v)))
1259        }
1260        "vidya" => {
1261            let cmo_len = get_usize_p(params, "cmo_len", 9, 1, 10000)?;
1262            let ema_len = get_usize_p(params, "ema_len", 12, 1, 10000)?;
1263            Ok(Box::new(Vidya::new(cmo_len, ema_len)))
1264        }
1265        "trix" => {
1266            let len = get_usize_p(params, "len", 15, 1, 10000)?;
1267            let signal_len = get_usize_p(params, "signal_len", 9, 1, 10000)?;
1268            Ok(Box::new(Trix::new(len, signal_len)))
1269        }
1270        "efi" => {
1271            let ema_len = get_usize_p(params, "ema_len", 13, 1, 10000)?;
1272            Ok(Box::new(ElderForceIndex::new(ema_len)))
1273        }
1274        "atr" => Ok(Box::new(build_atr(params, TrueRangeSmoothing::Rma)?)),
1275        "chande_kroll" | "cks" => {
1276            let atr_len = get_usize_p(params, "atr_len", 10, 1, 10000)?;
1277            let stop_len = get_usize_p(params, "stop_len", 9, 1, 10000)?;
1278            let mult = get_f64_p(params, "mult", 3.0, 0.01, 100.0)?;
1279            Ok(Box::new(ChandeKrollStop::new(atr_len, stop_len, mult)))
1280        }
1281        "chandelier_exit" | "ce" => {
1282            let length = get_usize_p(params, "length", 22, 1, 10000)?;
1283            let atr_mult = get_f64_p(params, "atr_mult", 3.0, 0.01, 100.0)?;
1284            Ok(Box::new(ChandelierExitEngine::new(length, atr_mult)))
1285        }
1286        "chandelier_flip_radar" | "chfr" => {
1287            let length = get_usize_p(params, "length", 30, 1, 10000)?;
1288            let atr_mult = get_f64_p(params, "atr_mult", 4.5, 0.01, 100.0)?;
1289            let body_filter_atr = get_f64_p(params, "body_filter_atr", 0.80, 0.0, 100.0)?;
1290            let danger_dist_atr = get_f64_p(params, "danger_dist_atr", 0.35, 0.0, 100.0)?;
1291            let warn_dist_atr = get_f64_p(params, "warn_dist_atr", 0.75, 0.0, 100.0)?;
1292            // `use_close_extremes`/`simple_adaptive` are fixed to their documented defaults
1293            // here (true/false) —
1294            // this f64-only registry surface has no boolean parameter type; use
1295            // `ChandelierFlipRadarEngine::new` directly to override them.
1296            Ok(Box::new(ChandelierFlipRadarEngine::new(
1297                length,
1298                atr_mult,
1299                true,
1300                false,
1301                body_filter_atr,
1302                danger_dist_atr,
1303                warn_dist_atr,
1304            )))
1305        }
1306        "midas" => {
1307            // Fixed Topfinder/Hlc3 via this f64-only entry point; use `build_typed` with `mode`
1308            // (ParamValue::Enum) and `source` to select Bottomfinder or another price source.
1309            let maturity_bars = get_usize_p(params, "maturity_bars", 20, 1, 10000)?;
1310            Ok(Box::new(MidasCurveEngine::new(
1311                MidasMode::Topfinder,
1312                crate::model::Source::Hlc3,
1313                maturity_bars as u32,
1314            )))
1315        }
1316        "trend_relationship" => {
1317            // Fixed EMA/EMA via this f64-only entry point; use `build_typed` with
1318            // `fast_kind`/`slow_kind` (ParamValue::Enum) to select other smoother kinds.
1319            let fast_len = get_usize_p(params, "fast_len", 9, 1, 10000)?;
1320            let slow_len = get_usize_p(params, "slow_len", 21, 1, 10000)?;
1321            ensure_less("fast_len", fast_len as f64, "slow_len", slow_len as f64)?;
1322            Ok(Box::new(
1323                super::trend_relationship::AdaptiveTrendRelationship::new(
1324                    super::smoothing::SmootherKind::Ema,
1325                    fast_len,
1326                    super::smoothing::SmootherKind::Ema,
1327                    slow_len,
1328                ),
1329            ))
1330        }
1331        "williams_r" | "wpr" => {
1332            let wpr_len = get_usize_p(params, "wpr_len", 14, 1, 10000)?;
1333            let overbought = get_f64_p(params, "overbought", 80.0, 0.0, 100.0)?;
1334            let oversold = get_f64_p(params, "oversold", 20.0, 0.0, 100.0)?;
1335            ensure_less("oversold", oversold, "overbought", overbought)?;
1336            Ok(Box::new(WilliamsR::new(
1337                wpr_len, 3, 3, 50.0, overbought, oversold, 5, true, 50, 4, 10.0,
1338            )))
1339        }
1340        "tsi" => {
1341            let long_len = get_usize_p(params, "long_len", 25, 1, 10000)?;
1342            let short_len = get_usize_p(params, "short_len", 13, 1, 10000)?;
1343            let sig_len = get_usize_p(params, "sig_len", 7, 1, 10000)?;
1344            ensure_less("short_len", short_len as f64, "long_len", long_len as f64)?;
1345            Ok(Box::new(Tsi::new(
1346                long_len, short_len, sig_len, 0.0, 25.0, -25.0, 5, true, 50, 25, 4, 5.0,
1347            )))
1348        }
1349        "fisher_transform" | "fisher" => {
1350            let fish_len = get_usize_p(params, "fish_len", 10, 1, 10000)?;
1351            let overbought = get_f64_p(params, "overbought", 1.5, -100.0, 100.0)?;
1352            let oversold = get_f64_p(params, "oversold", -1.5, -100.0, 100.0)?;
1353            ensure_less("oversold", oversold, "overbought", overbought)?;
1354            Ok(Box::new(FisherTransform::new(
1355                fish_len, 2, 3, 0.0, overbought, oversold, 5, true, 40, 4, 0.5,
1356            )))
1357        }
1358        "order_block" | "ob" => {
1359            let atr_len = get_usize_p(params, "atr_len", 14, 1, 10000)?;
1360            let min_disp = get_f64_p(params, "min_disp", 1.0, 0.01, 100.0)?;
1361            Ok(Box::new(OrderBlockEngine::new(atr_len, min_disp)))
1362        }
1363        "liquidity_fvg" | "fvg" | "smc" => {
1364            let lookback = get_usize_p(params, "lookback", 20, 1, 10000)?;
1365            Ok(Box::new(LiquidityFvgEngine::new(lookback)))
1366        }
1367        "market_structure_breaks" | "bos" | "choch" => {
1368            let lookback = get_usize_p(params, "lookback", 5, 1, 10000)?;
1369            Ok(Box::new(MarketStructureBreaksEngine::new(lookback)))
1370        }
1371        "pivots_structure" | "pivots" => {
1372            let left_bars = get_usize_p(params, "left_bars", 5, 1, 10000)?;
1373            let right_bars = get_usize_p(params, "right_bars", 5, 1, 10000)?;
1374            let score_window = get_usize_p(params, "score_window", 10, 1, 10000)?;
1375            Ok(Box::new(PivotStructureEngine::new(
1376                left_bars,
1377                right_bars,
1378                score_window,
1379            )))
1380        }
1381        "volume_profile" | "vp" => {
1382            let lookback = get_usize_p(params, "lookback", 70, 1, 10000)?;
1383            let num_bins = get_usize_p(params, "num_bins", 30, 1, 1000)?;
1384            Ok(Box::new(VolumeProfileEngine::new(lookback, num_bins)))
1385        }
1386        "money_flow_profile" | "mfp" => {
1387            let lookback = get_usize_p(params, "lookback", 200, 1, 10000)?;
1388            let rows = get_usize_p(params, "rows", 25, 1, 1000)?;
1389            let va_pct = get_f64_p(params, "va_pct", 0.70, 0.0, 1.0)?;
1390            Ok(Box::new(MoneyFlowProfileEngine::new(
1391                lookback, rows, va_pct,
1392            )))
1393        }
1394        "extended_volume_profile" | "vp_extended" => {
1395            let lookback = get_usize_p(params, "lookback", 70, 1, 10000)?;
1396            let num_bins = get_usize_p(params, "num_bins", 30, 1, 1000)?;
1397            Ok(Box::new(ExtendedVolumeProfileEngine::new(
1398                lookback, num_bins,
1399            )))
1400        }
1401        "persistent_volume_profile" | "vp_persistent" => {
1402            let lookback = get_usize_p(params, "lookback", 70, 1, 10000)?;
1403            let bin_width = get_f64_p(params, "bin_width", 1.0, 1e-6, 1_000_000.0)?;
1404            Ok(Box::new(PersistentVolumeProfileEngine::new(
1405                lookback, bin_width,
1406            )))
1407        }
1408        "vwap" => {
1409            let window = get_usize_p(params, "window", 390, 1, 10000)?;
1410            let slope_lookback = get_usize_p(params, "slope_lookback", 20, 1, 10000)?;
1411            Ok(Box::new(Vwap::new(window, slope_lookback)))
1412        }
1413        "vix_fix" | "wvf" => {
1414            let pd = get_usize_p(params, "pd", 22, 1, 10000)?;
1415            let bband_len = get_usize_p(params, "bband_len", 20, 1, 10000)?;
1416            let mult = get_f64_p(params, "mult", 2.0, 0.01, 100.0)?;
1417            Ok(Box::new(WilliamsVixFix::new(pd, bband_len, mult)))
1418        }
1419        "candle_story" | "pinbar" => {
1420            let d = CandleStoryConfig::default();
1421            Ok(Box::new(CandleStoryEngine::with_config(
1422                CandleStoryConfig {
1423                    pin_wick_min: get_f64_p(params, "pin_wick_min", d.pin_wick_min, 0.1, 0.95)?,
1424                    pin_close_pos: get_f64_p(params, "pin_close_pos", d.pin_close_pos, 0.5, 1.0)?,
1425                    marubozu_body_min: get_f64_p(
1426                        params,
1427                        "marubozu_body_min",
1428                        d.marubozu_body_min,
1429                        0.5,
1430                        1.0,
1431                    )?,
1432                    belt_hold_body_min: get_f64_p(
1433                        params,
1434                        "belt_hold_body_min",
1435                        d.belt_hold_body_min,
1436                        0.3,
1437                        1.0,
1438                    )?,
1439                    belt_hold_open_wick_max: get_f64_p(
1440                        params,
1441                        "belt_hold_open_wick_max",
1442                        d.belt_hold_open_wick_max,
1443                        0.0,
1444                        0.3,
1445                    )?,
1446                    doji_body_max: get_f64_p(params, "doji_body_max", d.doji_body_max, 0.0, 0.4)?,
1447                    spinning_top_body_max: get_f64_p(
1448                        params,
1449                        "spinning_top_body_max",
1450                        d.spinning_top_body_max,
1451                        0.05,
1452                        0.6,
1453                    )?,
1454                    hammer_wick_body_min: get_f64_p(
1455                        params,
1456                        "hammer_wick_body_min",
1457                        d.hammer_wick_body_min,
1458                        0.5,
1459                        20.0,
1460                    )?,
1461                    hammer_opposite_max: get_f64_p(
1462                        params,
1463                        "hammer_opposite_max",
1464                        d.hammer_opposite_max,
1465                        0.0,
1466                        5.0,
1467                    )?,
1468                    tweezer_tolerance: get_f64_p(
1469                        params,
1470                        "tweezer_tolerance",
1471                        d.tweezer_tolerance,
1472                        0.0,
1473                        0.1,
1474                    )?,
1475                    min_range_atr: get_f64_p(params, "min_range_atr", d.min_range_atr, 0.0, 10.0)?,
1476                    atr_len: get_usize_p(params, "atr_len", d.atr_len, 1, 10000)?,
1477                    trend_lookback: get_usize_p(
1478                        params,
1479                        "trend_lookback",
1480                        d.trend_lookback,
1481                        1,
1482                        10000,
1483                    )?,
1484                    trend_min_atr: get_f64_p(params, "trend_min_atr", d.trend_min_atr, 0.0, 100.0)?,
1485                },
1486            )))
1487        }
1488        "efficiency" | "leg_efficiency" | "er" => {
1489            let len = get_usize_p(params, "len", 14, 1, 10000)?;
1490            Ok(Box::new(LegEfficiencyEngine::new(len)))
1491        }
1492        "volume" => {
1493            let ma = get_usize_p(params, "ma_period", 20, 1, 10000)?;
1494            Ok(Box::new(VolumeEngine::new(ma)))
1495        }
1496        "rvol" => {
1497            let p = get_usize_p(params, "period", 20, 1, 10000)?;
1498            Ok(Box::new(RvolEngine::new(p)))
1499        }
1500        "obv" => Ok(Box::new(ObvEngine::new())),
1501        "cmf" => {
1502            let p = get_usize_p(params, "period", 20, 1, 10000)?;
1503            Ok(Box::new(CmfEngine::new(p)))
1504        }
1505        "acc_dist" => Ok(Box::new(AccDistEngine::new())),
1506        "true_range" => Ok(Box::new(TrueRangeEngine::new())),
1507        "keltner" => {
1508            // Canonical key is "ema_period" (matches the catalog contract and the EMA base the
1509            // engine actually uses); "ma_period" is accepted as a legacy alias. See finding 05.
1510            let ema = get_usize_p_aliased(params, "ema_period", "ma_period", 20, 1, 10000)?;
1511            let atr = get_usize_p(params, "atr_period", 10, 1, 10000)?;
1512            let mult = get_f64_p(params, "multiplier", 2.0, 0.01, 100.0)?;
1513            Ok(Box::new(KeltnerChannelEngine::new(ema, atr, mult)))
1514        }
1515        "donchian" => {
1516            let p = get_usize_p(params, "period", 20, 1, 10000)?;
1517            Ok(Box::new(DonchianChannelEngine::new(p)))
1518        }
1519        "historical_volatility" => {
1520            let p = get_usize_p(params, "period", 20, 1, 10000)?;
1521            Ok(Box::new(HistoricalVolatilityEngine::new(p)))
1522        }
1523        "garman_klass" => {
1524            let p = get_usize_p(params, "period", 20, 1, 10000)?;
1525            Ok(Box::new(GarmanKlassVolatilityEngine::new(p)))
1526        }
1527        "sma" => {
1528            let p = get_usize_p(params, "period", 20, 1, 10000)?;
1529            Ok(Box::new(SmaEngine::new(p)))
1530        }
1531        "ema" => Ok(Box::new(build_ema(params, EmaInit::FirstSample)?)),
1532        "wma" => {
1533            let p = get_usize_p(params, "period", 20, 1, 10000)?;
1534            Ok(Box::new(WmaEngine::new(p)))
1535        }
1536        "vwma" => {
1537            let p = get_usize_p(params, "period", 20, 1, 10000)?;
1538            Ok(Box::new(VwmaEngine::new(p)))
1539        }
1540        "hma" => {
1541            let p = get_usize_p(params, "period", 20, 1, 10000)?;
1542            Ok(Box::new(HmaEngine::new(p)))
1543        }
1544        "dema" => {
1545            let p = get_usize_p(params, "period", 20, 1, 10000)?;
1546            Ok(Box::new(DemaEngine::new(p)))
1547        }
1548        "kama" => {
1549            let p = get_usize_p(params, "period", 10, 1, 10000)?;
1550            let fast = get_usize_p(params, "fast_period", 2, 1, 10000)?;
1551            let slow = get_usize_p(params, "slow_period", 30, 1, 10000)?;
1552            ensure_less("fast_period", fast as f64, "slow_period", slow as f64)?;
1553            Ok(Box::new(KamaEngine::new(p, fast, slow)))
1554        }
1555        "dmi" => {
1556            let p = get_usize_p(params, "period", 14, 1, 10000)?;
1557            Ok(Box::new(DmiEngine::new(p)))
1558        }
1559        "aroon" => {
1560            let p = get_usize_p(params, "period", 14, 1, 10000)?;
1561            Ok(Box::new(AroonEngine::new(p)))
1562        }
1563        "parabolic_sar" => {
1564            let step = get_f64_p(params, "step", 0.02, 0.001, 1.0)?;
1565            let max_step = get_f64_p(params, "max_step", 0.20, 0.001, 1.0)?;
1566            if step > max_step {
1567                return Err(RegistryError::InvalidParameter {
1568                    parameter: "step".to_string(),
1569                    value: step,
1570                    reason: "step must not exceed max_step".to_string(),
1571                });
1572            }
1573            Ok(Box::new(ParabolicSarEngine::new(step, max_step)))
1574        }
1575        "supertrend" => {
1576            let p = get_usize_p(params, "period", 10, 1, 10000)?;
1577            let mult = get_f64_p(params, "multiplier", 3.0, 0.01, 100.0)?;
1578            Ok(Box::new(SupertrendEngine::new(p, mult)))
1579        }
1580        "ichimoku" => {
1581            let tenkan = get_usize_p(params, "tenkan_p", 9, 1, 10000)?;
1582            let kijun = get_usize_p(params, "kijun_p", 26, 1, 10000)?;
1583            let senkou_b = get_usize_p(params, "senkou_b_p", 52, 1, 10000)?;
1584            ensure_less("tenkan_p", tenkan as f64, "kijun_p", kijun as f64)?;
1585            ensure_less("kijun_p", kijun as f64, "senkou_b_p", senkou_b as f64)?;
1586            Ok(Box::new(IchimokuEngine::new(tenkan, kijun, senkou_b)))
1587        }
1588        "stochastic" => {
1589            let k = get_usize_p(params, "k_period", 14, 1, 10000)?;
1590            let d = get_usize_p(params, "d_period", 3, 1, 10000)?;
1591            Ok(Box::new(StochasticEngine::new(k, d)))
1592        }
1593        "roc" => {
1594            let p = get_usize_p(params, "period", 12, 1, 10000)?;
1595            Ok(Box::new(RocEngine::new(p)))
1596        }
1597        "ultimate_oscillator" => {
1598            let p1 = get_usize_p(params, "period1", 7, 1, 10000)?;
1599            let p2 = get_usize_p(params, "period2", 14, 1, 10000)?;
1600            let p3 = get_usize_p(params, "period3", 28, 1, 10000)?;
1601            ensure_less("period1", p1 as f64, "period2", p2 as f64)?;
1602            ensure_less("period2", p2 as f64, "period3", p3 as f64)?;
1603            Ok(Box::new(UltimateOscillatorEngine::new(p1, p2, p3)))
1604        }
1605        "awesome_oscillator" => {
1606            let fast = get_usize_p(params, "fast_period", 5, 1, 10000)?;
1607            let slow = get_usize_p(params, "slow_period", 34, 1, 10000)?;
1608            ensure_less("fast_period", fast as f64, "slow_period", slow as f64)?;
1609            Ok(Box::new(AwesomeOscillatorEngine::new(fast, slow)))
1610        }
1611        "ppo" => {
1612            let fast = get_usize_p(params, "fast_period", 12, 1, 10000)?;
1613            let slow = get_usize_p(params, "slow_period", 26, 1, 10000)?;
1614            let signal = get_usize_p(params, "signal_period", 9, 1, 10000)?;
1615            ensure_less("fast_period", fast as f64, "slow_period", slow as f64)?;
1616            Ok(Box::new(PpoEngine::new(fast, slow, signal)))
1617        }
1618        "wavetrend" | "wt" => {
1619            let n1 = get_usize_p(params, "n1", 10, 1, 10000)?;
1620            let n2 = get_usize_p(params, "n2", 21, 1, 10000)?;
1621            let ob = get_f64_p(params, "ob_level", 60.0, -100.0, 100.0)?;
1622            let os = get_f64_p(params, "os_level", -60.0, -100.0, 100.0)?;
1623            Ok(Box::new(WaveTrendEngine::new(n1, n2, ob, os)))
1624        }
1625        "cmo" => {
1626            let p = get_usize_p(params, "period", 14, 1, 10000)?;
1627            Ok(Box::new(CmoEngine::new(p)))
1628        }
1629        "elder_ray" => {
1630            let p = get_usize_p(params, "period", 13, 1, 10000)?;
1631            Ok(Box::new(ElderRayEngine::new(p)))
1632        }
1633        "anchored_vwap" | "avwap" => {
1634            let m1 = get_f64_p(params, "mult1", 1.0, 0.01, 100.0)?;
1635            let m2 = get_f64_p(params, "mult2", 2.0, 0.01, 100.0)?;
1636            Ok(Box::new(AnchoredVwapEngine::new(
1637                VwapAnchorKind::Session,
1638                m1,
1639                m2,
1640            )))
1641        }
1642        "cvd" => Ok(Box::new(CvdEngine::new())),
1643        "hires_volume_flow" => {
1644            let window_len = get_usize_p(params, "window_len", 20, 2, 10000)?;
1645            Ok(Box::new(HiResVolumeFlowEngine::new(window_len)))
1646        }
1647        "klinger" | "kvo" => {
1648            let fast = get_usize_p(params, "fast_len", 34, 1, 10000)?;
1649            let slow = get_usize_p(params, "slow_len", 55, 1, 10000)?;
1650            let sig = get_usize_p(params, "signal_len", 13, 1, 10000)?;
1651            ensure_less("fast_len", fast as f64, "slow_len", slow as f64)?;
1652            Ok(Box::new(KlingerVolumeForceEngine::new(fast, slow, sig)))
1653        }
1654        "zigzag" => {
1655            let depth = get_usize_p(params, "depth", 12, 2, 10000)?;
1656            let dev = get_f64_p(params, "deviation_pct", 5.0, 0.01, 100.0)?;
1657            Ok(Box::new(ZigZagEngine::new(depth, dev)))
1658        }
1659        "zigzag_advanced" => {
1660            // Fixed Percent deviation via this f64-only entry point; use `build_typed` with
1661            // `deviation_mode` (ParamValue::Enum) to select AtrMultiple instead.
1662            let depth = get_usize_p(params, "depth", 3, 1, 10000)?;
1663            let backstep = get_usize_p(params, "backstep", 2, 0, 10000)?;
1664            let deviation_pct = get_f64_p(params, "deviation_pct", 1.0, 0.001, 100.0)?;
1665            let atr_len = get_usize_p(params, "atr_len", 14, 1, 10000)?;
1666            Ok(Box::new(AdvancedZigZagEngine::new(
1667                depth,
1668                backstep,
1669                ZigZagDeviationMode::Percent(deviation_pct),
1670                atr_len,
1671            )))
1672        }
1673        "pivot_sets" | "multi_pivots" => Ok(Box::new(PivotSetsEngine::new(PivotSetType::Classic))),
1674        "tema" => {
1675            let p = get_usize_p(params, "period", 14, 1, 10000)?;
1676            Ok(Box::new(TemaEngine::new(p)))
1677        }
1678        "lsma" => {
1679            let p = get_usize_p(params, "period", 25, 2, 10000)?;
1680            Ok(Box::new(LsmaEngine::new(p)))
1681        }
1682        "mcginley" => {
1683            let p = get_usize_p(params, "period", 14, 1, 10000)?;
1684            Ok(Box::new(McGinleyDynamicEngine::new(p)))
1685        }
1686        "envelope" => {
1687            let p = get_usize_p(params, "period", 20, 1, 10000)?;
1688            let pct = get_f64_p(params, "percent", 2.5, 0.01, 100.0)?;
1689            Ok(Box::new(EnvelopeEngine::new(p, pct)))
1690        }
1691        "choppiness" | "chop" => {
1692            let p = get_usize_p(params, "period", 14, 2, 10000)?;
1693            Ok(Box::new(ChoppinessIndexEngine::new(p)))
1694        }
1695        "vortex" | "vi" => {
1696            let p = get_usize_p(params, "period", 14, 1, 10000)?;
1697            Ok(Box::new(VortexEngine::new(p)))
1698        }
1699        "alligator" => Ok(Box::new(AlligatorEngine::new())),
1700        "connors_rsi" => {
1701            let rsi_len = get_usize_p(params, "rsi_len", 3, 1, 10000)?;
1702            let streak_len = get_usize_p(params, "streak_len", 2, 1, 10000)?;
1703            let rank_len = get_usize_p(params, "rank_len", 100, 1, 10000)?;
1704            Ok(Box::new(ConnorsRsiEngine::new(
1705                rsi_len, streak_len, rank_len,
1706            )))
1707        }
1708        "coppock" => Ok(Box::new(CoppockCurveEngine::new())),
1709        "dpo" => {
1710            let p = get_usize_p(params, "period", 21, 2, 10000)?;
1711            Ok(Box::new(DpoEngine::new(p)))
1712        }
1713        "kst" => Ok(Box::new(KstEngine::new())),
1714        "mass_index" => {
1715            let p = get_usize_p(params, "period", 25, 1, 10000)?;
1716            Ok(Box::new(MassIndexEngine::new(p)))
1717        }
1718        "rvi" => {
1719            let p = get_usize_p(params, "period", 10, 1, 10000)?;
1720            Ok(Box::new(RviEngine::new(p)))
1721        }
1722        "bop" => {
1723            let p = get_usize_p(params, "period", 14, 1, 10000)?;
1724            Ok(Box::new(BalanceOfPowerEngine::new(p)))
1725        }
1726        "eom" => {
1727            let p = get_usize_p(params, "period", 14, 1, 10000)?;
1728            let div = get_f64_p(params, "volume_divisor", 10000.0, 1.0, 1e9)?;
1729            Ok(Box::new(EomEngine::new(p, div)))
1730        }
1731        "nvi" => Ok(Box::new(NviEngine::new())),
1732        "pvi" => Ok(Box::new(PviEngine::new())),
1733        "chaikin_oscillator" | "cho" => {
1734            let fast = get_usize_p(params, "fast_len", 3, 1, 10000)?;
1735            let slow = get_usize_p(params, "slow_len", 10, 1, 10000)?;
1736            ensure_less("fast_len", fast as f64, "slow_len", slow as f64)?;
1737            Ok(Box::new(ChaikinOscillatorEngine::new(fast, slow)))
1738        }
1739        "bos_choch" => {
1740            let pivot_len = get_usize_p(params, "pivot_len", 5, 2, 10000)?;
1741            Ok(Box::new(BosChochEngine::new(pivot_len)))
1742        }
1743        "liquidity_sweeps" | "sweeps" => {
1744            let p = get_usize_p(params, "pivot_len", 5, 2, 10000)?;
1745            let tol = get_f64_p(params, "tolerance_pct", 0.2, 0.01, 100.0)?;
1746            Ok(Box::new(LiquiditySweepEngine::new(p, tol)))
1747        }
1748        "liquidity_pools" => {
1749            let p = get_usize_p(params, "pivot_len", 5, 2, 10000)?;
1750            let tol = get_f64_p(params, "tolerance_pct", 0.2, 0.001, 100.0)?;
1751            Ok(Box::new(
1752                super::smart_money_structure::LiquidityPoolEngine::new(p, tol),
1753            ))
1754        }
1755        "wyckoff" => {
1756            let lookback = get_usize_p(params, "range_lookback", 20, 3, 10000)?;
1757            let atr_max = get_f64_p(params, "range_atr_max", 3.0, 0.1, 1000.0)?;
1758            let min_bars = get_usize_p(params, "min_range_bars", 6, 2, 10000)?;
1759            Ok(Box::new(super::wyckoff::WyckoffStateMachine::new(
1760                lookback, atr_max, min_bars,
1761            )))
1762        }
1763        "trend_quality" => {
1764            let p = get_usize_p(params, "period", 14, 2, 10000)?;
1765            Ok(Box::new(TrendQualityScoreEngine::new(p)))
1766        }
1767        "buy_sell_pressure" | "pressure" => {
1768            let p = get_usize_p(params, "period", 14, 1, 10000)?;
1769            Ok(Box::new(BuySellPressureEstimator::new(p)))
1770        }
1771        "volatility_regime" => {
1772            let p = get_usize_p(params, "period", 20, 1, 10000)?;
1773            let bb_mult = get_f64_p(params, "bb_mult", 2.0, 0.01, 100.0)?;
1774            let kc_mult = get_f64_p(params, "kc_mult", 1.5, 0.01, 100.0)?;
1775            Ok(Box::new(VolatilityRegimeDetector::new(p, bb_mult, kc_mult)))
1776        }
1777        "zscore" => {
1778            let p = get_usize_p(params, "period", 20, 2, 10000)?;
1779            Ok(Box::new(ZScoreEngine::new(p)))
1780        }
1781        "multi_factor" => {
1782            let p = get_usize_p(params, "period", 14, 2, 10000)?;
1783            Ok(Box::new(MultiFactorMarketScore::new(p)))
1784        }
1785        _ => Err(RegistryError::UnknownIndicator(name.to_string())),
1786    }
1787}
1788
1789/// Dynamically builds an `Indicator` instance by its catalog name and parameters.
1790/// Delegates to `build_checked` and discards errors to return `Option`.
1791pub fn build(name: &str, params: &HashMap<String, f64>) -> Option<Box<dyn Indicator>> {
1792    build_checked(name, params).ok()
1793}
1794
1795/// Builds an `Indicator` instance from typed parameters (see [`ParamValue`]).
1796///
1797/// Numeric-compatible values (`Float`, `Int`, `Bool`, `Timestamp`) are flattened to `f64` and
1798/// forwarded to [`build_checked`], reusing its full per-indicator validation. Values with no
1799/// scalar form (`Enum`, `Text`, `Timeframe`, `Source`) are rejected with
1800/// [`RegistryError::UnsupportedParameterType`], since no indicator in this registry currently
1801/// consumes them through the `f64` parameter map.
1802/// Indicators known to depend on the genuine OHLC range (true range, high/low pivots, volume-at-
1803/// price, market-structure detection, ...), for which [`super::source_mapped::SourceMapped`]
1804/// would silently collapse `high == low == open == close` to the selected source and degenerate
1805/// their math. `build_typed` rejects a non-`Close` `source` for these rather than silently
1806/// applying it. Not necessarily exhaustive over the full catalog — extend as new range-dependent
1807/// indicators are added.
1808const RANGE_DEPENDENT_INDICATORS: &[&str] = &[
1809    "atr",
1810    "relative_volatility",
1811    "smi",
1812    "chande_kroll",
1813    "cks",
1814    "true_range",
1815    "adx",
1816    "dmi",
1817    "chandelier_exit",
1818    "chandelier_flip_radar",
1819    "chfr",
1820    "wyckoff",
1821    "volume_profile",
1822    "vp",
1823    "money_flow_profile",
1824    "mfp",
1825    "extended_volume_profile",
1826    "vp_extended",
1827    "persistent_volume_profile",
1828    "vp_persistent",
1829    "pivots_structure",
1830    "pivots",
1831    "pivot_sets",
1832    "multi_pivots",
1833    "zigzag",
1834    "zigzag_advanced",
1835    "liquidity_pools",
1836    "liquidity_sweeps",
1837    "sweeps",
1838    "liquidity_fvg",
1839    "fvg",
1840    "smc",
1841    "order_block",
1842    "ob",
1843    "ce",
1844    "bos_choch",
1845    "market_structure_breaks",
1846    "bos",
1847    "choch",
1848    "vix_fix",
1849    "wvf",
1850    "keltner",
1851    "donchian",
1852    "vortex",
1853    "vi",
1854    "choppiness",
1855    "chop",
1856    "mass_index",
1857    "supertrend",
1858    "parabolic_sar",
1859    "ichimoku",
1860    "aroon",
1861    "garman_klass",
1862    "hires_volume_flow",
1863    "cvd",
1864    "klinger",
1865    "kvo",
1866    "volatility_regime",
1867    "swing_structure",
1868];
1869
1870/// Indicators whose price source is fixed by an explicit decision rather than by their math:
1871/// `SourceMapped` would compute a differently-defined series under the same name. `efi` weighs a
1872/// close-to-close change by the bar's volume; pairing that volume with a derived price (hlc3,
1873/// ohlc4, ...) needs its own documented contract before it is offered.
1874const SOURCE_FIXED_INDICATORS: &[&str] = &["efi"];
1875
1876pub fn build_typed(name: &str, params: &TypedParams) -> Result<Box<dyn Indicator>, RegistryError> {
1877    if name.to_lowercase() == "midas" {
1878        // Bypasses the generic `source` handling below; see `build_midas_typed`.
1879        return build_midas_typed(params);
1880    }
1881
1882    let source = match params.get("source") {
1883        None => None,
1884        Some(ParamValue::Source(s)) => Some(*s),
1885        Some(other) => {
1886            return Err(RegistryError::UnsupportedParameterType {
1887                parameter: "source".to_string(),
1888                type_name: other.type_name().to_string(),
1889            });
1890        }
1891    };
1892
1893    if let Some(s) = source {
1894        if s != crate::model::Source::Close
1895            && SOURCE_FIXED_INDICATORS.contains(&name.to_lowercase().as_str())
1896        {
1897            return Err(RegistryError::IncompatibleParameter {
1898                parameter: "source".to_string(),
1899                indicator: name.to_string(),
1900                reason: "price source is fixed to the close by contract; another source would \
1901                         redefine the series under the same name"
1902                    .to_string(),
1903            });
1904        }
1905
1906        if s != crate::model::Source::Close
1907            && RANGE_DEPENDENT_INDICATORS.contains(&name.to_lowercase().as_str())
1908        {
1909            return Err(RegistryError::IncompatibleParameter {
1910                parameter: "source".to_string(),
1911                indicator: name.to_string(),
1912                reason: "range/OHLC-dependent indicator; SourceMapped would collapse its true range to zero"
1913                    .to_string(),
1914            });
1915        }
1916    }
1917
1918    // `source` is a cross-cutting concern applied uniformly via `SourceMapped` below, not an
1919    // indicator-specific parameter, so it is stripped before delegating to the per-indicator
1920    // builders.
1921    let mut remaining = params.clone();
1922    remaining.remove("source");
1923
1924    let built = match name.to_lowercase().as_str() {
1925        "anchored_vwap" | "avwap" => build_anchored_vwap_typed(&remaining)?,
1926        "atr" => build_atr_typed(&remaining)?,
1927        "ema" => build_ema_typed(&remaining)?,
1928        "rsi" => build_rsi_typed(&remaining)?,
1929        "bbtrend" => build_bbtrend_typed(&remaining)?,
1930        "relative_volatility" => build_relative_volatility_typed(&remaining)?,
1931        "twap" => build_twap_typed(&remaining, source)?,
1932        "bollinger" | "bb" => build_bollinger_typed(&remaining)?,
1933        "pivot_sets" | "multi_pivots" => build_pivot_sets_typed(&remaining)?,
1934        "trend_relationship" => build_trend_relationship_typed(&remaining)?,
1935        "zigzag_advanced" => build_zigzag_advanced_typed(&remaining)?,
1936        _ => build_typed_by_flattening(name, &remaining)?,
1937    };
1938
1939    Ok(match source {
1940        Some(s) if s != crate::model::Source::Close => {
1941            Box::new(super::source_mapped::SourceMapped::new(built, s))
1942        }
1943        _ => built,
1944    })
1945}
1946
1947/// Default typed-build strategy for indicators whose full configuration surface is numeric:
1948/// flattens every value to `f64` via [`ParamValue::as_f64`] and delegates to [`build_checked`],
1949/// reusing its per-indicator validation. Rejects any value with no scalar form.
1950fn build_typed_by_flattening(
1951    name: &str,
1952    params: &TypedParams,
1953) -> Result<Box<dyn Indicator>, RegistryError> {
1954    build_checked(name, &flatten_typed(params)?)
1955}
1956
1957/// Flattens every entry to `f64`, rejecting values with no scalar form. Used both by
1958/// [`build_typed_by_flattening`] and by the per-indicator typed builders, which read their own
1959/// enum parameters first and then hand the numeric remainder to the same `build_checked`
1960/// validation as the `f64`-only surface.
1961fn flatten_typed(params: &TypedParams) -> Result<HashMap<String, f64>, RegistryError> {
1962    let mut flat = HashMap::with_capacity(params.len());
1963    for (key, value) in params {
1964        match value.as_f64() {
1965            Some(v) => {
1966                flat.insert(key.clone(), v);
1967            }
1968            None => {
1969                return Err(RegistryError::UnsupportedParameterType {
1970                    parameter: key.clone(),
1971                    type_name: value.type_name().to_string(),
1972                });
1973            }
1974        }
1975    }
1976    Ok(flat)
1977}
1978
1979/// The numeric part of an ATR configuration; see [`build_rsi`] for why this is shared.
1980fn build_atr(
1981    params: &HashMap<String, f64>,
1982    smoothing: TrueRangeSmoothing,
1983) -> Result<Atr, RegistryError> {
1984    let atr_len = get_usize_p(params, "atr_len", 14, 1, 10000)?;
1985    let sig_len = get_usize_p(params, "sig_len", 20, 1, 10000)?;
1986    Ok(Atr::new(atr_len, sig_len).with_smoothing(smoothing))
1987}
1988
1989fn build_atr_typed(params: &TypedParams) -> Result<Box<dyn Indicator>, RegistryError> {
1990    let smoothing = match get_enum_p(params, "smoothing")?.as_deref() {
1991        None | Some("rma") => TrueRangeSmoothing::Rma,
1992        Some("sma") => TrueRangeSmoothing::Sma,
1993        Some("ema") => TrueRangeSmoothing::Ema,
1994        Some("wma") => TrueRangeSmoothing::Wma,
1995        Some(other) => {
1996            return Err(RegistryError::InvalidEnumValue {
1997                parameter: "smoothing".to_string(),
1998                value: other.to_string(),
1999                reason: "expected one of rma|sma|ema|wma".to_string(),
2000            });
2001        }
2002    };
2003
2004    let mut numeric = params.clone();
2005    numeric.remove("smoothing");
2006    Ok(Box::new(build_atr(&flatten_typed(&numeric)?, smoothing)?))
2007}
2008
2009/// The numeric part of an EMA configuration; see [`build_rsi`] for why this is shared.
2010fn build_ema(params: &HashMap<String, f64>, init: EmaInit) -> Result<EmaEngine, RegistryError> {
2011    let period = get_usize_p(params, "period", 20, 1, 10000)?;
2012    Ok(EmaEngine::new(period).with_init(init))
2013}
2014
2015fn build_ema_typed(params: &TypedParams) -> Result<Box<dyn Indicator>, RegistryError> {
2016    let init = match get_enum_p(params, "init")?.as_deref() {
2017        None | Some("first_sample") => EmaInit::FirstSample,
2018        Some("sma") => EmaInit::Sma,
2019        Some(other) => {
2020            return Err(RegistryError::InvalidEnumValue {
2021                parameter: "init".to_string(),
2022                value: other.to_string(),
2023                reason: "expected one of first_sample|sma".to_string(),
2024            });
2025        }
2026    };
2027
2028    let mut numeric = params.clone();
2029    numeric.remove("init");
2030    Ok(Box::new(build_ema(&flatten_typed(&numeric)?, init)?))
2031}
2032
2033/// TWAP takes its own price source rather than being wrapped in `SourceMapped`: the wrapper
2034/// flattens a bar to one value, which would make a duration-weighted average of a flattened bar
2035/// series — the same number, but arrived at in a way that hides what was averaged.
2036fn build_twap_typed(
2037    params: &TypedParams,
2038    source: Option<crate::model::Source>,
2039) -> Result<Box<dyn Indicator>, RegistryError> {
2040    let weighting = match get_enum_p(params, "weighting")?.as_deref() {
2041        None | Some("per_bar") => TwapWeighting::PerBar,
2042        Some("by_duration") => TwapWeighting::ByDuration,
2043        Some(other) => {
2044            return Err(RegistryError::InvalidEnumValue {
2045                parameter: "weighting".to_string(),
2046                value: other.to_string(),
2047                reason: "expected one of per_bar|by_duration".to_string(),
2048            });
2049        }
2050    };
2051
2052    let numeric = extract_numeric_subset(params, &["day_start_offset"])?;
2053    let offset = get_f64_p(&numeric, "day_start_offset", 0.0, -86_400.0, 86_400.0)? as i64;
2054
2055    let anchor = match get_enum_p(params, "anchor")?.as_deref() {
2056        None | Some("daily") => TwapAnchor::Daily {
2057            start_offset_seconds: offset,
2058        },
2059        Some("continuous") => TwapAnchor::Continuous,
2060        Some("manual_timestamp") => {
2061            let timestamp = get_timestamp_p(params, "anchor_timestamp")?.ok_or_else(|| {
2062                RegistryError::InvalidEnumValue {
2063                    parameter: "anchor".to_string(),
2064                    value: "manual_timestamp".to_string(),
2065                    reason: "requires an accompanying 'anchor_timestamp' Timestamp parameter"
2066                        .to_string(),
2067                }
2068            })?;
2069            TwapAnchor::ManualTimestamp(timestamp)
2070        }
2071        Some(other) => {
2072            return Err(RegistryError::InvalidEnumValue {
2073                parameter: "anchor".to_string(),
2074                value: other.to_string(),
2075                reason: "expected one of continuous|daily|manual_timestamp".to_string(),
2076            });
2077        }
2078    };
2079
2080    Ok(Box::new(AnchoredTwap::new(
2081        anchor,
2082        source.unwrap_or(crate::model::Source::Close),
2083        weighting,
2084    )))
2085}
2086
2087/// The numeric part of a Relative Volatility configuration; the variant selects which prices are
2088/// measured.
2089fn build_relative_volatility(
2090    params: &HashMap<String, f64>,
2091    variant: RelativeVolatilityVariant,
2092) -> Result<RelativeVolatilityIndex, RegistryError> {
2093    let stdev_len = get_usize_p(params, "stdev_len", 10, 2, 10000)?;
2094    let smooth_len = get_usize_p(params, "smooth_len", 14, 1, 10000)?;
2095    Ok(RelativeVolatilityIndex::new(stdev_len, smooth_len, variant))
2096}
2097
2098fn build_relative_volatility_typed(
2099    params: &TypedParams,
2100) -> Result<Box<dyn Indicator>, RegistryError> {
2101    let variant = match get_enum_p(params, "variant")?.as_deref() {
2102        None | Some("close") => RelativeVolatilityVariant::Close,
2103        Some("high_low") => RelativeVolatilityVariant::HighLow,
2104        Some(other) => {
2105            return Err(RegistryError::InvalidEnumValue {
2106                parameter: "variant".to_string(),
2107                value: other.to_string(),
2108                reason: "expected one of close|high_low".to_string(),
2109            });
2110        }
2111    };
2112
2113    let mut numeric = params.clone();
2114    numeric.remove("variant");
2115    Ok(Box::new(build_relative_volatility(
2116        &flatten_typed(&numeric)?,
2117        variant,
2118    )?))
2119}
2120
2121/// The numeric part of a BBTrend configuration; the variance convention applies to both band
2122/// sets, since comparing sets built on different conventions would measure the convention.
2123fn build_bbtrend(
2124    params: &HashMap<String, f64>,
2125    variance: VarianceConvention,
2126) -> Result<BbTrend, RegistryError> {
2127    let short_len = get_usize_p(params, "short_len", 20, 1, 10000)?;
2128    let long_len = get_usize_p(params, "long_len", 50, 1, 10000)?;
2129    let mult = get_f64_p(params, "mult", 2.0, 0.01, 100.0)?;
2130    ensure_less("short_len", short_len as f64, "long_len", long_len as f64)?;
2131    if variance == VarianceConvention::Sample && short_len < 2 {
2132        return Err(RegistryError::IncompatibleParameter {
2133            parameter: "variance".to_string(),
2134            indicator: "bbtrend".to_string(),
2135            reason: "sample variance divides by len - 1 and is undefined for len < 2".to_string(),
2136        });
2137    }
2138    Ok(BbTrend::new(short_len, long_len, mult, variance))
2139}
2140
2141fn build_bbtrend_typed(params: &TypedParams) -> Result<Box<dyn Indicator>, RegistryError> {
2142    let variance = match get_enum_p(params, "variance")?.as_deref() {
2143        None | Some("population") => VarianceConvention::Population,
2144        Some("sample") => VarianceConvention::Sample,
2145        Some(other) => {
2146            return Err(RegistryError::InvalidEnumValue {
2147                parameter: "variance".to_string(),
2148                value: other.to_string(),
2149                reason: "expected one of population|sample".to_string(),
2150            });
2151        }
2152    };
2153
2154    let mut numeric = params.clone();
2155    numeric.remove("variance");
2156    Ok(Box::new(build_bbtrend(
2157        &flatten_typed(&numeric)?,
2158        variance,
2159    )?))
2160}
2161
2162/// The numeric part of an RSI configuration, shared by the `f64`-only and the typed surface so
2163/// both validate identically and only the smoothing method differs.
2164fn build_rsi(params: &HashMap<String, f64>, smoothing: RsiSmoothing) -> Result<Rsi, RegistryError> {
2165    let rsi_len = get_usize_p(params, "rsi_len", 14, 1, 10000)?;
2166    let avg_len = get_usize_p(params, "avg_len", 3, 1, 10000)?;
2167    let sig_len = get_usize_p(params, "sig_len", 3, 1, 10000)?;
2168    let overbought = get_f64_p(params, "overbought", 70.0, 0.0, 100.0)?;
2169    let oversold = get_f64_p(params, "oversold", 30.0, 0.0, 100.0)?;
2170    ensure_less("oversold", oversold, "overbought", overbought)?;
2171    Ok(Rsi::new(
2172        rsi_len, avg_len, sig_len, 50.0, overbought, oversold, 5, true, 100, 4, 10.0,
2173    )
2174    .with_smoothing(smoothing))
2175}
2176
2177fn build_rsi_typed(params: &TypedParams) -> Result<Box<dyn Indicator>, RegistryError> {
2178    let smoothing = match get_enum_p(params, "smoothing")?.as_deref() {
2179        None | Some("wilder") => RsiSmoothing::Wilder,
2180        Some("ema") => RsiSmoothing::Ema,
2181        Some(other) => {
2182            return Err(RegistryError::InvalidEnumValue {
2183                parameter: "smoothing".to_string(),
2184                value: other.to_string(),
2185                reason: "expected one of wilder|ema".to_string(),
2186            });
2187        }
2188    };
2189
2190    let mut numeric = params.clone();
2191    numeric.remove("smoothing");
2192    Ok(Box::new(build_rsi(&flatten_typed(&numeric)?, smoothing)?))
2193}
2194
2195/// The numeric part of a Bollinger configuration; see [`build_rsi`] for why this is shared.
2196fn build_bollinger(
2197    params: &HashMap<String, f64>,
2198    variance: VarianceConvention,
2199) -> Result<BollingerBands, RegistryError> {
2200    let len = get_usize_p(params, "len", 20, 1, 10000)?;
2201    let mult = get_f64_p(params, "mult", 2.0, 0.01, 100.0)?;
2202    if variance == VarianceConvention::Sample && len < 2 {
2203        return Err(RegistryError::IncompatibleParameter {
2204            parameter: "variance".to_string(),
2205            indicator: "bollinger".to_string(),
2206            reason: "sample variance divides by len - 1 and is undefined for len < 2".to_string(),
2207        });
2208    }
2209    Ok(BollingerBands::new(len, mult).with_variance(variance))
2210}
2211
2212fn build_bollinger_typed(params: &TypedParams) -> Result<Box<dyn Indicator>, RegistryError> {
2213    let variance = match get_enum_p(params, "variance")?.as_deref() {
2214        None | Some("population") => VarianceConvention::Population,
2215        Some("sample") => VarianceConvention::Sample,
2216        Some(other) => {
2217            return Err(RegistryError::InvalidEnumValue {
2218                parameter: "variance".to_string(),
2219                value: other.to_string(),
2220                reason: "expected one of population|sample".to_string(),
2221            });
2222        }
2223    };
2224
2225    let mut numeric = params.clone();
2226    numeric.remove("variance");
2227    Ok(Box::new(build_bollinger(
2228        &flatten_typed(&numeric)?,
2229        variance,
2230    )?))
2231}
2232
2233/// Reads a `ParamValue::Enum` parameter, lower-cased. Returns `Ok(None)` if the key is absent, and
2234/// [`RegistryError::UnsupportedParameterType`] if present with a non-`Enum` type.
2235fn get_enum_p(params: &TypedParams, name: &str) -> Result<Option<String>, RegistryError> {
2236    match params.get(name) {
2237        None => Ok(None),
2238        Some(ParamValue::Enum(value)) => Ok(Some(value.to_lowercase())),
2239        Some(other) => Err(RegistryError::UnsupportedParameterType {
2240            parameter: name.to_string(),
2241            type_name: other.type_name().to_string(),
2242        }),
2243    }
2244}
2245
2246/// Reads a `ParamValue::Timestamp` parameter. Returns `Ok(None)` if the key is absent, and
2247/// [`RegistryError::UnsupportedParameterType`] if present with a non-`Timestamp` type.
2248fn get_timestamp_p(params: &TypedParams, name: &str) -> Result<Option<i64>, RegistryError> {
2249    match params.get(name) {
2250        None => Ok(None),
2251        Some(ParamValue::Timestamp(value)) => Ok(Some(*value)),
2252        Some(other) => Err(RegistryError::UnsupportedParameterType {
2253            parameter: name.to_string(),
2254            type_name: other.type_name().to_string(),
2255        }),
2256    }
2257}
2258
2259/// Extracts the numeric-compatible entries of `keys` from `params` into a fresh `f64` map, for
2260/// forwarding to the existing `get_f64_p`/`get_usize_p` validators.
2261fn extract_numeric_subset(
2262    params: &TypedParams,
2263    keys: &[&str],
2264) -> Result<HashMap<String, f64>, RegistryError> {
2265    let mut numeric = HashMap::new();
2266    for key in keys {
2267        if let Some(value) = params.get(*key) {
2268            match value.as_f64() {
2269                Some(v) => {
2270                    numeric.insert((*key).to_string(), v);
2271                }
2272                None => {
2273                    return Err(RegistryError::UnsupportedParameterType {
2274                        parameter: (*key).to_string(),
2275                        type_name: value.type_name().to_string(),
2276                    });
2277                }
2278            }
2279        }
2280    }
2281    Ok(numeric)
2282}
2283
2284fn build_anchored_vwap_typed(params: &TypedParams) -> Result<Box<dyn Indicator>, RegistryError> {
2285    let anchor_kind = match get_enum_p(params, "anchor_kind")?.as_deref() {
2286        None | Some("session") => VwapAnchorKind::Session,
2287        Some("day") => VwapAnchorKind::Day,
2288        Some("week") => VwapAnchorKind::Week,
2289        Some("month") => VwapAnchorKind::Month,
2290        Some("external") => VwapAnchorKind::External,
2291        Some("manual_timestamp") => {
2292            let ts = get_timestamp_p(params, "anchor_timestamp")?.ok_or_else(|| {
2293                RegistryError::InvalidEnumValue {
2294                    parameter: "anchor_kind".to_string(),
2295                    value: "manual_timestamp".to_string(),
2296                    reason: "requires an accompanying 'anchor_timestamp' Timestamp parameter"
2297                        .to_string(),
2298                }
2299            })?;
2300            VwapAnchorKind::ManualTimestamp(ts)
2301        }
2302        Some(other) => {
2303            return Err(RegistryError::InvalidEnumValue {
2304                parameter: "anchor_kind".to_string(),
2305                value: other.to_string(),
2306                reason: "expected one of session|day|week|month|external|manual_timestamp"
2307                    .to_string(),
2308            });
2309        }
2310    };
2311
2312    let zero_volume_policy = match get_enum_p(params, "zero_volume_policy")?.as_deref() {
2313        None | Some("equal_weight") => ZeroVolumePolicy::EqualWeight,
2314        Some("skip") => ZeroVolumePolicy::Skip,
2315        Some(other) => {
2316            return Err(RegistryError::InvalidEnumValue {
2317                parameter: "zero_volume_policy".to_string(),
2318                value: other.to_string(),
2319                reason: "expected one of equal_weight|skip".to_string(),
2320            });
2321        }
2322    };
2323
2324    let numeric = extract_numeric_subset(params, &["mult1", "mult2"])?;
2325    let m1 = get_f64_p(&numeric, "mult1", 1.0, 0.01, 100.0)?;
2326    let m2 = get_f64_p(&numeric, "mult2", 2.0, 0.01, 100.0)?;
2327
2328    Ok(Box::new(
2329        AnchoredVwapEngine::new(anchor_kind, m1, m2).with_zero_volume_policy(zero_volume_policy),
2330    ))
2331}
2332
2333fn build_pivot_sets_typed(params: &TypedParams) -> Result<Box<dyn Indicator>, RegistryError> {
2334    let pivot_type = match get_enum_p(params, "pivot_type")?.as_deref() {
2335        None | Some("classic") => PivotSetType::Classic,
2336        Some("fibonacci") => PivotSetType::Fibonacci,
2337        Some("camarilla") => PivotSetType::Camarilla,
2338        Some("woodie") => PivotSetType::Woodie,
2339        Some("demark") => PivotSetType::DeMark,
2340        Some("cpr") => PivotSetType::Cpr,
2341        Some(other) => {
2342            return Err(RegistryError::InvalidEnumValue {
2343                parameter: "pivot_type".to_string(),
2344                value: other.to_string(),
2345                reason: "expected one of classic|fibonacci|camarilla|woodie|demark|cpr".to_string(),
2346            });
2347        }
2348    };
2349
2350    Ok(Box::new(PivotSetsEngine::new(pivot_type)))
2351}
2352
2353fn parse_smoother_kind(
2354    params: &TypedParams,
2355    parameter_name: &str,
2356    default: super::smoothing::SmootherKind,
2357) -> Result<super::smoothing::SmootherKind, RegistryError> {
2358    use super::smoothing::SmootherKind;
2359
2360    match get_enum_p(params, parameter_name)?.as_deref() {
2361        None => Ok(default),
2362        Some("ema") => Ok(SmootherKind::Ema),
2363        Some("sma") => Ok(SmootherKind::Sma),
2364        Some("rma") => Ok(SmootherKind::Rma),
2365        Some("alma") => Ok(SmootherKind::Alma),
2366        Some("jma") => Ok(SmootherKind::Jma),
2367        Some(other) => Err(RegistryError::InvalidEnumValue {
2368            parameter: parameter_name.to_string(),
2369            value: other.to_string(),
2370            reason: "expected one of ema|sma|rma|alma|jma".to_string(),
2371        }),
2372    }
2373}
2374
2375/// Builds a MIDAS engine directly from typed params, bypassing the generic `source`
2376/// strip-then-`SourceMapped`-wrap path in [`build_typed`]: MIDAS needs its own `Source` for the
2377/// cumulative curve while still reading genuine `bar.high`/`bar.low` for Topfinder/Bottomfinder
2378/// extreme tracking, which a bar flattened to a single OHLC value (what `SourceMapped` produces)
2379/// would break.
2380fn build_midas_typed(params: &TypedParams) -> Result<Box<dyn Indicator>, RegistryError> {
2381    let mode = match get_enum_p(params, "mode")?.as_deref() {
2382        None | Some("topfinder") => MidasMode::Topfinder,
2383        Some("bottomfinder") => MidasMode::Bottomfinder,
2384        Some(other) => {
2385            return Err(RegistryError::InvalidEnumValue {
2386                parameter: "mode".to_string(),
2387                value: other.to_string(),
2388                reason: "expected one of topfinder|bottomfinder".to_string(),
2389            });
2390        }
2391    };
2392
2393    let source = match params.get("source") {
2394        None => crate::model::Source::Hlc3,
2395        Some(ParamValue::Source(s)) => *s,
2396        Some(other) => {
2397            return Err(RegistryError::UnsupportedParameterType {
2398                parameter: "source".to_string(),
2399                type_name: other.type_name().to_string(),
2400            });
2401        }
2402    };
2403
2404    let numeric = extract_numeric_subset(params, &["maturity_bars"])?;
2405    let maturity_bars = get_usize_p(&numeric, "maturity_bars", 20, 1, 10000)?;
2406
2407    Ok(Box::new(MidasCurveEngine::new(
2408        mode,
2409        source,
2410        maturity_bars as u32,
2411    )))
2412}
2413
2414fn build_zigzag_advanced_typed(params: &TypedParams) -> Result<Box<dyn Indicator>, RegistryError> {
2415    let numeric =
2416        extract_numeric_subset(params, &["depth", "backstep", "deviation_value", "atr_len"])?;
2417    let depth = get_usize_p(&numeric, "depth", 3, 1, 10000)?;
2418    let backstep = get_usize_p(&numeric, "backstep", 2, 0, 10000)?;
2419    let atr_len = get_usize_p(&numeric, "atr_len", 14, 1, 10000)?;
2420
2421    let deviation = match get_enum_p(params, "deviation_mode")?.as_deref() {
2422        None | Some("percent") => {
2423            let value = get_f64_p(&numeric, "deviation_value", 1.0, 0.001, 100.0)?;
2424            ZigZagDeviationMode::Percent(value)
2425        }
2426        Some("atr_multiple") => {
2427            let value = get_f64_p(&numeric, "deviation_value", 1.0, 0.001, 1000.0)?;
2428            ZigZagDeviationMode::AtrMultiple(value)
2429        }
2430        Some(other) => {
2431            return Err(RegistryError::InvalidEnumValue {
2432                parameter: "deviation_mode".to_string(),
2433                value: other.to_string(),
2434                reason: "expected one of percent|atr_multiple".to_string(),
2435            });
2436        }
2437    };
2438
2439    Ok(Box::new(AdvancedZigZagEngine::new(
2440        depth, backstep, deviation, atr_len,
2441    )))
2442}
2443
2444fn build_trend_relationship_typed(
2445    params: &TypedParams,
2446) -> Result<Box<dyn Indicator>, RegistryError> {
2447    use super::smoothing::SmootherKind;
2448    use super::trend_relationship::AdaptiveTrendRelationship;
2449
2450    let fast_kind = parse_smoother_kind(params, "fast_kind", SmootherKind::Ema)?;
2451    let slow_kind = parse_smoother_kind(params, "slow_kind", SmootherKind::Ema)?;
2452
2453    let numeric = extract_numeric_subset(params, &["fast_len", "slow_len"])?;
2454    let fast_len = get_usize_p(&numeric, "fast_len", 9, 1, 10000)?;
2455    let slow_len = get_usize_p(&numeric, "slow_len", 21, 1, 10000)?;
2456    ensure_less("fast_len", fast_len as f64, "slow_len", slow_len as f64)?;
2457
2458    Ok(Box::new(AdaptiveTrendRelationship::new(
2459        fast_kind, fast_len, slow_kind, slow_len,
2460    )))
2461}
2462
2463// Note: `indicator::swing_structure::SwingStructureEngine` and
2464// `indicator::relative_strength::RelativeStrengthEngine` are intentionally not registered in the
2465// single-bar `build(name, params)` registry. `SwingStructureEngine` requires raw ATR injected per
2466// bar (`update(bar, atr)`), while `RelativeStrengthEngine` requires dual-series input (`update(own_bar, bench_bar)`).
2467// Both are exported directly from `kestrel_chartkit::indicator` for explicit use.
2468
2469/// Finding 04: every canonical indicator name `build_checked` can construct, kept as an explicit
2470/// list (rather than derived from the match arms at runtime) so `catalog()` is checked against a
2471/// concrete, reviewable contract instead of only a minimum count. Aliases (e.g. "vp", "ob", "wvf")
2472/// are deliberately excluded: they must remain buildable but are not separate catalog entries.
2473///
2474/// Public (not `#[cfg(test)]`-gated) so tooling outside this crate's test suite — e.g.
2475/// `examples/export_applicability.rs` — can iterate the same canonical name list rather than
2476/// maintaining a second one.
2477pub const CANONICAL_INDICATOR_NAMES: &[&str] = &[
2478    "rsi",
2479    "macd",
2480    "bollinger",
2481    "adx",
2482    "stoch_rsi",
2483    "cci",
2484    "mfi",
2485    "atr",
2486    "chande_kroll",
2487    "efi",
2488    "trix",
2489    "vidya",
2490    "t3",
2491    "ulcer_index",
2492    "rci",
2493    "smi",
2494    "rvat",
2495    "bbtrend",
2496    "relative_volatility",
2497    "twap",
2498    "pvt",
2499    "pmo",
2500    "chandelier_exit",
2501    "chandelier_flip_radar",
2502    "midas",
2503    "trend_relationship",
2504    "williams_r",
2505    "tsi",
2506    "fisher_transform",
2507    "order_block",
2508    "liquidity_fvg",
2509    "market_structure_breaks",
2510    "pivots_structure",
2511    "volume_profile",
2512    "money_flow_profile",
2513    "extended_volume_profile",
2514    "persistent_volume_profile",
2515    "vwap",
2516    "vix_fix",
2517    "candle_story",
2518    "efficiency",
2519    "volume",
2520    "rvol",
2521    "obv",
2522    "cmf",
2523    "acc_dist",
2524    "true_range",
2525    "keltner",
2526    "donchian",
2527    "historical_volatility",
2528    "garman_klass",
2529    "sma",
2530    "ema",
2531    "wma",
2532    "vwma",
2533    "hma",
2534    "dema",
2535    "kama",
2536    "dmi",
2537    "aroon",
2538    "parabolic_sar",
2539    "supertrend",
2540    "ichimoku",
2541    "stochastic",
2542    "roc",
2543    "ultimate_oscillator",
2544    "awesome_oscillator",
2545    "ppo",
2546    "wavetrend",
2547    "cmo",
2548    "elder_ray",
2549    "anchored_vwap",
2550    "cvd",
2551    "hires_volume_flow",
2552    "klinger",
2553    "zigzag",
2554    "zigzag_advanced",
2555    "pivot_sets",
2556    "tema",
2557    "lsma",
2558    "mcginley",
2559    "envelope",
2560    "choppiness",
2561    "vortex",
2562    "alligator",
2563    "connors_rsi",
2564    "coppock",
2565    "dpo",
2566    "kst",
2567    "mass_index",
2568    "rvi",
2569    "bop",
2570    "eom",
2571    "nvi",
2572    "pvi",
2573    "chaikin_oscillator",
2574    "bos_choch",
2575    "liquidity_sweeps",
2576    "liquidity_pools",
2577    "wyckoff",
2578    "trend_quality",
2579    "buy_sell_pressure",
2580    "volatility_regime",
2581    "zscore",
2582    "multi_factor",
2583];
2584
2585#[cfg(test)]
2586mod tests {
2587    use super::*;
2588    use std::collections::HashSet;
2589
2590    /// Finding 04: `catalog()` must expose exactly the canonical, buildable indicator names —
2591    /// no fewer (a name silently missing from discovery) and no more (a stray or alias-as-
2592    /// canonical entry). This is the "buildable canonical name -> catalog entry" direction that a
2593    /// simple `catalog().len() > N` check does not exercise.
2594    #[test]
2595    fn test_catalog_matches_canonical_indicator_names_exactly() {
2596        let catalog_names: HashSet<&str> = catalog().iter().map(|e| e.name).collect();
2597        let canonical: HashSet<&str> = CANONICAL_INDICATOR_NAMES.iter().copied().collect();
2598
2599        let missing_from_catalog: Vec<&&str> = canonical.difference(&catalog_names).collect();
2600        assert!(
2601            missing_from_catalog.is_empty(),
2602            "buildable but not discoverable via catalog(): {missing_from_catalog:?}"
2603        );
2604
2605        let extra_in_catalog: Vec<&&str> = catalog_names.difference(&canonical).collect();
2606        assert!(
2607            extra_in_catalog.is_empty(),
2608            "catalog() entries with no matching canonical build_checked arm: {extra_in_catalog:?}"
2609        );
2610
2611        assert_eq!(CANONICAL_INDICATOR_NAMES.len(), 105);
2612        assert_eq!(catalog().len(), 105);
2613    }
2614
2615    #[test]
2616    fn test_catalog_has_no_duplicate_names() {
2617        let names: Vec<&str> = catalog().iter().map(|e| e.name).collect();
2618        let unique: HashSet<&str> = names.iter().copied().collect();
2619        assert_eq!(
2620            names.len(),
2621            unique.len(),
2622            "catalog() contains a duplicate indicator name"
2623        );
2624    }
2625
2626    #[test]
2627    fn test_every_catalog_entry_builds_with_its_default_params() {
2628        for entry in catalog() {
2629            let built = build_checked(entry.name, &entry.default_params);
2630            assert!(
2631                built.is_ok(),
2632                "catalog entry '{}' failed to build with its own default params: {:?}",
2633                entry.name,
2634                built.err()
2635            );
2636        }
2637    }
2638
2639    /// Negative test: a name that is only meant to be an alias must not have snuck in as a
2640    /// second, separate catalog entry alongside its canonical name.
2641    #[test]
2642    fn test_catalog_does_not_contain_aliases() {
2643        let catalog_names: HashSet<&str> = catalog().iter().map(|e| e.name).collect();
2644        for alias in [
2645            "vp",
2646            "wvf",
2647            "ob",
2648            "fvg",
2649            "smc",
2650            "bos",
2651            "choch",
2652            "pivots",
2653            "pinbar",
2654            "leg_efficiency",
2655            "er",
2656            "vp_extended",
2657            "vp_persistent",
2658        ] {
2659            assert!(
2660                !catalog_names.contains(alias),
2661                "'{alias}' is an alias, not a canonical name, and must not be its own catalog entry"
2662            );
2663        }
2664    }
2665
2666    #[test]
2667    fn test_build_checked_valid_and_invalid_params() {
2668        let valid_params = HashMap::from([("period".to_string(), 14.0)]);
2669        assert!(build_checked("rsi", &valid_params).is_ok());
2670
2671        // Period 0 is invalid
2672        let zero_params = HashMap::from([("rsi_len".to_string(), 0.0)]);
2673        let err = match build_checked("rsi", &zero_params) {
2674            Err(e) => e,
2675            Ok(_) => panic!("Expected error for zero period"),
2676        };
2677        assert!(matches!(err, RegistryError::InvalidParameter { .. }));
2678
2679        // Negative period is invalid
2680        let neg_params = HashMap::from([("period".to_string(), -5.0)]);
2681        let err = match build_checked("sma", &neg_params) {
2682            Err(e) => e,
2683            Ok(_) => panic!("Expected error for negative period"),
2684        };
2685        assert!(matches!(err, RegistryError::InvalidParameter { .. }));
2686
2687        // Fractional periods are rejected instead of silently truncated
2688        let fractional_params = HashMap::from([("period".to_string(), 14.5)]);
2689        assert!(matches!(
2690            build_checked("sma", &fractional_params),
2691            Err(RegistryError::InvalidParameter { .. })
2692        ));
2693
2694        // Values above the allocation guard are rejected
2695        let huge_params = HashMap::from([("period".to_string(), 10_001.0)]);
2696        assert!(matches!(
2697            build_checked("sma", &huge_params),
2698            Err(RegistryError::InvalidParameter { .. })
2699        ));
2700
2701        // NaN is invalid
2702        let nan_params = HashMap::from([("period".to_string(), f64::NAN)]);
2703        let err = match build_checked("sma", &nan_params) {
2704            Err(e) => e,
2705            Ok(_) => panic!("Expected error for NaN period"),
2706        };
2707        assert!(matches!(err, RegistryError::InvalidParameter { .. }));
2708
2709        // Threshold and period relationships are validated
2710        let reversed_rsi = HashMap::from([
2711            ("oversold".to_string(), 80.0),
2712            ("overbought".to_string(), 20.0),
2713        ]);
2714        assert!(matches!(
2715            build_checked("rsi", &reversed_rsi),
2716            Err(RegistryError::InvalidParameter { .. })
2717        ));
2718
2719        let reversed_ultimate = HashMap::from([
2720            ("period1".to_string(), 28.0),
2721            ("period2".to_string(), 14.0),
2722            ("period3".to_string(), 7.0),
2723        ]);
2724        assert!(matches!(
2725            build_checked("ultimate_oscillator", &reversed_ultimate),
2726            Err(RegistryError::InvalidParameter { .. })
2727        ));
2728
2729        let oversized_bins = HashMap::from([("num_bins".to_string(), 1_001.0)]);
2730        assert!(matches!(
2731            build_checked("volume_profile", &oversized_bins),
2732            Err(RegistryError::InvalidParameter { .. })
2733        ));
2734
2735        // Infinity is invalid
2736        let inf_params = HashMap::from([("period".to_string(), f64::INFINITY)]);
2737        let err = match build_checked("sma", &inf_params) {
2738            Err(e) => e,
2739            Ok(_) => panic!("Expected error for Infinity period"),
2740        };
2741        assert!(matches!(err, RegistryError::InvalidParameter { .. }));
2742
2743        // MACD fast_len >= slow_len is invalid
2744        let macd_bad = HashMap::from([
2745            ("fast_len".to_string(), 30.0),
2746            ("slow_len".to_string(), 20.0),
2747        ]);
2748        let err = match build_checked("macd", &macd_bad) {
2749            Err(e) => e,
2750            Ok(_) => panic!("Expected error for fast >= slow"),
2751        };
2752        assert!(matches!(err, RegistryError::InvalidParameter { .. }));
2753
2754        // Unknown indicator
2755        let err = match build_checked("non_existent_ind", &HashMap::new()) {
2756            Err(e) => e,
2757            Ok(_) => panic!("Expected error for unknown indicator"),
2758        };
2759        assert_eq!(
2760            err,
2761            RegistryError::UnknownIndicator("non_existent_ind".to_string())
2762        );
2763    }
2764
2765    #[test]
2766    fn test_keltner_ema_period_is_the_canonical_catalog_key() {
2767        // Finding 05: the catalog publishes "ema_period"; the builder must actually read it
2768        // rather than silently ignoring it and falling back to the period-20 default.
2769        // `warmup_period` is `max(ema_period, atr_period)`, so atr_period is pinned to 1 here to
2770        // isolate what ema_period alone drove.
2771        let ind = build_checked(
2772            "keltner",
2773            &HashMap::from([
2774                ("ema_period".to_string(), 5.0),
2775                ("atr_period".to_string(), 1.0),
2776            ]),
2777        )
2778        .unwrap();
2779        assert_eq!(ind.warmup_period(), 5);
2780
2781        let default_ind =
2782            build_checked("keltner", &HashMap::from([("atr_period".to_string(), 1.0)])).unwrap();
2783        assert_eq!(default_ind.warmup_period(), 20);
2784    }
2785
2786    #[test]
2787    fn test_keltner_ma_period_legacy_alias_still_works() {
2788        let ind = build_checked(
2789            "keltner",
2790            &HashMap::from([
2791                ("ma_period".to_string(), 5.0),
2792                ("atr_period".to_string(), 1.0),
2793            ]),
2794        )
2795        .unwrap();
2796        assert_eq!(ind.warmup_period(), 5);
2797    }
2798
2799    #[test]
2800    fn test_keltner_ema_period_invalid_value_rejected() {
2801        let err = match build_checked("keltner", &HashMap::from([("ema_period".to_string(), 0.0)]))
2802        {
2803            Err(e) => e,
2804            Ok(_) => panic!("Expected error for zero ema_period"),
2805        };
2806        assert!(matches!(err, RegistryError::InvalidParameter { .. }));
2807    }
2808
2809    #[test]
2810    fn test_keltner_conflicting_canonical_and_alias_rejected() {
2811        let err = match build_checked(
2812            "keltner",
2813            &HashMap::from([
2814                ("ema_period".to_string(), 5.0),
2815                ("ma_period".to_string(), 10.0),
2816            ]),
2817        ) {
2818            Err(e) => e,
2819            Ok(_) => panic!("Expected error for conflicting ema_period/ma_period"),
2820        };
2821        assert!(matches!(err, RegistryError::InvalidParameter { .. }));
2822
2823        // Identical values under both names is not a conflict.
2824        assert!(build_checked(
2825            "keltner",
2826            &HashMap::from([
2827                ("ema_period".to_string(), 5.0),
2828                ("ma_period".to_string(), 5.0),
2829            ]),
2830        )
2831        .is_ok());
2832    }
2833
2834    #[test]
2835    fn test_build_typed_flattens_numeric_params() {
2836        let params: TypedParams = HashMap::from([("period".to_string(), ParamValue::Int(14))]);
2837        assert!(build_typed("rsi", &params).is_ok());
2838
2839        let bool_params: TypedParams =
2840            HashMap::from([("period".to_string(), ParamValue::Bool(true))]);
2841        // Bool flattens to 1.0, which build_checked's own validation then accepts or rejects.
2842        assert!(build_typed("rsi", &bool_params).is_ok());
2843    }
2844
2845    #[test]
2846    fn test_build_typed_rejects_non_numeric_params() {
2847        let params: TypedParams =
2848            HashMap::from([("period".to_string(), ParamValue::Enum("fast".to_string()))]);
2849        let err = match build_typed("rsi", &params) {
2850            Err(e) => e,
2851            Ok(_) => panic!("Expected error for enum parameter"),
2852        };
2853        assert!(matches!(
2854            err,
2855            RegistryError::UnsupportedParameterType { .. }
2856        ));
2857    }
2858
2859    #[test]
2860    fn test_build_typed_propagates_indicator_validation_errors() {
2861        let params: TypedParams =
2862            HashMap::from([("period".to_string(), ParamValue::Float(f64::INFINITY))]);
2863        let err = match build_typed("sma", &params) {
2864            Err(e) => e,
2865            Ok(_) => panic!("Expected error for infinite period"),
2866        };
2867        assert!(matches!(err, RegistryError::InvalidParameter { .. }));
2868    }
2869
2870    #[test]
2871    fn test_build_typed_anchored_vwap_native_enum_selection() {
2872        // Default (no anchor_kind given) stays Session, matching build_checked's behavior.
2873        let defaulted = build_typed("anchored_vwap", &TypedParams::new());
2874        assert!(defaulted.is_ok());
2875
2876        let day_params: TypedParams = HashMap::from([(
2877            "anchor_kind".to_string(),
2878            ParamValue::Enum("day".to_string()),
2879        )]);
2880        assert!(build_typed("avwap", &day_params).is_ok());
2881
2882        let skip_zero_vol: TypedParams = HashMap::from([(
2883            "zero_volume_policy".to_string(),
2884            ParamValue::Enum("skip".to_string()),
2885        )]);
2886        assert!(build_typed("anchored_vwap", &skip_zero_vol).is_ok());
2887
2888        let manual_without_timestamp: TypedParams = HashMap::from([(
2889            "anchor_kind".to_string(),
2890            ParamValue::Enum("manual_timestamp".to_string()),
2891        )]);
2892        let err = match build_typed("anchored_vwap", &manual_without_timestamp) {
2893            Err(e) => e,
2894            Ok(_) => panic!("Expected error: manual_timestamp requires anchor_timestamp"),
2895        };
2896        assert!(matches!(err, RegistryError::InvalidEnumValue { .. }));
2897
2898        let manual_with_timestamp: TypedParams = HashMap::from([
2899            (
2900                "anchor_kind".to_string(),
2901                ParamValue::Enum("manual_timestamp".to_string()),
2902            ),
2903            (
2904                "anchor_timestamp".to_string(),
2905                ParamValue::Timestamp(1_700_000_000),
2906            ),
2907        ]);
2908        assert!(build_typed("anchored_vwap", &manual_with_timestamp).is_ok());
2909
2910        let unknown_kind: TypedParams = HashMap::from([(
2911            "anchor_kind".to_string(),
2912            ParamValue::Enum("bogus".to_string()),
2913        )]);
2914        let err = match build_typed("anchored_vwap", &unknown_kind) {
2915            Err(e) => e,
2916            Ok(_) => panic!("Expected error for unknown anchor_kind"),
2917        };
2918        assert!(matches!(err, RegistryError::InvalidEnumValue { .. }));
2919    }
2920
2921    #[test]
2922    fn test_build_typed_pivot_sets_native_enum_selection() {
2923        for kind in [
2924            "classic",
2925            "fibonacci",
2926            "camarilla",
2927            "woodie",
2928            "demark",
2929            "cpr",
2930        ] {
2931            let params: TypedParams =
2932                HashMap::from([("pivot_type".to_string(), ParamValue::Enum(kind.to_string()))]);
2933            assert!(
2934                build_typed("pivot_sets", &params).is_ok(),
2935                "expected {kind} to build"
2936            );
2937        }
2938
2939        let unknown: TypedParams = HashMap::from([(
2940            "pivot_type".to_string(),
2941            ParamValue::Enum("bogus".to_string()),
2942        )]);
2943        let err = match build_typed("multi_pivots", &unknown) {
2944            Err(e) => e,
2945            Ok(_) => panic!("Expected error for unknown pivot_type"),
2946        };
2947        assert!(matches!(err, RegistryError::InvalidEnumValue { .. }));
2948    }
2949
2950    #[test]
2951    fn test_build_typed_source_propagates_to_computation() {
2952        use crate::model::{Bar, Source};
2953
2954        let bars = [
2955            Bar::new(0, 10.0, 12.0, 8.0, 11.0, 100.0),
2956            Bar::new(60, 20.0, 22.0, 18.0, 21.0, 100.0),
2957        ];
2958
2959        let close_params: TypedParams = HashMap::from([("period".to_string(), ParamValue::Int(2))]);
2960        let mut close_sma = build_typed("sma", &close_params).unwrap();
2961
2962        let open_params: TypedParams = HashMap::from([
2963            ("period".to_string(), ParamValue::Int(2)),
2964            ("source".to_string(), ParamValue::Source(Source::Open)),
2965        ]);
2966        let mut open_sma = build_typed("sma", &open_params).unwrap();
2967
2968        let mut close_out = None;
2969        let mut open_out = None;
2970        for bar in &bars {
2971            close_out = close_sma.on_bar(bar);
2972            open_out = open_sma.on_bar(bar);
2973        }
2974
2975        assert_eq!(close_out.unwrap().value, (11.0 + 21.0) / 2.0);
2976        assert_eq!(open_out.unwrap().value, (10.0 + 20.0) / 2.0);
2977    }
2978
2979    #[test]
2980    fn test_build_typed_source_close_is_a_no_op() {
2981        let params: TypedParams = HashMap::from([(
2982            "source".to_string(),
2983            ParamValue::Source(crate::model::Source::Close),
2984        )]);
2985        assert!(build_typed("sma", &params).is_ok());
2986    }
2987
2988    #[test]
2989    fn test_adx_with_defaults_matches_registry_default() {
2990        let mut via_struct = Adx::with_defaults();
2991        let mut via_registry = build_checked("adx", &HashMap::new()).unwrap();
2992
2993        let bars = crate::model::Bar::new(0, 100.0, 101.0, 99.0, 100.5, 1000.0);
2994        let mut struct_out = None;
2995        let mut registry_out = None;
2996        for i in 0..60 {
2997            let price = 100.0 + (i as f64 * 0.3).sin() * 5.0;
2998            let bar =
2999                crate::model::Bar::new(i, price, price + 1.0, price - 1.0, price + 0.5, 1000.0);
3000            struct_out = via_struct.on_bar(&bar);
3001            registry_out = via_registry.on_bar(&bar);
3002        }
3003        let _ = bars;
3004        assert_eq!(
3005            struct_out.map(|o| o.value),
3006            registry_out.map(|o| o.value),
3007            "Adx::with_defaults() must produce identical output to the registry's \"adx\" default"
3008        );
3009    }
3010
3011    #[test]
3012    fn test_atr_with_defaults_matches_registry_default() {
3013        let mut via_struct = Atr::with_defaults();
3014        let mut via_registry = build_checked("atr", &HashMap::new()).unwrap();
3015
3016        let mut struct_out = None;
3017        let mut registry_out = None;
3018        for i in 0..40 {
3019            let price = 100.0 + (i as f64 * 0.3).sin() * 5.0;
3020            let bar =
3021                crate::model::Bar::new(i, price, price + 1.0, price - 1.0, price + 0.5, 1000.0);
3022            struct_out = via_struct.on_bar(&bar);
3023            registry_out = via_registry.on_bar(&bar);
3024        }
3025        assert_eq!(
3026            struct_out.map(|o| o.value),
3027            registry_out.map(|o| o.value),
3028            "Atr::with_defaults() must produce identical output to the registry's \"atr\" default"
3029        );
3030    }
3031
3032    /// `efi` weighs a close-to-close change by the bar's volume; a derived price source would be
3033    /// a different series under the same name, so it is refused until that contract exists.
3034    #[test]
3035    fn test_build_typed_rejects_non_close_source_on_efi() {
3036        let params: TypedParams = HashMap::from([(
3037            "source".to_string(),
3038            ParamValue::Source(crate::model::Source::Hlc3),
3039        )]);
3040        let err = match build_typed("efi", &params) {
3041            Err(e) => e,
3042            Ok(_) => panic!("expected 'efi' to reject a non-Close source"),
3043        };
3044        assert!(
3045            matches!(err, RegistryError::IncompatibleParameter { .. }),
3046            "efi returned {err:?} instead of IncompatibleParameter"
3047        );
3048
3049        let close: TypedParams = HashMap::from([(
3050            "source".to_string(),
3051            ParamValue::Source(crate::model::Source::Close),
3052        )]);
3053        assert!(build_typed("efi", &close).is_ok());
3054    }
3055
3056    #[test]
3057    fn test_build_typed_rejects_non_close_source_on_range_dependent_indicators() {
3058        let params: TypedParams = HashMap::from([(
3059            "source".to_string(),
3060            ParamValue::Source(crate::model::Source::Open),
3061        )]);
3062        for name in [
3063            "atr",
3064            "wyckoff",
3065            "chandelier_exit",
3066            "ce",
3067            "extended_volume_profile",
3068            "zigzag_advanced",
3069            "keltner",
3070            "donchian",
3071            "garman_klass",
3072        ] {
3073            let err = match build_typed(name, &params) {
3074                Err(e) => e,
3075                Ok(_) => panic!("expected '{name}' to reject a non-Close source"),
3076            };
3077            assert!(
3078                matches!(err, RegistryError::IncompatibleParameter { .. }),
3079                "'{name}' returned {err:?} instead of IncompatibleParameter"
3080            );
3081        }
3082
3083        // Close is always a no-op regardless of range-dependence, so it must still succeed.
3084        let close_params: TypedParams = HashMap::from([(
3085            "source".to_string(),
3086            ParamValue::Source(crate::model::Source::Close),
3087        )]);
3088        assert!(build_typed("atr", &close_params).is_ok());
3089    }
3090
3091    #[test]
3092    fn test_build_typed_trend_relationship_native_smoother_kind_selection() {
3093        let params: TypedParams = HashMap::from([
3094            ("fast_kind".to_string(), ParamValue::Enum("jma".to_string())),
3095            (
3096                "slow_kind".to_string(),
3097                ParamValue::Enum("alma".to_string()),
3098            ),
3099            ("fast_len".to_string(), ParamValue::Int(5)),
3100            ("slow_len".to_string(), ParamValue::Int(20)),
3101        ]);
3102        assert!(build_typed("trend_relationship", &params).is_ok());
3103
3104        let unknown: TypedParams = HashMap::from([(
3105            "fast_kind".to_string(),
3106            ParamValue::Enum("bogus".to_string()),
3107        )]);
3108        let err = match build_typed("trend_relationship", &unknown) {
3109            Err(e) => e,
3110            Ok(_) => panic!("Expected error for unknown fast_kind"),
3111        };
3112        assert!(matches!(err, RegistryError::InvalidEnumValue { .. }));
3113    }
3114
3115    #[test]
3116    fn test_build_checked_trend_relationship_defaults_to_ema() {
3117        assert!(build_checked("trend_relationship", &HashMap::new()).is_ok());
3118    }
3119
3120    #[test]
3121    fn test_build_typed_midas_native_mode_and_source_selection() {
3122        let params: TypedParams = HashMap::from([
3123            (
3124                "mode".to_string(),
3125                ParamValue::Enum("bottomfinder".to_string()),
3126            ),
3127            (
3128                "source".to_string(),
3129                ParamValue::Source(crate::model::Source::Close),
3130            ),
3131        ]);
3132        assert!(build_typed("midas", &params).is_ok());
3133
3134        let unknown: TypedParams =
3135            HashMap::from([("mode".to_string(), ParamValue::Enum("bogus".to_string()))]);
3136        let err = match build_typed("midas", &unknown) {
3137            Err(e) => e,
3138            Ok(_) => panic!("Expected error for unknown mode"),
3139        };
3140        assert!(matches!(err, RegistryError::InvalidEnumValue { .. }));
3141    }
3142
3143    #[test]
3144    fn test_build_checked_midas_defaults_to_topfinder() {
3145        assert!(build_checked("midas", &HashMap::new()).is_ok());
3146    }
3147}