1use crate::error::{Error, Result};
4
5#[derive(Debug, Clone, Copy, PartialEq)]
25#[non_exhaustive]
26pub struct Candle {
27 pub open: f64,
29 pub high: f64,
31 pub low: f64,
33 pub close: f64,
35 pub volume: f64,
37 pub timestamp: i64,
39}
40
41impl Candle {
42 pub fn new(
52 open: f64,
53 high: f64,
54 low: f64,
55 close: f64,
56 volume: f64,
57 timestamp: i64,
58 ) -> Result<Self> {
59 if !(open.is_finite() && high.is_finite() && low.is_finite() && close.is_finite()) {
60 return Err(Error::InvalidCandle {
61 message: "open, high, low, close must all be finite",
62 });
63 }
64 if !volume.is_finite() {
65 return Err(Error::InvalidCandle {
66 message: "volume must be finite",
67 });
68 }
69 if volume < 0.0 {
70 return Err(Error::InvalidCandle {
71 message: "volume must be non-negative",
72 });
73 }
74 if high < low {
75 return Err(Error::InvalidCandle {
76 message: "high must be >= low",
77 });
78 }
79 if high < open || high < close {
80 return Err(Error::InvalidCandle {
81 message: "high must be >= open and >= close",
82 });
83 }
84 if low > open || low > close {
85 return Err(Error::InvalidCandle {
86 message: "low must be <= open and <= close",
87 });
88 }
89 Ok(Self {
90 open,
91 high,
92 low,
93 close,
94 volume,
95 timestamp,
96 })
97 }
98
99 pub const fn new_unchecked(
102 open: f64,
103 high: f64,
104 low: f64,
105 close: f64,
106 volume: f64,
107 timestamp: i64,
108 ) -> Self {
109 Self {
110 open,
111 high,
112 low,
113 close,
114 volume,
115 timestamp,
116 }
117 }
118
119 #[inline]
121 pub fn typical_price(&self) -> f64 {
122 (self.high + self.low + self.close) / 3.0
123 }
124
125 #[inline]
127 pub fn median_price(&self) -> f64 {
128 f64::midpoint(self.high, self.low)
129 }
130
131 #[inline]
133 pub fn weighted_close(&self) -> f64 {
134 (self.high + self.low + 2.0 * self.close) / 4.0
135 }
136
137 #[inline]
139 pub fn avg_price(&self) -> f64 {
140 (self.open + self.high + self.low + self.close) / 4.0
141 }
142
143 #[inline]
146 pub fn true_range(&self, prev_close: Option<f64>) -> f64 {
147 let hl = self.high - self.low;
148 match prev_close {
149 Some(prev) => {
150 let hp = (self.high - prev).abs();
151 let lp = (self.low - prev).abs();
152 hl.max(hp).max(lp)
153 }
154 None => hl,
155 }
156 }
157}
158
159#[derive(Debug, Clone, Copy, PartialEq)]
174#[non_exhaustive]
175pub struct Tick {
176 pub price: f64,
178 pub volume: f64,
180 pub timestamp: i64,
182}
183
184impl Tick {
185 pub fn new(price: f64, volume: f64, timestamp: i64) -> Result<Self> {
193 if !price.is_finite() || !volume.is_finite() {
194 return Err(Error::NonFiniteInput);
195 }
196 if volume < 0.0 {
197 return Err(Error::InvalidTick {
198 message: "tick volume must be non-negative",
199 });
200 }
201 Ok(Self {
202 price,
203 volume,
204 timestamp,
205 })
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212
213 #[test]
214 fn candle_new_accepts_valid_ohlc() {
215 let c = Candle::new(10.0, 11.0, 9.0, 10.5, 100.0, 1).unwrap();
216 assert_eq!(c.open, 10.0);
217 assert_eq!(c.high, 11.0);
218 assert_eq!(c.low, 9.0);
219 assert_eq!(c.close, 10.5);
220 assert_eq!(c.volume, 100.0);
221 assert_eq!(c.timestamp, 1);
222 }
223
224 #[test]
225 fn candle_new_rejects_high_below_low() {
226 let err = Candle::new(10.0, 9.0, 10.0, 10.0, 1.0, 0).unwrap_err();
227 assert!(matches!(err, Error::InvalidCandle { .. }));
228 }
229
230 #[test]
231 fn candle_new_rejects_high_below_close() {
232 let err = Candle::new(10.0, 10.0, 9.0, 11.0, 1.0, 0).unwrap_err();
233 assert!(matches!(err, Error::InvalidCandle { .. }));
234 }
235
236 #[test]
237 fn candle_new_rejects_low_above_open() {
238 let err = Candle::new(10.0, 11.0, 10.5, 10.5, 1.0, 0).unwrap_err();
239 assert!(matches!(err, Error::InvalidCandle { .. }));
240 }
241
242 #[test]
243 fn candle_new_rejects_negative_volume() {
244 let err = Candle::new(10.0, 11.0, 9.0, 10.5, -1.0, 0).unwrap_err();
245 assert!(matches!(err, Error::InvalidCandle { .. }));
246 }
247
248 #[test]
249 fn candle_new_rejects_nan_price() {
250 let err = Candle::new(f64::NAN, 11.0, 9.0, 10.5, 1.0, 0).unwrap_err();
251 assert!(matches!(err, Error::InvalidCandle { .. }));
252 }
253
254 #[test]
265 fn candle_new_unchecked_preserves_fields_verbatim() {
266 let c = Candle::new_unchecked(1.0, 2.0, 0.5, 1.5, 100.0, 42);
267 assert_eq!(c.open, 1.0);
268 assert_eq!(c.high, 2.0);
269 assert_eq!(c.low, 0.5);
270 assert_eq!(c.close, 1.5);
271 assert_eq!(c.volume, 100.0);
272 assert_eq!(c.timestamp, 42);
273
274 assert!(Candle::new(10.0, 9.0, 10.0, 10.0, 1.0, 0).is_err());
277 let unchecked = Candle::new_unchecked(10.0, 9.0, 10.0, 10.0, 1.0, 0);
278 assert_eq!(unchecked.high, 9.0);
279 assert_eq!(unchecked.low, 10.0);
280 }
281
282 #[test]
283 fn candle_typical_price() {
284 let c = Candle::new(10.0, 12.0, 9.0, 11.0, 1.0, 0).unwrap();
285 assert_eq!(c.typical_price(), (12.0 + 9.0 + 11.0) / 3.0);
286 }
287
288 #[test]
289 fn candle_median_price() {
290 let c = Candle::new(10.0, 12.0, 8.0, 11.0, 1.0, 0).unwrap();
291 assert_eq!(c.median_price(), 10.0);
292 }
293
294 #[test]
295 fn candle_weighted_close() {
296 let c = Candle::new(10.0, 12.0, 8.0, 11.0, 1.0, 0).unwrap();
297 assert_eq!(c.weighted_close(), (12.0 + 8.0 + 22.0) / 4.0);
298 }
299
300 #[test]
301 fn candle_true_range_without_prev() {
302 let c = Candle::new(10.0, 12.0, 8.0, 11.0, 1.0, 0).unwrap();
303 assert_eq!(c.true_range(None), 4.0);
304 }
305
306 #[test]
307 fn candle_true_range_with_gap_up() {
308 let c = Candle::new(10.0, 12.0, 8.0, 11.0, 1.0, 0).unwrap();
310 assert_eq!(c.true_range(Some(6.0)), 6.0);
311 }
312
313 #[test]
314 fn candle_true_range_with_gap_down() {
315 let c = Candle::new(10.0, 12.0, 8.0, 11.0, 1.0, 0).unwrap();
317 assert_eq!(c.true_range(Some(14.0)), 6.0);
318 }
319
320 #[test]
321 fn tick_new_accepts_valid() {
322 let t = Tick::new(100.5, 0.5, 42).unwrap();
323 assert_eq!(t.price, 100.5);
324 assert_eq!(t.volume, 0.5);
325 assert_eq!(t.timestamp, 42);
326 }
327
328 #[test]
329 fn tick_new_rejects_nan() {
330 assert!(matches!(
331 Tick::new(f64::NAN, 1.0, 0),
332 Err(Error::NonFiniteInput)
333 ));
334 }
335
336 #[test]
337 fn tick_new_rejects_inf() {
338 assert!(matches!(
339 Tick::new(f64::INFINITY, 1.0, 0),
340 Err(Error::NonFiniteInput)
341 ));
342 }
343
344 #[test]
345 fn tick_new_rejects_negative_volume() {
346 let err = Tick::new(100.0, -1.0, 0).unwrap_err();
350 assert!(matches!(err, Error::InvalidTick { .. }));
351 assert!(
352 err.to_string().contains("tick volume"),
353 "expected the InvalidTick message in the formatted error, got {err}"
354 );
355 }
356}