finance_query_core/models/
historical.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashSet;
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
5#[allow(clippy::upper_case_acronyms)]
6pub enum IndicatorType {
7    SMA,
8    EMA,
9}
10
11impl IndicatorType {
12    pub fn parse(s: &str) -> Option<Self> {
13        match s.to_lowercase().as_str() {
14            "sma" => Some(IndicatorType::SMA),
15            "ema" => Some(IndicatorType::EMA),
16            _ => None,
17        }
18    }
19    
20    pub fn parse_list(s: &str) -> HashSet<Self> {
21        s.split(',')
22            .map(|s| s.trim())
23            .filter_map(IndicatorType::parse)
24            .collect()
25    }
26}
27
28#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum TimeRange {
31    #[serde(rename = "1d")]
32    Day,
33    #[serde(rename = "5d")]
34    FiveDays,
35    #[serde(rename = "1mo")]
36    OneMonth,
37    #[serde(rename = "3mo")]
38    ThreeMonths,
39    #[serde(rename = "6mo")]
40    SixMonths,
41    #[serde(rename = "1y")]
42    Year,
43    #[serde(rename = "2y")]
44    TwoYears,
45    #[serde(rename = "5y")]
46    FiveYears,
47    #[serde(rename = "10y")]
48    TenYears,
49    #[serde(rename = "ytd")]
50    Ytd,
51    #[serde(rename = "max")]
52    Max,
53}
54
55impl TimeRange {
56    pub fn as_str(&self) -> &'static str {
57        match self {
58            TimeRange::Day => "1d",
59            TimeRange::FiveDays => "5d",
60            TimeRange::OneMonth => "1mo",
61            TimeRange::ThreeMonths => "3mo",
62            TimeRange::SixMonths => "6mo",
63            TimeRange::Year => "1y",
64            TimeRange::TwoYears => "2y",
65            TimeRange::FiveYears => "5y",
66            TimeRange::TenYears => "10y",
67            TimeRange::Ytd => "ytd",
68            TimeRange::Max => "max",
69        }
70    }
71}
72
73
74#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
75#[serde(rename_all = "snake_case")]
76pub enum Interval {
77    #[serde(rename = "1m")]
78    OneMinute,
79    #[serde(rename = "3m")]
80    ThreeMinutes,
81    #[serde(rename = "5m")]
82    FiveMinutes,
83    #[serde(rename = "10m")]
84    TenMinutes,
85    #[serde(rename = "15m")]
86    FifteenMinutes,
87    #[serde(rename = "20m")]
88    TwentyMinutes,
89    #[serde(rename = "30m")]
90    ThirtyMinutes,
91    #[serde(rename = "65m")]
92    SixtyFiveMinutes,
93    #[serde(rename = "95m")]
94    NinetyFiveMinutes,
95    #[serde(rename = "1h")]
96    OneHour,
97    #[serde(rename = "1d")]
98    Daily,
99    #[serde(rename = "1wk")]
100    Weekly,
101    #[serde(rename = "1mo")]
102    Monthly,
103}
104
105impl Interval {
106    pub fn as_str(&self) -> &'static str {
107        match self {
108            Interval::OneMinute => "1m",
109            Interval::ThreeMinutes => "3m",
110            Interval::FiveMinutes => "5m",
111            Interval::TenMinutes => "10m",
112            Interval::FifteenMinutes => "15m",
113            Interval::TwentyMinutes => "20m",
114            Interval::ThirtyMinutes => "30m",
115            Interval::SixtyFiveMinutes => "65m",
116            Interval::NinetyFiveMinutes => "95m",
117            Interval::OneHour => "1h",
118            Interval::Daily => "1d",
119            Interval::Weekly => "1wk",
120            Interval::Monthly => "1mo",
121        }
122    }
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
126#[serde(rename_all = "snake_case")]
127pub struct HistoricalData {
128    pub open: f64,
129    pub high: f64,
130    pub low: f64,
131    pub close: f64,
132    pub volume: i64,
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub adj_close: Option<f64>,
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub sma: Option<std::collections::HashMap<String, f64>>,
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub ema: Option<std::collections::HashMap<String, f64>>,
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize)]
142#[serde(rename_all = "snake_case")]
143pub struct HistoricalResponse {
144    pub data: std::collections::HashMap<String, HistoricalData>,
145}
146
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use proptest::prelude::*;
152
153    // Helper function to compare f64 values with tolerance for JSON round-trip
154    fn approx_eq(a: f64, b: f64) -> bool {
155        (a - b).abs() < 1e-10
156    }
157
158    fn approx_eq_opt(a: Option<f64>, b: Option<f64>) -> bool {
159        match (a, b) {
160            (Some(x), Some(y)) => approx_eq(x, y),
161            (None, None) => true,
162            _ => false,
163        }
164    }
165
166    // **Feature: crate-extraction, Property 1: Model Serialization Round-Trip**
167    // **Validates: Requirements 2.2**
168    proptest! {
169        #![proptest_config(ProptestConfig::with_cases(100))]
170
171        #[test]
172        fn historical_data_roundtrip(
173            open in 0.01f64..10000.0f64,
174            high in 0.01f64..10000.0f64,
175            low in 0.01f64..10000.0f64,
176            close in 0.01f64..10000.0f64,
177            volume in 0i64..1_000_000_000i64,
178            adj_close in proptest::option::of(0.01f64..10000.0f64),
179        ) {
180            let data = HistoricalData {
181                open,
182                high,
183                low,
184                close,
185                volume,
186                adj_close,
187                sma: None,
188                ema: None,
189            };
190
191            let json = serde_json::to_string(&data).unwrap();
192            let parsed: HistoricalData = serde_json::from_str(&json).unwrap();
193
194            // Use approximate comparison for f64 due to JSON serialization precision limits
195            prop_assert!(approx_eq(data.open, parsed.open), "open mismatch");
196            prop_assert!(approx_eq(data.high, parsed.high), "high mismatch");
197            prop_assert!(approx_eq(data.low, parsed.low), "low mismatch");
198            prop_assert!(approx_eq(data.close, parsed.close), "close mismatch");
199            prop_assert_eq!(data.volume, parsed.volume);
200            prop_assert!(approx_eq_opt(data.adj_close, parsed.adj_close), "adj_close mismatch");
201        }
202
203        #[test]
204        fn time_range_roundtrip(range in prop_oneof![
205            Just(TimeRange::Day),
206            Just(TimeRange::FiveDays),
207            Just(TimeRange::OneMonth),
208            Just(TimeRange::ThreeMonths),
209            Just(TimeRange::SixMonths),
210            Just(TimeRange::Year),
211            Just(TimeRange::TwoYears),
212            Just(TimeRange::FiveYears),
213            Just(TimeRange::TenYears),
214            Just(TimeRange::Ytd),
215            Just(TimeRange::Max),
216        ]) {
217            let json = serde_json::to_string(&range).unwrap();
218            let parsed: TimeRange = serde_json::from_str(&json).unwrap();
219
220            prop_assert_eq!(range.as_str(), parsed.as_str());
221        }
222
223        #[test]
224        fn interval_roundtrip(interval in prop_oneof![
225            Just(Interval::OneMinute),
226            Just(Interval::ThreeMinutes),
227            Just(Interval::FiveMinutes),
228            Just(Interval::TenMinutes),
229            Just(Interval::FifteenMinutes),
230            Just(Interval::TwentyMinutes),
231            Just(Interval::ThirtyMinutes),
232            Just(Interval::SixtyFiveMinutes),
233            Just(Interval::NinetyFiveMinutes),
234            Just(Interval::OneHour),
235            Just(Interval::Daily),
236            Just(Interval::Weekly),
237            Just(Interval::Monthly),
238        ]) {
239            let json = serde_json::to_string(&interval).unwrap();
240            let parsed: Interval = serde_json::from_str(&json).unwrap();
241
242            prop_assert_eq!(interval.as_str(), parsed.as_str());
243        }
244    }
245}