mylittleindicators 0.1.8

Multi-stream financial indicators library — 556 bar indicators + 21 event primitives across 35 categories. Consumes 27 stream kinds from digdigdig3 exchange connectors: OHLCV bars, ticks, orderbook (snapshot/delta/L3), funding/predicted funding/funding settlement, mark price, index price, open interest, liquidations, ticker, agg trades, long/short ratio, option greeks, volatility index, historical volatility, basis (derived), composite index, settlement events, block trades, insurance fund, risk limit, market warning, and three kline-family variants. Live-verified on 12 exchanges (89% pass-rate on a 150s BTC slice).
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
//! master_catalog.rs - Master Indicator Catalog Aggregator
//!
//! Provides unified access to all 23 indicator category catalogs through a single interface.
//! This is the central registry that connects the Universal Indicator System with optimizers.
//!
//! Architecture:
//! - Master Catalog unifies all 23 category catalogs (22 active + zigzag pending)
//! - Type-erased trait for catalog access
//! - O(1) lookups with HashMap
//! - Support for search and discovery
//!
//! Usage:
//! ```rust
//! use zengeld_chart_indicators::catalog::master_catalog::MasterIndicatorCatalog;
//! use zengeld_chart_indicators::catalog::indicator_signature::IndicatorCategory;
//!
//! let master = MasterIndicatorCatalog::new();
//!
//! // Check indicator count
//! assert!(master.total_count() > 450);
//!
//! // Get any indicator by ID
//! let sma_sig = master.get_signature("SMA").unwrap();
//! assert_eq!(sma_sig.id, "SMA");
//!
//! // Search indicators
//! let ma_indicators = master.search("moving average");
//! assert!(!ma_indicators.is_empty());
//! ```

use crate::catalog::{IndicatorSignature, IndicatorCategory};
use std::collections::HashMap;
use once_cell::sync::Lazy;

// Import all 21 category catalogs
use crate::bar_indicators::average::average_catalog;
use crate::bar_indicators::momentum::momentum_catalog;
use crate::bar_indicators::channels::channels_catalog;
use crate::bar_indicators::volatility::volatility_catalog;
use crate::bar_indicators::volume::volume_catalog;
use crate::bar_indicators::trend::trend_catalog;
use crate::bar_indicators::levels::levels_catalog;
use crate::bar_indicators::entropy::entropy_catalog;
use crate::bar_indicators::kalman::kalman_catalog;
use crate::bar_indicators::signal_processing::signal_processing_catalog;
use crate::bar_indicators::chaos::chaos_catalog;
use crate::bar_indicators::regression::regression_catalog;
use crate::bar_indicators::adaptive::adaptive_catalog;
use crate::bar_indicators::accumulation::accumulation_catalog;
use crate::bar_indicators::book::book_catalog;
use crate::bar_indicators::candles::candles_catalog;
use crate::bar_indicators::clusters::clusters_catalog;
use crate::bar_indicators::divergence::divergence_catalog;
use crate::bar_indicators::ratio::ratio_catalog;
use crate::bar_indicators::trend_stop::trend_stop_catalog;
use crate::bar_indicators::position::position_catalog;
use crate::bar_indicators::statistics::statistics_catalog;
use crate::bar_indicators::statistical_scoring::statistical_scoring_catalog;
use crate::bar_indicators::funding_advanced::funding_advanced_catalog;
use crate::bar_indicators::open_interest::open_interest_catalog;
use crate::bar_indicators::mark_price_advanced::mark_price_advanced_catalog;
use crate::bar_indicators::ticker_advanced::ticker_advanced_catalog;
use crate::bar_indicators::liquidations::liquidations_catalog;
use crate::bar_indicators::tick_advanced::tick_advanced_catalog;
use crate::bar_indicators::book_advanced::book_advanced_catalog;
use crate::bar_indicators::composites::composites_catalog;
use crate::bar_indicators::sentiment::sentiment_catalog;
use crate::bar_indicators::index_basis::index_basis_catalog;
use crate::bar_indicators::volatility_advanced::volatility_advanced_catalog;
use crate::bar_indicators::greeks::greeks_catalog;
use crate::bar_indicators::stress::stress_catalog;
use crate::bar_indicators::microstructure::microstructure_catalog;
use crate::bar_indicators::risk_funding::risk_funding_catalog;
// Note: zigzag_catalog not exported from zigzag module yet

