Skip to main content

kestrel_chartkit/
applicability.rs

1//! Plausibility check between what an indicator's calculation needs from a bar series
2//! ([`crate::applicability::DataRequirements`]) and what a series actually provides
3//! ([`crate::model::SeriesCapabilities`]).
4//!
5//! `build_checked` (see [`crate::indicator::registry`]) validates *parameters* — periods in
6//! range, threshold ordering. It says nothing about whether the *data* fits the indicator: a
7//! volume-profile indicator run on tick-volume CFD data will compute a POC/VAH/VAL that looks
8//! plausible and measures nothing meaningful. Nothing in the type system prevents that. This
9//! module is the check that catches it: [`crate::applicability::check_applicability`] compares an
10//! indicator's declared [`crate::applicability::DataRequirements`] against a series'
11//! [`crate::model::SeriesCapabilities`] and returns a three-valued verdict
12//! ([`crate::applicability::Applicability`]) with factual, publication-ready explanations
13//! attached.
14//!
15//! (Note: this module-level doc comment uses fully-qualified `crate::` paths for its own items
16//! rather than bare names — rustdoc resolves intra-doc links here against the *crate-root* scope,
17//! not this module's, because `pub mod applicability;` in `lib.rs` also carries an outer `///`
18//! doc comment that gets merged with this file's `//!` comment into one doc block for the module.)
19
20#[cfg(feature = "serde")]
21use serde::{Deserialize, Serialize};
22
23use crate::model::{
24    ContinuityKind, LiquidityTier, PriceAdjustment, Provenance, SeriesCapabilities, VolumeKind,
25};
26
27/// What an indicator's calculation needs from the bar series it runs over, beyond plain OHLC
28/// prices. All-`false` (the [`Default`]) is the right value for pure price indicators — the
29/// effort of declaring requirements is only spent on the indicators where it matters.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
31#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
32pub struct DataRequirements {
33    /// Needs real traded turnover, not tick/update count (e.g. volume profile, VWAP).
34    pub needs_real_volume: bool,
35    /// Needs classified individual trades (buy/sell direction), not just aggregate volume.
36    pub needs_trade_direction: bool,
37    /// Result depends on where the session is cut (anchors, session extremes).
38    pub session_sensitive: bool,
39    /// Result depends on contract continuity (rolls mix liquidity pools / shift historical
40    /// levels).
41    pub roll_sensitive: bool,
42    /// Result depends on historical price levels matching what was actually traded (broken by
43    /// split/dividend adjustment).
44    pub adjustment_sensitive: bool,
45    /// Needs market-depth information beyond what a thin series can provide.
46    pub needs_liquidity_depth: bool,
47}
48
49/// Which rule in [`check_applicability`] produced a given [`ApplicabilityNote`].
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
52pub enum ApplicabilityReason {
53    /// `needs_real_volume` against [`VolumeKind::Tick`]: the field holds update frequency, not
54    /// turnover.
55    VolumeIsTickNotTurnover,
56    /// `needs_real_volume` against [`VolumeKind::None`]: the series has no volume at all.
57    NoVolumeAvailable,
58    /// `needs_trade_direction` against a series without classified trades.
59    NoTradeDirection,
60    /// `roll_sensitive` against [`ContinuityKind::StitchedBackAdjusted`]: historical levels are
61    /// not the traded ones.
62    BackAdjustedLevelsNotTraded,
63    /// `roll_sensitive` against [`ContinuityKind::StitchedUnadjusted`]: roll jumps in the series.
64    UnadjustedRollJumps,
65    /// `adjustment_sensitive` against a non-[`PriceAdjustment::Raw`] series: historical marks sit
66    /// at different numbers in the adjusted series.
67    AdjustedLevelsShifted,
68    /// `needs_liquidity_depth` against [`LiquidityTier::Thin`].
69    ThinLiquidity,
70    /// An extreme-checking indicator (`session_sensitive || roll_sensitive`) run on
71    /// [`Provenance::Broker`] data: the extreme is provider-specific and does not transfer.
72    ///
73    /// The plan's original rule is stated as "`provenance == Broker` for anything that checks
74    /// extremes", but `DataRequirements` has no dedicated "checks extremes" field. This
75    /// substitutes `session_sensitive || roll_sensitive` as that signal: those are exactly the
76    /// two fields set on the plan's own list of extreme-checking indicators (pivots, structure,
77    /// zigzag, Elliott), so no new field is introduced for a case the two existing fields already
78    /// cover.
79    ProviderSpecificExtreme,
80}
81
82/// A single applicability finding: which rule fired, and a publication-ready explanation.
83///
84/// `explanation` is meant to end up verbatim on a public docs page (see the plan's "Export für
85/// die Doku" section): factual, no superlatives, no performance claims.
86///
87/// Derives `Serialize` only, not `Deserialize`: the `&'static str` explanation field can only be
88/// deserialized into a `'static` lifetime, which conflicts with `serde_derive`'s generic `'de`
89/// once this type sits behind a `Vec` inside [`Applicability`] (a struct field of a concrete
90/// `&'static str` alone can derive `Deserialize` by adding a `'de: 'static` bound to its own impl,
91/// but that bound cannot be discharged once another type calls into it generically). These are
92/// computed diagnostic outputs, never something reconstructed from JSON, so this is not a loss.
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94#[cfg_attr(feature = "serde", derive(Serialize))]
95pub struct ApplicabilityNote {
96    pub reason: ApplicabilityReason,
97    pub explanation: &'static str,
98}
99
100/// Which of the two non-`Applicable` buckets a rule belongs to.
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
103pub enum ApplicabilityTier {
104    Degraded,
105    Unsuitable,
106}
107
108/// Static description of one rule in [`check_applicability`]'s table: which reason it produces,
109/// which tier it falls into, and its publication-ready explanation. [`rule_catalog`] exposes all
110/// of them for the doc export (see the plan's "Export für die Doku" section) — this is the single
111/// source both `check_applicability` and the export pull from, so the exported table can never
112/// drift from the rules actually enforced.
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114#[cfg_attr(feature = "serde", derive(Serialize))]
115pub struct RuleDescription {
116    pub reason: ApplicabilityReason,
117    pub tier: ApplicabilityTier,
118    pub explanation: &'static str,
119}
120
121/// Three-valued result of matching an indicator's [`DataRequirements`] against a series'
122/// [`SeriesCapabilities`].
123///
124/// Derives `Serialize` only, not `Deserialize` — see [`ApplicabilityNote`]'s doc comment.
125#[derive(Debug, Clone, PartialEq, Eq)]
126#[cfg_attr(feature = "serde", derive(Serialize))]
127pub enum Applicability {
128    Applicable,
129    /// The indicator still computes, but the result's meaning shifts (e.g. a relative-volume
130    /// measure computed on tick data is an activity measure, not a turnover measure).
131    Degraded {
132        reasons: Vec<ApplicabilityNote>,
133    },
134    /// The result would be misleading; computing it anyway should not happen silently.
135    Unsuitable {
136        reasons: Vec<ApplicabilityNote>,
137    },
138}
139
140// The rule table: one `RuleDescription` constant per rule, each naming the reason it produces,
141// its tier, and its publication-ready explanation. `check_applicability` below turns each into an
142// `ApplicabilityNote` when its condition holds; `rule_catalog` exposes the same constants for the
143// doc export — one source, so the exported table can't drift from the rules actually enforced.
144
145/// Rule 1: `needs_real_volume` && `volume == Tick` -> Unsuitable.
146const RULE_VOLUME_IS_TICK: RuleDescription = RuleDescription {
147    reason: ApplicabilityReason::VolumeIsTickNotTurnover,
148    tier: ApplicabilityTier::Unsuitable,
149    explanation: "zeigt Update-Häufigkeit, nicht Umsatz",
150};
151
152/// Rule 2: `needs_real_volume` && `volume == None` -> Unsuitable.
153const RULE_NO_VOLUME: RuleDescription = RuleDescription {
154    reason: ApplicabilityReason::NoVolumeAvailable,
155    tier: ApplicabilityTier::Unsuitable,
156    explanation: "die Serie hat kein Volumen; ein Index ist ein berechneter Wert — \
157        Volumen-Referenz wäre Future oder ETF",
158};
159
160/// Rule 3: `needs_trade_direction` && `!trade_direction` -> Unsuitable.
161///
162/// **Kein Registry-Indikator loest diese Regel derzeit aus** (Stand
163/// 2026-09-08): keiner deklariert `needs_trade_direction`, das einzige
164/// `true` im Quelltext steht in den Tests weiter unten. Das ist kein
165/// Versehen — auch `cvd` braucht sie nicht, weil es die Aggressorseite aus
166/// der Bar-Form schaetzt statt Abschlüsse zu klassifizieren.
167///
168/// Der Hinweis steht hier, damit ein Leser eine ungenutzte Regel nicht fuer
169/// eine seltene haelt. Eine echte Aggressorseiten-Rechnung braeuchte
170/// Tickdaten mit Geld- und Briefkurs zum Abschlusszeitpunkt; keine der
171/// angebundenen Quellen liefert das.
172const RULE_NO_TRADE_DIRECTION: RuleDescription = RuleDescription {
173    reason: ApplicabilityReason::NoTradeDirection,
174    tier: ApplicabilityTier::Unsuitable,
175    explanation: "die Serie enthält keine klassifizierten Einzeltrades, nur aggregierte Bars",
176};
177
178/// Rule 4: `roll_sensitive` && `continuity == StitchedBackAdjusted` -> Degraded.
179const RULE_BACK_ADJUSTED: RuleDescription = RuleDescription {
180    reason: ApplicabilityReason::BackAdjustedLevelsNotTraded,
181    tier: ApplicabilityTier::Degraded,
182    explanation: "historische Niveaus sind nicht die gehandelten",
183};
184
185/// Rule 5: `roll_sensitive` && `continuity == StitchedUnadjusted` -> Degraded.
186const RULE_UNADJUSTED_ROLL: RuleDescription = RuleDescription {
187    reason: ApplicabilityReason::UnadjustedRollJumps,
188    tier: ApplicabilityTier::Degraded,
189    explanation: "Roll-Sprünge in der Reihe",
190};
191
192/// Rule 6: `adjustment_sensitive` && `price_adjustment != Raw` -> Degraded.
193const RULE_ADJUSTED_SHIFTED: RuleDescription = RuleDescription {
194    reason: ApplicabilityReason::AdjustedLevelsShifted,
195    tier: ApplicabilityTier::Degraded,
196    explanation: "historische Marken liegen in der bereinigten Reihe an anderen Zahlen",
197};
198
199/// Rule 7: `needs_liquidity_depth` && `liquidity_tier == Thin` -> Degraded.
200const RULE_THIN_LIQUIDITY: RuleDescription = RuleDescription {
201    reason: ApplicabilityReason::ThinLiquidity,
202    tier: ApplicabilityTier::Degraded,
203    explanation: "geringe Liquidität; Markttiefe für diese Kennzahl nicht ausreichend abgebildet",
204};
205
206/// Rule 8: `(session_sensitive || roll_sensitive)` && `provenance == Broker` -> Degraded.
207/// See [`ApplicabilityReason::ProviderSpecificExtreme`] for why this signal substitutes for the
208/// plan's unimplemented "checks extremes" field.
209const RULE_PROVIDER_SPECIFIC_EXTREME: RuleDescription = RuleDescription {
210    reason: ApplicabilityReason::ProviderSpecificExtreme,
211    tier: ApplicabilityTier::Degraded,
212    explanation: "Extreme sind anbieterspezifisch, eine Auszeichnung ist nicht übertragbar",
213};
214
215/// All rules in [`check_applicability`]'s table, in the same order they are evaluated — the
216/// source the doc export (see the plan's "Export für die Doku" section) reads from.
217pub fn rule_catalog() -> [RuleDescription; 8] {
218    [
219        RULE_VOLUME_IS_TICK,
220        RULE_NO_VOLUME,
221        RULE_NO_TRADE_DIRECTION,
222        RULE_BACK_ADJUSTED,
223        RULE_UNADJUSTED_ROLL,
224        RULE_ADJUSTED_SHIFTED,
225        RULE_THIN_LIQUIDITY,
226        RULE_PROVIDER_SPECIFIC_EXTREME,
227    ]
228}
229
230fn note_from(rule: RuleDescription) -> ApplicabilityNote {
231    ApplicabilityNote {
232        reason: rule.reason,
233        explanation: rule.explanation,
234    }
235}
236
237/// Matches `requirements` against `capabilities` and classifies the result.
238///
239/// Collects *all* matching notes across all rules (does not stop at the first match), then
240/// classifies: any note produced by an "Unsuitable" rule (1-3 above) makes the whole result
241/// [`Applicability::Unsuitable`], carrying every note (Unsuitable and Degraded) found; otherwise
242/// any note from a "Degraded" rule (4-8 above) makes it [`Applicability::Degraded`]; otherwise
243/// [`Applicability::Applicable`].
244pub fn check_applicability(
245    requirements: &DataRequirements,
246    capabilities: &SeriesCapabilities,
247) -> Applicability {
248    let mut unsuitable_notes = Vec::new();
249    let mut degraded_notes = Vec::new();
250
251    if requirements.needs_real_volume && capabilities.volume == VolumeKind::Tick {
252        unsuitable_notes.push(note_from(RULE_VOLUME_IS_TICK));
253    }
254
255    if requirements.needs_real_volume && capabilities.volume == VolumeKind::None {
256        unsuitable_notes.push(note_from(RULE_NO_VOLUME));
257    }
258
259    if requirements.needs_trade_direction && !capabilities.trade_direction {
260        unsuitable_notes.push(note_from(RULE_NO_TRADE_DIRECTION));
261    }
262
263    if requirements.roll_sensitive
264        && capabilities.continuity == ContinuityKind::StitchedBackAdjusted
265    {
266        degraded_notes.push(note_from(RULE_BACK_ADJUSTED));
267    }
268
269    if requirements.roll_sensitive && capabilities.continuity == ContinuityKind::StitchedUnadjusted
270    {
271        degraded_notes.push(note_from(RULE_UNADJUSTED_ROLL));
272    }
273
274    if requirements.adjustment_sensitive && capabilities.price_adjustment != PriceAdjustment::Raw {
275        degraded_notes.push(note_from(RULE_ADJUSTED_SHIFTED));
276    }
277
278    if requirements.needs_liquidity_depth && capabilities.liquidity_tier == LiquidityTier::Thin {
279        degraded_notes.push(note_from(RULE_THIN_LIQUIDITY));
280    }
281
282    if (requirements.session_sensitive || requirements.roll_sensitive)
283        && capabilities.provenance == Provenance::Broker
284    {
285        degraded_notes.push(note_from(RULE_PROVIDER_SPECIFIC_EXTREME));
286    }
287
288    if !unsuitable_notes.is_empty() {
289        unsuitable_notes.extend(degraded_notes);
290        Applicability::Unsuitable {
291            reasons: unsuitable_notes,
292        }
293    } else if !degraded_notes.is_empty() {
294        Applicability::Degraded {
295            reasons: degraded_notes,
296        }
297    } else {
298        Applicability::Applicable
299    }
300}
301
302/// Looks up the [`DataRequirements`] for a registry/catalog indicator name (case-insensitive,
303/// matching the `.to_lowercase()` convention used by
304/// [`crate::indicator::registry::build_checked`]/`build_typed`).
305///
306/// This is a separate lookup rather than a field on
307/// [`crate::indicator::registry::IndicatorCatalogEntry`] because two indicators that need
308/// requirements here (`elliott`, `swing_structure`) are not catalog/registry entries at all —
309/// they are excluded from the single-bar registry due to their call signature (see the note above
310/// `CANONICAL_INDICATOR_NAMES` in `src/indicator/registry.rs`). A standalone function is the only
311/// way to give catalog and non-catalog indicators one shared source of truth without forcing a
312/// signature change on those two engines.
313///
314/// Names not listed below (including all pure price indicators) return
315/// [`DataRequirements::default()`] — deliberately conservative: `needs_trade_direction` and
316/// `needs_liquidity_depth` are left `false` everywhere for now, since no indicator covered by the
317/// plan is described as needing actual classified-trade data (bar-derived CVD is a documented
318/// heuristic, not a hard requirement) or is named as needing liquidity-depth data.
319pub fn data_requirements(name: &str) -> DataRequirements {
320    match name.to_lowercase().as_str() {
321        "volume_profile"
322        | "extended_volume_profile"
323        | "persistent_volume_profile"
324        | "money_flow_profile" => DataRequirements {
325            needs_real_volume: true,
326            roll_sensitive: true,
327            ..DataRequirements::default()
328        },
329        "vwap" | "anchored_vwap" | "rvat" => DataRequirements {
330            needs_real_volume: true,
331            session_sensitive: true,
332            ..DataRequirements::default()
333        },
334        "cvd" | "buy_sell_pressure" | "efi" | "mfi" | "eom" | "nvi" | "pvi" | "pvt" | "klinger"
335        | "chaikin_oscillator" | "elder_ray" | "midas" => DataRequirements {
336            needs_real_volume: true,
337            ..DataRequirements::default()
338        },
339        "pivot_sets" | "pivots_structure" | "zigzag" | "zigzag_advanced" | "elliott"
340        | "swing_structure" => DataRequirements {
341            session_sensitive: true,
342            roll_sensitive: true,
343            adjustment_sensitive: true,
344            ..DataRequirements::default()
345        },
346        _ => DataRequirements::default(),
347    }
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353    use crate::model::SessionKind;
354
355    /// A neutral baseline: deep, exchange-provenance, regular-session, single-contract, raw,
356    /// real-turnover series. Individual tests override only the field(s) relevant to the rule
357    /// under test.
358    fn baseline_capabilities() -> SeriesCapabilities {
359        SeriesCapabilities {
360            volume: VolumeKind::RealTurnover,
361            trade_direction: false,
362            session: SessionKind::Regular,
363            continuity: ContinuityKind::SingleContract,
364            price_adjustment: PriceAdjustment::Raw,
365            provenance: Provenance::Exchange,
366            liquidity_tier: LiquidityTier::Deep,
367        }
368    }
369
370    fn only_unsuitable(applicability: &Applicability) -> &[ApplicabilityNote] {
371        match applicability {
372            Applicability::Unsuitable { reasons } => reasons,
373            other => panic!("expected Unsuitable, got {other:?}"),
374        }
375    }
376
377    fn only_degraded(applicability: &Applicability) -> &[ApplicabilityNote] {
378        match applicability {
379            Applicability::Degraded { reasons } => reasons,
380            other => panic!("expected Degraded, got {other:?}"),
381        }
382    }
383
384    #[test]
385    fn rule1_real_volume_on_tick_is_unsuitable() {
386        let requirements = DataRequirements {
387            needs_real_volume: true,
388            ..DataRequirements::default()
389        };
390        let capabilities = SeriesCapabilities {
391            volume: VolumeKind::Tick,
392            ..baseline_capabilities()
393        };
394        let result = check_applicability(&requirements, &capabilities);
395        let reasons = only_unsuitable(&result);
396        assert!(reasons
397            .iter()
398            .any(|n| n.reason == ApplicabilityReason::VolumeIsTickNotTurnover));
399    }
400
401    #[test]
402    fn rule2_real_volume_on_none_is_unsuitable() {
403        let requirements = DataRequirements {
404            needs_real_volume: true,
405            ..DataRequirements::default()
406        };
407        let capabilities = SeriesCapabilities {
408            volume: VolumeKind::None,
409            ..baseline_capabilities()
410        };
411        let result = check_applicability(&requirements, &capabilities);
412        let reasons = only_unsuitable(&result);
413        assert!(reasons
414            .iter()
415            .any(|n| n.reason == ApplicabilityReason::NoVolumeAvailable));
416    }
417
418    #[test]
419    fn rule3_trade_direction_missing_is_unsuitable() {
420        let requirements = DataRequirements {
421            needs_trade_direction: true,
422            ..DataRequirements::default()
423        };
424        let capabilities = SeriesCapabilities {
425            trade_direction: false,
426            ..baseline_capabilities()
427        };
428        let result = check_applicability(&requirements, &capabilities);
429        let reasons = only_unsuitable(&result);
430        assert!(reasons
431            .iter()
432            .any(|n| n.reason == ApplicabilityReason::NoTradeDirection));
433    }
434
435    #[test]
436    fn rule4_roll_sensitive_on_back_adjusted_is_degraded() {
437        let requirements = DataRequirements {
438            roll_sensitive: true,
439            ..DataRequirements::default()
440        };
441        let capabilities = SeriesCapabilities {
442            continuity: ContinuityKind::StitchedBackAdjusted,
443            ..baseline_capabilities()
444        };
445        let result = check_applicability(&requirements, &capabilities);
446        let reasons = only_degraded(&result);
447        assert!(reasons
448            .iter()
449            .any(|n| n.reason == ApplicabilityReason::BackAdjustedLevelsNotTraded));
450    }
451
452    #[test]
453    fn rule5_roll_sensitive_on_unadjusted_stitched_is_degraded() {
454        let requirements = DataRequirements {
455            roll_sensitive: true,
456            ..DataRequirements::default()
457        };
458        let capabilities = SeriesCapabilities {
459            continuity: ContinuityKind::StitchedUnadjusted,
460            ..baseline_capabilities()
461        };
462        let result = check_applicability(&requirements, &capabilities);
463        let reasons = only_degraded(&result);
464        assert!(reasons
465            .iter()
466            .any(|n| n.reason == ApplicabilityReason::UnadjustedRollJumps));
467    }
468
469    #[test]
470    fn rule6_adjustment_sensitive_on_adjusted_series_is_degraded() {
471        let requirements = DataRequirements {
472            adjustment_sensitive: true,
473            ..DataRequirements::default()
474        };
475        let capabilities = SeriesCapabilities {
476            price_adjustment: PriceAdjustment::SplitAndDividend,
477            ..baseline_capabilities()
478        };
479        let result = check_applicability(&requirements, &capabilities);
480        let reasons = only_degraded(&result);
481        assert!(reasons
482            .iter()
483            .any(|n| n.reason == ApplicabilityReason::AdjustedLevelsShifted));
484    }
485
486    #[test]
487    fn rule7_liquidity_depth_on_thin_series_is_degraded() {
488        let requirements = DataRequirements {
489            needs_liquidity_depth: true,
490            ..DataRequirements::default()
491        };
492        let capabilities = SeriesCapabilities {
493            liquidity_tier: LiquidityTier::Thin,
494            ..baseline_capabilities()
495        };
496        let result = check_applicability(&requirements, &capabilities);
497        let reasons = only_degraded(&result);
498        assert!(reasons
499            .iter()
500            .any(|n| n.reason == ApplicabilityReason::ThinLiquidity));
501    }
502
503    #[test]
504    fn rule8_extreme_checking_on_broker_data_is_degraded() {
505        let requirements = DataRequirements {
506            session_sensitive: true,
507            ..DataRequirements::default()
508        };
509        let capabilities = SeriesCapabilities {
510            provenance: Provenance::Broker,
511            ..baseline_capabilities()
512        };
513        let result = check_applicability(&requirements, &capabilities);
514        let reasons = only_degraded(&result);
515        assert!(reasons
516            .iter()
517            .any(|n| n.reason == ApplicabilityReason::ProviderSpecificExtreme));
518
519        // roll_sensitive alone is also a valid trigger for the same rule.
520        let requirements2 = DataRequirements {
521            roll_sensitive: true,
522            ..DataRequirements::default()
523        };
524        let result2 = check_applicability(&requirements2, &capabilities);
525        let reasons2 = only_degraded(&result2);
526        assert!(reasons2
527            .iter()
528            .any(|n| n.reason == ApplicabilityReason::ProviderSpecificExtreme));
529    }
530
531    #[test]
532    fn no_requirements_is_always_applicable() {
533        let requirements = DataRequirements::default();
534        let capabilities_variants = [
535            baseline_capabilities(),
536            SeriesCapabilities {
537                volume: VolumeKind::None,
538                continuity: ContinuityKind::StitchedBackAdjusted,
539                price_adjustment: PriceAdjustment::SplitAndDividend,
540                provenance: Provenance::Broker,
541                liquidity_tier: LiquidityTier::Thin,
542                ..baseline_capabilities()
543            },
544        ];
545        for capabilities in capabilities_variants {
546            assert_eq!(
547                check_applicability(&requirements, &capabilities),
548                Applicability::Applicable
549            );
550        }
551    }
552
553    #[test]
554    fn multiple_violations_collect_into_one_result_with_all_notes() {
555        // needs_real_volume (Unsuitable) + roll_sensitive (Degraded, twice-eligible collapsed to
556        // whichever continuity is set) + adjustment_sensitive (Degraded).
557        let requirements = DataRequirements {
558            needs_real_volume: true,
559            roll_sensitive: true,
560            adjustment_sensitive: true,
561            ..DataRequirements::default()
562        };
563        let capabilities = SeriesCapabilities {
564            volume: VolumeKind::Tick,
565            continuity: ContinuityKind::StitchedBackAdjusted,
566            price_adjustment: PriceAdjustment::Split,
567            ..baseline_capabilities()
568        };
569        let result = check_applicability(&requirements, &capabilities);
570        let reasons = only_unsuitable(&result);
571        assert_eq!(reasons.len(), 3);
572        assert!(reasons
573            .iter()
574            .any(|n| n.reason == ApplicabilityReason::VolumeIsTickNotTurnover));
575        assert!(reasons
576            .iter()
577            .any(|n| n.reason == ApplicabilityReason::BackAdjustedLevelsNotTraded));
578        assert!(reasons
579            .iter()
580            .any(|n| n.reason == ApplicabilityReason::AdjustedLevelsShifted));
581    }
582
583    #[test]
584    fn data_requirements_maps_known_names() {
585        assert_eq!(
586            data_requirements("volume_profile"),
587            DataRequirements {
588                needs_real_volume: true,
589                roll_sensitive: true,
590                ..DataRequirements::default()
591            }
592        );
593        assert_eq!(
594            data_requirements("VWAP"),
595            DataRequirements {
596                needs_real_volume: true,
597                session_sensitive: true,
598                ..DataRequirements::default()
599            }
600        );
601        assert_eq!(
602            data_requirements("elliott"),
603            DataRequirements {
604                session_sensitive: true,
605                roll_sensitive: true,
606                adjustment_sensitive: true,
607                ..DataRequirements::default()
608            }
609        );
610    }
611
612    #[test]
613    fn data_requirements_unmapped_name_is_default() {
614        assert_eq!(data_requirements("rsi"), DataRequirements::default());
615    }
616
617    #[test]
618    fn rule_catalog_has_one_entry_per_reason_with_matching_tier() {
619        let rules = rule_catalog();
620        assert_eq!(rules.len(), 8);
621
622        // Every `ApplicabilityReason` variant appears exactly once (catches a rule added to
623        // `check_applicability` without a matching `rule_catalog` entry, or vice versa).
624        let unsuitable_reasons = [
625            ApplicabilityReason::VolumeIsTickNotTurnover,
626            ApplicabilityReason::NoVolumeAvailable,
627            ApplicabilityReason::NoTradeDirection,
628        ];
629        for reason in unsuitable_reasons {
630            let rule = rules.iter().find(|r| r.reason == reason).unwrap();
631            assert_eq!(rule.tier, ApplicabilityTier::Unsuitable);
632        }
633        let degraded_reasons = [
634            ApplicabilityReason::BackAdjustedLevelsNotTraded,
635            ApplicabilityReason::UnadjustedRollJumps,
636            ApplicabilityReason::AdjustedLevelsShifted,
637            ApplicabilityReason::ThinLiquidity,
638            ApplicabilityReason::ProviderSpecificExtreme,
639        ];
640        for reason in degraded_reasons {
641            let rule = rules.iter().find(|r| r.reason == reason).unwrap();
642            assert_eq!(rule.tier, ApplicabilityTier::Degraded);
643        }
644    }
645}