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}