1use std::collections::BTreeMap;
2
3use bon::Builder;
4use time::{Date, OffsetDateTime};
5
6use crate::metadata::DataLineage;
7use crate::scanner::{InstrumentRef, Ticker};
8
9pub(crate) fn default_history_batch_concurrency() -> usize {
10 4
11}
12
13pub(crate) fn default_history_max_chunk_bars() -> u32 {
14 5_000
15}
16
17#[derive(Debug, Clone, PartialEq, Eq, Builder)]
18pub struct HistoryRequest {
19 #[builder(into)]
20 pub symbol: Ticker,
21 pub interval: Interval,
22 pub bars: u32,
23 #[builder(default)]
24 pub fetch_all: bool,
25 #[builder(default)]
26 pub session: TradingSession,
27 #[builder(default)]
28 pub adjustment: Adjustment,
29}
30
31impl HistoryRequest {
32 pub fn new(symbol: impl Into<Ticker>, interval: Interval, bars: u32) -> Self {
33 Self::builder()
34 .symbol(symbol)
35 .interval(interval)
36 .bars(bars)
37 .build()
38 }
39
40 pub fn max(symbol: impl Into<Ticker>, interval: Interval) -> Self {
41 Self::builder()
42 .symbol(symbol)
43 .interval(interval)
44 .bars(default_history_max_chunk_bars())
45 .fetch_all(true)
46 .build()
47 }
48
49 pub fn session(mut self, session: TradingSession) -> Self {
50 self.session = session;
51 self
52 }
53
54 pub fn adjustment(mut self, adjustment: Adjustment) -> Self {
55 self.adjustment = adjustment;
56 self
57 }
58
59 pub fn fetch_all(mut self) -> Self {
60 self.fetch_all = true;
61 self
62 }
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Builder)]
66pub struct HistoryBatchRequest {
67 pub symbols: Vec<Ticker>,
68 pub interval: Interval,
69 pub bars: u32,
70 #[builder(default)]
71 pub fetch_all: bool,
72 #[builder(default)]
73 pub session: TradingSession,
74 #[builder(default)]
75 pub adjustment: Adjustment,
76 #[builder(default = default_history_batch_concurrency())]
77 pub concurrency: usize,
78}
79
80impl HistoryBatchRequest {
81 pub fn new<I, T>(symbols: I, interval: Interval, bars: u32) -> Self
82 where
83 I: IntoIterator<Item = T>,
84 T: Into<Ticker>,
85 {
86 Self {
87 symbols: symbols.into_iter().map(Into::into).collect(),
88 interval,
89 bars,
90 fetch_all: false,
91 session: TradingSession::Regular,
92 adjustment: Adjustment::Splits,
93 concurrency: default_history_batch_concurrency(),
94 }
95 }
96
97 pub fn max<I, T>(symbols: I, interval: Interval) -> Self
98 where
99 I: IntoIterator<Item = T>,
100 T: Into<Ticker>,
101 {
102 Self {
103 symbols: symbols.into_iter().map(Into::into).collect(),
104 interval,
105 bars: default_history_max_chunk_bars(),
106 fetch_all: true,
107 session: TradingSession::Regular,
108 adjustment: Adjustment::Splits,
109 concurrency: default_history_batch_concurrency(),
110 }
111 }
112
113 pub fn symbols<I, T>(mut self, symbols: I) -> Self
114 where
115 I: IntoIterator<Item = T>,
116 T: Into<Ticker>,
117 {
118 self.symbols = symbols.into_iter().map(Into::into).collect();
119 self
120 }
121
122 pub fn push_symbol(mut self, symbol: impl Into<Ticker>) -> Self {
123 self.symbols.push(symbol.into());
124 self
125 }
126
127 pub fn session(mut self, session: TradingSession) -> Self {
128 self.session = session;
129 self
130 }
131
132 pub fn adjustment(mut self, adjustment: Adjustment) -> Self {
133 self.adjustment = adjustment;
134 self
135 }
136
137 pub fn concurrency(mut self, concurrency: usize) -> Self {
138 self.concurrency = concurrency;
139 self
140 }
141
142 pub fn fetch_all(mut self) -> Self {
143 self.fetch_all = true;
144 self
145 }
146
147 pub(crate) fn to_requests(&self) -> Vec<HistoryRequest> {
148 self.symbols
149 .iter()
150 .cloned()
151 .map(|symbol| HistoryRequest {
152 symbol,
153 interval: self.interval,
154 bars: self.bars,
155 fetch_all: self.fetch_all,
156 session: self.session,
157 adjustment: self.adjustment,
158 })
159 .collect()
160 }
161}
162
163#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
164pub enum Interval {
165 Min1,
166 Min3,
167 Min5,
168 Min15,
169 Min30,
170 Min45,
171 Hour1,
172 Hour2,
173 Hour3,
174 Hour4,
175 Day1,
176 Week1,
177 Month1,
178 Custom(&'static str),
179}
180
181impl Interval {
182 pub fn as_code(self) -> &'static str {
183 match self {
184 Self::Min1 => "1",
185 Self::Min3 => "3",
186 Self::Min5 => "5",
187 Self::Min15 => "15",
188 Self::Min30 => "30",
189 Self::Min45 => "45",
190 Self::Hour1 => "1H",
191 Self::Hour2 => "2H",
192 Self::Hour3 => "3H",
193 Self::Hour4 => "4H",
194 Self::Day1 => "1D",
195 Self::Week1 => "1W",
196 Self::Month1 => "1M",
197 Self::Custom(code) => code,
198 }
199 }
200}
201
202#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
203pub enum TradingSession {
204 #[default]
205 Regular,
206 Extended,
207}
208
209impl TradingSession {
210 pub(crate) fn as_code(self) -> &'static str {
211 match self {
212 Self::Regular => "regular",
213 Self::Extended => "extended",
214 }
215 }
216}
217
218#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
219pub enum Adjustment {
220 #[default]
221 Splits,
222 None,
223}
224
225impl Adjustment {
226 pub(crate) fn as_code(self) -> &'static str {
227 match self {
228 Self::Splits => "splits",
229 Self::None => "none",
230 }
231 }
232}
233
234#[derive(Debug, Clone, PartialEq)]
235pub struct Bar {
236 pub time: OffsetDateTime,
237 pub open: f64,
238 pub high: f64,
239 pub low: f64,
240 pub close: f64,
241 pub volume: Option<f64>,
242}
243
244#[derive(Debug, Clone, PartialEq)]
245pub struct HistorySeries {
246 pub symbol: Ticker,
247 pub interval: Interval,
248 pub bars: Vec<Bar>,
249 pub provenance: HistoryProvenance,
250}
251
252impl HistorySeries {
253 pub fn latest(&self) -> Option<&Bar> {
254 self.bars.last()
255 }
256
257 pub fn bar_on(&self, date: Date) -> Option<&Bar> {
258 self.bars.iter().find(|bar| bar.time.date() == date)
259 }
260
261 pub fn latest_on_or_before(&self, date: Date) -> Option<&Bar> {
262 self.bars.iter().rev().find(|bar| bar.time.date() <= date)
263 }
264}
265
266#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
267pub enum BarSelectionPolicy {
268 ExactDate,
269 #[default]
270 LatestOnOrBefore,
271}
272
273#[derive(Debug, Clone, PartialEq, Eq, Builder)]
274pub struct DailyBarRequest {
275 pub symbols: Vec<InstrumentRef>,
276 pub asof: Date,
277 #[builder(default)]
278 pub adjustment: Adjustment,
279 #[builder(default)]
280 pub session: TradingSession,
281 #[builder(default)]
282 pub selection: BarSelectionPolicy,
283 #[builder(default = default_history_batch_concurrency())]
284 pub concurrency: usize,
285}
286
287impl DailyBarRequest {
288 pub fn new<I>(symbols: I, asof: Date) -> Self
289 where
290 I: IntoIterator<Item = InstrumentRef>,
291 {
292 Self {
293 symbols: symbols.into_iter().collect(),
294 asof,
295 adjustment: Adjustment::default(),
296 session: TradingSession::default(),
297 selection: BarSelectionPolicy::default(),
298 concurrency: default_history_batch_concurrency(),
299 }
300 }
301}
302
303#[derive(Debug, Clone, PartialEq, Eq, Builder)]
304pub struct DailyBarRangeRequest {
305 pub symbols: Vec<InstrumentRef>,
306 pub start: Date,
307 pub end: Date,
308 #[builder(default)]
309 pub adjustment: Adjustment,
310 #[builder(default)]
311 pub session: TradingSession,
312 #[builder(default = default_history_batch_concurrency())]
313 pub concurrency: usize,
314}
315
316impl DailyBarRangeRequest {
317 pub fn new<I>(symbols: I, start: Date, end: Date) -> Self
318 where
319 I: IntoIterator<Item = InstrumentRef>,
320 {
321 Self {
322 symbols: symbols.into_iter().collect(),
323 start,
324 end,
325 adjustment: Adjustment::default(),
326 session: TradingSession::default(),
327 concurrency: default_history_batch_concurrency(),
328 }
329 }
330}
331
332pub type HistorySeriesMap = BTreeMap<Ticker, HistorySeries>;
333
334#[derive(Debug, Clone, PartialEq, Eq)]
335pub struct HistoryProvenance {
336 pub requested_symbol: Ticker,
337 pub resolved_symbol: Ticker,
338 pub exchange: Option<String>,
339 pub session: TradingSession,
340 pub adjustment: Adjustment,
341 pub authenticated: bool,
342 pub lineage: DataLineage,
343}
344
345#[cfg(test)]
346mod tests {
347 use time::macros::datetime;
348
349 use super::*;
350 use crate::metadata::{DataSourceKind, HistoryKind};
351
352 #[test]
353 fn selects_bars_by_date() {
354 let series = HistorySeries {
355 symbol: Ticker::from_static("NASDAQ:AAPL"),
356 interval: Interval::Day1,
357 bars: vec![
358 Bar {
359 time: datetime!(2026-03-18 00:00 UTC),
360 open: 1.0,
361 high: 2.0,
362 low: 0.5,
363 close: 1.5,
364 volume: Some(10.0),
365 },
366 Bar {
367 time: datetime!(2026-03-20 00:00 UTC),
368 open: 2.0,
369 high: 3.0,
370 low: 1.5,
371 close: 2.5,
372 volume: Some(12.0),
373 },
374 ],
375 provenance: HistoryProvenance {
376 requested_symbol: Ticker::from_static("NASDAQ:AAPL"),
377 resolved_symbol: Ticker::from_static("NASDAQ:AAPL"),
378 exchange: Some("NASDAQ".to_owned()),
379 session: TradingSession::Regular,
380 adjustment: Adjustment::Splits,
381 authenticated: false,
382 lineage: DataLineage::new(
383 DataSourceKind::HistoryWebSocket,
384 HistoryKind::Native,
385 datetime!(2026-03-22 00:00 UTC),
386 Some(datetime!(2026-03-20 00:00 UTC)),
387 ),
388 },
389 };
390
391 assert_eq!(
392 series
393 .bar_on(datetime!(2026-03-18 00:00 UTC).date())
394 .unwrap()
395 .close,
396 1.5
397 );
398 assert_eq!(
399 series
400 .latest_on_or_before(datetime!(2026-03-19 00:00 UTC).date())
401 .unwrap()
402 .close,
403 1.5
404 );
405 assert_eq!(series.latest().unwrap().close, 2.5);
406 }
407}