Skip to main content

fin_primitives/
async_signals.rs

1//! Tokio-based streaming signal pipeline: push bars in, receive `SignalUpdate`s out.
2//!
3//! ## Responsibility
4//! Wraps a `SignalPipeline` with Tokio MPSC channels to provide a non-blocking,
5//! async-friendly streaming interface.  Callers push `OhlcvBar` ticks through a
6//! sender channel; the spawned task updates the pipeline and broadcasts
7//! `SignalUpdate` messages on the output channel.
8//!
9//! ## Guarantees
10//! - The spawned task terminates cleanly when the tick sender is dropped
11//! - Output messages are sent in the order bars are received
12//! - Zero dynamic allocation on the hot path: output buffers are pre-allocated
13//!   at construction time
14//!
15//! ## NOT Responsible For
16//! - Persistence of signal history
17//! - Cross-pipeline fan-out (compose multiple `StreamingSignalPipeline`s yourself)
18
19use crate::ohlcv::OhlcvBar;
20use crate::signals::pipeline::SignalPipeline;
21use crate::signals::SignalValue;
22use chrono::{DateTime, Utc};
23use tokio::sync::mpsc;
24
25// ─── SignalUpdate ──────────────────────────────────────────────────────────────
26
27/// A single computed signal value emitted by the streaming pipeline.
28#[derive(Debug, Clone)]
29pub struct SignalUpdate {
30    /// Name of the signal that produced this value.
31    pub signal_name: String,
32    /// The computed value (or `SignalValue::Unavailable` during warm-up).
33    pub value: SignalValue,
34    /// Wall-clock time at which the update was produced.
35    pub timestamp: DateTime<Utc>,
36}
37
38impl SignalUpdate {
39    /// Creates a new `SignalUpdate`.
40    pub fn new(signal_name: impl Into<String>, value: SignalValue, timestamp: DateTime<Utc>) -> Self {
41        Self {
42            signal_name: signal_name.into(),
43            value,
44            timestamp,
45        }
46    }
47
48    /// Returns `true` if this update carries a ready (scalar) value.
49    pub fn is_ready(&self) -> bool {
50        self.value.is_scalar()
51    }
52}
53
54// ─── StreamingSignalPipeline ──────────────────────────────────────────────────
55
56/// Configuration for a `StreamingSignalPipeline`.
57#[derive(Debug, Clone)]
58pub struct StreamingConfig {
59    /// Capacity of the tick input channel.
60    pub tick_channel_capacity: usize,
61    /// Capacity of the signal output channel.
62    pub output_channel_capacity: usize,
63}
64
65impl Default for StreamingConfig {
66    fn default() -> Self {
67        Self {
68            tick_channel_capacity: 1_024,
69            output_channel_capacity: 4_096,
70        }
71    }
72}
73
74/// A `SignalPipeline` wrapped with Tokio MPSC channels for async streaming.
75///
76/// Construct via [`StreamingSignalPipeline::new`], then call
77/// [`StreamingSignalPipeline::spawn`] to start the background task and obtain
78/// the sender / receiver handles.
79pub struct StreamingSignalPipeline {
80    pipeline: SignalPipeline,
81    config: StreamingConfig,
82}
83
84impl StreamingSignalPipeline {
85    /// Creates a new `StreamingSignalPipeline` wrapping the given pipeline.
86    pub fn new(pipeline: SignalPipeline) -> Self {
87        Self {
88            pipeline,
89            config: StreamingConfig::default(),
90        }
91    }
92
93    /// Creates a new `StreamingSignalPipeline` with custom channel capacities.
94    pub fn with_config(pipeline: SignalPipeline, config: StreamingConfig) -> Self {
95        Self { pipeline, config }
96    }
97
98    /// Spawns the background processing task.
99    ///
100    /// Returns:
101    /// - `tick_tx`: send `OhlcvBar` values here to drive the pipeline.
102    /// - `update_rx`: receive `SignalUpdate` values from this end.
103    ///
104    /// The task runs until `tick_tx` (and all its clones) are dropped.
105    pub fn spawn(
106        self,
107    ) -> (mpsc::Sender<OhlcvBar>, mpsc::Receiver<SignalUpdate>) {
108        let (tick_tx, tick_rx) = mpsc::channel::<OhlcvBar>(self.config.tick_channel_capacity);
109        let (update_tx, update_rx) =
110            mpsc::channel::<SignalUpdate>(self.config.output_channel_capacity);
111
112        tokio::spawn(run_pipeline(self.pipeline, tick_rx, update_tx));
113
114        (tick_tx, update_rx)
115    }
116}
117
118// ─── spawn_signal_stream ──────────────────────────────────────────────────────
119
120/// Convenience function: spawns a signal-streaming task and returns the output receiver.
121///
122/// `pipeline` is consumed; `tick_rx` is the caller-owned tick input end.  The
123/// returned `mpsc::Receiver<SignalUpdate>` carries all computed signal values.
124///
125/// Pre-allocates output buffers using the default `StreamingConfig` capacities.
126pub fn spawn_signal_stream(
127    pipeline: SignalPipeline,
128    tick_rx: mpsc::Receiver<OhlcvBar>,
129) -> mpsc::Receiver<SignalUpdate> {
130    let capacity = StreamingConfig::default().output_channel_capacity;
131    let (update_tx, update_rx) = mpsc::channel::<SignalUpdate>(capacity);
132    tokio::spawn(run_pipeline(pipeline, tick_rx, update_tx));
133    update_rx
134}
135
136// ─── internal task ───────────────────────────────────────────────────────────
137
138/// Background task: consumes bars from `tick_rx`, updates `pipeline`, and sends
139/// all resulting `SignalUpdate`s on `update_tx`.
140///
141/// Terminates gracefully when `tick_rx` is closed (sender side dropped).
142async fn run_pipeline(
143    mut pipeline: SignalPipeline,
144    mut tick_rx: mpsc::Receiver<OhlcvBar>,
145    update_tx: mpsc::Sender<SignalUpdate>,
146) {
147    // Pre-allocate a reusable name buffer to avoid per-bar heap allocation.
148    // We build the list of signal names once before entering the hot loop.
149    // (SignalPipeline does not expose an iterator over names directly, so we
150    //  discover them lazily on the first bar and then reuse the vec.)
151    let mut known_names: Vec<String> = Vec::new();
152
153    while let Some(bar) = tick_rx.recv().await {
154        let ts = Utc::now();
155
156        // SignalPipeline::update takes &OhlcvBar and returns SignalMap (infallible).
157        let map = pipeline.update(&bar);
158
159        // Build name list from first non-empty map
160        if known_names.is_empty() {
161            known_names = map.names().iter().map(|s| (*s).to_owned()).collect();
162        }
163
164        // Emit one SignalUpdate per signal; reuse the known_names vec
165        for name in &known_names {
166            let value = map
167                .get(name)
168                .cloned()
169                .unwrap_or(SignalValue::Unavailable);
170
171            let update = SignalUpdate::new(name.clone(), value, ts);
172
173            // If the receiver is gone, stop processing
174            if update_tx.send(update).await.is_err() {
175                return;
176            }
177        }
178    }
179}
180
181// ─── tests ────────────────────────────────────────────────────────────────────
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use crate::ohlcv::OhlcvBar;
187    use crate::signals::indicators::Sma;
188    use crate::signals::pipeline::SignalPipeline;
189    use crate::types::{NanoTimestamp, Price, Quantity, Symbol};
190    use rust_decimal_macros::dec;
191
192    fn make_bar(close: rust_decimal::Decimal, ts: i64) -> OhlcvBar {
193        let sym = Symbol::new("X").unwrap();
194        let p = Price::new(close).unwrap();
195        OhlcvBar {
196            symbol: sym,
197            open: p,
198            high: p,
199            low: p,
200            close: p,
201            volume: Quantity::new(dec!(1)).unwrap(),
202            ts_open: NanoTimestamp::new(ts),
203            ts_close: NanoTimestamp::new(ts + 1),
204            tick_count: 1,
205        }
206    }
207
208    #[tokio::test]
209    async fn test_streaming_pipeline_receives_updates() {
210        let sma = Sma::new("sma3", 3).unwrap();
211        let pipeline = SignalPipeline::new().add(sma);
212
213        let (tick_tx, mut update_rx) = StreamingSignalPipeline::new(pipeline).spawn();
214
215        // Send 5 bars
216        for i in 1u32..=5 {
217            let bar = make_bar(rust_decimal::Decimal::from(i) * dec!(10), i64::from(i));
218            tick_tx.send(bar).await.unwrap();
219        }
220
221        // Drop sender to signal end-of-stream
222        drop(tick_tx);
223
224        let mut updates: Vec<SignalUpdate> = Vec::new();
225        while let Some(u) = update_rx.recv().await {
226            updates.push(u);
227        }
228
229        // We sent 5 bars with 1 signal → expect 5 updates
230        assert_eq!(updates.len(), 5);
231        // First two updates are Unavailable (SMA(3) needs 3 bars)
232        assert!(updates[0].value.is_unavailable());
233        assert!(updates[1].value.is_unavailable());
234        // Third and later should be scalar
235        assert!(updates[2].value.is_scalar());
236    }
237
238    #[tokio::test]
239    async fn test_spawn_signal_stream_convenience() {
240        let sma = Sma::new("sma2", 2).unwrap();
241        let pipeline = SignalPipeline::new().add(sma);
242
243        let (tick_tx, tick_rx) = mpsc::channel::<OhlcvBar>(16);
244        let mut update_rx = spawn_signal_stream(pipeline, tick_rx);
245
246        for i in 1u32..=3 {
247            let bar = make_bar(rust_decimal::Decimal::from(i) * dec!(5), i64::from(i));
248            tick_tx.send(bar).await.unwrap();
249        }
250        drop(tick_tx);
251
252        let mut count = 0usize;
253        while update_rx.recv().await.is_some() {
254            count += 1;
255        }
256        assert_eq!(count, 3);
257    }
258
259    #[tokio::test]
260    async fn test_pipeline_closes_when_sender_dropped() {
261        let sma = Sma::new("sma5", 5).unwrap();
262        let pipeline = SignalPipeline::new().add(sma);
263        let (tick_tx, mut update_rx) = StreamingSignalPipeline::new(pipeline).spawn();
264
265        // Drop immediately without sending anything
266        drop(tick_tx);
267
268        // Receiver should return None immediately
269        let result = update_rx.recv().await;
270        assert!(result.is_none());
271    }
272}