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
465    fn bar(close: &str) -> BarInput {
466        BarInput::from_close(close.parse().unwrap())
467    }
468
469    // ── WarmupContract blanket impl ──────────────────────────────────────────
470
471    #[test]
472    fn test_warmup_contract_sma_period() {
473        let sma = Sma::new("sma10", 10).unwrap();
474        assert_eq!(sma.warmup_period(), 10);
475    }
476
477    #[test]
478    fn test_warmup_contract_rsi_period() {
479        let rsi = Rsi::new("rsi14", 14).unwrap();
480        assert_eq!(rsi.warmup_period(), 14);
481    }
482
483    #[test]
484    fn test_warmup_contract_ema_not_ready_initially() {
485        let ema = Ema::new("ema5", 5).unwrap();
486        assert!(!WarmupContract::is_ready(&ema));
487    }
488
489    #[test]
490    fn test_warmup_contract_sma_ready_after_period() {
491        let mut sma = Sma::new("sma3", 3).unwrap();
492        sma.update(&bar("10")).unwrap();
493        sma.update(&bar("20")).unwrap();
494        assert!(!WarmupContract::is_ready(&sma));
495        sma.update(&bar("30")).unwrap();
496        assert!(WarmupContract::is_ready(&sma));
497        assert_eq!(sma.bars_remaining(), 0);
498    }
499
500    // ── WarmupGuard ──────────────────────────────────────────────────────────
501
502    #[test]
503    fn test_warmup_guard_returns_err_before_ready() {
504        let sma = Sma::new("sma5", 5).unwrap();
505        let mut guard = WarmupGuard::new(sma);
506        for _ in 0..4 {
507            let result = guard.update_checked(&bar("100"));
508            assert!(
509                matches!(result, Err(WarmupError::NotReady(_))),
510                "expected NotReady error"
511            );
512        }
513    }
514
515    #[test]
516    fn test_warmup_guard_returns_ok_after_warmup() {
517        let sma = Sma::new("sma3", 3).unwrap();
518        let mut guard = WarmupGuard::new(sma);
519        guard.update_checked(&bar("10")).ok();
520        guard.update_checked(&bar("20")).ok();
521        let result = guard.update_checked(&bar("30"));
522        assert!(result.is_ok(), "expected Ok after warmup");
523        assert!(matches!(result.unwrap(), SignalValue::Scalar(_)));
524    }
525
526    #[test]
527    fn test_warmup_guard_is_ready_tracks_correctly() {
528        let sma = Sma::new("sma2", 2).unwrap();
529        let mut guard = WarmupGuard::new(sma);
530        assert!(!guard.is_ready());
531        guard.update_checked(&bar("10")).ok();
532        assert!(!guard.is_ready());
533        guard.update_checked(&bar("20")).ok();
534        assert!(guard.is_ready());
535    }
536
537    #[test]
538    fn test_warmup_guard_bars_remaining_decrements() {
539        let sma = Sma::new("sma4", 4).unwrap();
540        let mut guard = WarmupGuard::new(sma);
541        assert_eq!(guard.bars_remaining(), 4);
542        guard.update_checked(&bar("1")).ok();
543        assert_eq!(guard.bars_remaining(), 3);
544        guard.update_checked(&bar("1")).ok();
545        assert_eq!(guard.bars_remaining(), 2);
546    }
547
548    #[test]
549    fn test_warmup_guard_bars_remaining_zero_when_ready() {
550        let sma = Sma::new("sma2", 2).unwrap();
551        let mut guard = WarmupGuard::new(sma);
552        guard.update_checked(&bar("10")).ok();
553        guard.update_checked(&bar("20")).ok();
554        assert_eq!(guard.bars_remaining(), 0);
555    }
556
557    #[test]
558    fn test_warmup_guard_reset_restarts_warmup() {
559        let sma = Sma::new("sma2", 2).unwrap();
560        let mut guard = WarmupGuard::new(sma);
561        guard.update_checked(&bar("10")).ok();
562        guard.update_checked(&bar("20")).ok();
563        assert!(guard.is_ready());
564        guard.reset();
565        assert!(!guard.is_ready());
566        assert_eq!(guard.bars_seen(), 0);
567        let result = guard.update_checked(&bar("10"));
568        assert!(matches!(result, Err(WarmupError::NotReady(_))));
569    }
570
571    #[test]
572    fn test_warmup_guard_not_ready_error_has_correct_name() {
573        let sma = Sma::new("my_sma", 5).unwrap();
574        let mut guard = WarmupGuard::new(sma);
575        match guard.update_checked(&bar("100")) {
576            Err(WarmupError::NotReady(e)) => {
577                assert_eq!(e.name, "my_sma");
578                assert_eq!(e.warmup_period, 5);
579            }
580            _ => panic!("expected NotReady"),
581        }
582    }
583
584    #[test]
585    fn test_warmup_guard_rsi_warmup_period() {
586        let rsi = Rsi::new("rsi14", 14).unwrap();
587        let guard = WarmupGuard::new(rsi);
588        assert_eq!(guard.warmup_period(), 14);
589    }
590
591    #[test]
592    fn test_warmup_guard_into_inner() {
593        let sma = Sma::new("sma3", 3).unwrap();
594        let guard = WarmupGuard::new(sma);
595        let inner = guard.into_inner();
596        assert_eq!(inner.name(), "sma3");
597    }
598
599    // ── WarmupReporter ───────────────────────────────────────────────────────
600
601    #[test]
602    fn test_warmup_reporter_all_warming_at_zero_bars() {
603        let reporter = WarmupReporter::new(vec![5, 14], vec!["sma5".into(), "rsi14".into()]);
604        let report = reporter.report(0);
605        assert!(!report.all_ready());
606        assert_eq!(report.warming_count(), 2);
607        assert_eq!(report.ready_count(), 0);
608    }
609
610    #[test]
611    fn test_warmup_reporter_partial_ready() {
612        let reporter = WarmupReporter::new(vec![5, 14], vec!["sma5".into(), "rsi14".into()]);
613        let report = reporter.report(5);
614        assert!(!report.all_ready());
615        assert_eq!(report.ready_count(), 1);
616        assert_eq!(report.warming_count(), 1);
617        assert!(report.statuses[0].is_ready);
618        assert!(!report.statuses[1].is_ready);
619    }
620
621    #[test]
622    fn test_warmup_reporter_all_ready() {
623        let reporter = WarmupReporter::new(vec![5, 14], vec!["sma5".into(), "rsi14".into()]);
624        let report = reporter.report(14);
625        assert!(report.all_ready());
626        assert_eq!(report.pipeline_bars_remaining(), 0);
627    }
628
629    #[test]
630    fn test_warmup_reporter_tick_advances_count() {
631        let mut reporter = WarmupReporter::new(vec![3], vec!["sma3".into()]);
632        reporter.tick();
633        reporter.tick();
634        reporter.tick();
635        assert_eq!(reporter.bars_consumed(), 3);
636        let report = reporter.report(reporter.bars_consumed());
637        assert!(report.all_ready());
638    }
639
640    #[test]
641    fn test_warmup_reporter_tick_n() {
642        let mut reporter = WarmupReporter::new(vec![10], vec!["sma10".into()]);
643        reporter.tick_n(10);
644        let report = reporter.report(reporter.bars_consumed());
645        assert!(report.statuses[0].is_ready);
646    }
647
648    #[test]
649    fn test_warmup_reporter_pipeline_bars_remaining() {
650        let reporter = WarmupReporter::new(vec![5, 14, 20], vec!["a".into(), "b".into(), "c".into()]);
651        let report = reporter.report(10);
652        // c needs 20 bars, 10 consumed → 10 remaining
653        assert_eq!(report.pipeline_bars_remaining(), 10);
654    }
655
656    #[test]
657    fn test_warmup_reporter_max_warmup_period() {
658        let reporter = WarmupReporter::new(vec![5, 14, 200], vec!["a".into(), "b".into(), "c".into()]);
659        assert_eq!(reporter.max_warmup_period(), 200);
660    }
661
662    #[test]
663    fn test_warmup_report_display_contains_signal_names() {
664        let reporter = WarmupReporter::new(vec![5], vec!["mysig".into()]);
665        let report = reporter.report(0);
666        let display = report.display();
667        assert!(display.contains("mysig"));
668        assert!(display.contains("WARMING"));
669    }
670
671    #[test]
672    fn test_warmup_report_display_shows_ready() {
673        let reporter = WarmupReporter::new(vec![5], vec!["mysig".into()]);
674        let report = reporter.report(5);
675        let display = report.display();
676        assert!(display.contains("READY"));
677    }
678
679    #[test]
680    fn test_signal_warmup_status_summary_ready() {
681        let status = SignalWarmupStatus {
682            name: "sma5".into(),
683            warmup_period: 5,
684            is_ready: true,
685            bars_remaining: 0,
686        };
687        assert!(status.summary().contains("READY"));
688        assert!(status.summary().contains("sma5"));
689    }
690
691    #[test]
692    fn test_signal_warmup_status_summary_warming() {
693        let status = SignalWarmupStatus {
694            name: "rsi14".into(),
695            warmup_period: 14,
696            is_ready: false,
697            bars_remaining: 7,
698        };
699        let s = status.summary();
700        assert!(s.contains("WARMING"));
701        assert!(s.contains("remaining=7"));
702    }
703
704    #[test]
705    fn test_warmup_reporter_reset() {
706        let mut reporter = WarmupReporter::new(vec![3], vec!["sma3".into()]);
707        reporter.tick_n(10);
708        reporter.reset();
709        assert_eq!(reporter.bars_consumed(), 0);
710        let report = reporter.report(reporter.bars_consumed());
711        assert!(!report.all_ready());
712    }
713
714    #[test]
715    fn test_warmup_reporter_empty_reports_all_ready() {
716        let reporter = WarmupReporter::new(vec![], vec![]);
717        let report = reporter.report(0);
718        // vacuously all ready (no signals)
719        assert!(report.all_ready());
720        assert_eq!(report.pipeline_bars_remaining(), 0);
721    }
722
723    #[test]
724    fn test_warmup_guard_period_1_immediate() {
725        let sma = Sma::new("sma1", 1).unwrap();
726        let mut guard = WarmupGuard::new(sma);
727        let result = guard.update_checked(&bar("42"));
728        assert!(result.is_ok());
729    }
730
731    #[test]
732    fn test_not_ready_error_display() {
733        let err = NotReady {
734            name: "sma10".into(),
735            warmup_period: 10,
736            bars_remaining: 5,
737        };
738        let msg = err.to_string();
739        assert!(msg.contains("sma10"));
740        assert!(msg.contains("5 bars"));
741    }
742
743    #[test]
744    fn test_warmup_guard_bars_seen_tracks_all_updates() {
745        let sma = Sma::new("sma20", 20).unwrap();
746        let mut guard = WarmupGuard::new(sma);
747        for i in 0..7 {
748            guard.update_checked(&bar("10")).ok();
749            assert_eq!(guard.bars_seen(), i + 1);
750        }
751    }
752
753    #[test]
754    fn test_warmup_contract_ema_warmup_period_matches_period() {
755        let ema = Ema::new("ema20", 20).unwrap();
756        assert_eq!(ema.warmup_period(), ema.period());
757    }
758}