1use std::fmt;
2
3#[cfg(feature = "serde")]
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
8#[cfg_attr(
9 feature = "serde",
10 derive(Serialize, Deserialize),
11 serde(rename_all = "lowercase")
12)]
13pub enum Resolution {
14 M1,
15 M5,
16 M15,
17 M30,
18 H1,
19 H4,
20 D1,
21 W1,
22}
23
24impl Resolution {
25 pub fn as_str(&self) -> &'static str {
26 match self {
27 Resolution::M1 => "1m",
28 Resolution::M5 => "5m",
29 Resolution::M15 => "15m",
30 Resolution::M30 => "30m",
31 Resolution::H1 => "1h",
32 Resolution::H4 => "4h",
33 Resolution::D1 => "1d",
34 Resolution::W1 => "1w",
35 }
36 }
37}
38
39impl fmt::Display for Resolution {
40 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41 write!(f, "{}", self.as_str())
42 }
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
47#[cfg_attr(
48 feature = "serde",
49 derive(Serialize, Deserialize),
50 serde(rename_all = "lowercase")
51)]
52pub enum Source {
53 #[default]
54 Close,
55 Open,
56 High,
57 Low,
58 Hl2,
59 Hlc3,
60 Ohlc4,
61 Volume,
62 TypicalPrice,
63}
64
65impl Source {
66 pub fn extract(&self, bar: &Bar) -> f64 {
68 match self {
69 Source::Close => bar.close,
70 Source::Open => bar.open,
71 Source::High => bar.high,
72 Source::Low => bar.low,
73 Source::Hl2 => (bar.high + bar.low) / 2.0,
74 Source::Hlc3 => (bar.high + bar.low + bar.close) / 3.0,
75 Source::Ohlc4 => (bar.open + bar.high + bar.low + bar.close) / 4.0,
76 Source::Volume => bar.volume,
77 Source::TypicalPrice => bar.typical_price(),
78 }
79 }
80}
81
82#[derive(Debug, Clone, PartialEq)]
84#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
85pub struct InstrumentMeta {
86 pub symbol: String,
87 pub tick_size: f64,
88 pub price_precision: usize,
89 pub timezone: String,
90}
91
92impl Default for InstrumentMeta {
93 fn default() -> Self {
94 Self {
95 symbol: "GENERIC".to_string(),
96 tick_size: 0.01,
97 price_precision: 2,
98 timezone: "UTC".to_string(),
99 }
100 }
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub enum InstrumentMetaError {
106 NonPositiveTickSize,
107 NonFiniteTickSize,
108 ExcessivePricePrecision,
109 EmptySymbol,
110 EmptyTimezone,
111}
112
113impl fmt::Display for InstrumentMetaError {
114 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115 let message = match self {
116 Self::NonPositiveTickSize => "tick_size must be greater than zero",
117 Self::NonFiniteTickSize => "tick_size must be finite",
118 Self::ExcessivePricePrecision => "price_precision must be <= 12",
119 Self::EmptySymbol => "symbol must not be empty",
120 Self::EmptyTimezone => "timezone must not be empty",
121 };
122 f.write_str(message)
123 }
124}
125
126impl std::error::Error for InstrumentMetaError {}
127
128impl InstrumentMeta {
129 pub fn validate(&self) -> Result<(), InstrumentMetaError> {
133 if !self.tick_size.is_finite() {
134 return Err(InstrumentMetaError::NonFiniteTickSize);
135 }
136 if self.tick_size <= 0.0 {
137 return Err(InstrumentMetaError::NonPositiveTickSize);
138 }
139 if self.price_precision > 12 {
140 return Err(InstrumentMetaError::ExcessivePricePrecision);
141 }
142 if self.symbol.trim().is_empty() {
143 return Err(InstrumentMetaError::EmptySymbol);
144 }
145 if self.timezone.trim().is_empty() {
146 return Err(InstrumentMetaError::EmptyTimezone);
147 }
148 Ok(())
149 }
150
151 pub fn round_to_tick(&self, price: f64) -> f64 {
157 if !self.tick_size.is_finite() || self.tick_size <= 0.0 || !price.is_finite() {
158 return price;
159 }
160 (price / self.tick_size).round() * self.tick_size
161 }
162}
163
164#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
167#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
168pub struct BarQuality {
169 pub volume_available: bool,
172 pub is_synthetic: bool,
175 pub is_forward_filled: bool,
177 pub has_gap: bool,
179}
180
181impl BarQuality {
182 pub fn observed() -> Self {
185 Self {
186 volume_available: true,
187 is_synthetic: false,
188 is_forward_filled: false,
189 has_gap: false,
190 }
191 }
192}
193
194#[derive(Debug, Clone, PartialEq)]
196#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
197pub struct QualifiedBar {
198 pub bar: Bar,
199 pub quality: BarQuality,
200}
201
202impl QualifiedBar {
203 pub fn new(bar: Bar, quality: BarQuality) -> Self {
204 Self { bar, quality }
205 }
206
207 pub fn observed(bar: Bar) -> Self {
209 Self {
210 bar,
211 quality: BarQuality::observed(),
212 }
213 }
214}
215
216#[derive(Debug, Clone, PartialEq)]
218#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
219pub struct Bar {
220 pub timestamp: i64,
221 pub open: f64,
222 pub high: f64,
223 pub low: f64,
224 pub close: f64,
225 pub volume: f64,
226}
227
228#[derive(Debug, Clone, Copy, PartialEq, Eq)]
230pub enum BarValidationError {
231 NonFiniteValue,
232 NonPositivePrice,
233 NegativeVolume,
234 InvalidPriceRange,
235}
236
237impl fmt::Display for BarValidationError {
238 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
239 let message = match self {
240 Self::NonFiniteValue => "OHLCV values must be finite",
241 Self::NonPositivePrice => "OHLC prices must be greater than zero",
242 Self::NegativeVolume => "volume must be non-negative",
243 Self::InvalidPriceRange => "low/high must contain open and close",
244 };
245 f.write_str(message)
246 }
247}
248
249impl std::error::Error for BarValidationError {}
250
251impl Bar {
252 pub fn new(timestamp: i64, open: f64, high: f64, low: f64, close: f64, volume: f64) -> Self {
257 Self {
258 timestamp,
259 open,
260 high,
261 low,
262 close,
263 volume,
264 }
265 }
266
267 pub fn try_new(
269 timestamp: i64,
270 open: f64,
271 high: f64,
272 low: f64,
273 close: f64,
274 volume: f64,
275 ) -> Result<Self, BarValidationError> {
276 let bar = Self::new(timestamp, open, high, low, close, volume);
277 bar.validate()?;
278 Ok(bar)
279 }
280
281 pub fn typical_price(&self) -> f64 {
282 (self.high + self.low + self.close) / 3.0
283 }
284
285 pub fn validate(&self) -> Result<(), BarValidationError> {
290 if !self.open.is_finite()
291 || !self.high.is_finite()
292 || !self.low.is_finite()
293 || !self.close.is_finite()
294 || !self.volume.is_finite()
295 {
296 return Err(BarValidationError::NonFiniteValue);
297 }
298
299 if self.open <= 0.0 || self.high <= 0.0 || self.low <= 0.0 || self.close <= 0.0 {
300 return Err(BarValidationError::NonPositivePrice);
301 }
302 if self.volume < 0.0 {
303 return Err(BarValidationError::NegativeVolume);
304 }
305
306 let min_oc = self.open.min(self.close);
307 let max_oc = self.open.max(self.close);
308
309 if self.low > min_oc || self.high < max_oc || self.low > self.high {
310 return Err(BarValidationError::InvalidPriceRange);
311 }
312
313 Ok(())
314 }
315
316 pub fn is_valid(&self) -> bool {
318 self.validate().is_ok()
319 }
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325
326 #[test]
327 fn test_bar_validation_contract() {
328 let valid = Bar::new(1000, 100.0, 105.0, 95.0, 104.0, 1000.0);
329 assert!(valid.is_valid());
330 assert_eq!(
331 Bar::try_new(1000, 100.0, 105.0, 95.0, 104.0, 1000.0),
332 Ok(valid)
333 );
334
335 let neg_price = Bar::new(1000, -100.0, 105.0, 95.0, 104.0, 1000.0);
337 assert!(!neg_price.is_valid());
338
339 let neg_vol = Bar::new(1000, 100.0, 105.0, 95.0, 104.0, -10.0);
341 assert!(!neg_vol.is_valid());
342
343 let bad_high = Bar::new(1000, 100.0, 90.0, 80.0, 95.0, 1000.0);
345 assert!(!bad_high.is_valid());
346
347 let nan_price = Bar::new(1000, f64::NAN, 105.0, 95.0, 104.0, 1000.0);
349 assert!(!nan_price.is_valid());
350 assert_eq!(
351 nan_price.validate(),
352 Err(BarValidationError::NonFiniteValue)
353 );
354 }
355
356 #[test]
357 fn test_instrument_meta_validate() {
358 assert_eq!(InstrumentMeta::default().validate(), Ok(()));
359
360 let bad_tick = InstrumentMeta {
361 tick_size: 0.0,
362 ..InstrumentMeta::default()
363 };
364 assert_eq!(
365 bad_tick.validate(),
366 Err(InstrumentMetaError::NonPositiveTickSize)
367 );
368
369 let bad_precision = InstrumentMeta {
370 price_precision: 13,
371 ..InstrumentMeta::default()
372 };
373 assert_eq!(
374 bad_precision.validate(),
375 Err(InstrumentMetaError::ExcessivePricePrecision)
376 );
377
378 let empty_symbol = InstrumentMeta {
379 symbol: "".to_string(),
380 ..InstrumentMeta::default()
381 };
382 assert_eq!(
383 empty_symbol.validate(),
384 Err(InstrumentMetaError::EmptySymbol)
385 );
386 }
387
388 #[test]
389 fn test_instrument_meta_round_to_tick() {
390 let meta = InstrumentMeta {
391 tick_size: 0.25,
392 ..InstrumentMeta::default()
393 };
394 assert_eq!(meta.round_to_tick(100.10), 100.0);
395 assert_eq!(meta.round_to_tick(100.13), 100.25);
396 assert_eq!(meta.round_to_tick(100.125), 100.25);
397
398 let invalid_tick = InstrumentMeta {
399 tick_size: 0.0,
400 ..InstrumentMeta::default()
401 };
402 assert_eq!(invalid_tick.round_to_tick(100.10), 100.10);
404 }
405
406 #[test]
407 fn test_bar_quality_defaults() {
408 let default_quality = BarQuality::default();
409 assert!(!default_quality.volume_available);
410 assert!(!default_quality.is_synthetic);
411
412 let observed = BarQuality::observed();
413 assert!(observed.volume_available);
414 assert!(!observed.is_synthetic);
415 assert!(!observed.is_forward_filled);
416 assert!(!observed.has_gap);
417
418 let bar = Bar::new(1000, 100.0, 105.0, 95.0, 104.0, 1000.0);
419 let qualified = QualifiedBar::observed(bar.clone());
420 assert_eq!(qualified.bar, bar);
421 assert_eq!(qualified.quality, BarQuality::observed());
422 }
423}
424
425#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
427#[cfg_attr(
428 feature = "serde",
429 derive(Serialize, Deserialize),
430 serde(rename_all = "snake_case")
431)]
432pub enum MarketRegime {
433 BullishExpansion,
434 BearishExpansion,
435 #[default]
436 Consolidation,
437 Transition,
438}
439
440impl fmt::Display for MarketRegime {
441 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
442 match self {
443 MarketRegime::BullishExpansion => write!(f, "Bullish Expansion"),
444 MarketRegime::BearishExpansion => write!(f, "Bearish Expansion"),
445 MarketRegime::Consolidation => write!(f, "Consolidation / Range"),
446 MarketRegime::Transition => write!(f, "Regime Transition"),
447 }
448 }
449}
450
451#[derive(Debug, Clone, Copy, PartialEq, Eq)]
452#[cfg_attr(
453 feature = "serde",
454 derive(Serialize, Deserialize),
455 serde(rename_all = "snake_case")
456)]
457pub enum ZoneKind {
458 Support,
459 Resistance,
460}
461
462#[derive(Debug, Clone, PartialEq)]
463#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
464pub struct SupportResistanceZone {
465 pub kind: ZoneKind,
466 pub price: f64,
467 pub price_top: f64,
468 pub price_bottom: f64,
469 pub strength: f64, pub distance_pct: f64,
471 pub touches: u32,
472}
473
474#[derive(Debug, Clone, PartialEq)]
475#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
476pub struct RiskPlan {
477 pub entry: f64,
478 pub stop_loss: f64,
479 pub target_1: f64,
480 pub target_2: f64,
481 pub risk_reward_ratio: f64,
482}
483
484impl RiskPlan {
485 pub fn rounded_to(&self, instrument: &InstrumentMeta) -> Self {
489 let entry = instrument.round_to_tick(self.entry);
490 let stop_loss = instrument.round_to_tick(self.stop_loss);
491 let target_1 = instrument.round_to_tick(self.target_1);
492 let target_2 = instrument.round_to_tick(self.target_2);
493
494 let risk = (entry - stop_loss).abs();
495 let reward = (target_2 - entry).abs();
496 let risk_reward_ratio = if risk > 0.0 {
497 reward / risk
498 } else {
499 self.risk_reward_ratio
500 };
501
502 Self {
503 entry,
504 stop_loss,
505 target_1,
506 target_2,
507 risk_reward_ratio,
508 }
509 }
510}
511
512#[cfg(test)]
513mod risk_plan_tests {
514 use super::*;
515
516 #[test]
517 fn test_risk_plan_rounded_to_tick() {
518 let plan = RiskPlan {
519 entry: 100.13,
520 stop_loss: 98.77,
521 target_1: 101.5,
522 target_2: 103.02,
523 risk_reward_ratio: 2.11,
524 };
525 let instrument = InstrumentMeta {
526 tick_size: 0.25,
527 ..InstrumentMeta::default()
528 };
529 let rounded = plan.rounded_to(&instrument);
530
531 assert_eq!(rounded.entry, 100.25);
532 assert_eq!(rounded.stop_loss, 98.75);
533 assert_eq!(rounded.target_1, 101.5);
534 assert_eq!(rounded.target_2, 103.0);
535
536 let expected_rrr = (103.0f64 - 100.25).abs() / (100.25f64 - 98.75).abs();
537 assert!((rounded.risk_reward_ratio - expected_rrr).abs() < 1e-9);
538 }
539}