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(enumeration = "OptionTypeProto", tag = "20")]
194    pub options_type: i32,
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 option type
274#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, prost::Enumeration)]
275#[repr(i32)]
276pub enum OptionTypeProto {
277    Call = 0,
278    Put = 1,
279}
280
281/// Protobuf enum for market hours type
282#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, prost::Enumeration)]
283#[repr(i32)]
284#[allow(clippy::enum_variant_names)]
285pub enum MarketHoursTypeProto {
286    PreMarket = 0,
287    RegularMarket = 1,
288    PostMarket = 2,
289    ExtendedHoursMarket = 3,
290}
291
292impl PricingData {
293    /// Decode from base64-encoded protobuf message
294    pub(crate) fn from_base64(encoded: &str) -> Result<Self, PricingDecodeError> {
295        let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, encoded)
296            .map_err(|e| PricingDecodeError::Base64(e.to_string()))?;
297
298        Self::decode(&bytes[..]).map_err(|e| PricingDecodeError::Protobuf(e.to_string()))
299    }
300}
301
302/// Real-time price update from Yahoo Finance WebSocket.
303///
304/// This is the user-facing struct with properly typed enum fields
305/// that serialize to readable strings like `"EQUITY"` or `"CRYPTOCURRENCY"`.
306#[derive(Clone, Debug, Serialize, Deserialize)]
307#[serde(rename_all = "camelCase")]
308#[allow(missing_docs)]
309pub struct PriceUpdate {
310    pub id: String,
311    pub price: f32,
312    pub time: i64,
313    pub currency: String,
314    pub exchange: String,
315    pub quote_type: QuoteType,
316    pub market_hours: MarketHoursType,
317    pub change_percent: f32,
318    pub day_volume: i64,
319    pub day_high: f32,
320    pub day_low: f32,
321    pub change: f32,
322    pub short_name: String,
323    pub expire_date: i64,
324    pub open_price: f32,
325    pub previous_close: f32,
326    pub strike_price: f32,
327    pub underlying_symbol: String,
328    pub open_interest: i64,
329    pub options_type: OptionType,
330    pub mini_option: i64,
331    pub last_size: i64,
332    pub bid: f32,
333    pub bid_size: i64,
334    pub ask: f32,
335    pub ask_size: i64,
336    pub price_hint: i64,
337    pub vol_24hr: i64,
338    pub vol_all_currencies: i64,
339    pub from_currency: String,
340    pub last_market: String,
341    pub circulating_supply: f64,
342    pub market_cap: f64,
343}
344
345impl From<PricingData> for PriceUpdate {
346    fn from(data: PricingData) -> Self {
347        Self {
348            id: data.id,
349            price: data.price,
350            time: data.time,
351            currency: data.currency,
352            exchange: data.exchange,
353            quote_type: QuoteType::from(data.quote_type),
354            market_hours: MarketHoursType::from(data.market_hours),
355            change_percent: data.change_percent,
356            day_volume: data.day_volume,
357            day_high: data.day_high,
358            day_low: data.day_low,
359            change: data.change,
360            short_name: data.short_name,
361            expire_date: data.expire_date,
362            open_price: data.open_price,
363            previous_close: data.previous_close,
364            strike_price: data.strike_price,
365            underlying_symbol: data.underlying_symbol,
366            open_interest: data.open_interest,
367            options_type: OptionType::from(data.options_type),
368            mini_option: data.mini_option,
369            last_size: data.last_size,
370            bid: data.bid,
371            bid_size: data.bid_size,
372            ask: data.ask,
373            ask_size: data.ask_size,
374            price_hint: data.price_hint,
375            vol_24hr: data.vol_24hr,
376            vol_all_currencies: data.vol_all_currencies,
377            from_currency: data.from_currency,
378            last_market: data.last_market,
379            circulating_supply: data.circulating_supply,
380            market_cap: data.market_cap,
381        }
382    }
383}
384
385/// Error decoding pricing data
386#[derive(Debug, Clone)]
387pub(crate) enum PricingDecodeError {
388    /// Base64 decoding failed
389    Base64(String),
390    /// Protobuf decoding failed
391    Protobuf(String),
392}
393
394impl std::fmt::Display for PricingDecodeError {
395    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
396        match self {
397            PricingDecodeError::Base64(e) => write!(f, "Base64 decode error: {}", e),
398            PricingDecodeError::Protobuf(e) => write!(f, "Protobuf decode error: {}", e),
399        }
400    }
401}
402
403impl std::error::Error for PricingDecodeError {}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408
409    #[test]
410    fn test_quote_type_from_i32() {
411        assert_eq!(QuoteType::from(8), QuoteType::Equity);
412        assert_eq!(QuoteType::from(41), QuoteType::Cryptocurrency);
413        assert_eq!(QuoteType::from(20), QuoteType::Etf);
414        assert_eq!(QuoteType::from(999), QuoteType::None);
415    }
416
417    #[test]
418    fn test_market_hours_from_i32() {
419        assert_eq!(MarketHoursType::from(0), MarketHoursType::PreMarket);
420        assert_eq!(MarketHoursType::from(1), MarketHoursType::RegularMarket);
421        assert_eq!(MarketHoursType::from(2), MarketHoursType::PostMarket);
422    }
423}