Skip to main content

finance_query/models/chart/
stock_bars.rs

1//! Lossless stock-bar downloads, separate from chart presentation.
2
3use crate::{FinanceError, Interval, Result, SortType};
4use serde::{Deserialize, Serialize};
5
6/// Price adjustment policy. There is no implicit provider default.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
8#[non_exhaustive]
9pub enum PriceAdjustment {
10    /// Preserve historical, unadjusted prices.
11    Unadjusted,
12    /// Ask the provider to adjust prices for splits.
13    SplitAdjusted,
14}
15
16/// A bounded minute or daily bar request.
17#[derive(Debug, Clone, Serialize)]
18pub struct StockBarsRequest {
19    pub(crate) symbol: String,
20    pub(crate) from: String,
21    pub(crate) to: String,
22    pub(crate) timespan: String,
23    pub(crate) adjustment: PriceAdjustment,
24    pub(crate) sort: SortType,
25    pub(crate) limit: u32,
26}
27
28impl StockBarsRequest {
29    /// Set inclusive date bounds and the adjustment policy. Currently supports
30    /// OneMinute and OneDay. Bars are oldest first unless [`with_sort`](Self::with_sort) says otherwise.
31    pub fn new(
32        symbol: &str,
33        from: &str,
34        to: &str,
35        interval: Interval,
36        adjustment: PriceAdjustment,
37    ) -> Result<Self> {
38        crate::models::discovery::listings::date(from)?;
39        crate::models::discovery::listings::date(to)?;
40        if symbol.trim().is_empty() || symbol == "." || symbol == ".." || from > to {
41            return Err(FinanceError::InvalidParameter {
42                param: "stock_bars".into(),
43                reason: "symbol and ordered date bounds are required".into(),
44            });
45        }
46        let timespan = match interval {
47            Interval::OneMinute => "minute",
48            Interval::OneDay => "day",
49            _ => {
50                return Err(FinanceError::InvalidParameter {
51                    param: "interval".into(),
52                    reason: "stock pages support OneMinute and OneDay".into(),
53                });
54            }
55        };
56        Ok(Self {
57            symbol: symbol.into(),
58            from: from.into(),
59            to: to.into(),
60            timespan: timespan.into(),
61            adjustment,
62            sort: SortType::Asc,
63            limit: 50_000,
64        })
65    }
66
67    /// Select oldest-first ([`SortType::Asc`]) or newest-first order.
68    pub fn with_sort(mut self, order: SortType) -> Self {
69        self.sort = order;
70        self
71    }
72    /// Set the provider page size in 1–50000.
73    pub fn with_page_size(mut self, limit: u32) -> Result<Self> {
74        if !(1..=50_000).contains(&limit) {
75            return Err(FinanceError::InvalidParameter {
76                param: "page_size".into(),
77                reason: "expected 1..=50000".into(),
78            });
79        }
80        self.limit = limit;
81        Ok(self)
82    }
83
84    /// Requested symbol.
85    pub fn symbol(&self) -> &str {
86        &self.symbol
87    }
88
89    /// First date of the range (`YYYY-MM-DD`), inclusive.
90    pub fn from(&self) -> &str {
91        &self.from
92    }
93
94    /// Last date of the range (`YYYY-MM-DD`), inclusive.
95    pub fn to(&self) -> &str {
96        &self.to
97    }
98
99    /// Bar interval: [`Interval::OneMinute`] or [`Interval::OneDay`].
100    pub fn interval(&self) -> Interval {
101        if self.timespan == "minute" {
102            Interval::OneMinute
103        } else {
104            Interval::OneDay
105        }
106    }
107
108    /// Requested price adjustment.
109    pub fn adjustment(&self) -> PriceAdjustment {
110        self.adjustment
111    }
112
113    /// Requested bar order.
114    pub fn sort(&self) -> SortType {
115        self.sort
116    }
117
118    /// Maximum bars per provider page.
119    pub fn page_size(&self) -> u32 {
120        self.limit
121    }
122
123    #[cfg(any(feature = "polygon", feature = "fmp"))]
124    pub(crate) fn split_adjusted(&self) -> bool {
125        self.adjustment == PriceAdjustment::SplitAdjusted
126    }
127
128    pub(crate) fn sort_param(&self) -> &'static str {
129        match self.sort {
130            SortType::Asc => "asc",
131            SortType::Desc => "desc",
132        }
133    }
134
135    /// The fields a continuation must match. Changing them requires a new cursor version.
136    pub(crate) fn cursor_identity(&self) -> String {
137        let adjustment = match self.adjustment {
138            PriceAdjustment::Unadjusted => "unadjusted",
139            PriceAdjustment::SplitAdjusted => "split",
140        };
141        serde_json::json!([
142            self.symbol,
143            self.from,
144            self.to,
145            self.timespan,
146            adjustment,
147            self.sort_param(),
148            self.limit
149        ])
150        .to_string()
151    }
152}
153
154/// A provider price bar with exact timestamp units and fractional volume.
155#[derive(Debug, Clone, Default, Serialize, Deserialize)]
156#[non_exhaustive]
157pub struct StockBar {
158    /// UTC Unix milliseconds, without rounding or timezone conversion.
159    pub timestamp_ms: i64,
160    /// Opening price.
161    pub open: f64,
162    /// Highest price.
163    pub high: f64,
164    /// Lowest price.
165    pub low: f64,
166    /// Closing price.
167    pub close: f64,
168    /// Volume, preserving fractional provider values.
169    pub volume: f64,
170    /// Number of transactions, absent when not supplied.
171    pub transactions: Option<u64>,
172    /// Volume-weighted average price, when supplied.
173    pub vwap: Option<f64>,
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    #[test]
181    fn cursor_identity_is_pinned_to_the_query_fields() {
182        let request = StockBarsRequest::new(
183            "AAPL",
184            "2020-01-02",
185            "2020-01-03",
186            Interval::OneDay,
187            PriceAdjustment::Unadjusted,
188        )
189        .unwrap();
190        assert_eq!(
191            request.cursor_identity(),
192            r#"["AAPL","2020-01-02","2020-01-03","day","unadjusted","asc",50000]"#
193        );
194        let changed = request
195            .with_sort(SortType::Desc)
196            .with_page_size(10)
197            .unwrap();
198        assert_eq!(
199            changed.cursor_identity(),
200            r#"["AAPL","2020-01-02","2020-01-03","day","unadjusted","desc",10]"#
201        );
202    }
203}