1use crate::error::{Error, Result};
4
5#[derive(Debug, Clone, Copy, PartialEq)]
11pub struct Candle {
12 pub open: f64,
14 pub high: f64,
16 pub low: f64,
18 pub close: f64,
20 pub volume: f64,
22 pub timestamp: i64,
24}
25
26impl Candle {
27 pub fn new(
37 open: f64,
38 high: f64,
39 low: f64,
40 close: f64,
41 volume: f64,
42 timestamp: i64,
43 ) -> Result<Self> {
44 if !(open.is_finite() && high.is_finite() && low.is_finite() && close.is_finite()) {
45 return Err(Error::InvalidCandle {
46 message: "open, high, low, close must all be finite",
47 });
48 }
49 if !volume.is_finite() {
50 return Err(Error::InvalidCandle {
51 message: "volume must be finite",
52 });
53 }
54 if volume < 0.0 {
55 return Err(Error::InvalidCandle {
56 message: "volume must be non-negative",
57 });
58 }
59 if high < low {
60 return Err(Error::InvalidCandle {
61 message: "high must be >= low",
62 });
63 }
64 if high < open || high < close {
65 return Err(Error::InvalidCandle {
66 message: "high must be >= open and >= close",
67 });
68 }
69 if low > open || low > close {
70 return Err(Error::InvalidCandle {
71 message: "low must be <= open and <= close",
72 });
73 }
74 Ok(Self {
75 open,
76 high,
77 low,
78 close,
79 volume,
80 timestamp,
81 })
82 }
83
84 pub const fn new_unchecked(
87 open: f64,
88 high: f64,
89 low: f64,
90 close: f64,
91 volume: f64,
92 timestamp: i64,
93 ) -> Self {
94 Self {
95 open,
96 high,
97 low,
98 close,
99 volume,
100 timestamp,
101 }
102 }
103
104 #[inline]
106 pub fn typical_price(&self) -> f64 {
107 (self.high + self.low + self.close) / 3.0
108 }
109
110 #[inline]
112 pub fn median_price(&self) -> f64 {
113 f64::midpoint(self.high, self.low)
114 }
115
116 #[inline]
118 pub fn weighted_close(&self) -> f64 {
119 (self.high + self.low + 2.0 * self.close) / 4.0
120 }
121
122 #[inline]
125 pub fn true_range(&self, prev_close: Option<f64>) -> f64 {
126 let hl = self.high - self.low;
127 match prev_close {
128 Some(prev) => {
129 let hp = (self.high - prev).abs();
130 let lp = (self.low - prev).abs();
131 hl.max(hp).max(lp)
132 }
133 None => hl,
134 }
135 }
136}
137
138#[derive(Debug, Clone, Copy, PartialEq)]
140pub struct Tick {
141 pub price: f64,
143 pub volume: f64,
145 pub timestamp: i64,
147}
148
149impl Tick {
150 pub fn new(price: f64, volume: f64, timestamp: i64) -> Result<Self> {
158 if !price.is_finite() || !volume.is_finite() {
159 return Err(Error::NonFiniteInput);
160 }
161 if volume < 0.0 {
162 return Err(Error::InvalidTick {
163 message: "tick volume must be non-negative",
164 });
165 }
166 Ok(Self {
167 price,
168 volume,
169 timestamp,
170 })
171 }
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 #[test]
179 fn candle_new_accepts_valid_ohlc() {
180 let c = Candle::new(10.0, 11.0, 9.0, 10.5, 100.0, 1).unwrap();
181 assert_eq!(c.open, 10.0);
182 assert_eq!(c.high, 11.0);
183 assert_eq!(c.low, 9.0);
184 assert_eq!(c.close, 10.5);
185 assert_eq!(c.volume, 100.0);
186 assert_eq!(c.timestamp, 1);
187 }
188
189 #[test]
190 fn candle_new_rejects_high_below_low() {
191 let err = Candle::new(10.0, 9.0, 10.0, 10.0, 1.0, 0).unwrap_err();
192 assert!(matches!(err, Error::InvalidCandle { .. }));
193 }
194
195 #[test]
196 fn candle_new_rejects_high_below_close() {
197 let err = Candle::new(10.0, 10.0, 9.0, 11.0, 1.0, 0).unwrap_err();
198 assert!(matches!(err, Error::InvalidCandle { .. }));
199 }
200
201 #[test]
202 fn candle_new_rejects_low_above_open() {
203 let err = Candle::new(10.0, 11.0, 10.5, 10.5, 1.0, 0).unwrap_err();
204 assert!(matches!(err, Error::InvalidCandle { .. }));
205 }
206
207 #[test]
208 fn candle_new_rejects_negative_volume() {
209 let err = Candle::new(10.0, 11.0, 9.0, 10.5, -1.0, 0).unwrap_err();
210 assert!(matches!(err, Error::InvalidCandle { .. }));
211 }
212
213 #[test]
214 fn candle_new_rejects_nan_price() {
215 let err = Candle::new(f64::NAN, 11.0, 9.0, 10.5, 1.0, 0).unwrap_err();
216 assert!(matches!(err, Error::InvalidCandle { .. }));
217 }
218
219 #[test]
230 fn candle_new_unchecked_preserves_fields_verbatim() {
231 let c = Candle::new_unchecked(1.0, 2.0, 0.5, 1.5, 100.0, 42);
232 assert_eq!(c.open, 1.0);
233 assert_eq!(c.high, 2.0);
234 assert_eq!(c.low, 0.5);
235 assert_eq!(c.close, 1.5);
236 assert_eq!(c.volume, 100.0);
237 assert_eq!(c.timestamp, 42);
238
239 assert!(Candle::new(10.0, 9.0, 10.0, 10.0, 1.0, 0).is_err());
242 let unchecked = Candle::new_unchecked(10.0, 9.0, 10.0, 10.0, 1.0, 0);
243 assert_eq!(unchecked.high, 9.0);
244 assert_eq!(unchecked.low, 10.0);
245 }
246
247 #[test]
248 fn candle_typical_price() {
249 let c = Candle::new(10.0, 12.0, 9.0, 11.0, 1.0, 0).unwrap();
250 assert_eq!(c.typical_price(), (12.0 + 9.0 + 11.0) / 3.0);
251 }
252
253 #[test]
254 fn candle_median_price() {
255 let c = Candle::new(10.0, 12.0, 8.0, 11.0, 1.0, 0).unwrap();
256 assert_eq!(c.median_price(), 10.0);
257 }
258
259 #[test]
260 fn candle_weighted_close() {
261 let c = Candle::new(10.0, 12.0, 8.0, 11.0, 1.0, 0).unwrap();
262 assert_eq!(c.weighted_close(), (12.0 + 8.0 + 22.0) / 4.0);
263 }
264
265 #[test]
266 fn candle_true_range_without_prev() {
267 let c = Candle::new(10.0, 12.0, 8.0, 11.0, 1.0, 0).unwrap();
268 assert_eq!(c.true_range(None), 4.0);
269 }
270
271 #[test]
272 fn candle_true_range_with_gap_up() {
273 let c = Candle::new(10.0, 12.0, 8.0, 11.0, 1.0, 0).unwrap();
275 assert_eq!(c.true_range(Some(6.0)), 6.0);
276 }
277
278 #[test]
279 fn candle_true_range_with_gap_down() {
280 let c = Candle::new(10.0, 12.0, 8.0, 11.0, 1.0, 0).unwrap();
282 assert_eq!(c.true_range(Some(14.0)), 6.0);
283 }
284
285 #[test]
286 fn tick_new_accepts_valid() {
287 let t = Tick::new(100.5, 0.5, 42).unwrap();
288 assert_eq!(t.price, 100.5);
289 assert_eq!(t.volume, 0.5);
290 assert_eq!(t.timestamp, 42);
291 }
292
293 #[test]
294 fn tick_new_rejects_nan() {
295 assert!(matches!(
296 Tick::new(f64::NAN, 1.0, 0),
297 Err(Error::NonFiniteInput)
298 ));
299 }
300
301 #[test]
302 fn tick_new_rejects_inf() {
303 assert!(matches!(
304 Tick::new(f64::INFINITY, 1.0, 0),
305 Err(Error::NonFiniteInput)
306 ));
307 }
308
309 #[test]
310 fn tick_new_rejects_negative_volume() {
311 let err = Tick::new(100.0, -1.0, 0).unwrap_err();
315 assert!(matches!(err, Error::InvalidTick { .. }));
316 assert!(
317 err.to_string().contains("tick volume"),
318 "expected the InvalidTick message in the formatted error, got {err}"
319 );
320 }
321}