/// Error type for catalog operations
#[derive(Debug, Clone)]
pub enum CatalogError {
    /// Indicator not found
    NotFound(String),
    /// Category not registered
    CategoryNotFound(IndicatorCategory),
    /// Ambiguous indicator ID (exists in multiple categories)
    Ambiguous(String, Vec<IndicatorCategory>),
}

impl std::fmt::Display for CatalogError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            CatalogError::NotFound(id) => write!(f, "Indicator '{}' not found in any catalog", id),
            CatalogError::CategoryNotFound(cat) => write!(f, "Category '{:?}' not registered", cat),
            CatalogError::Ambiguous(id, cats) => write!(f, "Indicator '{}' found in multiple categories: {:?}", id, cats),
        }
    }
}

impl std::error::Error for CatalogError {}

/// Trait for type-erased catalog access
pub trait CatalogProvider: Send + Sync {
    /// Get indicator signature by ID
    fn get_signature(&self, id: &str) -> Option<IndicatorSignature>;

    /// Get all indicator IDs in this catalog
    fn get_all_ids(&self) -> Vec<&'static str>;

    /// Get the category this catalog represents
    fn category(&self) -> IndicatorCategory;

    /// Get total indicator count
    fn count(&self) -> usize;
}

/// Wrapper for category catalogs that implement CatalogProvider
struct CategoryCatalogWrapper {
    category: IndicatorCategory,
    get_fn: fn(&str) -> Option<IndicatorSignature>,
    all_ids: Vec<&'static str>,
}

impl CategoryCatalogWrapper {
    fn new(
        category: IndicatorCategory,
        get_fn: fn(&str) -> Option<IndicatorSignature>,
        all_ids: Vec<&'static str>,
    ) -> Self {
        Self { category, get_fn, all_ids }
    }
}

impl CatalogProvider for CategoryCatalogWrapper {
    fn get_signature(&self, id: &str) -> Option<IndicatorSignature> {
        (self.get_fn)(id)
    }

    fn get_all_ids(&self) -> Vec<&'static str> {
        self.all_ids.clone()
    }

    fn category(&self) -> IndicatorCategory {
        self.category
    }

    fn count(&self) -> usize {
        self.all_ids.len()
    }
}

/// Master Indicator Catalog - unified access to all 480+ indicators
pub struct MasterIndicatorCatalog {
    /// Category catalogs
    catalogs: HashMap<IndicatorCategory, Box<dyn CatalogProvider>>,

    /// Fast lookup: ID -> Category (for ambiguity detection)
    id_to_categories: HashMap<String, Vec<IndicatorCategory>>,

    /// Total indicator count
    total_count: usize,
}

