Skip to main content

kestrel_chartkit/
model.rs

1use std::fmt;
2
3#[cfg(feature = "serde")]
4use serde::{Deserialize, Serialize};
5
6/// Supported resolution timeframes for market bars.
7#[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/// Selectable price/volume data source for indicators and series calculations.
46#[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    /// Extracts the scalar price/volume value from an OHLCV bar according to the chosen source.
67    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/// Provider-neutral instrument metadata.
83#[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/// Reason why [`InstrumentMeta`] fails the operative validity contract.
104#[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    /// Validates the operative contract required to use this metadata for rounding, risk and
130    /// session calculations: a finite positive tick size, a sane price precision, and non-empty
131    /// symbol/timezone identifiers.
132    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    /// Rounds `price` to the nearest multiple of [`InstrumentMeta::tick_size`].
152    ///
153    /// Returns `price` unchanged if `tick_size` is non-finite or non-positive, so this method is
154    /// safe to call on unvalidated metadata (see [`InstrumentMeta::validate`] to reject that case
155    /// explicitly at ingestion boundaries).
156    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/// Explicit availability/quality metadata for an OHLCV bar, replacing implicit `f64`
165/// conventions (e.g. `volume == 0.0` meaning "unknown" versus "genuinely zero").
166#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
167#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
168pub struct BarQuality {
169    /// False when the feed cannot report volume for this bar (as opposed to a true zero-volume
170    /// bar). Consumers should fall back to equal-weight/price-based heuristics when false.
171    pub volume_available: bool,
172    /// True when the bar was synthesized (e.g. holiday padding, session stitching) rather than
173    /// observed directly from the feed.
174    pub is_synthetic: bool,
175    /// True when the bar's price/volume was forward-filled from a prior bar rather than observed.
176    pub is_forward_filled: bool,
177    /// True when a time gap precedes this bar (missing bar(s) between it and the prior bar).
178    pub has_gap: bool,
179}
180
181impl BarQuality {
182    /// Quality flags for a directly observed, complete bar: volume available, no synthetic or
183    /// forward-filled data, no gap.
184    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/// An OHLCV [`Bar`] paired with explicit [`BarQuality`] metadata.
195#[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    /// Wraps a bar with [`BarQuality::observed`] flags.
208    pub fn observed(bar: Bar) -> Self {
209        Self {
210            bar,
211            quality: BarQuality::observed(),
212        }
213    }
214}
215
216/// Generic OHLCV Bar data point.
217#[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/// Reason why an OHLCV bar violates the public input contract.
229#[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    /// Creates a bar without validation.
253    ///
254    /// Use [`Bar::try_new`] at data-ingestion boundaries. This unchecked constructor is retained
255    /// for trusted feeds and compatibility with existing consumers.
256    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    /// Creates a bar after validating the OHLCV input contract.
268    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    /// Verifies that OHLCV prices and volumes satisfy mathematical and physical domain requirements:
286    /// - All price values are finite and strictly positive (> 0.0)
287    /// - Volume is finite and non-negative (>= 0.0)
288    /// - Structural inequality holds: `low <= min(open, close)` and `high >= max(open, close)`
289    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    /// Returns whether the bar satisfies [`Bar::validate`].
317    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        // Negative price
336        let neg_price = Bar::new(1000, -100.0, 105.0, 95.0, 104.0, 1000.0);
337        assert!(!neg_price.is_valid());
338
339        // Negative volume
340        let neg_vol = Bar::new(1000, 100.0, 105.0, 95.0, 104.0, -10.0);
341        assert!(!neg_vol.is_valid());
342
343        // High lower than open/close
344        let bad_high = Bar::new(1000, 100.0, 90.0, 80.0, 95.0, 1000.0);
345        assert!(!bad_high.is_valid());
346
347        // NaN price
348        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        // Falls back to the unrounded price rather than dividing by zero.
403        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/// Classification of current market regime.
426#[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, // 0.0 ..= 1.0
470    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    /// Rounds `entry`/`stop_loss`/`target_1`/`target_2` to `instrument`'s tick size and
486    /// recomputes `risk_reward_ratio` from the rounded prices, so a plan built from raw
487    /// ATR-derived math becomes tradable at the instrument's actual price granularity.
488    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}