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;
13
14use super::handle::SourceStream;
15use super::pricing::PriceUpdate;
16use super::source::{ReconnectConfig, StreamSource};
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: SourceStream<PriceUpdate>,
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            ReconnectConfig::new(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<PriceUpdate>>,
127        symbols: I,
128        reconnect: ReconnectConfig,
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        Ok(PriceStream {
137            inner: SourceStream::start(source, initial_symbols, reconnect, CHANNEL_CAPACITY),
138        })
139    }
140
141    /// Create a new receiver for this stream.
142    ///
143    /// Useful when you need multiple consumers of the same price data.
144    pub fn resubscribe(&self) -> Self {
145        PriceStream {
146            inner: self.inner.resubscribe(),
147        }
148    }
149
150    /// Add more symbols to the subscription.
151    ///
152    /// # Example
153    ///
154    /// ```no_run
155    /// use finance_query::streaming::PriceStream;
156    ///
157    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
158    /// let stream = PriceStream::subscribe(["AAPL"]).await?;
159    /// stream.add_symbols(["NVDA", "TSLA"]).await;
160    /// # Ok(())
161    /// # }
162    /// ```
163    pub async fn add_symbols<S, I>(&self, symbols: I)
164    where
165        S: Into<String>,
166        I: IntoIterator<Item = S>,
167    {
168        self.inner.add(symbols).await;
169    }
170
171    /// Remove symbols from the subscription.
172    ///
173    /// # Example
174    ///
175    /// ```no_run
176    /// use finance_query::streaming::PriceStream;
177    ///
178    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
179    /// let stream = PriceStream::subscribe(["AAPL", "NVDA"]).await?;
180    /// stream.remove_symbols(["NVDA"]).await;
181    /// # Ok(())
182    /// # }
183    /// ```
184    pub async fn remove_symbols<S, I>(&self, symbols: I)
185    where
186        S: Into<String>,
187        I: IntoIterator<Item = S>,
188    {
189        self.inner.remove(symbols).await;
190    }
191
192    /// Close the stream and disconnect from the WebSocket.
193    pub async fn close(&self) {
194        self.inner.close().await;
195    }
196}
197
198impl Stream for PriceStream {
199    type Item = PriceUpdate;
200
201    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
202        Pin::new(&mut self.inner).poll_next(cx)
203    }
204}
205
206/// Which upstream backend a [`PriceStream`] connects to.
207///
208/// Yahoo multiplexes every asset class onto one connection; Polygon runs a
209/// separate cluster per asset class, so its variant carries the class to use.
210#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
211#[non_exhaustive]
212pub enum PriceSource {
213    /// Yahoo Finance WebSocket — keyless, all asset classes (default).
214    #[default]
215    Yahoo,
216    /// Polygon.io real-time cluster for one asset class. Requires the
217    /// `polygon` feature and the `POLYGON_API_KEY` environment variable set.
218    #[cfg(feature = "polygon")]
219    Polygon(crate::streaming::AssetClass),
220}
221
222/// Builder for creating price streams with custom configuration
223pub struct PriceStreamBuilder {
224    symbols: Vec<String>,
225    retry_delay: Duration,
226    max_reconnect_attempts: Option<u32>,
227    source: PriceSource,
228}
229
230impl PriceStreamBuilder {
231    /// Create a new builder
232    pub fn new() -> Self {
233        Self {
234            symbols: Vec::new(),
235            retry_delay: Duration::from_secs(RECONNECT_BACKOFF_SECS),
236            max_reconnect_attempts: None,
237            source: PriceSource::Yahoo,
238        }
239    }
240
241    /// Choose the upstream backend (default: [`PriceSource::Yahoo`]).
242    ///
243    /// # Example
244    ///
245    /// ```no_run
246    /// # #[cfg(feature = "polygon")]
247    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
248    /// use finance_query::streaming::{AssetClass, PriceSource, PriceStreamBuilder};
249    ///
250    /// let stream = PriceStreamBuilder::new()
251    ///     .symbols(["BTC-USD"])
252    ///     .source(PriceSource::Polygon(AssetClass::Crypto))
253    ///     .build()
254    ///     .await?;
255    /// # Ok(())
256    /// # }
257    /// ```
258    pub fn source(mut self, source: PriceSource) -> Self {
259        self.source = source;
260        self
261    }
262
263    /// Add symbols to subscribe to
264    pub fn symbols<S, I>(mut self, symbols: I) -> Self
265    where
266        S: Into<String>,
267        I: IntoIterator<Item = S>,
268    {
269        self.symbols.extend(symbols.into_iter().map(Into::into));
270        self
271    }
272
273    /// Set the base delay before the first reconnection attempt (default:
274    /// 3s). Later attempts grow exponentially from this, capped and
275    /// jittered — see [`Self::max_reconnect_attempts`] to also cap how many
276    /// attempts are made.
277    pub fn retry(mut self, delay: Duration) -> Self {
278        self.retry_delay = delay;
279        self
280    }
281
282    /// Cap the number of consecutive reconnect attempts before the stream
283    /// gives up and ends (default: unlimited, i.e. retry forever).
284    pub fn max_reconnect_attempts(mut self, max: u32) -> Self {
285        self.max_reconnect_attempts = Some(max);
286        self
287    }
288
289    /// Build and start the price stream using the configured source.
290    pub async fn build(self) -> StreamResult<PriceStream> {
291        let source: Arc<dyn StreamSource<PriceUpdate>> = match self.source {
292            PriceSource::Yahoo => Arc::new(YahooStreamSource),
293            #[cfg(feature = "polygon")]
294            PriceSource::Polygon(class) => Arc::new(super::polygon::PolygonPriceSource::new(class)),
295        };
296        let reconnect =
297            ReconnectConfig::new(self.retry_delay).max_attempts(self.max_reconnect_attempts);
298        PriceStream::subscribe_with_source(source, self.symbols, reconnect).await
299    }
300}
301
302impl Default for PriceStreamBuilder {
303    fn default() -> Self {
304        Self::new()
305    }
306}