kestrel-chartkit 0.11.3

High-performance Rust technical analysis library for indicator math, market regime classification, composite scoring, and SVG visualization.
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
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
//! Plausibility check between what an indicator's calculation needs from a bar series
//! ([`crate::applicability::DataRequirements`]) and what a series actually provides
//! ([`crate::model::SeriesCapabilities`]).
//!
//! `build_checked` (see [`crate::indicator::registry`]) validates *parameters* — periods in
//! range, threshold ordering. It says nothing about whether the *data* fits the indicator: a
//! volume-profile indicator run on tick-volume CFD data will compute a POC/VAH/VAL that looks
//! plausible and measures nothing meaningful. Nothing in the type system prevents that. This
//! module is the check that catches it: [`crate::applicability::check_applicability`] compares an
//! indicator's declared [`crate::applicability::DataRequirements`] against a series'
//! [`crate::model::SeriesCapabilities`] and returns a three-valued verdict
//! ([`crate::applicability::Applicability`]) with factual, publication-ready explanations
//! attached.
//!
//! (Note: this module-level doc comment uses fully-qualified `crate::` paths for its own items
//! rather than bare names — rustdoc resolves intra-doc links here against the *crate-root* scope,
//! not this module's, because `pub mod applicability;` in `lib.rs` also carries an outer `///`
//! doc comment that gets merged with this file's `//!` comment into one doc block for the module.)

#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};

use crate::model::{
    ContinuityKind, LiquidityTier, PriceAdjustment, Provenance, SeriesCapabilities, VolumeKind,
};

/// What an indicator's calculation needs from the bar series it runs over, beyond plain OHLC
/// prices. All-`false` (the [`Default`]) is the right value for pure price indicators — the
/// effort of declaring requirements is only spent on the indicators where it matters.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct DataRequirements {
    /// Needs real traded turnover, not tick/update count (e.g. volume profile, VWAP).
    pub needs_real_volume: bool,
    /// Needs classified individual trades (buy/sell direction), not just aggregate volume.
    pub needs_trade_direction: bool,
    /// Result depends on where the session is cut (anchors, session extremes).
    pub session_sensitive: bool,
    /// Result depends on contract continuity (rolls mix liquidity pools / shift historical
    /// levels).
    pub roll_sensitive: bool,
    /// Result depends on historical price levels matching what was actually traded (broken by
    /// split/dividend adjustment).
    pub adjustment_sensitive: bool,
    /// Needs market-depth information beyond what a thin series can provide.
    pub needs_liquidity_depth: bool,
}

/// Which rule in [`check_applicability`] produced a given [`ApplicabilityNote`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum ApplicabilityReason {
    /// `needs_real_volume` against [`VolumeKind::Tick`]: the field holds update frequency, not
    /// turnover.
    VolumeIsTickNotTurnover,
    /// `needs_real_volume` against [`VolumeKind::None`]: the series has no volume at all.
    NoVolumeAvailable,
    /// `needs_trade_direction` against a series without classified trades.
    NoTradeDirection,
    /// `roll_sensitive` against [`ContinuityKind::StitchedBackAdjusted`]: historical levels are
    /// not the traded ones.
    BackAdjustedLevelsNotTraded,
    /// `roll_sensitive` against [`ContinuityKind::StitchedUnadjusted`]: roll jumps in the series.
    UnadjustedRollJumps,
    /// `adjustment_sensitive` against a non-[`PriceAdjustment::Raw`] series: historical marks sit
    /// at different numbers in the adjusted series.
    AdjustedLevelsShifted,
    /// `needs_liquidity_depth` against [`LiquidityTier::Thin`].
    ThinLiquidity,
    /// An extreme-checking indicator (`session_sensitive || roll_sensitive`) run on
    /// [`Provenance::Broker`] data: the extreme is provider-specific and does not transfer.
    ///
    /// The plan's original rule is stated as "`provenance == Broker` for anything that checks
    /// extremes", but `DataRequirements` has no dedicated "checks extremes" field. This
    /// substitutes `session_sensitive || roll_sensitive` as that signal: those are exactly the
    /// two fields set on the plan's own list of extreme-checking indicators (pivots, structure,
    /// zigzag, Elliott), so no new field is introduced for a case the two existing fields already
    /// cover.
    ProviderSpecificExtreme,
}

