Skip to main content

finance_query/streaming/
client.rs

1//! Streaming client providing a Stream-based API for real-time price updates.
2//!
3//! Backed by a pluggable [`StreamSource`](super::source::StreamSource) — Yahoo
4//! is the default implementation, with additional providers (e.g. Polygon)
5//! supported through the same abstraction.
6
7use std::pin::Pin;
8use std::sync::Arc;
9use std::task::{Context, Poll};
10use std::time::Duration;
11
12use futures::stream::Stream;
13use tokio::sync::{broadcast, mpsc};
14use tokio_stream::wrappers::BroadcastStream;
15use tracing::warn;
16
17use super::pricing::PriceUpdate;
18use super::source::{StreamCommand, StreamSource, run_stream_loop};
19use super::yahoo::YahooStreamSource;
20use crate::error::FinanceError;
21
22/// Result type for streaming operations
23pub type StreamResult<T> = std::result::Result<T, StreamError>;
24
25/// Errors that can occur during streaming
26#[derive(Debug, Clone)]
27pub enum StreamError {
28    /// WebSocket connection failed
29    ConnectionFailed(String),
30    /// WebSocket send/receive error
31    WebSocketError(String),
32    /// Failed to decode message
33    DecodeError(String),
34}
35
36impl std::fmt::Display for StreamError {
37    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38        match self {
39            StreamError::ConnectionFailed(e) => write!(f, "Connection failed: {}", e),
40            StreamError::WebSocketError(e) => write!(f, "WebSocket error: {}", e),
41            StreamError::DecodeError(e) => write!(f, "Decode error: {}", e),
42        }
43    }
44}
45
46impl std::error::Error for StreamError {}
47
48impl From<StreamError> for FinanceError {
49    fn from(e: StreamError) -> Self {
50        FinanceError::ResponseStructureError {
51            field: "streaming".to_string(),
52            context: e.to_string(),
53        }
54    }
55}
56
57/// Reconnection backoff duration
58const RECONNECT_BACKOFF_SECS: u64 = 3;
59
60/// Channel capacity for price updates
61const CHANNEL_CAPACITY: usize = 1024;
62
63/// A streaming price subscription that yields real-time price updates.
64///
65/// This provides a Flow-like API for receiving real-time price data.
66/// Backed by a pluggable source (Yahoo by default).
67///
68/// # Example
69///
70/// ```no_run
71/// use finance_query::streaming::PriceStream;
72/// use futures::StreamExt;
73///
74/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
75/// // Subscribe to multiple symbols
76/// let mut stream = PriceStream::subscribe(&["AAPL", "NVDA", "TSLA"]).await?;
77///
78/// // Receive price updates
79/// while let Some(price) = stream.next().await {
80///     println!("{}: ${:.2} ({:+.2}%)",
81///         price.id,
82///         price.price,
83///         price.change_percent
84///     );
85/// }
86/// # Ok(())
87/// # }
88/// ```
89pub struct PriceStream {
90    inner: BroadcastStream<PriceUpdate>,
91    _handle: Arc<StreamHandle>,
92}
93
94/// Handle to manage the streaming session
95struct StreamHandle {
96    command_tx: mpsc::Sender<StreamCommand>,
97    broadcast_tx: broadcast::Sender<PriceUpdate>,
98}
99
100impl PriceStream {
101    /// Subscribe to real-time price updates for the given symbols.
102    ///
103    /// # Arguments
104    ///
105    /// * `symbols` - Ticker symbols to subscribe to (e.g., `["AAPL", "NVDA"]`)
106    ///
107    /// # Example
108    ///
109    /// ```no_run
110    /// use finance_query::streaming::PriceStream;
111    ///
112    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
113    /// let stream = PriceStream::subscribe(&["AAPL", "GOOGL"]).await?;
114    /// # Ok(())
115    /// # }
116    /// ```
117    pub async fn subscribe(symbols: &[&str]) -> StreamResult<Self> {
118        Self::subscribe_with_source(
119            Arc::new(YahooStreamSource),
120            symbols,
121            Duration::from_secs(RECONNECT_BACKOFF_SECS),
122        )
123        .await
124    }
125
126    /// Subscribe using a specific [`StreamSource`] backend.
127    ///
128    /// Yahoo is the default ([`subscribe`](Self::subscribe)); this is the
129    /// generic entry point shared with [`PriceStreamBuilder`].
130    pub(crate) async fn subscribe_with_source(
131        source: Arc<dyn StreamSource>,
132        symbols: &[&str],
133        retry_delay: Duration,
134    ) -> StreamResult<Self> {
135        let (broadcast_tx, broadcast_rx) = broadcast::channel(CHANNEL_CAPACITY);
136        let (command_tx, command_rx) = mpsc::channel(32);
137
138        let initial_symbols: Vec<String> = symbols.iter().map(|s| s.to_string()).collect();
139
140        let tx_clone = broadcast_tx.clone();
141
142        // Spawn the streaming task driving the chosen source.
143        tokio::spawn(run_stream_loop(
144            source,
145            initial_symbols,
146            broadcast_tx,
147            command_rx,
148            retry_delay,
149        ));
150
151        let handle = Arc::new(StreamHandle {
152            command_tx,
153            broadcast_tx: tx_clone,
154        });
155
156        Ok(PriceStream {
157            inner: BroadcastStream::new(broadcast_rx),
158            _handle: handle,
159        })
160    }
161
162    /// Create a new receiver for this stream.
163    ///
164    /// Useful when you need multiple consumers of the same price data.
165    pub fn resubscribe(&self) -> Self {
166        PriceStream {
167            inner: BroadcastStream::new(self._handle.broadcast_tx.subscribe()),
168            _handle: Arc::clone(&self._handle),
169        }
170    }
171
172    /// Add more symbols to the subscription.
173    ///
174    /// # Example
175    ///
176    /// ```no_run
177    /// use finance_query::streaming::PriceStream;
178    ///
179    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
180    /// let stream = PriceStream::subscribe(&["AAPL"]).await?;
181    /// stream.add_symbols(&["NVDA", "TSLA"]).await;
182    /// # Ok(())
183    /// # }
184    /// ```
185    pub async fn add_symbols(&self, symbols: &[&str]) {
186        let symbols: Vec<String> = symbols.iter().map(|s| s.to_string()).collect();
187        let _ = self
188            ._handle
189            .command_tx
190            .send(StreamCommand::Subscribe(symbols))
191            .await;
192    }
193
194    /// Remove symbols from the subscription.
195    ///
196    /// # Example
197    ///
198    /// ```no_run
199    /// use finance_query::streaming::PriceStream;
200    ///
201    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
202    /// let stream = PriceStream::subscribe(&["AAPL", "NVDA"]).await?;
203    /// stream.remove_symbols(&["NVDA"]).await;
204    /// # Ok(())
205    /// # }
206    /// ```
207    pub async fn remove_symbols(&self, symbols: &[&str]) {
208        let symbols: Vec<String> = symbols.iter().map(|s| s.to_string()).collect();
209        let _ = self
210            ._handle
211            .command_tx
212            .send(StreamCommand::Unsubscribe(symbols))
213            .await;
214    }
215
216    /// Close the stream and disconnect from the WebSocket.
217    pub async fn close(&self) {
218        let _ = self._handle.command_tx.send(StreamCommand::Close).await;
219    }
220}
221
222impl Stream for PriceStream {
223    type Item = PriceUpdate;
224
225    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
226        match Pin::new(&mut self.inner).poll_next(cx) {
227            Poll::Ready(Some(Ok(data))) => Poll::Ready(Some(data)),
228            Poll::Ready(Some(Err(e))) => {
229                warn!("Broadcast error: {:?}", e);
230                // Try again on lag
231                cx.waker().wake_by_ref();
232                Poll::Pending
233            }
234            Poll::Ready(None) => Poll::Ready(None),
235            Poll::Pending => Poll::Pending,
236        }
237    }
238}
239
240/// Builder for creating price streams with custom configuration
241pub struct PriceStreamBuilder {
242    symbols: Vec<String>,
243    retry_delay: Duration,
244}
245
246impl PriceStreamBuilder {
247    /// Create a new builder
248    pub fn new() -> Self {
249        Self {
250            symbols: Vec::new(),
251            retry_delay: Duration::from_secs(RECONNECT_BACKOFF_SECS),
252        }
253    }
254
255    /// Add symbols to subscribe to
256    pub fn symbols(mut self, symbols: &[&str]) -> Self {
257        self.symbols.extend(symbols.iter().map(|s| s.to_string()));
258        self
259    }
260
261    /// Set the delay between reconnection attempts (default: 3s)
262    pub fn retry(mut self, delay: Duration) -> Self {
263        self.retry_delay = delay;
264        self
265    }
266
267    /// Build and start the price stream (Yahoo-backed).
268    pub async fn build(self) -> StreamResult<PriceStream> {
269        let symbol_refs: Vec<&str> = self.symbols.iter().map(|s| s.as_str()).collect();
270        PriceStream::subscribe_with_source(
271            Arc::new(YahooStreamSource),
272            &symbol_refs,
273            self.retry_delay,
274        )
275        .await
276    }
277}
278
279impl Default for PriceStreamBuilder {
280    fn default() -> Self {
281        Self::new()
282    }
283}