1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7use use_market_price::{MarketPrice, MarketPriceError};
8
9pub mod prelude {
11 pub use crate::{
12 BarError, BarInterval, BarIntervalParseError, BarTime, BarTimeError, OhlcBar, OhlcvBar,
13 };
14}
15
16#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
18pub struct BarTime(String);
19
20impl BarTime {
21 pub fn new(value: impl AsRef<str>) -> Result<Self, BarTimeError> {
27 let trimmed = value.as_ref().trim();
28 if trimmed.is_empty() {
29 Err(BarTimeError::Empty)
30 } else {
31 Ok(Self(trimmed.to_string()))
32 }
33 }
34
35 #[must_use]
37 pub fn as_str(&self) -> &str {
38 &self.0
39 }
40}
41
42impl AsRef<str> for BarTime {
43 fn as_ref(&self) -> &str {
44 self.as_str()
45 }
46}
47
48impl fmt::Display for BarTime {
49 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
50 formatter.write_str(self.as_str())
51 }
52}
53
54impl FromStr for BarTime {
55 type Err = BarTimeError;
56
57 fn from_str(value: &str) -> Result<Self, Self::Err> {
58 Self::new(value)
59 }
60}
61
62#[derive(Clone, Copy, Debug, Eq, PartialEq)]
64pub enum BarTimeError {
65 Empty,
67}
68
69impl fmt::Display for BarTimeError {
70 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
71 match self {
72 Self::Empty => formatter.write_str("bar time cannot be empty"),
73 }
74 }
75}
76
77impl Error for BarTimeError {}
78
79#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
81pub enum BarInterval {
82 Tick,
84 Second,
86 Minute,
88 Hour,
90 Day,
92 Week,
94 Month,
96 Custom(String),
98}
99
100impl fmt::Display for BarInterval {
101 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
102 formatter.write_str(match self {
103 Self::Tick => "tick",
104 Self::Second => "second",
105 Self::Minute => "minute",
106 Self::Hour => "hour",
107 Self::Day => "day",
108 Self::Week => "week",
109 Self::Month => "month",
110 Self::Custom(value) => value.as_str(),
111 })
112 }
113}
114
115impl FromStr for BarInterval {
116 type Err = BarIntervalParseError;
117
118 fn from_str(value: &str) -> Result<Self, Self::Err> {
119 let trimmed = value.trim();
120 if trimmed.is_empty() {
121 return Err(BarIntervalParseError::Empty);
122 }
123
124 match normalized_token(trimmed).as_str() {
125 "tick" => Ok(Self::Tick),
126 "second" => Ok(Self::Second),
127 "minute" => Ok(Self::Minute),
128 "hour" => Ok(Self::Hour),
129 "day" => Ok(Self::Day),
130 "week" => Ok(Self::Week),
131 "month" => Ok(Self::Month),
132 _ => Ok(Self::Custom(trimmed.to_string())),
133 }
134 }
135}
136
137#[derive(Clone, Copy, Debug, Eq, PartialEq)]
139pub enum BarIntervalParseError {
140 Empty,
142}
143
144impl fmt::Display for BarIntervalParseError {
145 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
146 match self {
147 Self::Empty => formatter.write_str("bar interval cannot be empty"),
148 }
149 }
150}
151
152impl Error for BarIntervalParseError {}
153
154#[derive(Clone, Debug, PartialEq)]
156pub struct OhlcBar {
157 time: BarTime,
158 interval: BarInterval,
159 open: MarketPrice,
160 high: MarketPrice,
161 low: MarketPrice,
162 close: MarketPrice,
163}
164
165impl OhlcBar {
166 pub fn new(
173 time: BarTime,
174 interval: BarInterval,
175 open: MarketPrice,
176 high: MarketPrice,
177 low: MarketPrice,
178 close: MarketPrice,
179 ) -> Result<Self, BarError> {
180 validate_ohlc(open, high, low, close)?;
181
182 Ok(Self {
183 time,
184 interval,
185 open,
186 high,
187 low,
188 close,
189 })
190 }
191
192 pub fn from_values(
198 time: BarTime,
199 interval: BarInterval,
200 open: f64,
201 high: f64,
202 low: f64,
203 close: f64,
204 ) -> Result<Self, BarError> {
205 Self::new(
206 time,
207 interval,
208 MarketPrice::new(open)?,
209 MarketPrice::new(high)?,
210 MarketPrice::new(low)?,
211 MarketPrice::new(close)?,
212 )
213 }
214
215 #[must_use]
217 pub const fn time(&self) -> &BarTime {
218 &self.time
219 }
220
221 #[must_use]
223 pub const fn interval(&self) -> &BarInterval {
224 &self.interval
225 }
226
227 #[must_use]
229 pub const fn open(&self) -> MarketPrice {
230 self.open
231 }
232
233 #[must_use]
235 pub const fn high(&self) -> MarketPrice {
236 self.high
237 }
238
239 #[must_use]
241 pub const fn low(&self) -> MarketPrice {
242 self.low
243 }
244
245 #[must_use]
247 pub const fn close(&self) -> MarketPrice {
248 self.close
249 }
250}
251
252#[derive(Clone, Debug, PartialEq)]
254pub struct OhlcvBar {
255 bar: OhlcBar,
256 volume: f64,
257}
258
259impl OhlcvBar {
260 pub fn new(bar: OhlcBar, volume: f64) -> Result<Self, BarError> {
267 validate_volume(volume)?;
268
269 Ok(Self { bar, volume })
270 }
271
272 #[allow(clippy::too_many_arguments)]
278 pub fn from_values(
279 time: BarTime,
280 interval: BarInterval,
281 open: f64,
282 high: f64,
283 low: f64,
284 close: f64,
285 volume: f64,
286 ) -> Result<Self, BarError> {
287 Self::new(
288 OhlcBar::from_values(time, interval, open, high, low, close)?,
289 volume,
290 )
291 }
292
293 #[must_use]
295 pub const fn bar(&self) -> &OhlcBar {
296 &self.bar
297 }
298
299 #[must_use]
301 pub const fn volume(&self) -> f64 {
302 self.volume
303 }
304}
305
306#[derive(Clone, Copy, Debug, Eq, PartialEq)]
308pub enum BarError {
309 InvalidPrice(MarketPriceError),
311 NonFiniteVolume,
313 NegativeVolume,
315 InvalidHigh,
317 InvalidLow,
319}
320
321impl fmt::Display for BarError {
322 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
323 match self {
324 Self::InvalidPrice(error) => write!(formatter, "{error}"),
325 Self::NonFiniteVolume => formatter.write_str("bar volume must be finite"),
326 Self::NegativeVolume => formatter.write_str("bar volume cannot be negative"),
327 Self::InvalidHigh => {
328 formatter.write_str("bar high must be at least open, low, and close")
329 },
330 Self::InvalidLow => {
331 formatter.write_str("bar low must be at most open, high, and close")
332 },
333 }
334 }
335}
336
337impl Error for BarError {
338 fn source(&self) -> Option<&(dyn Error + 'static)> {
339 match self {
340 Self::InvalidPrice(error) => Some(error),
341 Self::NonFiniteVolume | Self::NegativeVolume | Self::InvalidHigh | Self::InvalidLow => {
342 None
343 },
344 }
345 }
346}
347
348impl From<MarketPriceError> for BarError {
349 fn from(error: MarketPriceError) -> Self {
350 Self::InvalidPrice(error)
351 }
352}
353
354fn validate_ohlc(
355 open: MarketPrice,
356 high: MarketPrice,
357 low: MarketPrice,
358 close: MarketPrice,
359) -> Result<(), BarError> {
360 if high.value() < open.value() || high.value() < low.value() || high.value() < close.value() {
361 return Err(BarError::InvalidHigh);
362 }
363
364 if low.value() > open.value() || low.value() > high.value() || low.value() > close.value() {
365 return Err(BarError::InvalidLow);
366 }
367
368 Ok(())
369}
370
371fn validate_volume(volume: f64) -> Result<(), BarError> {
372 if !volume.is_finite() {
373 return Err(BarError::NonFiniteVolume);
374 }
375
376 if volume < 0.0 {
377 return Err(BarError::NegativeVolume);
378 }
379
380 Ok(())
381}
382
383fn normalized_token(value: &str) -> String {
384 value
385 .trim()
386 .chars()
387 .map(|character| match character {
388 '_' | ' ' => '-',
389 other => other.to_ascii_lowercase(),
390 })
391 .collect()
392}
393
394#[cfg(test)]
395mod tests {
396 use super::{BarError, BarInterval, BarTime, OhlcBar, OhlcvBar};
397
398 #[test]
399 fn constructs_valid_ohlc_bar() {
400 let bar = OhlcBar::from_values(
401 BarTime::new("2026-05-17").expect("time should be valid"),
402 BarInterval::Day,
403 100.0,
404 102.0,
405 99.5,
406 101.25,
407 )
408 .expect("bar should be valid");
409
410 assert!((bar.high().value() - 102.0).abs() < f64::EPSILON);
411 assert!((bar.low().value() - 99.5).abs() < f64::EPSILON);
412 }
413
414 #[test]
415 fn constructs_valid_ohlcv_bar() {
416 let bar = OhlcvBar::from_values(
417 BarTime::new("2026-05-17").expect("time should be valid"),
418 BarInterval::Day,
419 100.0,
420 102.0,
421 99.5,
422 101.25,
423 42_000.0,
424 )
425 .expect("bar should be valid");
426
427 assert!((bar.volume() - 42_000.0).abs() < f64::EPSILON);
428 }
429
430 #[test]
431 fn rejects_invalid_high() {
432 assert_eq!(
433 OhlcBar::from_values(
434 BarTime::new("t").expect("time should be valid"),
435 BarInterval::Day,
436 100.0,
437 99.0,
438 98.0,
439 100.0,
440 ),
441 Err(BarError::InvalidHigh)
442 );
443 }
444
445 #[test]
446 fn rejects_invalid_low() {
447 assert_eq!(
448 OhlcBar::from_values(
449 BarTime::new("t").expect("time should be valid"),
450 BarInterval::Day,
451 100.0,
452 102.0,
453 100.5,
454 101.0,
455 ),
456 Err(BarError::InvalidLow)
457 );
458 }
459
460 #[test]
461 fn displays_and_parses_interval() {
462 let interval: BarInterval = "Minute".parse().expect("interval should parse");
463
464 assert_eq!(interval, BarInterval::Minute);
465 assert_eq!(interval.to_string(), "minute");
466 }
467
468 #[test]
469 fn supports_custom_interval() {
470 let interval: BarInterval = "session".parse().expect("interval should parse");
471
472 assert_eq!(interval, BarInterval::Custom("session".to_string()));
473 }
474}