/// A single applicability finding: which rule fired, and a publication-ready explanation.
///
/// `explanation` is meant to end up verbatim on a public docs page (see the plan's "Export für
/// die Doku" section): factual, no superlatives, no performance claims.
///
/// Derives `Serialize` only, not `Deserialize`: the `&'static str` explanation field can only be
/// deserialized into a `'static` lifetime, which conflicts with `serde_derive`'s generic `'de`
/// once this type sits behind a `Vec` inside [`Applicability`] (a struct field of a concrete
/// `&'static str` alone can derive `Deserialize` by adding a `'de: 'static` bound to its own impl,
/// but that bound cannot be discharged once another type calls into it generically). These are
/// computed diagnostic outputs, never something reconstructed from JSON, so this is not a loss.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize))]
pub struct ApplicabilityNote {
    pub reason: ApplicabilityReason,
    pub explanation: &'static str,
}

/// Which of the two non-`Applicable` buckets a rule belongs to.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum ApplicabilityTier {
    Degraded,
    Unsuitable,
}

/// Static description of one rule in [`check_applicability`]'s table: which reason it produces,
/// which tier it falls into, and its publication-ready explanation. [`rule_catalog`] exposes all
/// of them for the doc export (see the plan's "Export für die Doku" section) — this is the single
/// source both `check_applicability` and the export pull from, so the exported table can never
/// drift from the rules actually enforced.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize))]
pub struct RuleDescription {
    pub reason: ApplicabilityReason,
    pub tier: ApplicabilityTier,
    pub explanation: &'static str,
}

/// Three-valued result of matching an indicator's [`DataRequirements`] against a series'
/// [`SeriesCapabilities`].
///
/// Derives `Serialize` only, not `Deserialize` — see [`ApplicabilityNote`]'s doc comment.
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize))]
pub enum Applicability {
    Applicable,
    /// The indicator still computes, but the result's meaning shifts (e.g. a relative-volume
    /// measure computed on tick data is an activity measure, not a turnover measure).
    Degraded {
        reasons: Vec<ApplicabilityNote>,
    },
    /// The result would be misleading; computing it anyway should not happen silently.
    Unsuitable {
        reasons: Vec<ApplicabilityNote>,
    },
}

// The rule table: one `RuleDescription` constant per rule, each naming the reason it produces,
// its tier, and its publication-ready explanation. `check_applicability` below turns each into an
// `ApplicabilityNote` when its condition holds; `rule_catalog` exposes the same constants for the
// doc export — one source, so the exported table can't drift from the rules actually enforced.

/// Rule 1: `needs_real_volume` && `volume == Tick` -> Unsuitable.
const RULE_VOLUME_IS_TICK: RuleDescription = RuleDescription {
    reason: ApplicabilityReason::VolumeIsTickNotTurnover,
    tier: ApplicabilityTier::Unsuitable,
    explanation: "zeigt Update-Häufigkeit, nicht Umsatz",
};

/// Rule 2: `needs_real_volume` && `volume == None` -> Unsuitable.
const RULE_NO_VOLUME: RuleDescription = RuleDescription {
    reason: ApplicabilityReason::NoVolumeAvailable,
    tier: ApplicabilityTier::Unsuitable,
    explanation: "die Serie hat kein Volumen; ein Index ist ein berechneter Wert — \
        Volumen-Referenz wäre Future oder ETF",
};

/// Rule 3: `needs_trade_direction` && `!trade_direction` -> Unsuitable.
///
/// **Kein Registry-Indikator loest diese Regel derzeit aus** (Stand
/// 2026-09-08): keiner deklariert `needs_trade_direction`, das einzige
/// `true` im Quelltext steht in den Tests weiter unten. Das ist kein
/// Versehen — auch `cvd` braucht sie nicht, weil es die Aggressorseite aus
/// der Bar-Form schaetzt statt Abschlüsse zu klassifizieren.
///
/// Der Hinweis steht hier, damit ein Leser eine ungenutzte Regel nicht fuer
/// eine seltene haelt. Eine echte Aggressorseiten-Rechnung braeuchte
/// Tickdaten mit Geld- und Briefkurs zum Abschlusszeitpunkt; keine der
/// angebundenen Quellen liefert das.
const RULE_NO_TRADE_DIRECTION: RuleDescription = RuleDescription {
    reason: ApplicabilityReason::NoTradeDirection,
    tier: ApplicabilityTier::Unsuitable,
    explanation: "die Serie enthält keine klassifizierten Einzeltrades, nur aggregierte Bars",
};

