Skip to main content

egui_charts/model/bar/
bar.rs

1//! Core OHLCV Bar Structure
2//!
3//! The fundamental data unit for financial charting.
4
5use chrono::{DateTime, NaiveDate, NaiveTime, Utc};
6use egui::Color32;
7use serde::{Deserialize, Deserializer, Serialize};
8use std::fmt;
9use std::sync::Arc;
10
11/// Percent change from `reference` to `value`, divided by the magnitude of the
12/// reference so it has the sign of the change for a negative reference too (spreads,
13/// some futures). A zero or non-finite reference or value gives 0.
14pub(crate) fn percent_change(reference: f64, value: f64) -> f64 {
15    let pct = (value - reference) / reference.abs() * 100.0;
16    if pct.is_finite() { pct } else { 0.0 }
17}
18
19/// The last bar of `bars` with a value, skipping whitespace bars.
20pub(crate) fn last_with_value(bars: &[Bar]) -> Option<&Bar> {
21    bars.iter().rev().find(|bar| !bar.is_whitespace())
22}
23
24/// Represents a single OHLCV (Open, High, Low, Close, Volume) bar
25///
26/// Each bar represents a discrete time period of trading activity. A bar can also carry
27/// [`colors`](Self::colors) of its own and a host [`payload`](Self::payload), stand for a single
28/// value ([`from_value`](Self::from_value)), or hold a slot of the time axis without a value
29/// ([`whitespace`](Self::whitespace)).
30///
31/// # Example
32///
33/// ```
34/// use chrono::Utc;
35/// use egui_charts::model::Bar;
36///
37/// let bar = Bar::new(
38///     Utc::now(),
39///     100.0, // open
40///     105.0, // high
41///     98.0,  // low
42///     103.0, // close
43///     1000.0 // volume
44/// );
45///
46/// assert!(bar.is_bullish()); // close > open
47/// ```
48#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
49pub struct Bar {
50    /// Timestamp of the bar. Read from JSON as an RFC 3339 time, or as a date alone
51    /// (`"2024-03-15"`), which is midnight UTC of that day, the open of its day bar.
52    #[serde(deserialize_with = "time_or_date")]
53    pub time: DateTime<Utc>,
54    /// Opening price
55    #[serde(default = "no_value", deserialize_with = "value_or_null")]
56    pub open: f64,
57    /// Highest price during the period
58    #[serde(default = "no_value", deserialize_with = "value_or_null")]
59    pub high: f64,
60    /// Lowest price during the period
61    #[serde(default = "no_value", deserialize_with = "value_or_null")]
62    pub low: f64,
63    /// Closing price
64    #[serde(default = "no_value", deserialize_with = "value_or_null")]
65    pub close: f64,
66    /// Trading volume
67    #[serde(default = "no_value", deserialize_with = "value_or_null")]
68    pub volume: f64,
69    /// Colours of this bar that replace the series' colours, `None` for the series' colours.
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub colors: Option<Box<BarColors>>,
72    /// Data of the host's own attached to this bar. The chart does not read it; it comes back
73    /// with the bar in the chart's pointer and data events and in its bar lookups.
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub payload: Option<Arc<serde_json::Value>>,
76}
77
78/// Colours of one bar that replace the colours of its series for that bar, in
79/// [`Bar::colors`]. A colour left `None` keeps the series' colour, and each chart type reads the
80/// colours that apply to it.
81#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
82#[serde(default)]
83pub struct BarColors {
84    /// The body of a candle; an OHLC bar, a column or a line's marker; or the line from this bar
85    /// to the next on line, step line, area and HLC area charts.
86    pub color: Option<Color32>,
87    /// The border of a candle's body.
88    pub border: Option<Color32>,
89    /// The wick of a candle.
90    pub wick: Option<Color32>,
91    /// A baseline's line above its base value, from this bar to the next.
92    pub top_line: Option<Color32>,
93    /// A baseline's fill at the pane's high-price edge, from this bar to the next.
94    pub top_fill: Option<Color32>,
95    /// A baseline's line below its base value, from this bar to the next.
96    pub bottom_line: Option<Color32>,
97    /// A baseline's fill at the pane's low-price edge, from this bar to the next.
98    pub bottom_fill: Option<Color32>,
99    /// A baseline's fill at the base on its higher-price side, from this bar to the next.
100    /// Falls back to the series' base endpoint, or the resolved `top_fill` for a flat fill.
101    pub top_fill_at_base: Option<Color32>,
102    /// A baseline's fill at the base on its lower-price side, from this bar to the next.
103    /// Falls back to the series' base endpoint, or the resolved `bottom_fill` for a flat fill.
104    pub bottom_fill_at_base: Option<Color32>,
105}
106
107/// A value missing from a JSON bar, which leaves the bar without it.
108fn no_value() -> f64 {
109    f64::NAN
110}
111
112/// A number, or `null`, which JSON writes for a value that is not a number, such as the values
113/// of a whitespace bar.
114fn value_or_null<'de, D: Deserializer<'de>>(deserializer: D) -> Result<f64, D::Error> {
115    Ok(Option::<f64>::deserialize(deserializer)?.unwrap_or(f64::NAN))
116}
117
118/// Midnight UTC of a calendar date, the time of a date-only bar.
119fn midnight_utc(date: NaiveDate) -> DateTime<Utc> {
120    date.and_time(NaiveTime::MIN).and_utc()
121}
122
123/// An RFC 3339 time, or a date alone for midnight UTC of that day.
124fn time_or_date<'de, D: Deserializer<'de>>(deserializer: D) -> Result<DateTime<Utc>, D::Error> {
125    let text = String::deserialize(deserializer)?;
126    text.parse::<DateTime<Utc>>()
127        .or_else(|error| {
128            NaiveDate::parse_from_str(&text, "%Y-%m-%d")
129                .map(midnight_utc)
130                .map_err(|_| error)
131        })
132        .map_err(serde::de::Error::custom)
133}
134
135impl Bar {
136    /// Creates a new bar
137    pub fn new(
138        time: DateTime<Utc>,
139        open: f64,
140        high: f64,
141        low: f64,
142        close: f64,
143        volume: f64,
144    ) -> Self {
145        Self {
146            time,
147            open,
148            high,
149            low,
150            close,
151            volume,
152            colors: None,
153            payload: None,
154        }
155    }
156
157    /// Creates a daily OHLCV bar from a calendar date, at midnight UTC, matching date-only
158    /// JSON and CSV imports. The date is independent of the exchange's time zone. The chart
159    /// formats this UTC instant in its display time zone, which can show a different date
160    /// (for example, the previous day at UTC-05:00).
161    ///
162    /// # Example
163    /// ```
164    /// use chrono::NaiveDate;
165    /// use egui_charts::model::Bar;
166    ///
167    /// let date = NaiveDate::from_ymd_opt(2024, 2, 29).unwrap();
168    /// let bar = Bar::from_date(date, 100.0, 105.0, 98.0, 103.0, 1000.0);
169    /// let point = Bar::from_value_on_date(date, 103.0);
170    /// let gap = Bar::whitespace_on_date(date);
171    /// assert_eq!(bar.time.to_rfc3339(), "2024-02-29T00:00:00+00:00");
172    /// assert_eq!(point.time, bar.time);
173    /// assert_eq!(gap.time, bar.time);
174    /// assert!(gap.is_whitespace());
175    /// ```
176    pub fn from_date(
177        date: NaiveDate,
178        open: f64,
179        high: f64,
180        low: f64,
181        close: f64,
182        volume: f64,
183    ) -> Self {
184        Self::new(midnight_utc(date), open, high, low, close, volume)
185    }
186
187    /// A bar of a single value, such as a point of a line, area, baseline or column series: its
188    /// open, high, low and close are `value`, its volume zero.
189    pub fn from_value(time: DateTime<Utc>, value: f64) -> Self {
190        Self::new(time, value, value, value, value, 0.0)
191    }
192
193    /// A [`from_value`](Self::from_value) bar on a calendar date, at midnight UTC.
194    /// Date and display time zone conventions are those of [`from_date`](Self::from_date).
195    pub fn from_value_on_date(date: NaiveDate, value: f64) -> Self {
196        Self::from_value(midnight_utc(date), value)
197    }
198
199    /// A bar without a value: it holds its slot on the time axis, so a gap such as a halted
200    /// session stays visible and a time of another series has a slot, but nothing is drawn there
201    /// and studies take it as a bar without a value. Its open, high, low, close and volume are NaN.
202    pub fn whitespace(time: DateTime<Utc>) -> Self {
203        Self::new(time, f64::NAN, f64::NAN, f64::NAN, f64::NAN, f64::NAN)
204    }
205
206    /// A [`whitespace`](Self::whitespace) bar on a calendar date, at midnight UTC.
207    /// Date and display time zone conventions are those of [`from_date`](Self::from_date).
208    pub fn whitespace_on_date(date: NaiveDate) -> Self {
209        Self::whitespace(midnight_utc(date))
210    }
211
212    /// Whether this is a [`whitespace`](Self::whitespace) bar: open, high, low, close and volume
213    /// are all NaN.
214    pub fn is_whitespace(&self) -> bool {
215        [self.open, self.high, self.low, self.close, self.volume]
216            .into_iter()
217            .all(f64::is_nan)
218    }
219
220    // ============= Direction Methods =============
221
222    /// Returns true if this is a bullish bar (close > open)
223    #[inline]
224    pub fn is_bullish(&self) -> bool {
225        self.close > self.open
226    }
227
228    /// Returns true if this is a bearish bar (close < open)
229    #[inline]
230    pub fn is_bearish(&self) -> bool {
231        self.close < self.open
232    }
233
234    /// Returns true if this is a doji (open ≈ close)
235    ///
236    /// # Arguments
237    /// * `threshold` - Ratio of body to range below which it's considered a doji
238    pub fn is_doji(&self, threshold: f64) -> bool {
239        let range = self.range();
240        if range == 0.0 {
241            return true;
242        }
243        (self.close - self.open).abs() / range < threshold
244    }
245
246    // ============= Price Measurements =============
247
248    /// Returns the body height (absolute difference between open and close)
249    #[inline]
250    pub fn body_height(&self) -> f64 {
251        (self.close - self.open).abs()
252    }
253
254    /// Returns the total range (high - low)
255    #[inline]
256    pub fn range(&self) -> f64 {
257        self.high - self.low
258    }
259
260    /// Returns the upper wick height
261    #[inline]
262    pub fn upper_wick(&self) -> f64 {
263        self.high - self.open.max(self.close)
264    }
265
266    /// Returns the lower wick height
267    #[inline]
268    pub fn lower_wick(&self) -> f64 {
269        self.open.min(self.close) - self.low
270    }
271
272    // ============= Derived Prices =============
273
274    /// Returns the typical price ((high + low + close) / 3)
275    ///
276    /// Commonly used in technical analysis (e.g., VWAP, CCI)
277    #[inline]
278    pub fn typical_price(&self) -> f64 {
279        (self.high + self.low + self.close) / 3.0
280    }
281
282    /// Returns the weighted close ((high + low + close + close) / 4)
283    #[inline]
284    pub fn weighted_close(&self) -> f64 {
285        (self.high + self.low + self.close * 2.0) / 4.0
286    }
287
288    /// Returns the midpoint price ((high + low) / 2)
289    #[inline]
290    pub fn midpoint(&self) -> f64 {
291        (self.high + self.low) / 2.0
292    }
293
294    /// Returns the average price (OHLC / 4)
295    #[inline]
296    pub fn avg_price(&self) -> f64 {
297        (self.open + self.high + self.low + self.close) / 4.0
298    }
299
300    // ============= Ratios and Percentages =============
301
302    /// Returns the body as a percentage of the total range (0.0 to 1.0)
303    pub fn body_percentage(&self) -> f64 {
304        let range = self.range();
305        if range == 0.0 {
306            return 0.0;
307        }
308        self.body_height() / range
309    }
310
311    /// Returns the ratio of upper wick to lower wick
312    ///
313    /// Returns infinity if lower wick is zero.
314    pub fn wick_ratio(&self) -> f64 {
315        let lower = self.lower_wick();
316        if lower == 0.0 {
317            return f64::INFINITY;
318        }
319        self.upper_wick() / lower
320    }
321
322    /// Returns the price change (close - open)
323    #[inline]
324    pub fn change(&self) -> f64 {
325        self.close - self.open
326    }
327
328    /// Returns the percentage change ((close - open) / |open| * 100), divided by the
329    /// magnitude of the open so it has the sign of the change for a negative open too
330    pub fn change_percent(&self) -> f64 {
331        percent_change(self.open, self.close)
332    }
333
334    // ============= Body Position Helpers =============
335
336    /// Returns the top of the body (max of open and close)
337    #[inline]
338    pub fn body_top(&self) -> f64 {
339        self.open.max(self.close)
340    }
341
342    /// Returns the bottom of the body (min of open and close)
343    #[inline]
344    pub fn body_bottom(&self) -> f64 {
345        self.open.min(self.close)
346    }
347}
348
349impl fmt::Display for Bar {
350    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
351        write!(
352            f,
353            "Bar[{}, O:{:.2}, H:{:.2}, L:{:.2}, C:{:.2}, V:{:.2}]",
354            self.time.format("%Y-%m-%d %H:%M:%S"),
355            self.open,
356            self.high,
357            self.low,
358            self.close,
359            self.volume
360        )
361    }
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367
368    fn sample_bullish_bar() -> Bar {
369        Bar::new(Utc::now(), 100.0, 110.0, 95.0, 105.0, 1000.0)
370    }
371
372    fn sample_bearish_bar() -> Bar {
373        Bar::new(Utc::now(), 105.0, 110.0, 95.0, 100.0, 1000.0)
374    }
375
376    #[test]
377    fn bar_shape() {
378        for (bar, bullish, bearish) in [
379            (sample_bullish_bar(), true, false),
380            (sample_bearish_bar(), false, true),
381        ] {
382            assert_eq!(bar.is_bullish(), bullish);
383            assert_eq!(bar.is_bearish(), bearish);
384            assert_eq!(bar.body_height(), 5.0);
385            assert_eq!(bar.range(), 15.0);
386            assert_eq!(bar.upper_wick(), 5.0);
387            assert_eq!(bar.lower_wick(), 5.0);
388            assert_eq!(bar.body_top(), 105.0);
389            assert_eq!(bar.body_bottom(), 100.0);
390        }
391    }
392
393    #[test]
394    fn test_derived_prices() {
395        let bar = Bar::new(Utc::now(), 100.0, 120.0, 80.0, 110.0, 1000.0);
396
397        // typical = (120 + 80 + 110) / 3 = 103.33...
398        assert!((bar.typical_price() - 103.333).abs() < 0.01);
399
400        // midpoint = (120 + 80) / 2 = 100
401        assert_eq!(bar.midpoint(), 100.0);
402
403        // avg = (100 + 120 + 80 + 110) / 4 = 102.5
404        assert_eq!(bar.avg_price(), 102.5);
405    }
406
407    /// The percentage has the sign of the price change for any reference sign;
408    /// a zero or non-finite reference or value gives 0 instead of inf or NaN.
409    #[test]
410    fn percent_change_follows_the_sign_of_the_change() {
411        let rows = [
412            // (reference, value, expected)
413            (100.0, 105.0, 5.0),
414            (10.0, 5.0, -50.0),
415            (-10.0, -5.0, 50.0),
416            (-5.0, -10.0, -100.0),
417            (0.0, 5.0, 0.0),
418            (f64::NAN, 5.0, 0.0),
419            (f64::INFINITY, 5.0, 0.0),
420            (5.0, f64::NAN, 0.0),
421        ];
422        for (reference, value, expected) in rows {
423            assert_eq!(
424                percent_change(reference, value),
425                expected,
426                "{reference} -> {value}"
427            );
428        }
429        let bar = Bar::new(Utc::now(), -10.0, -4.0, -11.0, -5.0, 1000.0);
430        assert_eq!(bar.change_percent(), 50.0);
431    }
432}