impl MasterIndicatorCatalog {
    /// Create a new master catalog with all 23 categories registered (22 active + zigzag pending)
    pub fn new() -> Self {
        let mut catalogs: HashMap<IndicatorCategory, Box<dyn CatalogProvider>> = HashMap::new();
        let mut id_to_categories: HashMap<String, Vec<IndicatorCategory>> = HashMap::new();

        // Register all 22 category catalogs (zigzag catalog not yet exported)
        let catalog_configs = vec![
            (IndicatorCategory::Average, average_catalog::get_signature as fn(&str) -> Option<IndicatorSignature>, average_catalog::all_indicator_ids()),
            (IndicatorCategory::Momentum, momentum_catalog::get_signature, momentum_catalog::all_indicator_ids()),
            (IndicatorCategory::Channels, channels_catalog::get_signature, channels_catalog::all_indicator_ids()),
            (IndicatorCategory::Volatility, volatility_catalog::get_signature, volatility_catalog::all_indicator_ids()),
            (IndicatorCategory::Volume, volume_catalog::get_signature, volume_catalog::all_indicator_ids()),
            (IndicatorCategory::Trend, trend_catalog::get_signature, trend_catalog::all_indicator_ids()),
            (IndicatorCategory::Levels, levels_catalog::get_signature, levels_catalog::all_indicator_ids()),
            (IndicatorCategory::Entropy, entropy_catalog::get_signature, entropy_catalog::all_indicator_ids()),
            (IndicatorCategory::Kalman, kalman_catalog::get_signature, kalman_catalog::all_indicator_ids()),
            (IndicatorCategory::SignalProcessing, signal_processing_catalog::get_signature, signal_processing_catalog::all_indicator_ids()),
            (IndicatorCategory::Chaos, chaos_catalog::get_signature, chaos_catalog::all_indicator_ids()),
            (IndicatorCategory::Regression, regression_catalog::get_signature, regression_catalog::all_indicator_ids()),
            (IndicatorCategory::Adaptive, adaptive_catalog::get_signature, adaptive_catalog::all_indicator_ids()),
            (IndicatorCategory::Accumulation, accumulation_catalog::get_signature, accumulation_catalog::all_indicator_ids()),
            (IndicatorCategory::Book, book_catalog::get_signature, book_catalog::all_indicator_ids()),
            (IndicatorCategory::Candles, candles_catalog::get_signature, candles_catalog::all_indicator_ids()),
            (IndicatorCategory::Clusters, clusters_catalog::get_signature, clusters_catalog::all_indicator_ids()),
            (IndicatorCategory::Divergence, divergence_catalog::get_signature, divergence_catalog::all_indicator_ids()),
            (IndicatorCategory::Ratio, ratio_catalog::get_signature, ratio_catalog::all_indicator_ids()),
            (IndicatorCategory::TrendStop, trend_stop_catalog::get_signature, trend_stop_catalog::all_indicator_ids()),
            (IndicatorCategory::Position, position_catalog::get_signature, position_catalog::all_indicator_ids()),
            (IndicatorCategory::Statistics, statistics_catalog::get_signature, statistics_catalog::all_indicator_ids()),
            (IndicatorCategory::StatisticalScoring, statistical_scoring_catalog::get_signature, statistical_scoring_catalog::all_indicator_ids()),
            (IndicatorCategory::FundingAdvanced, funding_advanced_catalog::get_signature, funding_advanced_catalog::all_indicator_ids()),
            (IndicatorCategory::OpenInterest, open_interest_catalog::get_signature, open_interest_catalog::all_indicator_ids()),
            (IndicatorCategory::MarkPriceAdvanced, mark_price_advanced_catalog::get_signature, mark_price_advanced_catalog::all_indicator_ids()),
            (IndicatorCategory::TickerAdvanced, ticker_advanced_catalog::get_signature, ticker_advanced_catalog::all_indicator_ids()),
            (IndicatorCategory::Liquidations, liquidations_catalog::get_signature, liquidations_catalog::all_indicator_ids()),
            (IndicatorCategory::TickAdvanced, tick_advanced_catalog::get_signature, tick_advanced_catalog::all_indicator_ids()),
            (IndicatorCategory::BookAdvanced, book_advanced_catalog::get_signature, book_advanced_catalog::all_indicator_ids()),
            (IndicatorCategory::Composites, composites_catalog::get_signature, composites_catalog::all_indicator_ids()),
            (IndicatorCategory::Sentiment, sentiment_catalog::get_signature, sentiment_catalog::all_indicator_ids()),
            (IndicatorCategory::IndexBasis, index_basis_catalog::get_signature, index_basis_catalog::all_indicator_ids()),
            (IndicatorCategory::VolatilityAdvanced, volatility_advanced_catalog::get_signature, volatility_advanced_catalog::all_indicator_ids()),
            (IndicatorCategory::Greeks, greeks_catalog::get_signature, greeks_catalog::all_indicator_ids()),
            (IndicatorCategory::Stress, stress_catalog::get_signature, stress_catalog::all_indicator_ids()),
            (IndicatorCategory::Microstructure, microstructure_catalog::get_signature, microstructure_catalog::all_indicator_ids()),
            (IndicatorCategory::RiskFunding, risk_funding_catalog::get_signature, risk_funding_catalog::all_indicator_ids()),
            // Note: Zigzag catalog will be added when exported from zigzag module
        ];

        let mut total_count = 0;

        for (category, get_fn, ids) in catalog_configs {
            total_count += ids.len();

            // Build ID -> Category mapping for ambiguity detection
            // Register both main IDs and all aliases
            for &id in &ids {
                // Register main ID
                id_to_categories
                    .entry(id.to_string())
                    .or_default()
                    .push(category);

                // Register all aliases from signature
                if let Some(sig) = get_fn(id) {
                    for alias in &sig.aliases {
                        id_to_categories
                            .entry(alias.clone())
                            .or_default()
                            .push(category);
                    }
                }
            }

            let wrapper = CategoryCatalogWrapper::new(category, get_fn, ids);
            catalogs.insert(category, Box::new(wrapper));
        }

        Self {
            catalogs,
            id_to_categories,
            total_count,
        }
    }

