Skip to main content

use_bar/
lib.rs

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
9/// Common bar primitives.
10pub mod prelude {
11    pub use crate::{
12        BarError, BarInterval, BarIntervalParseError, BarTime, BarTimeError, OhlcBar, OhlcvBar,
13    };
14}
15
16/// A simple bar time label.
17#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
18pub struct BarTime(String);
19
20impl BarTime {
21    /// Creates a bar time label from non-empty text.
22    ///
23    /// # Errors
24    ///
25    /// Returns [`BarTimeError::Empty`] when the trimmed label is empty.
26    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    /// Returns the time label.
36    #[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/// Errors returned while constructing bar time labels.
63#[derive(Clone, Copy, Debug, Eq, PartialEq)]
64pub enum BarTimeError {
65    /// The label was empty after trimming whitespace.
66    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/// Descriptive bar interval vocabulary.
80#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
81pub enum BarInterval {
82    /// Tick-level interval.
83    Tick,
84    /// Second interval.
85    Second,
86    /// Minute interval.
87    Minute,
88    /// Hour interval.
89    Hour,
90    /// Day interval.
91    Day,
92    /// Week interval.
93    Week,
94    /// Month interval.
95    Month,
96    /// Caller-defined interval.
97    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/// Errors returned while parsing bar intervals.
138#[derive(Clone, Copy, Debug, Eq, PartialEq)]
139pub enum BarIntervalParseError {
140    /// The input was empty after trimming whitespace.
141    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/// A primitive OHLC bar.
155#[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    /// Creates an OHLC bar from validated price values.
167    ///
168    /// # Errors
169    ///
170    /// Returns [`BarError::InvalidHigh`] or [`BarError::InvalidLow`] when obvious OHLC
171    /// constraints are violated.
172    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    /// Creates an OHLC bar from raw `f64` price values.
193    ///
194    /// # Errors
195    ///
196    /// Returns [`BarError`] when any price is invalid or OHLC constraints are violated.
197    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    /// Returns the bar time label.
216    #[must_use]
217    pub const fn time(&self) -> &BarTime {
218        &self.time
219    }
220
221    /// Returns the bar interval.
222    #[must_use]
223    pub const fn interval(&self) -> &BarInterval {
224        &self.interval
225    }
226
227    /// Returns the open price.
228    #[must_use]
229    pub const fn open(&self) -> MarketPrice {
230        self.open
231    }
232
233    /// Returns the high price.
234    #[must_use]
235    pub const fn high(&self) -> MarketPrice {
236        self.high
237    }
238
239    /// Returns the low price.
240    #[must_use]
241    pub const fn low(&self) -> MarketPrice {
242        self.low
243    }
244
245    /// Returns the close price.
246    #[must_use]
247    pub const fn close(&self) -> MarketPrice {
248        self.close
249    }
250}
251
252/// A primitive OHLCV bar.
253#[derive(Clone, Debug, PartialEq)]
254pub struct OhlcvBar {
255    bar: OhlcBar,
256    volume: f64,
257}
258
259impl OhlcvBar {
260    /// Creates an OHLCV bar from an OHLC bar and finite non-negative volume.
261    ///
262    /// # Errors
263    ///
264    /// Returns [`BarError::NonFiniteVolume`] or [`BarError::NegativeVolume`] when volume is
265    /// invalid.
266    pub fn new(bar: OhlcBar, volume: f64) -> Result<Self, BarError> {
267        validate_volume(volume)?;
268
269        Ok(Self { bar, volume })
270    }
271
272    /// Creates an OHLCV bar from raw `f64` price and volume values.
273    ///
274    /// # Errors
275    ///
276    /// Returns [`BarError`] when any price, volume, or OHLC relationship is invalid.
277    #[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    /// Returns the OHLC portion of the bar.
294    #[must_use]
295    pub const fn bar(&self) -> &OhlcBar {
296        &self.bar
297    }
298
299    /// Returns the volume value.
300    #[must_use]
301    pub const fn volume(&self) -> f64 {
302        self.volume
303    }
304}
305
306/// Errors returned by bar construction.
307#[derive(Clone, Copy, Debug, Eq, PartialEq)]
308pub enum BarError {
309    /// One of the price values was invalid.
310    InvalidPrice(MarketPriceError),
311    /// Volume must be finite.
312    NonFiniteVolume,
313    /// Volume must not be negative.
314    NegativeVolume,
315    /// The high price was lower than open, low, or close.
316    InvalidHigh,
317    /// The low price was higher than open, high, or close.
318    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}