Skip to main content

alpaca_data/stocks/
convenience.rs

1use std::collections::HashMap;
2
3use chrono::{DateTime, FixedOffset};
4use rust_decimal::Decimal;
5
6use super::{Bar, DataFeed, Snapshot};
7use crate::Error;
8
9#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
10pub struct BarPoint {
11    pub timestamp: String,
12    pub open: Decimal,
13    pub high: Decimal,
14    pub low: Decimal,
15    pub close: Decimal,
16    pub volume: i64,
17}
18
19fn timestamp_parts<'a>(
20    latest_trade: Option<&'a str>,
21    latest_quote: Option<&'a str>,
22    minute_bar: Option<&'a str>,
23    daily_bar: Option<&'a str>,
24    prev_daily_bar: Option<&'a str>,
25) -> Option<&'a str> {
26    [
27        latest_trade,
28        latest_quote,
29        minute_bar,
30        daily_bar,
31        prev_daily_bar,
32    ]
33    .into_iter()
34    .flatten()
35    .filter(|value| !value.trim().is_empty())
36    .max()
37}
38
39fn price_parts(
40    latest_trade: Option<Decimal>,
41    bid: Option<Decimal>,
42    ask: Option<Decimal>,
43    minute_close: Option<Decimal>,
44    daily_close: Option<Decimal>,
45    prev_daily_close: Option<Decimal>,
46) -> Option<Decimal> {
47    latest_trade
48        .or_else(|| match (bid, ask) {
49            (Some(bid), Some(ask)) => Some((bid + ask) / Decimal::from(2u8)),
50            (Some(bid), None) => Some(bid),
51            (None, Some(ask)) => Some(ask),
52            (None, None) => None,
53        })
54        .or(minute_close)
55        .or(daily_close)
56        .or(prev_daily_close)
57}
58
59fn quote_bid(snapshot: &Snapshot) -> Option<Decimal> {
60    snapshot.latest_quote.as_ref().and_then(|quote| quote.bp)
61}
62
63fn quote_ask(snapshot: &Snapshot) -> Option<Decimal> {
64    snapshot.latest_quote.as_ref().and_then(|quote| quote.ap)
65}
66
67fn session_open(snapshot: &Snapshot) -> Option<Decimal> {
68    snapshot.daily_bar.as_ref().and_then(|bar| bar.o)
69}
70
71fn session_high(snapshot: &Snapshot) -> Option<Decimal> {
72    snapshot.daily_bar.as_ref().and_then(|bar| bar.h)
73}
74
75fn session_low(snapshot: &Snapshot) -> Option<Decimal> {
76    snapshot.daily_bar.as_ref().and_then(|bar| bar.l)
77}
78
79fn session_close(snapshot: &Snapshot) -> Option<Decimal> {
80    snapshot.daily_bar.as_ref().and_then(|bar| bar.c)
81}
82
83fn previous_close(snapshot: &Snapshot) -> Option<Decimal> {
84    snapshot.prev_daily_bar.as_ref().and_then(|bar| bar.c)
85}
86
87fn session_volume(snapshot: &Snapshot) -> Option<u64> {
88    snapshot.daily_bar.as_ref().and_then(|bar| bar.v)
89}
90
91impl Snapshot {
92    #[must_use]
93    pub fn timestamp(&self) -> Option<&str> {
94        timestamp_parts(
95            self.latest_trade
96                .as_ref()
97                .and_then(|trade| trade.t.as_deref()),
98            self.latest_quote
99                .as_ref()
100                .and_then(|quote| quote.t.as_deref()),
101            self.minute_bar.as_ref().and_then(|bar| bar.t.as_deref()),
102            self.daily_bar.as_ref().and_then(|bar| bar.t.as_deref()),
103            self.prev_daily_bar
104                .as_ref()
105                .and_then(|bar| bar.t.as_deref()),
106        )
107    }
108
109    #[must_use]
110    pub fn price(&self) -> Option<Decimal> {
111        price_parts(
112            self.latest_trade.as_ref().and_then(|trade| trade.p),
113            self.bid_price(),
114            self.ask_price(),
115            self.minute_bar.as_ref().and_then(|bar| bar.c),
116            self.session_close(),
117            self.previous_close(),
118        )
119    }
120
121    pub fn reference_price(&self) -> Result<Decimal, Error> {
122        let trade = stock_trade_reference(self)?;
123        let quote = stock_quote_reference(self)?;
124        let reference = match (trade, quote) {
125            (Some(trade), Some(quote)) if quote.timestamp > trade.timestamp => quote,
126            (Some(trade), _) => trade,
127            (None, Some(quote)) => quote,
128            (None, None) => {
129                return Err(Error::InvalidRequest(
130                    "live stock snapshot has no trade or quote".to_string(),
131                ));
132            }
133        };
134        Ok(reference.price)
135    }
136
137    #[must_use]
138    pub fn bid_price(&self) -> Option<Decimal> {
139        quote_bid(self)
140    }
141
142    #[must_use]
143    pub fn ask_price(&self) -> Option<Decimal> {
144        quote_ask(self)
145    }
146
147    #[must_use]
148    pub fn session_open(&self) -> Option<Decimal> {
149        session_open(self)
150    }
151
152    #[must_use]
153    pub fn session_high(&self) -> Option<Decimal> {
154        session_high(self)
155    }
156
157    #[must_use]
158    pub fn session_low(&self) -> Option<Decimal> {
159        session_low(self)
160    }
161
162    #[must_use]
163    pub fn session_close(&self) -> Option<Decimal> {
164        session_close(self)
165    }
166
167    #[must_use]
168    pub fn previous_close(&self) -> Option<Decimal> {
169        previous_close(self)
170    }
171
172    #[must_use]
173    pub fn session_volume(&self) -> Option<u64> {
174        session_volume(self)
175    }
176}
177
178#[derive(Clone, Copy)]
179struct StockReference {
180    price: Decimal,
181    timestamp: DateTime<FixedOffset>,
182}
183
184fn stock_event_timestamp(
185    value: Option<&str>,
186    source: &str,
187) -> Result<DateTime<FixedOffset>, Error> {
188    let value = value
189        .ok_or_else(|| Error::InvalidRequest(format!("live stock {source} has no timestamp")))?;
190    DateTime::parse_from_rfc3339(value)
191        .map_err(|_| Error::InvalidRequest(format!("live stock {source} has an invalid timestamp")))
192}
193
194fn stock_trade_reference(snapshot: &Snapshot) -> Result<Option<StockReference>, Error> {
195    let Some(trade) = snapshot.latest_trade.as_ref() else {
196        return Ok(None);
197    };
198    let price = trade
199        .p
200        .ok_or_else(|| Error::InvalidRequest("live stock trade has no price".to_string()))?;
201    if price <= Decimal::ZERO {
202        return Err(Error::InvalidRequest(
203            "live stock trade price is not positive".to_string(),
204        ));
205    }
206    Ok(Some(StockReference {
207        price,
208        timestamp: stock_event_timestamp(trade.t.as_deref(), "trade")?,
209    }))
210}
211
212fn stock_quote_reference(snapshot: &Snapshot) -> Result<Option<StockReference>, Error> {
213    let Some(quote) = snapshot.latest_quote.as_ref() else {
214        return Ok(None);
215    };
216    let bid = quote
217        .bp
218        .ok_or_else(|| Error::InvalidRequest("live stock quote has no bid".to_string()))?;
219    let ask = quote
220        .ap
221        .ok_or_else(|| Error::InvalidRequest("live stock quote has no ask".to_string()))?;
222    if bid <= Decimal::ZERO {
223        return Err(Error::InvalidRequest(
224            "live stock quote bid is not positive".to_string(),
225        ));
226    }
227    if ask <= Decimal::ZERO {
228        return Err(Error::InvalidRequest(
229            "live stock quote ask is not positive".to_string(),
230        ));
231    }
232    if bid > ask {
233        return Err(Error::InvalidRequest(
234            "live stock quote is crossed".to_string(),
235        ));
236    }
237    let price = bid
238        .checked_add(ask)
239        .and_then(|sum| sum.checked_div(Decimal::from(2u8)))
240        .ok_or_else(|| Error::InvalidRequest("live stock quote midpoint overflow".to_string()))?;
241    Ok(Some(StockReference {
242        price,
243        timestamp: stock_event_timestamp(quote.t.as_deref(), "quote")?,
244    }))
245}
246
247impl Bar {
248    #[must_use]
249    pub fn point(&self, daily: bool) -> BarPoint {
250        let raw_timestamp = self.t.clone().unwrap_or_default();
251        let timestamp = if daily {
252            raw_timestamp
253                .get(..10)
254                .unwrap_or(raw_timestamp.as_str())
255                .to_owned()
256        } else {
257            raw_timestamp
258        };
259
260        BarPoint {
261            timestamp,
262            open: self.o.unwrap_or_default(),
263            high: self.h.unwrap_or_default(),
264            low: self.l.unwrap_or_default(),
265            close: self.c.unwrap_or_default(),
266            volume: match self.v {
267                Some(value) => i64::try_from(value).unwrap_or(i64::MAX),
268                None => 0,
269            },
270        }
271    }
272}
273
274#[must_use]
275pub fn ordered_snapshots(snapshots: &HashMap<String, Snapshot>) -> Vec<(&str, &Snapshot)> {
276    let mut symbols = snapshots.keys().map(String::as_str).collect::<Vec<_>>();
277    symbols.sort_unstable();
278    symbols
279        .into_iter()
280        .filter_map(|symbol| {
281            snapshots
282                .get_key_value(symbol)
283                .map(|(symbol, snapshot)| (symbol.as_str(), snapshot))
284        })
285        .collect()
286}
287
288#[must_use]
289pub fn preferred_feed(extended_hours: bool) -> DataFeed {
290    if extended_hours {
291        DataFeed::Boats
292    } else {
293        DataFeed::Sip
294    }
295}