    /// Get indicator signature by ID (searches all categories)
    ///
    /// Returns error if indicator is not found or exists in multiple categories.
    /// Use `get_by_category()` if you know the category or for ambiguous indicators.
    pub fn get_signature(&self, id: &str) -> Result<IndicatorSignature, CatalogError> {
        // Check if ID exists and is unambiguous
        match self.id_to_categories.get(id) {
            None => Err(CatalogError::NotFound(id.to_string())),
            Some(categories) if categories.len() > 1 => {
                Err(CatalogError::Ambiguous(id.to_string(), categories.clone()))
            }
            Some(categories) => {
                let category = categories[0];
                self.get_by_category(category, id)
            }
        }
    }

    /// Get indicator signature by category and ID
    pub fn get_by_category(&self, category: IndicatorCategory, id: &str) -> Result<IndicatorSignature, CatalogError> {
        let catalog = self.catalogs
            .get(&category)
            .ok_or(CatalogError::CategoryNotFound(category))?;

        catalog.get_signature(id)
            .ok_or_else(|| CatalogError::NotFound(format!("{}::{}", category.as_str(), id)))
    }

    /// Get all indicators in a specific category
    pub fn get_category_indicators(&self, category: IndicatorCategory) -> Result<Vec<IndicatorSignature>, CatalogError> {
        let catalog = self.catalogs
            .get(&category)
            .ok_or(CatalogError::CategoryNotFound(category))?;

        Ok(catalog.get_all_ids()
            .into_iter()
            .filter_map(|id| catalog.get_signature(id))
            .collect())
    }

    /// Search indicators by name or description (case-insensitive)
    pub fn search(&self, query: &str) -> Vec<IndicatorSignature> {
        let query_lower = query.to_lowercase();
        let mut results = Vec::new();

        for catalog in self.catalogs.values() {
            for id in catalog.get_all_ids() {
                if let Some(sig) = catalog.get_signature(id) {
                    // Search in ID, name, and description
                    let matches = sig.id.to_lowercase().contains(&query_lower)
                        || sig.name.to_lowercase().contains(&query_lower)
                        || sig.metadata.get("description")
                            .map(|d| d.to_lowercase().contains(&query_lower))
                            .unwrap_or(false);

                    if matches {
                        results.push(sig);
                    }
                }
            }
        }

        results
    }

    /// Check if an indicator exists
    pub fn contains(&self, id: &str) -> bool {
        self.id_to_categories.contains_key(id)
    }

    /// Get total number of indicators across all categories
    pub fn total_count(&self) -> usize {
        self.total_count
    }

