Skip to main content

fin_primitives/signals/
warmup.rs

1//! # Signal Warmup Contracts
2//!
3//! Formalises the warmup period that every indicator implicitly has, making it
4//! queryable, enforceable at the type level, and reportable across a full pipeline.
5//!
6//! ## Overview
7//!
8//! Every signal in `fin-primitives` returns [`SignalValue::Unavailable`] until it has
9//! accumulated enough bars to produce a meaningful value. Before this module, that
10//! behaviour was implicit — callers had to inspect each value and handle `Unavailable`
11//! ad-hoc. The warmup contract system makes it explicit and composable:
12//!
13//! | Type | Purpose |
14//! |------|---------|
15//! | [`WarmupContract`] | Trait: query warmup period, readiness, and remaining bars |
16//! | [`WarmupGuard`] | Wrapper: converts `Unavailable` into a typed `Err(NotReady)` |
17//! | [`WarmupReport`] | Report: snapshot of all signal warmup states in a pipeline |
18//! | [`SignalWarmupStatus`] | Per-signal status entry within a `WarmupReport` |
19//!
20//! ## Example
21//!
22//! ```rust
23//! use fin_primitives::signals::indicators::Sma;
24//! use fin_primitives::signals::{BarInput, Signal};
25//! use fin_primitives::signals::warmup::{WarmupContract, WarmupGuard};
26//! use rust_decimal_macros::dec;
27//!
28//! let sma = Sma::new("sma5", 5).unwrap();
29//! let mut guard = WarmupGuard::new(sma);
30//!
31//! // Feed 4 bars — guard returns Err(NotReady)
32//! for _ in 0..4 {
33//!     let bar = BarInput::from_close(dec!(100));
34//!     let result = guard.update_checked(&bar);
35//!     assert!(result.is_err());
36//! }
37//!
38//! // 5th bar — guard returns Ok(SignalValue::Scalar(...))
39//! let bar = BarInput::from_close(dec!(100));
40//! let result = guard.update_checked(&bar);
41//! assert!(result.is_ok());
42//! ```
43
44use crate::error::FinError;
45use crate::signals::{BarInput, Signal, SignalValue};
46
47// ── WarmupContract ────────────────────────────────────────────────────────────
48
49/// A formal contract for inspecting a signal's warmup state.
50///
51/// Every signal that implements [`Signal`] has an implicit warmup period; this
52/// trait exposes that information as a structured, queryable contract. Implement
53/// this trait alongside `Signal` to participate in [`WarmupReport`] generation.
54pub trait WarmupContract {
55    /// Returns the total number of bars required before the signal is ready.
56    ///
57    /// This is a static property of the indicator: it does not change during
58    /// the signal's lifetime. Corresponds to the indicator's `period()`.
59    fn warmup_period(&self) -> usize;
60
61    /// Returns `true` if the signal has accumulated enough bars to produce a value.
62    ///
63    /// After this returns `true`, [`Signal::update`] will no longer return
64    /// `SignalValue::Unavailable` due to insufficient data.
65    fn is_ready(&self) -> bool;
66
67    /// Returns the number of bars still needed before the signal becomes ready.
68    ///
69    /// Returns `0` if the signal is already ready.
70    fn bars_remaining(&self) -> usize;
71}
72
73/// Blanket implementation of [`WarmupContract`] for any type that implements [`Signal`].
74///
75/// This allows all built-in indicators (`Sma`, `Ema`, `Rsi`, …) to be used with
76/// [`WarmupGuard`] and [`WarmupReport`] without additional boilerplate.
77///
78/// Note: because [`Signal`] already exposes `is_ready()` and `period()`, the blanket
79/// impl delegates via fully-qualified syntax to avoid ambiguity.
80impl<S: Signal> WarmupContract for S {
81    fn warmup_period(&self) -> usize {
82        // Fully-qualified call to Signal::period to avoid name collision.
83        <S as Signal>::period(self)
84    }
85
86    fn is_ready(&self) -> bool {
87        // Fully-qualified call to Signal::is_ready to avoid name collision.
88        <S as Signal>::is_ready(self)
89    }
90
91    fn bars_remaining(&self) -> usize {
92        if <S as Signal>::is_ready(self) {
93            0
94        } else {
95            // `period()` is the total warmup needed. Since we cannot observe the
96            // internal bar count through the Signal trait alone, we return the full
97            // period as an upper bound. `WarmupGuard` tracks `bars_seen` independently.
98            <S as Signal>::period(self)
99        }
100    }
101}
102
103// ── NotReady error ────────────────────────────────────────────────────────────
104
105/// Error returned by [`WarmupGuard`] when a signal update is requested before warmup completes.
106#[derive(Debug, Clone, PartialEq, thiserror::Error)]
107#[error("Signal '{name}' not ready: {bars_remaining} bars still needed (period = {warmup_period})")]
108pub struct NotReady {
109    /// Name of the signal that is not yet ready.
110    pub name: String,
111    /// Total bars required before the signal becomes ready.
112    pub warmup_period: usize,
113    /// Bars still needed.
114    pub bars_remaining: usize,
115}
116
117// ── WarmupGuard ───────────────────────────────────────────────────────────────
118
119/// Wraps any [`Signal`] and converts `SignalValue::Unavailable` into a typed error.
120///
121/// Without a guard, callers must match on `SignalValue::Unavailable` manually at
122/// every call site. `WarmupGuard` makes the warmup contract explicit and enforceable:
123/// any call to [`WarmupGuard::update_checked`] before warmup completes returns
124/// `Err(NotReady { … })` rather than silently emitting an incomplete value.
125///
126/// The inner signal is still updated on every call, so the guard does not delay
127/// warmup — it just fails loudly until the signal is ready.
128///
129/// # Example
130///
131/// ```rust
132/// use fin_primitives::signals::indicators::Ema;
133/// use fin_primitives::signals::{BarInput, Signal};
134/// use fin_primitives::signals::warmup::WarmupGuard;
135/// use rust_decimal_macros::dec;
136///
137/// let ema = Ema::new("ema3", 3).unwrap();
138/// let mut guard = WarmupGuard::new(ema);
139///
140/// let bar = BarInput::from_close(dec!(50));
141/// assert!(guard.update_checked(&bar).is_err()); // not ready yet
142/// ```
143pub struct WarmupGuard<S: Signal> {
144    inner: S,
145    /// Number of bars fed into the inner signal since construction or last reset.
146    bars_seen: usize,
147}
148
149impl<S: Signal> WarmupGuard<S> {
150    /// Wraps `signal` in a `WarmupGuard`.
151    pub fn new(signal: S) -> Self {
152        Self { inner: signal, bars_seen: 0 }
153    }
154
155    /// Updates the inner signal and returns its value, or `Err(NotReady)` if not yet ready.
156    ///
157    /// The inner signal is updated regardless of readiness, so warmup progresses
158    /// normally. Once ready, subsequent calls return `Ok(SignalValue::Scalar(_))`.
159    ///
160    /// # Errors
161    ///
162    /// - Returns `Err(NotReady)` if the signal has not yet accumulated enough bars.
163    /// - Returns `Err(FinError)` (wrapped) if the inner signal returns an arithmetic error.
164    pub fn update_checked(&mut self, bar: &BarInput) -> Result<SignalValue, WarmupError> {
165        self.bars_seen += 1;
166        let value = self.inner.update(bar).map_err(WarmupError::Signal)?;
167        match &value {
168            SignalValue::Unavailable => {
169                let period = self.inner.period();
170                let remaining = period.saturating_sub(self.bars_seen);
171                Err(WarmupError::NotReady(NotReady {
172                    name: self.inner.name().to_owned(),
173                    warmup_period: period,
174                    bars_remaining: remaining,
175                }))
176            }
177            SignalValue::Scalar(_) => Ok(value),
178        }
179    }
180
181    /// Returns `true` if the inner signal is ready.
182    pub fn is_ready(&self) -> bool {
183        self.inner.is_ready()
184    }
185
186    /// Returns the number of bars remaining until the signal is ready.
187    ///
188    /// Returns `0` if already ready.
189    pub fn bars_remaining(&self) -> usize {
190        self.inner.period().saturating_sub(self.bars_seen)
191    }
192
193    /// Returns the total warmup period of the inner signal.
194    pub fn warmup_period(&self) -> usize {
195        self.inner.period()
196    }
197
198    /// Returns the number of bars fed into this guard since construction or last reset.
199    pub fn bars_seen(&self) -> usize {
200        self.bars_seen
201    }
202
203    /// Resets the inner signal and the bar counter.
204    pub fn reset(&mut self) {
205        self.inner.reset();
206        self.bars_seen = 0;
207    }
208
209    /// Returns a reference to the inner signal.
210    pub fn inner(&self) -> &S {
211        &self.inner
212    }
213
214    /// Consumes the guard and returns the inner signal.
215    pub fn into_inner(self) -> S {
216        self.inner
217    }
218}
219
220// ── WarmupError ───────────────────────────────────────────────────────────────
221
222/// Errors that can be returned by [`WarmupGuard::update_checked`].
223#[derive(Debug, thiserror::Error)]
224pub enum WarmupError {
225    /// The signal has not yet accumulated enough bars to produce a value.
226    #[error("{0}")]
227    NotReady(NotReady),
228
229    /// The inner signal returned a `FinError` (e.g., arithmetic overflow).
230    #[error("signal error: {0}")]
231    Signal(FinError),
232}
233
234// ── WarmupReport ─────────────────────────────────────────────────────────────
235
236/// Warmup status for a single signal within a [`WarmupReport`].
237#[derive(Debug, Clone, PartialEq)]
238pub struct SignalWarmupStatus {
239    /// The name of the signal.
240    pub name: String,
241    /// The number of bars required for this signal to become ready.
242    pub warmup_period: usize,
243    /// Whether the signal is currently ready.
244    pub is_ready: bool,
245    /// Bars still needed (0 if ready).
246    pub bars_remaining: usize,
247}
248
249impl SignalWarmupStatus {
250    /// Returns a human-readable summary line for this status entry.
251    pub fn summary(&self) -> String {
252        if self.is_ready {
253            format!("[READY]   {} (period={})", self.name, self.warmup_period)
254        } else {
255            format!(
256                "[WARMING] {} (period={}, remaining={})",
257                self.name, self.warmup_period, self.bars_remaining
258            )
259        }
260    }
261}
262
263/// A snapshot of warmup states for all signals tracked by a [`WarmupReporter`].
264///
265/// Use [`WarmupReporter::report`] to generate a `WarmupReport` after each bar.
266/// The report lets callers determine which signals are ready and which are still
267/// warming up, without inspecting each signal individually.
268///
269/// # Example
270///
271/// ```rust
272/// use fin_primitives::signals::indicators::{Sma, Rsi};
273/// use fin_primitives::signals::warmup::{WarmupContract, WarmupReporter};
274///
275/// let sma = Sma::new("sma10", 10).unwrap();
276/// let rsi = Rsi::new("rsi14", 14).unwrap();
277/// let reporter = WarmupReporter::new(vec![
278///     sma.warmup_period(),
279///     rsi.warmup_period(),
280/// ], vec!["sma10".into(), "rsi14".into()]);
281/// let report = reporter.report(0);
282/// assert!(!report.all_ready());
283/// ```
284#[derive(Debug, Clone)]
285pub struct WarmupReport {
286    /// Per-signal status entries, in the order they were registered.
287    pub statuses: Vec<SignalWarmupStatus>,
288    /// Total bars consumed by the reporting pipeline so far.
289    pub bars_consumed: usize,
290}
291
292impl WarmupReport {
293    /// Returns `true` if every tracked signal is ready.
294    pub fn all_ready(&self) -> bool {
295        self.statuses.iter().all(|s| s.is_ready)
296    }
297
298    /// Returns `true` if at least one tracked signal is not yet ready.
299    pub fn any_warming(&self) -> bool {
300        self.statuses.iter().any(|s| !s.is_ready)
301    }
302
303    /// Returns the number of signals that are currently ready.
304    pub fn ready_count(&self) -> usize {
305        self.statuses.iter().filter(|s| s.is_ready).count()
306    }
307
308    /// Returns the number of signals still warming up.
309    pub fn warming_count(&self) -> usize {
310        self.statuses.iter().filter(|s| !s.is_ready).count()
311    }
312
313    /// Returns the maximum number of bars remaining across all warming signals.
314    ///
315    /// This is the number of additional bars needed before the *entire* pipeline
316    /// is ready. Returns `0` if all signals are already ready.
317    pub fn pipeline_bars_remaining(&self) -> usize {
318        self.statuses.iter().map(|s| s.bars_remaining).max().unwrap_or(0)
319    }
320
321    /// Returns an iterator over signals that are ready.
322    pub fn ready_signals(&self) -> impl Iterator<Item = &SignalWarmupStatus> {
323        self.statuses.iter().filter(|s| s.is_ready)
324    }
325
326    /// Returns an iterator over signals that are still warming up.
327    pub fn warming_signals(&self) -> impl Iterator<Item = &SignalWarmupStatus> {
328        self.statuses.iter().filter(|s| !s.is_ready)
329    }
330
331    /// Returns a multi-line human-readable summary of the pipeline warmup state.
332    pub fn display(&self) -> String {
333        let mut lines = vec![format!(
334            "WarmupReport [bars_consumed={}, ready={}/{}, pipeline_remaining={}]",
335            self.bars_consumed,
336            self.ready_count(),
337            self.statuses.len(),
338            self.pipeline_bars_remaining(),
339        )];
340        for status in &self.statuses {
341            lines.push(format!("  {}", status.summary()));
342        }
343        lines.join("\n")
344    }
345}
346
347// ── WarmupReporter ────────────────────────────────────────────────────────────
348
349/// Tracks warmup progress for a set of named signals and produces [`WarmupReport`]s.
350///
351/// `WarmupReporter` stores the static warmup period for each signal by name, along
352/// with the current bar count, allowing it to generate accurate [`WarmupReport`]s
353/// at any point without holding mutable references to the signals themselves.
354///
355/// Construct via [`WarmupReporter::new`], then call [`WarmupReporter::tick`] after
356/// each bar update and [`WarmupReporter::report`] whenever you need a snapshot.
357///
358/// # Example
359///
360/// ```rust
361/// use fin_primitives::signals::warmup::WarmupReporter;
362///
363/// let mut reporter = WarmupReporter::new(
364///     vec![5, 14],
365///     vec!["sma5".into(), "rsi14".into()],
366/// );
367///
368/// for _ in 0..5 {
369///     reporter.tick();
370/// }
371///
372/// let report = reporter.report(reporter.bars_consumed());
373/// assert!(report.statuses[0].is_ready);   // sma5 ready
374/// assert!(!report.statuses[1].is_ready);  // rsi14 still warming
375/// ```
376pub struct WarmupReporter {
377    /// Registered signal names, in registration order.
378    names: Vec<String>,
379    /// Warmup periods for each signal, parallel to `names`.
380    periods: Vec<usize>,
381    /// Total bars consumed.
382    bars_consumed: usize,
383}
384
385impl WarmupReporter {
386    /// Creates a new `WarmupReporter` for the given named signals.
387    ///
388    /// `periods` and `names` must have the same length. The i-th entry in `periods`
389    /// corresponds to the warmup period for the i-th name in `names`.
390    ///
391    /// # Panics
392    /// Panics in debug builds if `periods.len() != names.len()`.
393    pub fn new(periods: Vec<usize>, names: Vec<String>) -> Self {
394        debug_assert_eq!(periods.len(), names.len(), "periods and names must have equal length");
395        Self { names, periods, bars_consumed: 0 }
396    }
397
398    /// Advances the bar counter by one.
399    ///
400    /// Call this after each bar is processed to keep the reporter in sync with
401    /// the indicator pipeline.
402    pub fn tick(&mut self) {
403        self.bars_consumed += 1;
404    }
405
406    /// Advances the bar counter by `n` bars at once.
407    pub fn tick_n(&mut self, n: usize) {
408        self.bars_consumed += n;
409    }
410
411    /// Returns the total number of bars consumed so far.
412    pub fn bars_consumed(&self) -> usize {
413        self.bars_consumed
414    }
415
416    /// Resets the bar counter to zero.
417    pub fn reset(&mut self) {
418        self.bars_consumed = 0;
419    }
420
421    /// Generates a [`WarmupReport`] given the current `bars_consumed`.
422    ///
423    /// Pass `self.bars_consumed()` for live reporting, or any integer for
424    /// hypothetical "how ready would we be after N bars?" queries.
425    pub fn report(&self, bars_consumed: usize) -> WarmupReport {
426        let statuses = self
427            .names
428            .iter()
429            .zip(self.periods.iter())
430            .map(|(name, &period)| {
431                let is_ready = bars_consumed >= period;
432                let bars_remaining = period.saturating_sub(bars_consumed);
433                SignalWarmupStatus {
434                    name: name.clone(),
435                    warmup_period: period,
436                    is_ready,
437                    bars_remaining,
438                }
439            })
440            .collect();
441        WarmupReport { statuses, bars_consumed }
442    }
443
444    /// Returns the number of signals registered with this reporter.
445    pub fn signal_count(&self) -> usize {
446        self.names.len()
447    }
448
449    /// Returns the maximum warmup period across all registered signals.
450    ///
451    /// This is the minimum number of bars before the full pipeline can be considered
452    /// ready. Returns `0` if no signals are registered.
453    pub fn max_warmup_period(&self) -> usize {
454        self.periods.iter().copied().max().unwrap_or(0)
455    }
456}
457
458// ── Tests ─────────────────────────────────────────────────────────────────────
459
460#[cfg(test)]
461mod tests {
462    use super::*;
463    use crate::signals::indicators::{Ema, Rsi, Sma};
464    use rust_decimal_macros::dec;
465
466    fn bar(close: &str) -> BarInput {
467        BarInput::from_close(close.parse().unwrap())
468    }
469
470    // ── WarmupContract blanket impl ──────────────────────────────────────────
471
472    #[test]
473    fn test_warmup_contract_sma_period() {
474        let sma = Sma::new("sma10", 10).unwrap();
475        assert_eq!(sma.warmup_period(), 10);
476    }
477
478    #[test]
479    fn test_warmup_contract_rsi_period() {
480        let rsi = Rsi::new("rsi14", 14).unwrap();
481        assert_eq!(rsi.warmup_period(), 14);
482    }
483
484    #[test]
485    fn test_warmup_contract_ema_not_ready_initially() {
486        let ema = Ema::new("ema5", 5).unwrap();
487        assert!(!WarmupContract::is_ready(&ema));
488    }
489
490    #[test]
491    fn test_warmup_contract_sma_ready_after_period() {
492        let mut sma = Sma::new("sma3", 3).unwrap();
493        sma.update(&bar("10")).unwrap();
494        sma.update(&bar("20")).unwrap();
495        assert!(!WarmupContract::is_ready(&sma));
496        sma.update(&bar("30")).unwrap();
497        assert!(WarmupContract::is_ready(&sma));
498        assert_eq!(sma.bars_remaining(), 0);
499    }
500
501    // ── WarmupGuard ──────────────────────────────────────────────────────────
502
503    #[test]
504    fn test_warmup_guard_returns_err_before_ready() {
505        let sma = Sma::new("sma5", 5).unwrap();
506        let mut guard = WarmupGuard::new(sma);
507        for _ in 0..4 {
508            let result = guard.update_checked(&bar("100"));
509            assert!(
510                matches!(result, Err(WarmupError::NotReady(_))),
511                "expected NotReady error"
512            );
513        }
514    }
515
516    #[test]
517    fn test_warmup_guard_returns_ok_after_warmup() {
518        let sma = Sma::new("sma3", 3).unwrap();
519        let mut guard = WarmupGuard::new(sma);
520        guard.update_checked(&bar("10")).ok();
521        guard.update_checked(&bar("20")).ok();
522        let result = guard.update_checked(&bar("30"));
523        assert!(result.is_ok(), "expected Ok after warmup");
524        assert!(matches!(result.unwrap(), SignalValue::Scalar(_)));
525    }
526
527    #[test]
528    fn test_warmup_guard_is_ready_tracks_correctly() {
529        let sma = Sma::new("sma2", 2).unwrap();
530        let mut guard = WarmupGuard::new(sma);
531        assert!(!guard.is_ready());
532        guard.update_checked(&bar("10")).ok();
533        assert!(!guard.is_ready());
534        guard.update_checked(&bar("20")).ok();
535        assert!(guard.is_ready());
536    }
537
538    #[test]
539    fn test_warmup_guard_bars_remaining_decrements() {
540        let sma = Sma::new("sma4", 4).unwrap();
541        let mut guard = WarmupGuard::new(sma);
542        assert_eq!(guard.bars_remaining(), 4);
543        guard.update_checked(&bar("1")).ok();
544        assert_eq!(guard.bars_remaining(), 3);
545        guard.update_checked(&bar("1")).ok();
546        assert_eq!(guard.bars_remaining(), 2);
547    }
548
549    #[test]
550    fn test_warmup_guard_bars_remaining_zero_when_ready() {
551        let sma = Sma::new("sma2", 2).unwrap();
552        let mut guard = WarmupGuard::new(sma);
553        guard.update_checked(&bar("10")).ok();
554        guard.update_checked(&bar("20")).ok();
555        assert_eq!(guard.bars_remaining(), 0);
556    }
557
558    #[test]
559    fn test_warmup_guard_reset_restarts_warmup() {
560        let sma = Sma::new("sma2", 2).unwrap();
561        let mut guard = WarmupGuard::new(sma);
562        guard.update_checked(&bar("10")).ok();
563        guard.update_checked(&bar("20")).ok();
564        assert!(guard.is_ready());
565        guard.reset();
566        assert!(!guard.is_ready());
567        assert_eq!(guard.bars_seen(), 0);
568        let result = guard.update_checked(&bar("10"));
569        assert!(matches!(result, Err(WarmupError::NotReady(_))));
570    }
571
572    #[test]
573    fn test_warmup_guard_not_ready_error_has_correct_name() {
574        let sma = Sma::new("my_sma", 5).unwrap();
575        let mut guard = WarmupGuard::new(sma);
576        match guard.update_checked(&bar("100")) {
577            Err(WarmupError::NotReady(e)) => {
578                assert_eq!(e.name, "my_sma");
579                assert_eq!(e.warmup_period, 5);
580            }
581            _ => panic!("expected NotReady"),
582        }
583    }
584
585    #[test]
586    fn test_warmup_guard_rsi_warmup_period() {
587        let rsi = Rsi::new("rsi14", 14).unwrap();
588        let guard = WarmupGuard::new(rsi);
589        assert_eq!(guard.warmup_period(), 14);
590    }
591
592    #[test]
593    fn test_warmup_guard_into_inner() {
594        let sma = Sma::new("sma3", 3).unwrap();
595        let guard = WarmupGuard::new(sma);
596        let inner = guard.into_inner();
597        assert_eq!(inner.name(), "sma3");
598    }
599
600    // ── WarmupReporter ───────────────────────────────────────────────────────
601
602    #[test]
603    fn test_warmup_reporter_all_warming_at_zero_bars() {
604        let reporter = WarmupReporter::new(vec![5, 14], vec!["sma5".into(), "rsi14".into()]);
605        let report = reporter.report(0);
606        assert!(!report.all_ready());
607        assert_eq!(report.warming_count(), 2);
608        assert_eq!(report.ready_count(), 0);
609    }
610
611    #[test]
612    fn test_warmup_reporter_partial_ready() {
613        let reporter = WarmupReporter::new(vec![5, 14], vec!["sma5".into(), "rsi14".into()]);
614        let report = reporter.report(5);
615        assert!(!report.all_ready());
616        assert_eq!(report.ready_count(), 1);
617        assert_eq!(report.warming_count(), 1);
618        assert!(report.statuses[0].is_ready);
619        assert!(!report.statuses[1].is_ready);
620    }
621
622    #[test]
623    fn test_warmup_reporter_all_ready() {
624        let reporter = WarmupReporter::new(vec![5, 14], vec!["sma5".into(), "rsi14".into()]);
625        let report = reporter.report(14);
626        assert!(report.all_ready());
627        assert_eq!(report.pipeline_bars_remaining(), 0);
628    }
629
630    #[test]
631    fn test_warmup_reporter_tick_advances_count() {
632        let mut reporter = WarmupReporter::new(vec![3], vec!["sma3".into()]);
633        reporter.tick();
634        reporter.tick();
635        reporter.tick();
636        assert_eq!(reporter.bars_consumed(), 3);
637        let report = reporter.report(reporter.bars_consumed());
638        assert!(report.all_ready());
639    }
640
641    #[test]
642    fn test_warmup_reporter_tick_n() {
643        let mut reporter = WarmupReporter::new(vec![10], vec!["sma10".into()]);
644        reporter.tick_n(10);
645        let report = reporter.report(reporter.bars_consumed());
646        assert!(report.statuses[0].is_ready);
647    }
648
649    #[test]
650    fn test_warmup_reporter_pipeline_bars_remaining() {
651        let reporter = WarmupReporter::new(vec![5, 14, 20], vec!["a".into(), "b".into(), "c".into()]);
652        let report = reporter.report(10);
653        // c needs 20 bars, 10 consumed → 10 remaining
654        assert_eq!(report.pipeline_bars_remaining(), 10);
655    }
656
657    #[test]
658    fn test_warmup_reporter_max_warmup_period() {
659        let reporter = WarmupReporter::new(vec![5, 14, 200], vec!["a".into(), "b".into(), "c".into()]);
660        assert_eq!(reporter.max_warmup_period(), 200);
661    }
662
663    #[test]
664    fn test_warmup_report_display_contains_signal_names() {
665        let reporter = WarmupReporter::new(vec![5], vec!["mysig".into()]);
666        let report = reporter.report(0);
667        let display = report.display();
668        assert!(display.contains("mysig"));
669        assert!(display.contains("WARMING"));
670    }
671
672    #[test]
673    fn test_warmup_report_display_shows_ready() {
674        let reporter = WarmupReporter::new(vec![5], vec!["mysig".into()]);
675        let report = reporter.report(5);
676        let display = report.display();
677        assert!(display.contains("READY"));
678    }
679
680    #[test]
681    fn test_signal_warmup_status_summary_ready() {
682        let status = SignalWarmupStatus {
683            name: "sma5".into(),
684            warmup_period: 5,
685            is_ready: true,
686            bars_remaining: 0,
687        };
688        assert!(status.summary().contains("READY"));
689        assert!(status.summary().contains("sma5"));
690    }
691
692    #[test]
693    fn test_signal_warmup_status_summary_warming() {
694        let status = SignalWarmupStatus {
695            name: "rsi14".into(),
696            warmup_period: 14,
697            is_ready: false,
698            bars_remaining: 7,
699        };
700        let s = status.summary();
701        assert!(s.contains("WARMING"));
702        assert!(s.contains("remaining=7"));
703    }
704
705    #[test]
706    fn test_warmup_reporter_reset() {
707        let mut reporter = WarmupReporter::new(vec![3], vec!["sma3".into()]);
708        reporter.tick_n(10);
709        reporter.reset();
710        assert_eq!(reporter.bars_consumed(), 0);
711        let report = reporter.report(reporter.bars_consumed());
712        assert!(!report.all_ready());
713    }
714
715    #[test]
716    fn test_warmup_reporter_empty_reports_all_ready() {
717        let reporter = WarmupReporter::new(vec![], vec![]);
718        let report = reporter.report(0);
719        // vacuously all ready (no signals)
720        assert!(report.all_ready());
721        assert_eq!(report.pipeline_bars_remaining(), 0);
722    }
723
724    #[test]
725    fn test_warmup_guard_period_1_immediate() {
726        let sma = Sma::new("sma1", 1).unwrap();
727        let mut guard = WarmupGuard::new(sma);
728        let result = guard.update_checked(&bar("42"));
729        assert!(result.is_ok());
730    }
731
732    #[test]
733    fn test_not_ready_error_display() {
734        let err = NotReady {
735            name: "sma10".into(),
736            warmup_period: 10,
737            bars_remaining: 5,
738        };
739        let msg = err.to_string();
740        assert!(msg.contains("sma10"));
741        assert!(msg.contains("5 bars"));
742    }
743
744    #[test]
745    fn test_warmup_guard_bars_seen_tracks_all_updates() {
746        let sma = Sma::new("sma20", 20).unwrap();
747        let mut guard = WarmupGuard::new(sma);
748        for i in 0..7 {
749            guard.update_checked(&bar("10")).ok();
750            assert_eq!(guard.bars_seen(), i + 1);
751        }
752    }
753
754    #[test]
755    fn test_warmup_contract_ema_warmup_period_matches_period() {
756        let ema = Ema::new("ema20", 20).unwrap();
757        assert_eq!(ema.warmup_period(), ema.period());
758    }
759}