Skip to main content

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#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
74#[serde(rename_all = "snake_case")]
75pub enum Interval {
76    #[serde(rename = "1m")]
77    OneMinute,
78    #[serde(rename = "3m")]
79    ThreeMinutes,
80    #[serde(rename = "5m")]
81    FiveMinutes,
82    #[serde(rename = "10m")]
83    TenMinutes,
84    #[serde(rename = "15m")]
85    FifteenMinutes,
86    #[serde(rename = "20m")]
87    TwentyMinutes,
88    #[serde(rename = "30m")]
89    ThirtyMinutes,
90    #[serde(rename = "65m")]
91    SixtyFiveMinutes,
92    #[serde(rename = "95m")]
93    NinetyFiveMinutes,
94    #[serde(rename = "1h")]
95    OneHour,
96    #[serde(rename = "1d")]
97    Daily,
98    #[serde(rename = "1wk")]
99    Weekly,
100    #[serde(rename = "1mo")]
101    Monthly,
102}
103
104impl Interval {
105    pub fn as_str(&self) -> &'static str {
106        match self {
107            Interval::OneMinute => "1m",
108            Interval::ThreeMinutes => "3m",
109            Interval::FiveMinutes => "5m",
110            Interval::TenMinutes => "10m",
111            Interval::FifteenMinutes => "15m",
112            Interval::TwentyMinutes => "20m",
113            Interval::ThirtyMinutes => "30m",
114            Interval::SixtyFiveMinutes => "65m",
115            Interval::NinetyFiveMinutes => "95m",
116            Interval::OneHour => "1h",
117            Interval::Daily => "1d",
118            Interval::Weekly => "1wk",
119            Interval::Monthly => "1mo",
120        }
121    }
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
125#[serde(rename_all = "snake_case")]
126pub struct HistoricalData {
127    pub open: f64,
128    pub high: f64,
129    pub low: f64,
130    pub close: f64,
131    pub volume: i64,
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub adj_close: Option<f64>,
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub sma: Option<std::collections::HashMap<String, f64>>,
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub ema: Option<std::collections::HashMap<String, f64>>,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
141#[serde(rename_all = "snake_case")]
142pub struct HistoricalResponse {
143    pub data: std::collections::HashMap<String, HistoricalData>,
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use proptest::prelude::*;
150
151    // Helper function to compare f64 values with tolerance for JSON round-trip
152    fn approx_eq(a: f64, b: f64) -> bool {
153        (a - b).abs() < 1e-10
154    }
155
156    fn approx_eq_opt(a: Option<f64>, b: Option<f64>) -> bool {
157        match (a, b) {
158            (Some(x), Some(y)) => approx_eq(x, y),
159            (None, None) => true,
160            _ => false,
161        }
162    }
163
164    // **Feature: crate-extraction, Property 1: Model Serialization Round-Trip**
165    // **Validates: Requirements 2.2**
166    proptest! {
167        #![proptest_config(ProptestConfig::with_cases(100))]
168
169        #[test]
170        fn historical_data_roundtrip(
171            open in 0.01f64..10000.0f64,
172            high in 0.01f64..10000.0f64,
173            low in 0.01f64..10000.0f64,
174            close in 0.01f64..10000.0f64,
175            volume in 0i64..1_000_000_000i64,
176            adj_close in proptest::option::of(0.01f64..10000.0f64),
177        ) {
178            let data = HistoricalData {
179                open,
180                high,
181                low,
182                close,
183                volume,
184                adj_close,
185                sma: None,
186                ema: None,
187            };
188
189            let json = serde_json::to_string(&data).unwrap();
190            let parsed: HistoricalData = serde_json::from_str(&json).unwrap();
191
192            // Use approximate comparison for f64 due to JSON serialization precision limits
193            prop_assert!(approx_eq(data.open, parsed.open), "open mismatch");
194            prop_assert!(approx_eq(data.high, parsed.high), "high mismatch");
195            prop_assert!(approx_eq(data.low, parsed.low), "low mismatch");
196            prop_assert!(approx_eq(data.close, parsed.close), "close mismatch");
197            prop_assert_eq!(data.volume, parsed.volume);
198            prop_assert!(approx_eq_opt(data.adj_close, parsed.adj_close), "adj_close mismatch");
199        }
200
201        #[test]
202        fn time_range_roundtrip(range in prop_oneof![
203            Just(TimeRange::Day),
204            Just(TimeRange::FiveDays),
205            Just(TimeRange::OneMonth),
206            Just(TimeRange::ThreeMonths),
207            Just(TimeRange::SixMonths),
208            Just(TimeRange::Year),
209            Just(TimeRange::TwoYears),
210            Just(TimeRange::FiveYears),
211            Just(TimeRange::TenYears),
212            Just(TimeRange::Ytd),
213            Just(TimeRange::Max),
214        ]) {
215            let json = serde_json::to_string(&range).unwrap();
216            let parsed: TimeRange = serde_json::from_str(&json).unwrap();
217
218            prop_assert_eq!(range.as_str(), parsed.as_str());
219        }
220
221        #[test]
222        fn interval_roundtrip(interval in prop_oneof![
223            Just(Interval::OneMinute),
224            Just(Interval::ThreeMinutes),
225            Just(Interval::FiveMinutes),
226            Just(Interval::TenMinutes),
227            Just(Interval::FifteenMinutes),
228            Just(Interval::TwentyMinutes),
229            Just(Interval::ThirtyMinutes),
230            Just(Interval::SixtyFiveMinutes),
231            Just(Interval::NinetyFiveMinutes),
232            Just(Interval::OneHour),
233            Just(Interval::Daily),
234            Just(Interval::Weekly),
235            Just(Interval::Monthly),
236        ]) {
237            let json = serde_json::to_string(&interval).unwrap();
238            let parsed: Interval = serde_json::from_str(&json).unwrap();
239
240            prop_assert_eq!(interval.as_str(), parsed.as_str());
241        }
242    }
243}