finance_query_core/models/
quote.rs

1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Serialize, Deserialize)]
4#[serde(rename_all = "snake_case")]
5pub struct Quote {
6    pub symbol: String,
7    pub name: String,
8    pub price: String,
9    #[serde(skip_serializing_if = "Option::is_none")]
10    pub pre_market_price: Option<String>,
11    #[serde(skip_serializing_if = "Option::is_none")]
12    pub after_hours_price: Option<String>,
13    pub change: String,
14    pub percent_change: String,
15    #[serde(skip_serializing_if = "Option::is_none")]
16    pub open: Option<String>,
17    #[serde(skip_serializing_if = "Option::is_none")]
18    pub high: Option<String>,
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub low: Option<String>,
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub year_high: Option<String>,
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub year_low: Option<String>,
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub volume: Option<i64>,
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub avg_volume: Option<i64>,
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub market_cap: Option<String>,
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub beta: Option<String>,
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub pe: Option<String>,
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub eps: Option<String>,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub dividend: Option<String>,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub dividend_yield: Option<String>,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub ex_dividend: Option<String>,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub net_assets: Option<String>,
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub nav: Option<String>,
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub expense_ratio: Option<String>,
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub category: Option<String>,
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub last_capital_gain: Option<String>,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub morningstar_rating: Option<String>,
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub morningstar_risk_rating: Option<String>,
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub holdings_turnover: Option<String>,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub earnings_date: Option<String>,
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub last_dividend: Option<String>,
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub inception_date: Option<String>,
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub sector: Option<String>,
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub industry: Option<String>,
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub about: Option<String>,
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub employees: Option<String>,
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub five_days_return: Option<String>,
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub one_month_return: Option<String>,
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub three_month_return: Option<String>,
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub six_month_return: Option<String>,
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub ytd_return: Option<String>,
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub year_return: Option<String>,
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub three_year_return: Option<String>,
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub five_year_return: Option<String>,
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub ten_year_return: Option<String>,
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub max_return: Option<String>,
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub logo: Option<String>,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
98#[serde(rename_all = "snake_case")]
99pub struct SimpleQuote {
100    pub symbol: String,
101    pub name: String,
102    pub price: String,
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub pre_market_price: Option<String>,
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub after_hours_price: Option<String>,
107    pub change: String,
108    pub percent_change: String,
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub logo: Option<String>,
111}
112
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct DetailedQuote {
116    pub symbol: String,
117    pub name: String,
118    pub price: String,
119    #[serde(skip_serializing_if = "Option::is_none", rename = "preMarketPrice")]
120    pub pre_market_price: Option<String>,
121    #[serde(skip_serializing_if = "Option::is_none", rename = "afterHoursPrice")]
122    pub after_hours_price: Option<String>,
123    pub change: String,
124    #[serde(rename = "percentChange")]
125    pub percent_change: String,
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub open: Option<String>,
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub high: Option<String>,
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub low: Option<String>,
132    #[serde(skip_serializing_if = "Option::is_none", rename = "yearHigh")]
133    pub year_high: Option<String>,
134    #[serde(skip_serializing_if = "Option::is_none", rename = "yearLow")]
135    pub year_low: Option<String>,
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub volume: Option<i64>,
138    #[serde(skip_serializing_if = "Option::is_none", rename = "avgVolume")]
139    pub avg_volume: Option<i64>,
140    #[serde(skip_serializing_if = "Option::is_none", rename = "marketCap")]
141    pub market_cap: Option<String>,
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub beta: Option<String>,
144    #[serde(skip_serializing_if = "Option::is_none")]
145    pub pe: Option<String>,
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub eps: Option<String>,
148    #[serde(skip_serializing_if = "Option::is_none")]
149    pub dividend: Option<String>,
150    #[serde(skip_serializing_if = "Option::is_none", rename = "yield")]
151    pub dividend_yield: Option<String>,
152    #[serde(skip_serializing_if = "Option::is_none", rename = "exDividend")]
153    pub ex_dividend: Option<String>,
154    #[serde(skip_serializing_if = "Option::is_none", rename = "netAssets")]
155    pub net_assets: Option<String>,
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub nav: Option<String>,
158    #[serde(skip_serializing_if = "Option::is_none", rename = "expenseRatio")]
159    pub expense_ratio: Option<String>,
160    #[serde(skip_serializing_if = "Option::is_none")]
161    pub category: Option<String>,
162    #[serde(skip_serializing_if = "Option::is_none", rename = "lastCapitalGain")]
163    pub last_capital_gain: Option<String>,
164    #[serde(skip_serializing_if = "Option::is_none", rename = "morningstarRating")]
165    pub morningstar_rating: Option<String>,
166    #[serde(skip_serializing_if = "Option::is_none", rename = "morningstarRiskRating")]
167    pub morningstar_risk_rating: Option<String>,
168    #[serde(skip_serializing_if = "Option::is_none", rename = "holdingsTurnover")]
169    pub holdings_turnover: Option<String>,
170    #[serde(skip_serializing_if = "Option::is_none", rename = "earningsDate")]
171    pub earnings_date: Option<String>,
172    #[serde(skip_serializing_if = "Option::is_none", rename = "lastDividend")]
173    pub last_dividend: Option<String>,
174    #[serde(skip_serializing_if = "Option::is_none", rename = "inceptionDate")]
175    pub inception_date: Option<String>,
176    #[serde(skip_serializing_if = "Option::is_none")]
177    pub sector: Option<String>,
178    #[serde(skip_serializing_if = "Option::is_none")]
179    pub industry: Option<String>,
180    #[serde(skip_serializing_if = "Option::is_none")]
181    pub about: Option<String>,
182    #[serde(skip_serializing_if = "Option::is_none")]
183    pub employees: Option<String>,
184    #[serde(skip_serializing_if = "Option::is_none", rename = "fiveDaysReturn")]
185    pub five_days_return: Option<String>,
186    #[serde(skip_serializing_if = "Option::is_none", rename = "oneMonthReturn")]
187    pub one_month_return: Option<String>,
188    #[serde(skip_serializing_if = "Option::is_none", rename = "threeMonthReturn")]
189    pub three_month_return: Option<String>,
190    #[serde(skip_serializing_if = "Option::is_none", rename = "sixMonthReturn")]
191    pub six_month_return: Option<String>,
192    #[serde(skip_serializing_if = "Option::is_none", rename = "ytdReturn")]
193    pub ytd_return: Option<String>,
194    #[serde(skip_serializing_if = "Option::is_none", rename = "yearReturn")]
195    pub year_return: Option<String>,
196    #[serde(skip_serializing_if = "Option::is_none", rename = "threeYearReturn")]
197    pub three_year_return: Option<String>,
198    #[serde(skip_serializing_if = "Option::is_none", rename = "fiveYearReturn")]
199    pub five_year_return: Option<String>,
200    #[serde(skip_serializing_if = "Option::is_none", rename = "tenYearReturn")]
201    pub ten_year_return: Option<String>,
202    #[serde(skip_serializing_if = "Option::is_none", rename = "maxReturn")]
203    pub max_return: Option<String>,
204    #[serde(skip_serializing_if = "Option::is_none")]
205    pub logo: Option<String>,
206}
207
208impl From<Quote> for DetailedQuote {
209    fn from(quote: Quote) -> Self {
210        DetailedQuote {
211            symbol: quote.symbol,
212            name: quote.name,
213            price: quote.price,
214            pre_market_price: quote.pre_market_price,
215            after_hours_price: quote.after_hours_price,
216            change: quote.change,
217            percent_change: quote.percent_change,
218            open: quote.open,
219            high: quote.high,
220            low: quote.low,
221            year_high: quote.year_high,
222            year_low: quote.year_low,
223            volume: quote.volume,
224            avg_volume: quote.avg_volume,
225            market_cap: quote.market_cap,
226            beta: quote.beta,
227            pe: quote.pe,
228            eps: quote.eps,
229            dividend: quote.dividend,
230            dividend_yield: quote.dividend_yield,
231            ex_dividend: quote.ex_dividend,
232            net_assets: quote.net_assets,
233            nav: quote.nav,
234            expense_ratio: quote.expense_ratio,
235            category: quote.category,
236            last_capital_gain: quote.last_capital_gain,
237            morningstar_rating: quote.morningstar_rating,
238            morningstar_risk_rating: quote.morningstar_risk_rating,
239            holdings_turnover: quote.holdings_turnover,
240            earnings_date: quote.earnings_date,
241            last_dividend: quote.last_dividend,
242            inception_date: quote.inception_date,
243            sector: quote.sector,
244            industry: quote.industry,
245            about: quote.about,
246            employees: quote.employees,
247            five_days_return: quote.five_days_return,
248            one_month_return: quote.one_month_return,
249            three_month_return: quote.three_month_return,
250            six_month_return: quote.six_month_return,
251            ytd_return: quote.ytd_return,
252            year_return: quote.year_return,
253            three_year_return: quote.three_year_return,
254            five_year_return: quote.five_year_return,
255            ten_year_return: quote.ten_year_return,
256            max_return: quote.max_return,
257            logo: quote.logo,
258        }
259    }
260}
261
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266    use proptest::prelude::*;
267
268    // Helper strategy for generating optional strings
269    fn optional_string() -> impl Strategy<Value = Option<String>> {
270        proptest::option::of("[a-zA-Z0-9 .-]{0,20}")
271    }
272
273    // Helper strategy for generating optional i64
274    fn optional_i64() -> impl Strategy<Value = Option<i64>> {
275        proptest::option::of(0i64..1_000_000_000i64)
276    }
277
278    // **Feature: crate-extraction, Property 1: Model Serialization Round-Trip**
279    // **Validates: Requirements 2.2**
280    proptest! {
281        #![proptest_config(ProptestConfig::with_cases(100))]
282
283        #[test]
284        fn simple_quote_roundtrip(
285            symbol in "[A-Z]{1,5}",
286            name in "[A-Za-z ]{1,50}",
287            price in "[0-9]{1,4}\\.[0-9]{2}",
288            pre_market_price in optional_string(),
289            after_hours_price in optional_string(),
290            change in "-?[0-9]{1,3}\\.[0-9]{2}",
291            percent_change in "-?[0-9]{1,3}\\.[0-9]{2}%",
292            logo in optional_string(),
293        ) {
294            let quote = SimpleQuote {
295                symbol: symbol.clone(),
296                name: name.clone(),
297                price: price.clone(),
298                pre_market_price: pre_market_price.clone(),
299                after_hours_price: after_hours_price.clone(),
300                change: change.clone(),
301                percent_change: percent_change.clone(),
302                logo: logo.clone(),
303            };
304
305            let json = serde_json::to_string(&quote).unwrap();
306            let parsed: SimpleQuote = serde_json::from_str(&json).unwrap();
307
308            prop_assert_eq!(quote.symbol, parsed.symbol);
309            prop_assert_eq!(quote.name, parsed.name);
310            prop_assert_eq!(quote.price, parsed.price);
311            prop_assert_eq!(quote.pre_market_price, parsed.pre_market_price);
312            prop_assert_eq!(quote.after_hours_price, parsed.after_hours_price);
313            prop_assert_eq!(quote.change, parsed.change);
314            prop_assert_eq!(quote.percent_change, parsed.percent_change);
315            prop_assert_eq!(quote.logo, parsed.logo);
316        }
317
318        #[test]
319        fn quote_roundtrip(
320            symbol in "[A-Z]{1,5}",
321            name in "[A-Za-z ]{1,50}",
322            price in "[0-9]{1,4}\\.[0-9]{2}",
323            pre_market_price in optional_string(),
324            after_hours_price in optional_string(),
325            change in "-?[0-9]{1,3}\\.[0-9]{2}",
326            percent_change in "-?[0-9]{1,3}\\.[0-9]{2}%",
327            open in optional_string(),
328            high in optional_string(),
329            low in optional_string(),
330            year_high in optional_string(),
331            year_low in optional_string(),
332            volume in optional_i64(),
333            avg_volume in optional_i64(),
334            market_cap in optional_string(),
335            logo in optional_string(),
336        ) {
337            let quote = Quote {
338                symbol: symbol.clone(),
339                name: name.clone(),
340                price: price.clone(),
341                pre_market_price: pre_market_price.clone(),
342                after_hours_price: after_hours_price.clone(),
343                change: change.clone(),
344                percent_change: percent_change.clone(),
345                open: open.clone(),
346                high: high.clone(),
347                low: low.clone(),
348                year_high: year_high.clone(),
349                year_low: year_low.clone(),
350                volume,
351                avg_volume,
352                market_cap: market_cap.clone(),
353                beta: None,
354                pe: None,
355                eps: None,
356                dividend: None,
357                dividend_yield: None,
358                ex_dividend: None,
359                net_assets: None,
360                nav: None,
361                expense_ratio: None,
362                category: None,
363                last_capital_gain: None,
364                morningstar_rating: None,
365                morningstar_risk_rating: None,
366                holdings_turnover: None,
367                earnings_date: None,
368                last_dividend: None,
369                inception_date: None,
370                sector: None,
371                industry: None,
372                about: None,
373                employees: None,
374                five_days_return: None,
375                one_month_return: None,
376                three_month_return: None,
377                six_month_return: None,
378                ytd_return: None,
379                year_return: None,
380                three_year_return: None,
381                five_year_return: None,
382                ten_year_return: None,
383                max_return: None,
384                logo: logo.clone(),
385            };
386
387            let json = serde_json::to_string(&quote).unwrap();
388            let parsed: Quote = serde_json::from_str(&json).unwrap();
389
390            prop_assert_eq!(quote.symbol, parsed.symbol);
391            prop_assert_eq!(quote.name, parsed.name);
392            prop_assert_eq!(quote.price, parsed.price);
393            prop_assert_eq!(quote.pre_market_price, parsed.pre_market_price);
394            prop_assert_eq!(quote.after_hours_price, parsed.after_hours_price);
395            prop_assert_eq!(quote.change, parsed.change);
396            prop_assert_eq!(quote.percent_change, parsed.percent_change);
397            prop_assert_eq!(quote.open, parsed.open);
398            prop_assert_eq!(quote.high, parsed.high);
399            prop_assert_eq!(quote.low, parsed.low);
400            prop_assert_eq!(quote.year_high, parsed.year_high);
401            prop_assert_eq!(quote.year_low, parsed.year_low);
402            prop_assert_eq!(quote.volume, parsed.volume);
403            prop_assert_eq!(quote.avg_volume, parsed.avg_volume);
404            prop_assert_eq!(quote.market_cap, parsed.market_cap);
405            prop_assert_eq!(quote.logo, parsed.logo);
406        }
407    }
408}