finance_query/streaming/polygon/
mod.rs1mod book;
9mod options;
10mod price;
11mod trades;
12
13use std::collections::{HashMap, HashSet};
14use std::sync::Arc;
15
16use futures::StreamExt;
17use tokio::sync::{RwLock, broadcast, mpsc};
18use tracing::{debug, info, warn};
19
20use crate::adapters::polygon::websocket::{ClusterDTO, PolygonMessage, PolygonStream};
21
22use super::client::{StreamError, StreamResult};
23use super::source::{StreamCommand, apply_command};
24
25pub(crate) use book::PolygonBookSource;
26pub(crate) use options::PolygonOptionsSource;
27pub(crate) use price::PolygonPriceSource;
28pub(crate) use trades::PolygonTradeSource;
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
35#[non_exhaustive]
36pub enum AssetClass {
37 #[default]
39 Stocks,
40 Options,
42 Forex,
44 Crypto,
46 Futures,
48 Indices,
50}
51
52impl AssetClass {
53 pub(crate) fn cluster(self) -> ClusterDTO {
54 match self {
55 Self::Stocks => ClusterDTO::Stocks,
56 Self::Options => ClusterDTO::Options,
57 Self::Forex => ClusterDTO::Forex,
58 Self::Crypto => ClusterDTO::Crypto,
59 Self::Futures => ClusterDTO::Futures,
60 Self::Indices => ClusterDTO::Indices,
61 }
62 }
63
64 pub(crate) fn price_channels(self) -> &'static [&'static str] {
66 match self {
67 Self::Stocks | Self::Options | Self::Futures => &["T", "Q"],
68 Self::Forex => &["C", "CA"],
69 Self::Crypto => &["XT", "XQ"],
70 Self::Indices => &["V"],
71 }
72 }
73
74 pub(crate) fn trade_channel(self) -> Option<&'static str> {
76 match self {
77 Self::Stocks | Self::Options | Self::Futures => Some("T"),
78 Self::Crypto => Some("XT"),
79 Self::Forex | Self::Indices => None,
81 }
82 }
83
84 pub(crate) fn label(self) -> &'static str {
85 match self {
86 Self::Stocks => "polygon-stocks",
87 Self::Options => "polygon-options",
88 Self::Forex => "polygon-forex",
89 Self::Crypto => "polygon-crypto",
90 Self::Futures => "polygon-futures",
91 Self::Indices => "polygon-indices",
92 }
93 }
94
95 pub(crate) fn wire_symbol(self, symbol: &str) -> String {
100 let symbol = symbol.trim().to_uppercase();
101 match self {
102 Self::Indices if !symbol.starts_with("I:") => format!("I:{symbol}"),
103 Self::Options => {
104 let body = symbol.strip_prefix("O:").unwrap_or(&symbol);
105 if body.chars().any(|c| c.is_ascii_digit()) {
106 format!("O:{body}")
107 } else {
108 format!("O:{}*", body.trim_end_matches('*'))
109 }
110 }
111 _ => symbol,
112 }
113 }
114}
115
116pub(crate) fn channels_for(
118 class: AssetClass,
119 prefixes: &[&str],
120 symbols: impl IntoIterator<Item = String>,
121) -> Vec<String> {
122 let symbols: Vec<String> = symbols
123 .into_iter()
124 .map(|s| class.wire_symbol(&s))
125 .filter(|s| !s.is_empty())
126 .collect();
127 prefixes
128 .iter()
129 .flat_map(|p| symbols.iter().map(move |s| format!("{p}.{s}")))
130 .collect()
131}
132
133pub(crate) trait SessionHandler<T>: Send {
138 fn on_event(&mut self, msg: PolygonMessage) -> Vec<T>;
140
141 fn on_unsubscribe(&mut self, _removed: &[String]) {}
143}
144
145pub(crate) struct Decode<F>(pub(crate) F);
147
148impl<T, F> SessionHandler<T> for Decode<F>
149where
150 F: FnMut(PolygonMessage) -> Vec<T> + Send,
151{
152 fn on_event(&mut self, msg: PolygonMessage) -> Vec<T> {
153 (self.0)(msg)
154 }
155}
156
157fn covers(pattern: &str, key: &str) -> bool {
159 match pattern.strip_suffix('*') {
160 Some(prefix) => key.starts_with(prefix),
161 None => key == pattern,
162 }
163}
164
165pub(crate) fn prune_symbols<V>(
170 state: &mut HashMap<String, V>,
171 class: AssetClass,
172 removed: &[String],
173) {
174 let patterns: Vec<String> = removed.iter().map(|s| class.wire_symbol(s)).collect();
175 state.retain(|key, _| !patterns.iter().any(|p| covers(p, key)));
176}
177
178pub(crate) async fn run_polygon_session<T, H>(
180 class: AssetClass,
181 prefixes: &[&str],
182 subscriptions: &Arc<RwLock<HashSet<String>>>,
183 broadcast_tx: &broadcast::Sender<T>,
184 command_rx: &mut mpsc::Receiver<StreamCommand>,
185 mut handler: H,
186) -> StreamResult<()>
187where
188 T: Clone + Send + 'static,
189 H: SessionHandler<T>,
190{
191 let initial: Vec<String> = subscriptions.read().await.iter().cloned().collect();
192 let channels = channels_for(class, prefixes, initial);
193 let channel_refs: Vec<&str> = channels.iter().map(String::as_str).collect();
194
195 let mut stream = PolygonStream::from_singleton()
196 .map_err(|e| StreamError::ConnectionFailed(e.to_string()))?
197 .cluster(class.cluster())
198 .subscribe(&channel_refs)
199 .build()
200 .await
201 .map_err(|e| StreamError::ConnectionFailed(e.to_string()))?;
202
203 let sender = stream.sender();
204 info!("Connected to Polygon {} cluster", class.label());
205
206 loop {
207 tokio::select! {
208 Some(msg) = stream.next() => {
209 match msg {
210 PolygonMessage::Status(status) => debug!("polygon status: {status}"),
211 PolygonMessage::Unknown(raw) => warn!("unparsed polygon frame: {raw}"),
212 msg => {
213 for item in handler.on_event(msg) {
214 let _ = broadcast_tx.send(item);
215 }
216 }
217 }
218 }
219
220 Some(cmd) = command_rx.recv() => {
221 let Some(changed) = apply_command(&cmd, subscriptions).await else {
222 return Ok(());
223 };
224 if changed.is_empty() {
225 continue;
226 }
227 let subscribing = matches!(cmd, StreamCommand::Subscribe(_));
228 if !subscribing {
229 handler.on_unsubscribe(&changed);
230 }
231 let channels = channels_for(class, prefixes, changed);
232 let result = if subscribing {
233 sender.subscribe_channels(&channels).await
234 } else {
235 sender.unsubscribe_channels(&channels).await
236 };
237 if let Err(e) = result {
238 return Err(StreamError::WebSocketError(e.to_string()));
239 }
240 }
241
242 else => break,
243 }
244 }
245
246 Err(StreamError::WebSocketError(format!(
248 "{} connection closed",
249 class.label()
250 )))
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256
257 #[test]
258 fn wire_symbol_applies_cluster_prefixes() {
259 assert_eq!(AssetClass::Indices.wire_symbol("spx"), "I:SPX");
260 assert_eq!(AssetClass::Indices.wire_symbol("I:SPX"), "I:SPX");
261 assert_eq!(AssetClass::Stocks.wire_symbol("aapl"), "AAPL");
262 assert_eq!(AssetClass::Crypto.wire_symbol("btc-usd"), "BTC-USD");
263 assert_eq!(
264 AssetClass::Options.wire_symbol("AAPL250117C00150000"),
265 "O:AAPL250117C00150000"
266 );
267 assert_eq!(AssetClass::Options.wire_symbol("aapl"), "O:AAPL*");
268 assert_eq!(AssetClass::Options.wire_symbol("O:AAPL*"), "O:AAPL*");
269 }
270
271 #[test]
272 fn pruning_drops_exact_and_wildcard_matches() {
273 let mut state: HashMap<String, u8> = HashMap::from([
274 ("O:AAPL250117C00150000".to_string(), 1),
275 ("O:AAPL250117P00150000".to_string(), 2),
276 ("O:SPY250117C00500000".to_string(), 3),
277 ]);
278
279 prune_symbols(&mut state, AssetClass::Options, &["AAPL".to_string()]);
282 assert_eq!(state.len(), 1);
283 assert!(state.contains_key("O:SPY250117C00500000"));
284
285 let mut equities: HashMap<String, u8> =
286 HashMap::from([("AAPL".to_string(), 1), ("NVDA".to_string(), 2)]);
287 prune_symbols(&mut equities, AssetClass::Stocks, &["aapl".to_string()]);
288 assert_eq!(equities.keys().collect::<Vec<_>>(), vec!["NVDA"]);
289 }
290
291 #[test]
292 fn channels_expand_across_prefixes_and_symbols() {
293 let channels = channels_for(
294 AssetClass::Crypto,
295 &["XT", "XQ"],
296 ["btc-usd".to_string(), "eth-usd".to_string()],
297 );
298 assert_eq!(
299 channels,
300 vec!["XT.BTC-USD", "XT.ETH-USD", "XQ.BTC-USD", "XQ.ETH-USD"]
301 );
302 }
303}