Skip to main content

alpaca_data/stocks/
convenience.rs

1use std::collections::HashMap;
2
3use rust_decimal::Decimal;
4
5use super::{Bar, DataFeed, Snapshot};
6
7#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
8pub struct BarPoint {
9    pub timestamp: String,
10    pub open: Decimal,
11    pub high: Decimal,
12    pub low: Decimal,
13    pub close: Decimal,
14    pub volume: i64,
15}
16
17fn timestamp_parts<'a>(
18    latest_trade: Option<&'a str>,
19    latest_quote: Option<&'a str>,
20    minute_bar: Option<&'a str>,
21    daily_bar: Option<&'a str>,
22    prev_daily_bar: Option<&'a str>,
23) -> Option<&'a str> {
24    [
25        latest_trade,
26        latest_quote,
27        minute_bar,
28        daily_bar,
29        prev_daily_bar,
30    ]
31    .into_iter()
32    .flatten()
33    .filter(|value| !value.trim().is_empty())
34    .max()
35}
36
37fn price_parts(
38    latest_trade: Option<Decimal>,
39    bid: Option<Decimal>,
40    ask: Option<Decimal>,
41    minute_close: Option<Decimal>,
42    daily_close: Option<Decimal>,
43    prev_daily_close: Option<Decimal>,
44) -> Option<Decimal> {
45    latest_trade
46        .or_else(|| match (bid, ask) {
47            (Some(bid), Some(ask)) => Some((bid + ask) / Decimal::from(2u8)),
48            (Some(bid), None) => Some(bid),
49            (None, Some(ask)) => Some(ask),
50            (None, None) => None,
51        })
52        .or(minute_close)
53        .or(daily_close)
54        .or(prev_daily_close)
55}
56
57fn quote_bid(snapshot: &Snapshot) -> Option<Decimal> {
58    snapshot.latest_quote.as_ref().and_then(|quote| quote.bp)
59}
60
61fn quote_ask(snapshot: &Snapshot) -> Option<Decimal> {
62    snapshot.latest_quote.as_ref().and_then(|quote| quote.ap)
63}
64
65fn session_open(snapshot: &Snapshot) -> Option<Decimal> {
66    snapshot.daily_bar.as_ref().and_then(|bar| bar.o)
67}
68
69fn session_high(snapshot: &Snapshot) -> Option<Decimal> {
70    snapshot.daily_bar.as_ref().and_then(|bar| bar.h)
71}
72
73fn session_low(snapshot: &Snapshot) -> Option<Decimal> {
74    snapshot.daily_bar.as_ref().and_then(|bar| bar.l)
75}
76
77fn session_close(snapshot: &Snapshot) -> Option<Decimal> {
78    snapshot.daily_bar.as_ref().and_then(|bar| bar.c)
79}
80
81fn previous_close(snapshot: &Snapshot) -> Option<Decimal> {
82    snapshot.prev_daily_bar.as_ref().and_then(|bar| bar.c)
83}
84
85fn session_volume(snapshot: &Snapshot) -> Option<u64> {
86    snapshot.daily_bar.as_ref().and_then(|bar| bar.v)
87}
88
89impl Snapshot {
90    #[must_use]
91    pub fn timestamp(&self) -> Option<&str> {
92        timestamp_parts(
93            self.latest_trade
94                .as_ref()
95                .and_then(|trade| trade.t.as_deref()),
96            self.latest_quote
97                .as_ref()
98                .and_then(|quote| quote.t.as_deref()),
99            self.minute_bar.as_ref().and_then(|bar| bar.t.as_deref()),
100            self.daily_bar.as_ref().and_then(|bar| bar.t.as_deref()),
101            self.prev_daily_bar
102                .as_ref()
103                .and_then(|bar| bar.t.as_deref()),
104        )
105    }
106
107    #[must_use]
108    pub fn price(&self) -> Option<Decimal> {
109        price_parts(
110            self.latest_trade.as_ref().and_then(|trade| trade.p),
111            self.bid_price(),
112            self.ask_price(),
113            self.minute_bar.as_ref().and_then(|bar| bar.c),
114            self.session_close(),
115            self.previous_close(),
116        )
117    }
118
119    #[must_use]
120    pub fn bid_price(&self) -> Option<Decimal> {
121        quote_bid(self)
122    }
123
124    #[must_use]
125    pub fn ask_price(&self) -> Option<Decimal> {
126        quote_ask(self)
127    }
128
129    #[must_use]
130    pub fn session_open(&self) -> Option<Decimal> {
131        session_open(self)
132    }
133
134    #[must_use]
135    pub fn session_high(&self) -> Option<Decimal> {
136        session_high(self)
137    }
138
139    #[must_use]
140    pub fn session_low(&self) -> Option<Decimal> {
141        session_low(self)
142    }
143
144    #[must_use]
145    pub fn session_close(&self) -> Option<Decimal> {
146        session_close(self)
147    }
148
149    #[must_use]
150    pub fn previous_close(&self) -> Option<Decimal> {
151        previous_close(self)
152    }
153
154    #[must_use]
155    pub fn session_volume(&self) -> Option<u64> {
156        session_volume(self)
157    }
158}
159
160impl Bar {
161    #[must_use]
162    pub fn point(&self, daily: bool) -> BarPoint {
163        let raw_timestamp = self.t.clone().unwrap_or_default();
164        let timestamp = if daily {
165            raw_timestamp
166                .get(..10)
167                .unwrap_or(raw_timestamp.as_str())
168                .to_owned()
169        } else {
170            raw_timestamp
171        };
172
173        BarPoint {
174            timestamp,
175            open: self.o.unwrap_or_default(),
176            high: self.h.unwrap_or_default(),
177            low: self.l.unwrap_or_default(),
178            close: self.c.unwrap_or_default(),
179            volume: match self.v {
180                Some(value) => i64::try_from(value).unwrap_or(i64::MAX),
181                None => 0,
182            },
183        }
184    }
185}
186
187#[must_use]
188pub fn ordered_snapshots(snapshots: &HashMap<String, Snapshot>) -> Vec<(&str, &Snapshot)> {
189    let mut symbols = snapshots.keys().map(String::as_str).collect::<Vec<_>>();
190    symbols.sort_unstable();
191    symbols
192        .into_iter()
193        .filter_map(|symbol| {
194            snapshots
195                .get_key_value(symbol)
196                .map(|(symbol, snapshot)| (symbol.as_str(), snapshot))
197        })
198        .collect()
199}
200
201#[must_use]
202pub fn preferred_feed(extended_hours: bool) -> DataFeed {
203    if extended_hours {
204        DataFeed::Boats
205    } else {
206        DataFeed::Sip
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use std::collections::HashMap;
213
214    use rust_decimal::Decimal;
215
216    use super::{BarPoint, Snapshot, ordered_snapshots, preferred_feed};
217    use crate::stocks::{Bar, DataFeed, Quote, Trade};
218
219    #[test]
220    fn snapshot_timestamp_prefers_the_freshest_available_value() {
221        let snapshot = Snapshot {
222            latest_trade: Some(Trade {
223                t: Some("2026-04-13T13:30:01Z".to_owned()),
224                ..Trade::default()
225            }),
226            latest_quote: Some(Quote {
227                t: Some("2026-04-13T13:30:05Z".to_owned()),
228                ..Quote::default()
229            }),
230            minute_bar: Some(Bar {
231                t: Some("2026-04-13T13:30:00Z".to_owned()),
232                ..Bar::default()
233            }),
234            ..Snapshot::default()
235        };
236
237        assert_eq!(snapshot.timestamp(), Some("2026-04-13T13:30:05Z"));
238    }
239
240    #[test]
241    fn snapshot_price_absorbs_single_sided_quotes_and_trade_fallbacks() {
242        let with_both_sides = Snapshot {
243            latest_quote: Some(Quote {
244                bp: Some(Decimal::new(125, 2)),
245                ap: Some(Decimal::new(135, 2)),
246                ..Quote::default()
247            }),
248            ..Snapshot::default()
249        };
250        let with_bid_only = Snapshot {
251            latest_quote: Some(Quote {
252                bp: Some(Decimal::new(125, 2)),
253                ..Quote::default()
254            }),
255            ..Snapshot::default()
256        };
257        let with_trade = Snapshot {
258            latest_trade: Some(Trade {
259                p: Some(Decimal::new(141, 2)),
260                ..Trade::default()
261            }),
262            latest_quote: Some(Quote {
263                bp: Some(Decimal::new(125, 2)),
264                ap: Some(Decimal::new(135, 2)),
265                ..Quote::default()
266            }),
267            ..Snapshot::default()
268        };
269
270        assert_eq!(with_both_sides.price(), Some(Decimal::new(130, 2)));
271        assert_eq!(with_bid_only.price(), Some(Decimal::new(125, 2)));
272        assert_eq!(with_trade.price(), Some(Decimal::new(141, 2)));
273    }
274
275    #[test]
276    fn snapshot_canonical_session_readers_hide_provider_nesting() {
277        let snapshot = Snapshot {
278            latest_quote: Some(Quote {
279                bp: Some(Decimal::new(50000, 2)),
280                ap: Some(Decimal::new(50030, 2)),
281                ..Quote::default()
282            }),
283            daily_bar: Some(Bar {
284                o: Some(Decimal::new(49810, 2)),
285                h: Some(Decimal::new(50320, 2)),
286                l: Some(Decimal::new(49750, 2)),
287                c: Some(Decimal::new(50140, 2)),
288                v: Some(1_234_567),
289                ..Bar::default()
290            }),
291            prev_daily_bar: Some(Bar {
292                c: Some(Decimal::new(49680, 2)),
293                ..Bar::default()
294            }),
295            ..Snapshot::default()
296        };
297
298        assert_eq!(snapshot.bid_price(), Some(Decimal::new(50000, 2)));
299        assert_eq!(snapshot.ask_price(), Some(Decimal::new(50030, 2)));
300        assert_eq!(snapshot.session_open(), Some(Decimal::new(49810, 2)));
301        assert_eq!(snapshot.session_high(), Some(Decimal::new(50320, 2)));
302        assert_eq!(snapshot.session_low(), Some(Decimal::new(49750, 2)));
303        assert_eq!(snapshot.session_close(), Some(Decimal::new(50140, 2)));
304        assert_eq!(snapshot.previous_close(), Some(Decimal::new(49680, 2)));
305        assert_eq!(snapshot.session_volume(), Some(1_234_567));
306    }
307
308    #[test]
309    fn ordered_snapshots_returns_stable_symbol_order() {
310        let mut snapshots = HashMap::new();
311        snapshots.insert("QQQ".to_owned(), Snapshot::default());
312        snapshots.insert("AAPL".to_owned(), Snapshot::default());
313
314        let ordered = ordered_snapshots(&snapshots);
315        assert_eq!(ordered[0].0, "AAPL");
316        assert_eq!(ordered[1].0, "QQQ");
317    }
318
319    #[test]
320    fn preferred_feed_uses_premium_stock_feeds() {
321        assert_eq!(preferred_feed(false), DataFeed::Sip);
322        assert_eq!(preferred_feed(true), DataFeed::Boats);
323    }
324
325    #[test]
326    fn bar_point_normalizes_daily_timestamp_and_missing_fields() {
327        let bar = Bar {
328            t: Some("2026-04-17T20:00:00Z".to_owned()),
329            c: Some(Decimal::new(51234, 2)),
330            ..Bar::default()
331        };
332
333        assert_eq!(
334            bar.point(true),
335            BarPoint {
336                timestamp: "2026-04-17".to_owned(),
337                open: Decimal::ZERO,
338                high: Decimal::ZERO,
339                low: Decimal::ZERO,
340                close: Decimal::new(51234, 2),
341                volume: 0,
342            }
343        );
344    }
345}