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}