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}