    /// Get count of indicators in a specific category
    pub fn category_count(&self, category: IndicatorCategory) -> usize {
        self.catalogs
            .get(&category)
            .map(|c| c.count())
            .unwrap_or(0)
    }

    /// Iterate all signatures across every registered category.
    ///
    /// Yields one `IndicatorSignature` per catalog entry.  Useful for one-pass
    /// collection (e.g. building `HashMap<BarIndicatorId, StreamKind>` in the
    /// live validator / collector).
    pub fn iter_signatures(&self) -> impl Iterator<Item = IndicatorSignature> + '_ {
        self.catalogs.values().flat_map(|catalog| {
            catalog
                .get_all_ids()
                .into_iter()
                .filter_map(|id| catalog.get_signature(id))
        })
    }

    /// Get statistics about the catalog
    pub fn stats(&self) -> CatalogStats {
        CatalogStats {
            total_indicators: self.total_count,
            total_categories: self.catalogs.len(),
            category_counts: self.catalogs
                .iter()
                .map(|(cat, catalog)| (*cat, catalog.count()))
                .collect(),
        }
    }
}

impl Default for MasterIndicatorCatalog {
    fn default() -> Self {
        Self::new()
    }
}

/// Catalog statistics
#[derive(Debug, Clone)]
pub struct CatalogStats {
    pub total_indicators: usize,
    pub total_categories: usize,
    pub category_counts: HashMap<IndicatorCategory, usize>,
}

impl CatalogStats {
    /// Generate a human-readable report
    pub fn report(&self) -> String {
        let mut report = String::new();
        report.push_str("═══════════════════════════════════════════════════════════\n");
        report.push_str("     MASTER INDICATOR CATALOG STATISTICS\n");
        report.push_str("═══════════════════════════════════════════════════════════\n\n");

        report.push_str(&format!("Total Categories:  {}\n", self.total_categories));
        report.push_str(&format!("Total Indicators:  {}\n\n", self.total_indicators));

        report.push_str("Category Breakdown:\n");
        report.push_str("───────────────────────────────────────────────────────────\n");

        let mut sorted: Vec<_> = self.category_counts.iter().collect();
        sorted.sort_by_key(|(cat, _)| cat.as_str());

        for (category, count) in sorted {
            report.push_str(&format!("  {:20} {:3} indicators\n", category.as_str(), count));
        }

        report.push_str("═══════════════════════════════════════════════════════════\n");

        report
    }
}

/// Global master catalog instance (lazy initialized)
pub static MASTER_CATALOG: Lazy<MasterIndicatorCatalog> = Lazy::new(MasterIndicatorCatalog::new);

/// Convenience function to get the global master catalog
pub fn catalog() -> &'static MasterIndicatorCatalog {
    &MASTER_CATALOG
}

/// Look up one signature by string id against the global catalog.
///
/// Free-function form used by downstream codegen (mlq-strategies-codegen
/// `pools.rs`) which composes with `?` in an `Option` context. Returns `None`
/// for not-found or ambiguous ids (the codegen treats both as "no signature").
pub fn get_signature(id: &str) -> Option<IndicatorSignature> {
    MASTER_CATALOG.get_signature(id).ok()
}

/// Iterate every signature in the global catalog.
///
/// Free-function form mirroring `MasterIndicatorCatalog::iter_signatures`,
/// used by downstream codegen pool filters.
pub fn all_signatures() -> impl Iterator<Item = IndicatorSignature> {
    MASTER_CATALOG.iter_signatures()
}

