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]
124 pub fn avg_price(&self) -> f64 {
125 (self.open + self.high + self.low + self.close) / 4.0
126 }
127
128 #[inline]
131 pub fn true_range(&self, prev_close: Option<f64>) -> f64 {
132 let hl = self.high - self.low;
133 match prev_close {
134 Some(prev) => {
135 let hp = (self.high - prev).abs();
136 let lp = (self.low - prev).abs();
137 hl.max(hp).max(lp)
138 }
139 None => hl,
140 }
141 }
142}
143
144#[derive(Debug, Clone, Copy, PartialEq)]
146pub struct Tick {
147 pub price: f64,
149 pub volume: f64,
151 pub timestamp: i64,
153}
154
155impl Tick {
156 pub fn new(price: f64, volume: f64, timestamp: i64) -> Result<Self> {
164 if !price.is_finite() || !volume.is_finite() {
165 return Err(Error::NonFiniteInput);
166 }
167 if volume < 0.0 {
168 return Err(Error::InvalidTick {
169 message: "tick volume must be non-negative",
170 });
171 }
172 Ok(Self {
173 price,
174 volume,
175 timestamp,
176 })
177 }
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183
184 #[test]
185 fn candle_new_accepts_valid_ohlc() {
186 let c = Candle::new(10.0, 11.0, 9.0, 10.5, 100.0, 1).unwrap();
187 assert_eq!(c.open, 10.0);
188 assert_eq!(c.high, 11.0);
189 assert_eq!(c.low, 9.0);
190 assert_eq!(c.close, 10.5);
191 assert_eq!(c.volume, 100.0);
192 assert_eq!(c.timestamp, 1);
193 }
194
195 #[test]
196 fn candle_new_rejects_high_below_low() {
197 let err = Candle::new(10.0, 9.0, 10.0, 10.0, 1.0, 0).unwrap_err();
198 assert!(matches!(err, Error::InvalidCandle { .. }));
199 }
200
201 #[test]
202 fn candle_new_rejects_high_below_close() {
203 let err = Candle::new(10.0, 10.0, 9.0, 11.0, 1.0, 0).unwrap_err();
204 assert!(matches!(err, Error::InvalidCandle { .. }));
205 }
206
207 #[test]
208 fn candle_new_rejects_low_above_open() {
209 let err = Candle::new(10.0, 11.0, 10.5, 10.5, 1.0, 0).unwrap_err();
210 assert!(matches!(err, Error::InvalidCandle { .. }));
211 }
212
213 #[test]
214 fn candle_new_rejects_negative_volume() {
215 let err = Candle::new(10.0, 11.0, 9.0, 10.5, -1.0, 0).unwrap_err();
216 assert!(matches!(err, Error::InvalidCandle { .. }));
217 }
218
219 #[test]
220 fn candle_new_rejects_nan_price() {
221 let err = Candle::new(f64::NAN, 11.0, 9.0, 10.5, 1.0, 0).unwrap_err();
222 assert!(matches!(err, Error::InvalidCandle { .. }));
223 }
224
225 #[test]
236 fn candle_new_unchecked_preserves_fields_verbatim() {
237 let c = Candle::new_unchecked(1.0, 2.0, 0.5, 1.5, 100.0, 42);
238 assert_eq!(c.open, 1.0);
239 assert_eq!(c.high, 2.0);
240 assert_eq!(c.low, 0.5);
241 assert_eq!(c.close, 1.5);
242 assert_eq!(c.volume, 100.0);
243 assert_eq!(c.timestamp, 42);
244
245 assert!(Candle::new(10.0, 9.0, 10.0, 10.0, 1.0, 0).is_err());
248 let unchecked = Candle::new_unchecked(10.0, 9.0, 10.0, 10.0, 1.0, 0);
249 assert_eq!(unchecked.high, 9.0);
250 assert_eq!(unchecked.low, 10.0);
251 }
252
253 #[test]
254 fn candle_typical_price() {
255 let c = Candle::new(10.0, 12.0, 9.0, 11.0, 1.0, 0).unwrap();
256 assert_eq!(c.typical_price(), (12.0 + 9.0 + 11.0) / 3.0);
257 }
258
259 #[test]
260 fn candle_median_price() {
261 let c = Candle::new(10.0, 12.0, 8.0, 11.0, 1.0, 0).unwrap();
262 assert_eq!(c.median_price(), 10.0);
263 }
264
265 #[test]
266 fn candle_weighted_close() {
267 let c = Candle::new(10.0, 12.0, 8.0, 11.0, 1.0, 0).unwrap();
268 assert_eq!(c.weighted_close(), (12.0 + 8.0 + 22.0) / 4.0);
269 }
270
271 #[test]
272 fn candle_true_range_without_prev() {
273 let c = Candle::new(10.0, 12.0, 8.0, 11.0, 1.0, 0).unwrap();
274 assert_eq!(c.true_range(None), 4.0);
275 }
276
277 #[test]
278 fn candle_true_range_with_gap_up() {
279 let c = Candle::new(10.0, 12.0, 8.0, 11.0, 1.0, 0).unwrap();
281 assert_eq!(c.true_range(Some(6.0)), 6.0);
282 }
283
284 #[test]
285 fn candle_true_range_with_gap_down() {
286 let c = Candle::new(10.0, 12.0, 8.0, 11.0, 1.0, 0).unwrap();
288 assert_eq!(c.true_range(Some(14.0)), 6.0);
289 }
290
291 #[test]
292 fn tick_new_accepts_valid() {
293 let t = Tick::new(100.5, 0.5, 42).unwrap();
294 assert_eq!(t.price, 100.5);
295 assert_eq!(t.volume, 0.5);
296 assert_eq!(t.timestamp, 42);
297 }
298
299 #[test]
300 fn tick_new_rejects_nan() {
301 assert!(matches!(
302 Tick::new(f64::NAN, 1.0, 0),
303 Err(Error::NonFiniteInput)
304 ));
305 }
306
307 #[test]
308 fn tick_new_rejects_inf() {
309 assert!(matches!(
310 Tick::new(f64::INFINITY, 1.0, 0),
311 Err(Error::NonFiniteInput)
312 ));
313 }
314
315 #[test]
316 fn tick_new_rejects_negative_volume() {
317 let err = Tick::new(100.0, -1.0, 0).unwrap_err();
321 assert!(matches!(err, Error::InvalidTick { .. }));
322 assert!(
323 err.to_string().contains("tick volume"),
324 "expected the InvalidTick message in the formatted error, got {err}"
325 );
326 }
327}