/// Rule 4: `roll_sensitive` && `continuity == StitchedBackAdjusted` -> Degraded.
const RULE_BACK_ADJUSTED: RuleDescription = RuleDescription {
    reason: ApplicabilityReason::BackAdjustedLevelsNotTraded,
    tier: ApplicabilityTier::Degraded,
    explanation: "historische Niveaus sind nicht die gehandelten",
};

/// Rule 5: `roll_sensitive` && `continuity == StitchedUnadjusted` -> Degraded.
const RULE_UNADJUSTED_ROLL: RuleDescription = RuleDescription {
    reason: ApplicabilityReason::UnadjustedRollJumps,
    tier: ApplicabilityTier::Degraded,
    explanation: "Roll-Sprünge in der Reihe",
};

/// Rule 6: `adjustment_sensitive` && `price_adjustment != Raw` -> Degraded.
const RULE_ADJUSTED_SHIFTED: RuleDescription = RuleDescription {
    reason: ApplicabilityReason::AdjustedLevelsShifted,
    tier: ApplicabilityTier::Degraded,
    explanation: "historische Marken liegen in der bereinigten Reihe an anderen Zahlen",
};

/// Rule 7: `needs_liquidity_depth` && `liquidity_tier == Thin` -> Degraded.
const RULE_THIN_LIQUIDITY: RuleDescription = RuleDescription {
    reason: ApplicabilityReason::ThinLiquidity,
    tier: ApplicabilityTier::Degraded,
    explanation: "geringe Liquidität; Markttiefe für diese Kennzahl nicht ausreichend abgebildet",
};

/// Rule 8: `(session_sensitive || roll_sensitive)` && `provenance == Broker` -> Degraded.
/// See [`ApplicabilityReason::ProviderSpecificExtreme`] for why this signal substitutes for the
/// plan's unimplemented "checks extremes" field.
const RULE_PROVIDER_SPECIFIC_EXTREME: RuleDescription = RuleDescription {
    reason: ApplicabilityReason::ProviderSpecificExtreme,
    tier: ApplicabilityTier::Degraded,
    explanation: "Extreme sind anbieterspezifisch, eine Auszeichnung ist nicht übertragbar",
};

/// All rules in [`check_applicability`]'s table, in the same order they are evaluated — the
/// source the doc export (see the plan's "Export für die Doku" section) reads from.
pub fn rule_catalog() -> [RuleDescription; 8] {
    [
        RULE_VOLUME_IS_TICK,
        RULE_NO_VOLUME,
        RULE_NO_TRADE_DIRECTION,
        RULE_BACK_ADJUSTED,
        RULE_UNADJUSTED_ROLL,
        RULE_ADJUSTED_SHIFTED,
        RULE_THIN_LIQUIDITY,
        RULE_PROVIDER_SPECIFIC_EXTREME,
    ]
}

fn note_from(rule: RuleDescription) -> ApplicabilityNote {
    ApplicabilityNote {
        reason: rule.reason,
        explanation: rule.explanation,
    }
}

/// Matches `requirements` against `capabilities` and classifies the result.
///
/// Collects *all* matching notes across all rules (does not stop at the first match), then
/// classifies: any note produced by an "Unsuitable" rule (1-3 above) makes the whole result
/// [`Applicability::Unsuitable`], carrying every note (Unsuitable and Degraded) found; otherwise
/// any note from a "Degraded" rule (4-8 above) makes it [`Applicability::Degraded`]; otherwise
/// [`Applicability::Applicable`].
pub fn check_applicability(
    requirements: &DataRequirements,
    capabilities: &SeriesCapabilities,
) -> Applicability {
    let mut unsuitable_notes = Vec::new();
    let mut degraded_notes = Vec::new();

    if requirements.needs_real_volume && capabilities.volume == VolumeKind::Tick {
        unsuitable_notes.push(note_from(RULE_VOLUME_IS_TICK));
    }

    if requirements.needs_real_volume && capabilities.volume == VolumeKind::None {
        unsuitable_notes.push(note_from(RULE_NO_VOLUME));
    }

    if requirements.needs_trade_direction && !capabilities.trade_direction {
        unsuitable_notes.push(note_from(RULE_NO_TRADE_DIRECTION));
    }

    if requirements.roll_sensitive
        && capabilities.continuity == ContinuityKind::StitchedBackAdjusted
    {
        degraded_notes.push(note_from(RULE_BACK_ADJUSTED));
    }

    if requirements.roll_sensitive && capabilities.continuity == ContinuityKind::StitchedUnadjusted
    {
        degraded_notes.push(note_from(RULE_UNADJUSTED_ROLL));
    }

    if requirements.adjustment_sensitive && capabilities.price_adjustment != PriceAdjustment::Raw {
        degraded_notes.push(note_from(RULE_ADJUSTED_SHIFTED));
    }

    if requirements.needs_liquidity_depth && capabilities.liquidity_tier == LiquidityTier::Thin {
        degraded_notes.push(note_from(RULE_THIN_LIQUIDITY));
    }

    if (requirements.session_sensitive || requirements.roll_sensitive)
        && capabilities.provenance == Provenance::Broker
    {
        degraded_notes.push(note_from(RULE_PROVIDER_SPECIFIC_EXTREME));
    }

    if !unsuitable_notes.is_empty() {
        unsuitable_notes.extend(degraded_notes);
        Applicability::Unsuitable {
            reasons: unsuitable_notes,
        }
    } else if !degraded_notes.is_empty() {
        Applicability::Degraded {
            reasons: degraded_notes,
        }
    } else {
        Applicability::Applicable
    }
}

