Skip to main content

flowsurface_data/chart/kline/indicators/
registry.rs

1use super::{
2    AvailabilityContext, IndicatorAvailability, IndicatorDefinition, IndicatorDefinitionError,
3    IndicatorHistoryRequirement, IndicatorInputRequirement, IndicatorInputSource, IndicatorSetting,
4    IndicatorSettingValue, IndicatorSettingsContext, IndicatorUnsupportedReason,
5};
6use crate::chart::Basis;
7use crate::util::stable_hash;
8use exchange::UnixMs;
9use indicator_engine::CompiledIndicator;
10use rustc_hash::{FxHashMap, FxHashSet};
11use serde::{Deserialize, Serialize};
12use std::fmt::{Display, Formatter};
13use std::sync::Arc;
14use std::sync::OnceLock;
15
16static CATALOG: OnceLock<IndicatorCatalog> = OnceLock::new();
17static EMPTY_CATALOG: std::sync::LazyLock<IndicatorCatalog> =
18    std::sync::LazyLock::new(|| IndicatorCatalog::from_registrations(Vec::new()));
19
20pub fn catalog() -> &'static IndicatorCatalog {
21    CATALOG.get().unwrap_or(&EMPTY_CATALOG)
22}
23
24/// Stable identity independent of how a source is distributed.
25#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
26pub struct IndicatorId(indicator_engine::IndicatorId);
27
28impl IndicatorId {
29    pub fn new(id: impl Into<String>) -> Result<Self, IndicatorIdError> {
30        indicator_engine::IndicatorId::new(id)
31            .map(Self)
32            .map_err(IndicatorIdError)
33    }
34
35    pub fn parse(id: &str) -> Result<Self, IndicatorIdError> {
36        Self::new(id)
37    }
38
39    pub fn id(&self) -> &str {
40        self.0.as_str()
41    }
42
43    pub fn as_str(&self) -> &str {
44        self.id()
45    }
46
47    pub fn default_instance_slot(&self) -> u64 {
48        stable_hash(self.id().as_bytes()) & 0x0000_7fff_ffff_ffff
49    }
50
51    pub fn is_registered(&self) -> bool {
52        catalog().get(self).is_some()
53    }
54
55    pub fn definition(&self) -> &'static IndicatorDefinition {
56        catalog()
57            .get(self)
58            .map(|registration| &registration.definition)
59            .unwrap_or_else(missing_definition)
60    }
61
62    pub fn default_config(&self) -> IndicatorConfig {
63        catalog()
64            .get(self)
65            .map(|registration| registration.default_config())
66            .unwrap_or_default()
67    }
68
69    fn normalized_config(&self, config: IndicatorConfig) -> IndicatorConfig {
70        catalog()
71            .get(self)
72            .map(|registration| registration.normalize_config(config.clone()))
73            .unwrap_or(config)
74    }
75
76    pub fn settings(
77        &self,
78        config: &IndicatorConfig,
79        context: IndicatorSettingsContext,
80    ) -> Result<Vec<IndicatorSetting>, IndicatorConfigError> {
81        catalog()
82            .get(self)
83            .ok_or_else(|| IndicatorConfigError::MissingIndicator(self.clone()))
84            .and_then(|registration| registration.settings(config.clone(), context))
85    }
86
87    pub fn with_setting(
88        &self,
89        config: IndicatorConfig,
90        id: &str,
91        value: IndicatorSettingValue,
92    ) -> Option<IndicatorConfig> {
93        catalog()
94            .get(self)
95            .and_then(|registration| registration.with_setting(config, id, value))
96    }
97
98    pub fn history_requirement(
99        &self,
100        config: IndicatorConfig,
101        basis: Basis,
102        reference_time: UnixMs,
103    ) -> IndicatorHistoryRequirement {
104        catalog()
105            .get(self)
106            .map(|registration| registration.history_requirement(config, basis, reference_time))
107            .unwrap_or_default()
108    }
109
110    pub(crate) fn history_requirement_with_warmup(
111        &self,
112        config: IndicatorConfig,
113        basis: Basis,
114        reference_time: UnixMs,
115        warmup_bars: Option<u64>,
116    ) -> IndicatorHistoryRequirement {
117        catalog()
118            .get(self)
119            .map(|registration| {
120                registration.history_requirement_with_warmup(
121                    config,
122                    basis,
123                    reference_time,
124                    warmup_bars,
125                )
126            })
127            .unwrap_or_default()
128    }
129
130    pub fn warmup_bars(&self, config: &IndicatorConfig) -> Option<u64> {
131        catalog()
132            .get(self)
133            .and_then(|registration| registration.warmup_bars(config))
134    }
135
136    pub fn unsupported_reason(
137        &self,
138        context: AvailabilityContext,
139    ) -> Option<IndicatorUnsupportedReason> {
140        let Some(registration) = catalog().get(self) else {
141            return Some(IndicatorUnsupportedReason::NotInstalled);
142        };
143        registration.unsupported_reason(context)
144    }
145
146    pub fn availability(&self, context: AvailabilityContext) -> IndicatorAvailability {
147        if let Some(reason) = self.unsupported_reason(context) {
148            return IndicatorAvailability::Unsupported(reason);
149        }
150        IndicatorAvailability::Available
151    }
152
153    pub fn declared_input_requirements(&self) -> Vec<IndicatorInputRequirement> {
154        self.definition().inputs.to_vec()
155    }
156
157    pub fn output_channels(&self) -> &'static [super::OutputChannelSpec] {
158        catalog()
159            .get(self)
160            .map(|registration| registration.definition.outputs.as_slice())
161            .unwrap_or(&[])
162    }
163
164    pub fn default_view(&self) -> &'static super::IndicatorPanelRecipe {
165        self.definition().default_view()
166    }
167
168    pub fn requires_trade_data(&self) -> bool {
169        self.definition().requires(IndicatorInputSource::Trades)
170    }
171
172    pub fn color_specs(&self) -> &'static [super::IndicatorColorSpec] {
173        catalog()
174            .get(self)
175            .map(|registration| registration.definition.style_slots.as_slice())
176            .unwrap_or(&[])
177    }
178
179    pub fn mark_specs(&self) -> &'static [super::IndicatorMarkSpec] {
180        catalog()
181            .get(self)
182            .map(|registration| registration.definition.mark_specs.as_slice())
183            .unwrap_or(&[])
184    }
185
186    pub fn segment_specs(&self) -> &'static [super::IndicatorSegmentSpec] {
187        catalog()
188            .get(self)
189            .map(|registration| registration.definition.segment_specs.as_slice())
190            .unwrap_or(&[])
191    }
192
193    pub fn region_style_slots(&self) -> &'static [String] {
194        catalog()
195            .get(self)
196            .map(|registration| registration.definition.region_style_slots.as_slice())
197            .unwrap_or(&[])
198    }
199
200    pub fn mark_source_channel(&self, mark: super::MarkKind) -> Option<&'static str> {
201        self.mark_spec(mark)
202            .map(|spec| spec.source_channel.as_str())
203    }
204
205    pub fn mark_spec(&self, mark: super::MarkKind) -> Option<&'static super::IndicatorMarkSpec> {
206        self.mark_specs().iter().find(|spec| spec.mark == mark)
207    }
208
209    pub fn fallback_mark_for(&self, mark: super::MarkKind) -> Option<super::MarkKind> {
210        self.mark_spec(mark).and_then(|spec| spec.fallback_mark)
211    }
212
213    pub fn overlay_channels(&self) -> &'static [super::IndicatorOverlayChannelSpec] {
214        catalog()
215            .get(self)
216            .map(|registration| registration.definition.overlay_channels.as_slice())
217            .unwrap_or(&[])
218    }
219
220    pub fn readout_specs(&self) -> &'static [super::IndicatorReadoutSpec] {
221        match self.default_view() {
222            super::IndicatorPanelRecipe::Plot { readouts, .. } => readouts,
223            _ => &[],
224        }
225    }
226
227    pub fn fill_specs(&self) -> &'static [super::IndicatorFillSpec] {
228        catalog()
229            .get(self)
230            .map(|registration| registration.definition.fill_specs.as_slice())
231            .unwrap_or(&[])
232    }
233
234    pub fn mark_uses_style_slot(&self, mark: super::MarkKind, slot: &str) -> bool {
235        self.mark_specs().iter().any(|spec| {
236            spec.mark == mark
237                && (spec.color_slot == slot
238                    || spec.true_color_slot.as_deref() == Some(slot)
239                    || spec.false_color_slot.as_deref() == Some(slot)
240                    || spec.highlight_color_slot.as_deref() == Some(slot))
241        })
242    }
243}
244
245impl Display for IndicatorId {
246    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
247        if let Some(registration) = catalog().get(self) {
248            return formatter.write_str(&registration.definition.display_name);
249        }
250        formatter.write_str(self.id())
251    }
252}
253
254impl Serialize for IndicatorId {
255    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
256    where
257        S: serde::Serializer,
258    {
259        serializer.serialize_str(self.id())
260    }
261}
262
263impl<'de> Deserialize<'de> for IndicatorId {
264    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
265    where
266        D: serde::Deserializer<'de>,
267    {
268        let id = String::deserialize(deserializer)?;
269        Self::parse(&id).map_err(serde::de::Error::custom)
270    }
271}
272
273#[derive(Debug, Clone, PartialEq, Eq)]
274pub struct IndicatorIdError(String);
275
276impl Display for IndicatorIdError {
277    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
278        formatter.write_str(&self.0)
279    }
280}
281
282impl std::error::Error for IndicatorIdError {}
283
284impl std::str::FromStr for IndicatorId {
285    type Err = IndicatorIdError;
286
287    fn from_str(id: &str) -> Result<Self, Self::Err> {
288        Self::parse(id)
289    }
290}
291
292/// Serialized configuration owned by an indicator instance.
293#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
294#[serde(transparent)]
295pub struct IndicatorConfig(serde_json::Value);
296
297impl Default for IndicatorConfig {
298    fn default() -> Self {
299        Self(serde_json::Value::Object(serde_json::Map::new()))
300    }
301}
302
303impl IndicatorConfig {
304    pub fn default_for(indicator: &IndicatorId) -> Self {
305        indicator.default_config()
306    }
307
308    pub fn from_typed<T: Serialize>(config: &T) -> Result<Self, IndicatorConfigError> {
309        serde_json::to_value(config)
310            .map(Self)
311            .map_err(|error| IndicatorConfigError::Serialize(error.to_string()))
312    }
313
314    pub fn from_value(value: serde_json::Value) -> Self {
315        Self(value)
316    }
317
318    pub fn into_value(self) -> serde_json::Value {
319        self.0
320    }
321
322    pub fn value(&self) -> &serde_json::Value {
323        &self.0
324    }
325
326    pub fn is_valid_for(&self, indicator: &IndicatorId) -> bool {
327        catalog().get(indicator).is_some_and(|registration| {
328            registration
329                .validate_config(self)
330                .inspect_err(|error| {
331                    log::warn!(
332                        "Rejecting indicator {} configuration: {error}",
333                        indicator.id()
334                    );
335                })
336                .is_ok()
337        })
338    }
339
340    pub fn normalized_for(self, indicator: &IndicatorId) -> Self {
341        indicator.normalized_config(self)
342    }
343
344    pub fn settings_for(
345        &self,
346        indicator: &IndicatorId,
347        context: IndicatorSettingsContext,
348    ) -> Result<Vec<IndicatorSetting>, IndicatorConfigError> {
349        indicator.settings(self, context)
350    }
351
352    pub fn with_setting_for(
353        self,
354        indicator: &IndicatorId,
355        id: &str,
356        value: IndicatorSettingValue,
357    ) -> Option<Self> {
358        indicator.with_setting(self, id, value)
359    }
360
361    pub fn history_requirement_for(
362        self,
363        indicator: &IndicatorId,
364        basis: Basis,
365        reference_time: UnixMs,
366    ) -> IndicatorHistoryRequirement {
367        indicator.history_requirement(self, basis, reference_time)
368    }
369}
370
371#[derive(Debug, Clone, PartialEq, Eq)]
372pub enum IndicatorConfigError {
373    MissingIndicator(IndicatorId),
374    Deserialize(String),
375    Serialize(String),
376    InvalidDefinition(IndicatorDefinitionError),
377}
378
379impl Display for IndicatorConfigError {
380    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
381        match self {
382            Self::MissingIndicator(id) => {
383                write!(formatter, "indicator {} is not installed", id.id())
384            }
385            Self::Deserialize(error) => {
386                write!(formatter, "invalid indicator configuration: {error}")
387            }
388            Self::Serialize(error) => write!(
389                formatter,
390                "could not serialize indicator configuration: {error}"
391            ),
392            Self::InvalidDefinition(error) => {
393                write!(formatter, "invalid indicator definition: {error:?}")
394            }
395        }
396    }
397}
398
399impl std::error::Error for IndicatorConfigError {}
400
401#[derive(Clone)]
402pub struct IndicatorRegistration {
403    /// Stable source ID used in persistence and catalog lookup.
404    pub id: IndicatorId,
405    pub origin: super::language::PackageOrigin,
406    /// Renderer and feed metadata shared by every instance of the indicator.
407    pub definition: IndicatorDefinition,
408    pub(super) compiled: Arc<CompiledIndicator>,
409}
410
411impl std::fmt::Debug for IndicatorRegistration {
412    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
413        formatter
414            .debug_struct("IndicatorRegistration")
415            .field("id", &self.id)
416            .field("origin", &self.origin)
417            .field("definition", &self.definition)
418            .finish_non_exhaustive()
419    }
420}
421
422impl IndicatorRegistration {
423    pub(super) fn from_parts(
424        id: IndicatorId,
425        origin: super::language::PackageOrigin,
426        definition: IndicatorDefinition,
427        compiled: Arc<CompiledIndicator>,
428    ) -> Self {
429        Self {
430            id,
431            origin,
432            definition,
433            compiled,
434        }
435    }
436}
437
438#[derive(Debug, Clone, PartialEq, Eq)]
439pub enum CatalogInstallError {
440    AlreadyInitialized,
441    Diagnostics(Vec<String>),
442}
443
444impl Display for CatalogInstallError {
445    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
446        match self {
447            Self::AlreadyInitialized => {
448                formatter.write_str("indicator catalog is already initialized")
449            }
450            Self::Diagnostics(diagnostics) => write!(formatter, "{}", diagnostics.join("\n")),
451        }
452    }
453}
454
455impl std::error::Error for CatalogInstallError {}
456
457/// Install a catalog while retaining source-loader diagnostics alongside
458/// validation diagnostics. Valid registrations are installed and the rejected source remains
459/// visible through `IndicatorCatalog::diagnostics`.
460pub(crate) fn install_catalog_with_bundled_diagnostics(
461    bundled_registrations: Vec<IndicatorRegistration>,
462    installed_registrations: Vec<IndicatorRegistration>,
463    loader_diagnostics: Vec<String>,
464) -> Result<&'static IndicatorCatalog, CatalogInstallError> {
465    let (registrations, loader_diagnostics) = merge_bundled_and_installed(
466        bundled_registrations,
467        installed_registrations,
468        loader_diagnostics,
469    );
470    let catalog =
471        IndicatorCatalog::from_registrations_with_diagnostics(registrations, loader_diagnostics);
472    for diagnostic in &catalog.diagnostics {
473        log::warn!("indicator catalog: quarantined source: {diagnostic}");
474    }
475
476    CATALOG
477        .set(catalog)
478        .map_err(|_| CatalogInstallError::AlreadyInitialized)?;
479    Ok(CATALOG.get().expect("catalog was installed"))
480}
481
482fn merge_bundled_and_installed(
483    bundled_registrations: Vec<IndicatorRegistration>,
484    installed_registrations: Vec<IndicatorRegistration>,
485    mut diagnostics: Vec<String>,
486) -> (Vec<IndicatorRegistration>, Vec<String>) {
487    let mut registrations = bundled_registrations;
488    let bundled_ids = registrations
489        .iter()
490        .map(|registration| registration.id.clone())
491        .collect::<FxHashSet<_>>();
492    let mut seen_ids = bundled_ids.clone();
493
494    for registration in installed_registrations {
495        let id = registration.id.clone();
496        if !seen_ids.insert(id.clone()) {
497            let reason = if bundled_ids.contains(&id) {
498                "it is provided by the bundled catalog"
499            } else {
500                "an earlier installed source already provides it"
501            };
502            diagnostics.push(format!(
503                "installed indicator '{}' ignored because {reason}",
504                id.id()
505            ));
506            log::warn!(
507                "indicator catalog: ignoring later installed source '{}'",
508                id.id()
509            );
510            continue;
511        }
512        registrations.push(registration);
513    }
514
515    (registrations, diagnostics)
516}
517
518#[derive(Debug)]
519pub struct IndicatorCatalog {
520    registrations: Vec<IndicatorRegistration>,
521    by_id: FxHashMap<IndicatorId, usize>,
522    diagnostics: Vec<String>,
523}
524
525impl IndicatorCatalog {
526    /// Build an isolated catalog for validation or integration tests.
527    fn from_registrations(registrations: Vec<IndicatorRegistration>) -> Self {
528        Self::from_registrations_with_diagnostics(registrations, Vec::new())
529    }
530
531    fn from_registrations_with_diagnostics(
532        mut registrations: Vec<IndicatorRegistration>,
533        mut diagnostics: Vec<String>,
534    ) -> Self {
535        registrations.sort_by(|left, right| left.id.id().cmp(right.id.id()));
536
537        let mut valid = Vec::with_capacity(registrations.len());
538        let mut by_id = FxHashMap::default();
539        for registration in registrations {
540            if let Err(error) = IndicatorId::parse(registration.id.id()) {
541                diagnostics.push(format!(
542                    "{}: invalid indicator ID: {error}",
543                    registration.id.id()
544                ));
545                continue;
546            }
547            let id = registration.id.clone();
548            if by_id.contains_key(&id) {
549                diagnostics.push(format!("duplicate indicator ID: {}", registration.id.id()));
550                continue;
551            }
552            let default_config = registration.default_config();
553            if let Err(error) = registration.validate_config(&default_config) {
554                diagnostics.push(format!(
555                    "{}: invalid default configuration: {error}",
556                    registration.id.id()
557                ));
558                continue;
559            }
560            let index = valid.len();
561            valid.push(registration);
562            by_id.insert(id, index);
563        }
564        for diagnostic in &diagnostics {
565            log::error!("indicator catalog: {diagnostic}");
566        }
567        Self {
568            registrations: valid,
569            by_id,
570            diagnostics,
571        }
572    }
573
574    pub fn registrations(&self) -> impl ExactSizeIterator<Item = &IndicatorRegistration> + '_ {
575        self.registrations.iter()
576    }
577
578    pub fn ids(&self) -> impl ExactSizeIterator<Item = IndicatorId> + '_ {
579        self.registrations
580            .iter()
581            .map(|registration| registration.id.clone())
582    }
583
584    pub fn get(&self, id: &IndicatorId) -> Option<&IndicatorRegistration> {
585        self.by_id
586            .get(id)
587            .and_then(|index| self.registrations.get(*index))
588    }
589
590    pub fn diagnostics(&self) -> &[String] {
591        &self.diagnostics
592    }
593}
594
595pub fn all_indicators() -> Vec<IndicatorId> {
596    catalog().ids().collect()
597}
598
599pub fn indicator_definition(indicator: &IndicatorId) -> &'static IndicatorDefinition {
600    indicator.definition()
601}
602
603pub fn declared_input_requirements(indicator: &IndicatorId) -> Vec<IndicatorInputRequirement> {
604    indicator.declared_input_requirements()
605}
606
607pub fn display_name(indicator: &IndicatorId) -> &'static str {
608    &indicator.definition().display_name
609}
610
611pub fn panel_recipe(indicator: &IndicatorId) -> &'static super::IndicatorPanelRecipe {
612    indicator.default_view()
613}
614
615pub fn requires_trade_data(indicator: &IndicatorId) -> bool {
616    indicator.requires_trade_data()
617}
618
619pub fn table_columns(indicator: &IndicatorId) -> &'static [super::TableColumnSpec] {
620    match &indicator.definition().panel_recipe {
621        super::IndicatorPanelRecipe::Table { columns, .. } => columns,
622        _ => &[],
623    }
624}
625
626pub fn unsupported_reason(
627    indicator: &IndicatorId,
628    context: AvailabilityContext,
629) -> Option<IndicatorUnsupportedReason> {
630    indicator.unsupported_reason(context)
631}
632
633pub fn availability(
634    indicator: &IndicatorId,
635    context: AvailabilityContext,
636) -> IndicatorAvailability {
637    indicator.availability(context)
638}
639
640fn missing_definition() -> &'static IndicatorDefinition {
641    static MISSING_DEFINITION: OnceLock<IndicatorDefinition> = OnceLock::new();
642    MISSING_DEFINITION.get_or_init(|| {
643        IndicatorDefinition::new(
644            "Unavailable indicator",
645            Vec::new(),
646            Vec::new(),
647            super::IndicatorPanelRecipe::Plot {
648                panel_title: "Unavailable indicator".to_owned(),
649                layer_name: "Unavailable indicator".to_owned(),
650                value_precision: super::PanelValuePrecision::FixedStep(1.0),
651                value_label_policy: super::PanelValueLabelPolicy {
652                    axis_mode: super::super::composition::PanelValueLabelMode::Compact,
653                    header_mode: super::super::composition::PanelValueLabelMode::Commas,
654                    max_decimals: None,
655                },
656                preferred_scale: super::PanelScaleMode::FitVisible,
657                layers: Vec::new(),
658                readouts: Vec::new(),
659            },
660        )
661    })
662}
663
664#[cfg(test)]
665mod tests {
666    use super::*;
667
668    #[test]
669    fn ids_validate_publisher_namespaces() {
670        for id in ["community.ema-cross", "core.custom", "catalog.rsi"] {
671            let parsed = IndicatorId::parse(id).expect("valid publisher ID");
672            let serialized = serde_json::to_string(&parsed).unwrap();
673            assert_eq!(
674                serde_json::from_str::<IndicatorId>(&serialized).unwrap(),
675                parsed
676            );
677        }
678        for id in ["ema-cross", "Community.EMA"] {
679            assert!(IndicatorId::parse(id).is_err());
680            assert!(serde_json::from_str::<IndicatorId>(&format!("{id:?}")).is_err());
681        }
682    }
683
684    #[test]
685    fn duplicate_registrations_are_excluded() {
686        let scripted = super::super::language::repository_registrations()
687            .into_iter()
688            .find(|registration| registration.id.id() == "core.rsi")
689            .expect("repository RSI registration");
690        let registrations = vec![scripted.clone(), scripted];
691        let catalog = IndicatorCatalog::from_registrations(registrations);
692        assert_eq!(catalog.registrations().len(), 1);
693        assert!(
694            catalog
695                .diagnostics()
696                .iter()
697                .any(|diagnostic| diagnostic.contains("duplicate"))
698        );
699    }
700
701    #[test]
702    fn bundled_wins_and_installed_sources_keep_first_registration() {
703        let registrations = super::super::language::repository_registrations();
704        let core = registrations
705            .iter()
706            .find(|registration| registration.id.id() == "core.volume")
707            .expect("repository Volume registration")
708            .clone();
709        let rsi = registrations
710            .iter()
711            .find(|registration| registration.id.id() == "core.rsi")
712            .expect("repository RSI registration");
713        let mut first_installed = rsi.clone();
714        first_installed.origin = super::super::language::PackageOrigin::Installed;
715        first_installed.definition = IndicatorDefinition {
716            display_name: "First installed registration".to_owned(),
717            ..first_installed.definition
718        };
719        let mut later_installed = rsi.clone();
720        later_installed.origin = super::super::language::PackageOrigin::Installed;
721        later_installed.definition = IndicatorDefinition {
722            display_name: "Later installed registration".to_owned(),
723            ..later_installed.definition
724        };
725
726        let (merged, diagnostics) = merge_bundled_and_installed(
727            vec![core.clone()],
728            vec![core, first_installed, later_installed],
729            Vec::new(),
730        );
731        let catalog = IndicatorCatalog::from_registrations_with_diagnostics(merged, diagnostics);
732
733        assert_eq!(
734            catalog
735                .registrations()
736                .map(|registration| registration.id.id())
737                .collect::<Vec<_>>(),
738            vec!["core.rsi", "core.volume"]
739        );
740        assert_eq!(
741            catalog
742                .get(&IndicatorId::parse("core.volume").unwrap())
743                .expect("core Volume should be registered")
744                .definition
745                .display_name,
746            "Volume"
747        );
748        assert_eq!(
749            catalog
750                .get(&IndicatorId::parse("core.rsi").unwrap())
751                .expect("first installed RSI should be registered")
752                .definition
753                .display_name,
754            "First installed registration"
755        );
756        assert!(
757            catalog
758                .diagnostics()
759                .iter()
760                .any(|diagnostic| diagnostic.contains("provided by the bundled catalog"))
761        );
762        assert!(
763            catalog
764                .diagnostics()
765                .iter()
766                .any(|diagnostic| diagnostic
767                    .contains("earlier installed source already provides it"))
768        );
769    }
770}