Skip to main content

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