/// Looks up the [`DataRequirements`] for a registry/catalog indicator name (case-insensitive,
/// matching the `.to_lowercase()` convention used by
/// [`crate::indicator::registry::build_checked`]/`build_typed`).
///
/// This is a separate lookup rather than a field on
/// [`crate::indicator::registry::IndicatorCatalogEntry`] because two indicators that need
/// requirements here (`elliott`, `swing_structure`) are not catalog/registry entries at all —
/// they are excluded from the single-bar registry due to their call signature (see the note above
/// `CANONICAL_INDICATOR_NAMES` in `src/indicator/registry.rs`). A standalone function is the only
/// way to give catalog and non-catalog indicators one shared source of truth without forcing a
/// signature change on those two engines.
///
/// Names not listed below (including all pure price indicators) return
/// [`DataRequirements::default()`] — deliberately conservative: `needs_trade_direction` and
/// `needs_liquidity_depth` are left `false` everywhere for now, since no indicator covered by the
/// plan is described as needing actual classified-trade data (bar-derived CVD is a documented
/// heuristic, not a hard requirement) or is named as needing liquidity-depth data.
pub fn data_requirements(name: &str) -> DataRequirements {
    match name.to_lowercase().as_str() {
        "volume_profile"
        | "extended_volume_profile"
        | "persistent_volume_profile"
        | "money_flow_profile" => DataRequirements {
            needs_real_volume: true,
            roll_sensitive: true,
            ..DataRequirements::default()
        },
        "vwap" | "anchored_vwap" | "rvat" => DataRequirements {
            needs_real_volume: true,
            session_sensitive: true,
            ..DataRequirements::default()
        },
        "cvd" | "buy_sell_pressure" | "efi" | "mfi" | "eom" | "nvi" | "pvi" | "pvt" | "klinger"
        | "chaikin_oscillator" | "elder_ray" | "midas" => DataRequirements {
            needs_real_volume: true,
            ..DataRequirements::default()
        },
        "pivot_sets" | "pivots_structure" | "zigzag" | "zigzag_advanced" | "elliott"
        | "swing_structure" => DataRequirements {
            session_sensitive: true,
            roll_sensitive: true,
            adjustment_sensitive: true,
            ..DataRequirements::default()
        },
        _ => DataRequirements::default(),
    }
}

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

    /// A neutral baseline: deep, exchange-provenance, regular-session, single-contract, raw,
    /// real-turnover series. Individual tests override only the field(s) relevant to the rule
    /// under test.
    fn baseline_capabilities() -> SeriesCapabilities {
        SeriesCapabilities {
            volume: VolumeKind::RealTurnover,
            trade_direction: false,
            session: SessionKind::Regular,
            continuity: ContinuityKind::SingleContract,
            price_adjustment: PriceAdjustment::Raw,
            provenance: Provenance::Exchange,
            liquidity_tier: LiquidityTier::Deep,
        }
    }

    fn only_unsuitable(applicability: &Applicability) -> &[ApplicabilityNote] {
        match applicability {
            Applicability::Unsuitable { reasons } => reasons,
            other => panic!("expected Unsuitable, got {other:?}"),
        }
    }

    fn only_degraded(applicability: &Applicability) -> &[ApplicabilityNote] {
        match applicability {
            Applicability::Degraded { reasons } => reasons,
            other => panic!("expected Degraded, got {other:?}"),
        }
    }

    #[test]
    fn rule1_real_volume_on_tick_is_unsuitable() {
        let requirements = DataRequirements {
            needs_real_volume: true,
            ..DataRequirements::default()
        };
        let capabilities = SeriesCapabilities {
            volume: VolumeKind::Tick,
            ..baseline_capabilities()
        };
        let result = check_applicability(&requirements, &capabilities);
        let reasons = only_unsuitable(&result);
        assert!(reasons
            .iter()
            .any(|n| n.reason == ApplicabilityReason::VolumeIsTickNotTurnover));
    }

    #[test]
    fn rule2_real_volume_on_none_is_unsuitable() {
        let requirements = DataRequirements {
            needs_real_volume: true,
            ..DataRequirements::default()
        };
        let capabilities = SeriesCapabilities {
            volume: VolumeKind::None,
            ..baseline_capabilities()
        };
        let result = check_applicability(&requirements, &capabilities);
        let reasons = only_unsuitable(&result);
        assert!(reasons
            .iter()
            .any(|n| n.reason == ApplicabilityReason::NoVolumeAvailable));
    }

    #[test]
    fn rule3_trade_direction_missing_is_unsuitable() {
        let requirements = DataRequirements {
            needs_trade_direction: true,
            ..DataRequirements::default()
        };
        let capabilities = SeriesCapabilities {
            trade_direction: false,
            ..baseline_capabilities()
        };
        let result = check_applicability(&requirements, &capabilities);
        let reasons = only_unsuitable(&result);
        assert!(reasons
            .iter()
            .any(|n| n.reason == ApplicabilityReason::NoTradeDirection));
    }

    #[test]
    fn rule4_roll_sensitive_on_back_adjusted_is_degraded() {
        let requirements = DataRequirements {
            roll_sensitive: true,
            ..DataRequirements::default()
        };
        let capabilities = SeriesCapabilities {
            continuity: ContinuityKind::StitchedBackAdjusted,
            ..baseline_capabilities()
        };
        let result = check_applicability(&requirements, &capabilities);
        let reasons = only_degraded(&result);
        assert!(reasons
            .iter()
            .any(|n| n.reason == ApplicabilityReason::BackAdjustedLevelsNotTraded));
    }

    #[test]
    fn rule5_roll_sensitive_on_unadjusted_stitched_is_degraded() {
        let requirements = DataRequirements {
            roll_sensitive: true,
            ..DataRequirements::default()
        };
        let capabilities = SeriesCapabilities {
            continuity: ContinuityKind::StitchedUnadjusted,
            ..baseline_capabilities()
        };
        let result = check_applicability(&requirements, &capabilities);
        let reasons = only_degraded(&result);
        assert!(reasons
            .iter()
            .any(|n| n.reason == ApplicabilityReason::UnadjustedRollJumps));
    }

    #[test]
    fn rule6_adjustment_sensitive_on_adjusted_series_is_degraded() {
        let requirements = DataRequirements {
            adjustment_sensitive: true,
            ..DataRequirements::default()
        };
        let capabilities = SeriesCapabilities {
            price_adjustment: PriceAdjustment::SplitAndDividend,
            ..baseline_capabilities()
        };
        let result = check_applicability(&requirements, &capabilities);
        let reasons = only_degraded(&result);
        assert!(reasons
            .iter()
            .any(|n| n.reason == ApplicabilityReason::AdjustedLevelsShifted));
    }

    #[test]
    fn rule7_liquidity_depth_on_thin_series_is_degraded() {
        let requirements = DataRequirements {
            needs_liquidity_depth: true,
            ..DataRequirements::default()
        };
        let capabilities = SeriesCapabilities {
            liquidity_tier: LiquidityTier::Thin,
            ..baseline_capabilities()
        };
        let result = check_applicability(&requirements, &capabilities);
        let reasons = only_degraded(&result);
        assert!(reasons
            .iter()
            .any(|n| n.reason == ApplicabilityReason::ThinLiquidity));
    }

    #[test]
    fn rule8_extreme_checking_on_broker_data_is_degraded() {
        let requirements = DataRequirements {
            session_sensitive: true,
            ..DataRequirements::default()
        };
        let capabilities = SeriesCapabilities {
            provenance: Provenance::Broker,
            ..baseline_capabilities()
        };
        let result = check_applicability(&requirements, &capabilities);
        let reasons = only_degraded(&result);
        assert!(reasons
            .iter()
            .any(|n| n.reason == ApplicabilityReason::ProviderSpecificExtreme));

        // roll_sensitive alone is also a valid trigger for the same rule.
        let requirements2 = DataRequirements {
            roll_sensitive: true,
            ..DataRequirements::default()
        };
        let result2 = check_applicability(&requirements2, &capabilities);
        let reasons2 = only_degraded(&result2);
        assert!(reasons2
            .iter()
            .any(|n| n.reason == ApplicabilityReason::ProviderSpecificExtreme));
    }

    #[test]
    fn no_requirements_is_always_applicable() {
        let requirements = DataRequirements::default();
        let capabilities_variants = [
            baseline_capabilities(),
            SeriesCapabilities {
                volume: VolumeKind::None,
                continuity: ContinuityKind::StitchedBackAdjusted,
                price_adjustment: PriceAdjustment::SplitAndDividend,
                provenance: Provenance::Broker,
                liquidity_tier: LiquidityTier::Thin,
                ..baseline_capabilities()
            },
        ];
        for capabilities in capabilities_variants {
            assert_eq!(
                check_applicability(&requirements, &capabilities),
                Applicability::Applicable
            );
        }
    }

    #[test]
    fn multiple_violations_collect_into_one_result_with_all_notes() {
        // needs_real_volume (Unsuitable) + roll_sensitive (Degraded, twice-eligible collapsed to
        // whichever continuity is set) + adjustment_sensitive (Degraded).
        let requirements = DataRequirements {
            needs_real_volume: true,
            roll_sensitive: true,
            adjustment_sensitive: true,
            ..DataRequirements::default()
        };
        let capabilities = SeriesCapabilities {
            volume: VolumeKind::Tick,
            continuity: ContinuityKind::StitchedBackAdjusted,
            price_adjustment: PriceAdjustment::Split,
            ..baseline_capabilities()
        };
        let result = check_applicability(&requirements, &capabilities);
        let reasons = only_unsuitable(&result);
        assert_eq!(reasons.len(), 3);
        assert!(reasons
            .iter()
            .any(|n| n.reason == ApplicabilityReason::VolumeIsTickNotTurnover));
        assert!(reasons
            .iter()
            .any(|n| n.reason == ApplicabilityReason::BackAdjustedLevelsNotTraded));
        assert!(reasons
            .iter()
            .any(|n| n.reason == ApplicabilityReason::AdjustedLevelsShifted));
    }

    #[test]
    fn data_requirements_maps_known_names() {
        assert_eq!(
            data_requirements("volume_profile"),
            DataRequirements {
                needs_real_volume: true,
                roll_sensitive: true,
                ..DataRequirements::default()
            }
        );
        assert_eq!(
            data_requirements("VWAP"),
            DataRequirements {
                needs_real_volume: true,
                session_sensitive: true,
                ..DataRequirements::default()
            }
        );
        assert_eq!(
            data_requirements("elliott"),
            DataRequirements {
                session_sensitive: true,
                roll_sensitive: true,
                adjustment_sensitive: true,
                ..DataRequirements::default()
            }
        );
    }

    #[test]
    fn data_requirements_unmapped_name_is_default() {
        assert_eq!(data_requirements("rsi"), DataRequirements::default());
    }

    #[test]
    fn rule_catalog_has_one_entry_per_reason_with_matching_tier() {
        let rules = rule_catalog();
        assert_eq!(rules.len(), 8);

        // Every `ApplicabilityReason` variant appears exactly once (catches a rule added to
        // `check_applicability` without a matching `rule_catalog` entry, or vice versa).
        let unsuitable_reasons = [
            ApplicabilityReason::VolumeIsTickNotTurnover,
            ApplicabilityReason::NoVolumeAvailable,
            ApplicabilityReason::NoTradeDirection,
        ];
        for reason in unsuitable_reasons {
            let rule = rules.iter().find(|r| r.reason == reason).unwrap();
            assert_eq!(rule.tier, ApplicabilityTier::Unsuitable);
        }
        let degraded_reasons = [
            ApplicabilityReason::BackAdjustedLevelsNotTraded,
            ApplicabilityReason::UnadjustedRollJumps,
            ApplicabilityReason::AdjustedLevelsShifted,
            ApplicabilityReason::ThinLiquidity,
            ApplicabilityReason::ProviderSpecificExtreme,
        ];
        for reason in degraded_reasons {
            let rule = rules.iter().find(|r| r.reason == reason).unwrap();
            assert_eq!(rule.tier, ApplicabilityTier::Degraded);
        }
    }
}