finance_query/streaming/
pricing.rs

1//! Real-time pricing data from Yahoo Finance WebSocket
2//!
3//! This module contains the protobuf message definition for streaming price data.
4
5use prost::Message;
6use serde::{Deserialize, Serialize};
7
8/// Quote type enumeration
9#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
10#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
11#[allow(missing_docs)]
12#[derive(Default)]
13pub enum QuoteType {
14    #[default]
15    None,
16    AltSymbol,
17    Heartbeat,
18    Equity,
19    Index,
20    MutualFund,
21    MoneyMarket,
22    Option,
23    Currency,
24    Warrant,
25    Bond,
26    Future,
27    Etf,
28    Commodity,
29    EcnQuote,
30    Cryptocurrency,
31    Indicator,
32    Industry,
33}
34
35impl From<i32> for QuoteType {
36    fn from(value: i32) -> Self {
37        match value {
38            0 => QuoteType::None,
39            5 => QuoteType::AltSymbol,
40            7 => QuoteType::Heartbeat,
41            8 => QuoteType::Equity,
42            9 => QuoteType::Index,
43            11 => QuoteType::MutualFund,
44            12 => QuoteType::MoneyMarket,
45            13 => QuoteType::Option,
46            14 => QuoteType::Currency,
47            15 => QuoteType::Warrant,
48            17 => QuoteType::Bond,
49            18 => QuoteType::Future,
50            20 => QuoteType::Etf,
51            23 => QuoteType::Commodity,
52            28 => QuoteType::EcnQuote,
53            41 => QuoteType::Cryptocurrency,
54            42 => QuoteType::Indicator,
55            1000 => QuoteType::Industry,
56            _ => QuoteType::None,
57        }
58    }
59}
60
61/// Option type enumeration
62#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
63#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
64#[allow(missing_docs)]
65#[derive(Default)]
66pub enum OptionType {
67    #[default]
68    Call,
69    Put,
70}
71
72impl From<i32> for OptionType {
73    fn from(value: i32) -> Self {
74        match value {
75            0 => OptionType::Call,
76            1 => OptionType::Put,
77            _ => OptionType::Call,
78        }
79    }
80}
81
82/// Market hours type enumeration
83#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
84#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
85#[allow(missing_docs)]
86#[derive(Default)]
87pub enum MarketHoursType {
88    #[default]
89    PreMarket,
90    RegularMarket,
91    PostMarket,
92    ExtendedHoursMarket,
93}
94
95impl From<i32> for MarketHoursType {
96    fn from(value: i32) -> Self {
97        match value {
98            0 => MarketHoursType::PreMarket,
99            1 => MarketHoursType::RegularMarket,
100            2 => MarketHoursType::PostMarket,
101            3 => MarketHoursType::ExtendedHoursMarket,
102            _ => MarketHoursType::PreMarket,
103        }
104    }
105}
106
107/// Internal protobuf struct for decoding Yahoo Finance WebSocket messages.
108///
109/// Not all fields are populated for every message - typically only fields that have
110/// changed since the last update are included.
111#[derive(Clone, PartialEq, Message)]
112pub(crate) struct PricingData {
113    /// Ticker symbol (e.g., "AAPL", "NVDA")
114    #[prost(string, tag = "1")]
115    pub id: String,
116
117    /// Current price
118    #[prost(float, tag = "2")]
119    pub price: f32,
120
121    /// Unix timestamp in milliseconds
122    #[prost(sint64, tag = "3")]
123    pub time: i64,
124
125    /// Currency code (e.g., "USD")
126    #[prost(string, tag = "4")]
127    pub currency: String,
128
129    /// Exchange code (e.g., "NMS", "NYQ")
130    #[prost(string, tag = "5")]
131    pub exchange: String,
132
133    /// Quote type
134    #[prost(enumeration = "QuoteTypeProto", tag = "6")]
135    pub quote_type: i32,
136
137    /// Market hours indicator
138    #[prost(enumeration = "MarketHoursTypeProto", tag = "7")]
139    pub market_hours: i32,
140
141    /// Percent change from previous close
142    #[prost(float, tag = "8")]
143    pub change_percent: f32,
144
145    /// Day's trading volume
146    #[prost(sint64, tag = "9")]
147    pub day_volume: i64,
148
149    /// Day's high price
150    #[prost(float, tag = "10")]
151    pub day_high: f32,
152
153    /// Day's low price
154    #[prost(float, tag = "11")]
155    pub day_low: f32,
156
157    /// Price change from previous close
158    #[prost(float, tag = "12")]
159    pub change: f32,
160
161    /// Short name/description
162    #[prost(string, tag = "13")]
163    pub short_name: String,
164
165    /// Options expiration date (Unix timestamp)
166    #[prost(sint64, tag = "14")]
167    pub expire_date: i64,
168
169    /// Opening price
170    #[prost(float, tag = "15")]
171    pub open_price: f32,
172
173    /// Previous close price
174    #[prost(float, tag = "16")]
175    pub previous_close: f32,
176
177    /// Strike price (for options)
178    #[prost(float, tag = "17")]
179    pub strike_price: f32,
180
181    /// Underlying symbol (for options/derivatives)
182    #[prost(string, tag = "18")]
183    pub underlying_symbol: String,
184
185    /// Open interest (for options)
186    #[prost(sint64, tag = "19")]
187    pub open_interest: i64,
188
189    /// Options type (call/put)
190    #[prost(enumeration = "OptionTypeProto", tag = "20")]
191    pub options_type: i32,
192
193    /// Mini option indicator
194    #[prost(sint64, tag = "21")]
195    pub mini_option: i64,
196
197    /// Last trade size
198    #[prost(sint64, tag = "22")]
199    pub last_size: i64,
200
201    /// Bid price
202    #[prost(float, tag = "23")]
203    pub bid: f32,
204
205    /// Bid size
206    #[prost(sint64, tag = "24")]
207    pub bid_size: i64,
208
209    /// Ask price
210    #[prost(float, tag = "25")]
211    pub ask: f32,
212
213    /// Ask size
214    #[prost(sint64, tag = "26")]
215    pub ask_size: i64,
216
217    /// Price hint (decimal places)
218    #[prost(sint64, tag = "27")]
219    pub price_hint: i64,
220
221    /// 24-hour volume (for crypto)
222    #[prost(sint64, tag = "28")]
223    pub vol_24hr: i64,
224
225    /// Volume across all currencies (for crypto)
226    #[prost(sint64, tag = "29")]
227    pub vol_all_currencies: i64,
228
229    /// From currency (for forex/crypto)
230    #[prost(string, tag = "30")]
231    pub from_currency: String,
232
233    /// Last market (for crypto)
234    #[prost(string, tag = "31")]
235    pub last_market: String,
236
237    /// Circulating supply (for crypto)
238    #[prost(double, tag = "32")]
239    pub circulating_supply: f64,
240
241    /// Market capitalization (for crypto)
242    #[prost(double, tag = "33")]
243    pub market_cap: f64,
244}
245
246/// Protobuf enum for quote type
247#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, prost::Enumeration)]
248#[repr(i32)]
249pub enum QuoteTypeProto {
250    None = 0,
251    AltSymbol = 5,
252    Heartbeat = 7,
253    Equity = 8,
254    Index = 9,
255    MutualFund = 11,
256    MoneyMarket = 12,
257    Option = 13,
258    Currency = 14,
259    Warrant = 15,
260    Bond = 17,
261    Future = 18,
262    Etf = 20,
263    Commodity = 23,
264    EcnQuote = 28,
265    Cryptocurrency = 41,
266    Indicator = 42,
267    Industry = 1000,
268}
269
270/// Protobuf enum for option type
271#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, prost::Enumeration)]
272#[repr(i32)]
273pub enum OptionTypeProto {
274    Call = 0,
275    Put = 1,
276}
277
278/// Protobuf enum for market hours type
279#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, prost::Enumeration)]
280#[repr(i32)]
281#[allow(clippy::enum_variant_names)]
282pub enum MarketHoursTypeProto {
283    PreMarket = 0,
284    RegularMarket = 1,
285    PostMarket = 2,
286    ExtendedHoursMarket = 3,
287}
288
289impl PricingData {
290    /// Decode from base64-encoded protobuf message
291    pub(crate) fn from_base64(encoded: &str) -> Result<Self, PricingDecodeError> {
292        let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, encoded)
293            .map_err(|e| PricingDecodeError::Base64(e.to_string()))?;
294
295        Self::decode(&bytes[..]).map_err(|e| PricingDecodeError::Protobuf(e.to_string()))
296    }
297}
298
299/// Real-time price update from Yahoo Finance WebSocket.
300///
301/// This is the user-facing struct with properly typed enum fields
302/// that serialize to readable strings like `"EQUITY"` or `"CRYPTOCURRENCY"`.
303#[derive(Clone, Debug, Serialize, Deserialize)]
304#[serde(rename_all = "camelCase")]
305#[allow(missing_docs)]
306pub struct PriceUpdate {
307    pub id: String,
308    pub price: f32,
309    pub time: i64,
310    pub currency: String,
311    pub exchange: String,
312    pub quote_type: QuoteType,
313    pub market_hours: MarketHoursType,
314    pub change_percent: f32,
315    pub day_volume: i64,
316    pub day_high: f32,
317    pub day_low: f32,
318    pub change: f32,
319    pub short_name: String,
320    pub expire_date: i64,
321    pub open_price: f32,
322    pub previous_close: f32,
323    pub strike_price: f32,
324    pub underlying_symbol: String,
325    pub open_interest: i64,
326    pub options_type: OptionType,
327    pub mini_option: i64,
328    pub last_size: i64,
329    pub bid: f32,
330    pub bid_size: i64,
331    pub ask: f32,
332    pub ask_size: i64,
333    pub price_hint: i64,
334    pub vol_24hr: i64,
335    pub vol_all_currencies: i64,
336    pub from_currency: String,
337    pub last_market: String,
338    pub circulating_supply: f64,
339    pub market_cap: f64,
340}
341
342impl From<PricingData> for PriceUpdate {
343    fn from(data: PricingData) -> Self {
344        Self {
345            id: data.id,
346            price: data.price,
347            time: data.time,
348            currency: data.currency,
349            exchange: data.exchange,
350            quote_type: QuoteType::from(data.quote_type),
351            market_hours: MarketHoursType::from(data.market_hours),
352            change_percent: data.change_percent,
353            day_volume: data.day_volume,
354            day_high: data.day_high,
355            day_low: data.day_low,
356            change: data.change,
357            short_name: data.short_name,
358            expire_date: data.expire_date,
359            open_price: data.open_price,
360            previous_close: data.previous_close,
361            strike_price: data.strike_price,
362            underlying_symbol: data.underlying_symbol,
363            open_interest: data.open_interest,
364            options_type: OptionType::from(data.options_type),
365            mini_option: data.mini_option,
366            last_size: data.last_size,
367            bid: data.bid,
368            bid_size: data.bid_size,
369            ask: data.ask,
370            ask_size: data.ask_size,
371            price_hint: data.price_hint,
372            vol_24hr: data.vol_24hr,
373            vol_all_currencies: data.vol_all_currencies,
374            from_currency: data.from_currency,
375            last_market: data.last_market,
376            circulating_supply: data.circulating_supply,
377            market_cap: data.market_cap,
378        }
379    }
380}
381
382/// Error decoding pricing data
383#[derive(Debug, Clone)]
384pub(crate) enum PricingDecodeError {
385    /// Base64 decoding failed
386    Base64(String),
387    /// Protobuf decoding failed
388    Protobuf(String),
389}
390
391impl std::fmt::Display for PricingDecodeError {
392    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
393        match self {
394            PricingDecodeError::Base64(e) => write!(f, "Base64 decode error: {}", e),
395            PricingDecodeError::Protobuf(e) => write!(f, "Protobuf decode error: {}", e),
396        }
397    }
398}
399
400impl std::error::Error for PricingDecodeError {}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405
406    #[test]
407    fn test_quote_type_from_i32() {
408        assert_eq!(QuoteType::from(8), QuoteType::Equity);
409        assert_eq!(QuoteType::from(41), QuoteType::Cryptocurrency);
410        assert_eq!(QuoteType::from(20), QuoteType::Etf);
411        assert_eq!(QuoteType::from(999), QuoteType::None);
412    }
413
414    #[test]
415    fn test_market_hours_from_i32() {
416        assert_eq!(MarketHoursType::from(0), MarketHoursType::PreMarket);
417        assert_eq!(MarketHoursType::from(1), MarketHoursType::RegularMarket);
418        assert_eq!(MarketHoursType::from(2), MarketHoursType::PostMarket);
419    }
420}