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