/// Configurability-granularity of an indicator — how many independently-iterable
/// axes (periods, MA-type choices, scalar params, flags, enum selectors, source)
/// it exposes, computed over its **canonical-max** config (every reachable slot
/// filled). This is the native entry point an auto-picker / strategy-assembler
/// calls to decide how wide an indicator's parameter space is BEFORE handing
/// slices to the optimizer. It measures config granularity, NOT compute cost.
///
/// Equivalent to
/// `config_granularity(&canonical_max_config(id))`; see
/// [`crate::bar_indicators::granularity`].
pub fn indicator_granularity(
    id: crate::bar_indicators::bar_indicator_id::BarIndicatorId,
) -> crate::bar_indicators::granularity::GranularitySpec {
    use crate::bar_indicators::granularity::{canonical_max_config, config_granularity};
    config_granularity(&canonical_max_config(id))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_master_catalog_creation() {
        let master = MasterIndicatorCatalog::new();
        assert!(master.total_count() > 430, "Should have 430+ indicators");
    }

    #[test]
    fn test_get_signature_unambiguous() {
        let master = MasterIndicatorCatalog::new();

        // SMA should be unambiguous (only in Average category)
        let sma = master.get_signature("SMA");
        assert!(sma.is_ok(), "SMA should be found");
        assert_eq!(sma.unwrap().id, "SMA");
    }

    #[test]
    fn test_get_by_category() {
        let master = MasterIndicatorCatalog::new();

        // Get RSI from Momentum category
        let rsi = master.get_by_category(IndicatorCategory::Momentum, "RSI");
        assert!(rsi.is_ok(), "RSI should be found in Momentum");
        assert_eq!(rsi.unwrap().category, IndicatorCategory::Momentum);
    }

    #[test]
    fn test_get_category_indicators() {
        let master = MasterIndicatorCatalog::new();

        // Get all average indicators
        let avg_indicators = master.get_category_indicators(IndicatorCategory::Average);
        assert!(avg_indicators.is_ok());
        let indicators = avg_indicators.unwrap();
        assert!(indicators.len() >= 20, "Average category should have 20+ indicators");

        // All should be in Average category
        for ind in indicators {
            assert_eq!(ind.category, IndicatorCategory::Average);
        }
    }

    #[test]
    fn test_search() {
        let master = MasterIndicatorCatalog::new();

        // Search for "moving average"
        let results = master.search("moving average");
        assert!(!results.is_empty(), "Should find moving average indicators");

        // Search for "RSI"
        let rsi_results = master.search("rsi");
        assert!(!rsi_results.is_empty(), "Should find RSI-related indicators");
    }

    #[test]
    fn test_contains() {
        let master = MasterIndicatorCatalog::new();

        assert!(master.contains("SMA"), "Should contain SMA");
        assert!(master.contains("RSI"), "Should contain RSI");
        assert!(!master.contains("NONEXISTENT"), "Should not contain fake indicator");
    }

    #[test]
    fn test_category_count() {
        let master = MasterIndicatorCatalog::new();

        let avg_count = master.category_count(IndicatorCategory::Average);
        assert!(avg_count >= 20, "Average category should have 20+ indicators");

        let momentum_count = master.category_count(IndicatorCategory::Momentum);
        assert!(momentum_count >= 50, "Momentum category should have 50+ indicators");
    }

    #[test]
    fn test_stats() {
        let master = MasterIndicatorCatalog::new();
        let stats = master.stats();

        // total_categories tracks the registered sub-catalog count in `new()`.
        // It grows as new categories are exported, so assert structural invariants
        // (non-empty, consistent map size) rather than a frozen literal that drifts.
        assert!(stats.total_categories >= 23, "expected 23+ categories, got {}", stats.total_categories);
        assert_eq!(stats.category_counts.len(), stats.total_categories);
        assert!(stats.total_indicators > 430);
    }

    #[test]
    fn test_global_catalog() {
        // Access global catalog
        let master = catalog();

        assert!(master.total_count() > 430);
        assert!(master.contains("SMA"));
        assert!(master.contains("RSI"));
    }

    #[test]
    fn test_not_found_error() {
        let master = MasterIndicatorCatalog::new();

        let result = master.get_signature("FAKE_INDICATOR");
        assert!(result.is_err());

        match result {
            Err(CatalogError::NotFound(id)) => {
                assert_eq!(id, "FAKE_INDICATOR");
            }
            _ => panic!("Expected NotFound error"),
        }
    }
}