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/// What a bar's `volume` field actually measures.
217///
218/// Volume-based indicators (VWAP, volume profile, CVD, ...) assume traded turnover. Many feeds
219/// cannot provide that: a CFD/FX broker's `volume` is typically tick/update count (activity, not
220/// turnover), and a cash index has no volume at all (it is a computed value, not a traded
221/// instrument). Declaring which kind a series has lets [`crate::applicability`] tell these cases
222/// apart instead of silently computing a profile/VWAP over the wrong quantity.
223#[derive(Debug, Clone, Copy, PartialEq, Eq)]
224#[cfg_attr(
225    feature = "serde",
226    derive(Serialize, Deserialize),
227    serde(rename_all = "snake_case")
228)]
229pub enum VolumeKind {
230    /// No volume data at all (e.g. a cash index).
231    None,
232    /// Update/tick count, not traded turnover (typical of CFD/FX broker feeds).
233    Tick,
234    /// Real traded turnover (shares, contracts, lots) as reported by an exchange.
235    RealTurnover,
236}
237
238/// Which part of the trading day a bar series covers.
239///
240/// Session-anchored indicators (VWAP anchors, opening-range pivots, ...) need to know whether a
241/// series is cut to the regular session, includes extended hours, or is a continuous (e.g. 24h
242/// FX/crypto) feed — anchors and session extremes land in different places depending on this.
243#[derive(Debug, Clone, Copy, PartialEq, Eq)]
244#[cfg_attr(
245    feature = "serde",
246    derive(Serialize, Deserialize),
247    serde(rename_all = "snake_case")
248)]
249pub enum SessionKind {
250    /// Regular trading hours only.
251    Regular,
252    /// Includes pre-/post-market or overnight extensions.
253    Extended,
254    /// No session boundary at all (continuous trading, e.g. FX/crypto).
255    Continuous,
256}
257
258/// How a bar series was assembled across instrument/contract boundaries.
259///
260/// Futures contracts expire; a "continuous" series is stitched from consecutive contracts. Roll-
261/// sensitive indicators (structure/extreme detectors, seasonality) behave differently depending
262/// on whether the series is a single, never-rolled contract, or stitched — and if stitched,
263/// whether historical prices were shifted (back-adjusted) to remove roll gaps or left as-traded.
264#[derive(Debug, Clone, Copy, PartialEq, Eq)]
265#[cfg_attr(
266    feature = "serde",
267    derive(Serialize, Deserialize),
268    serde(rename_all = "snake_case")
269)]
270pub enum ContinuityKind {
271    /// A cash/spot instrument with no contract expiry at all.
272    Spot,
273    /// A single futures contract, never rolled.
274    SingleContract,
275    /// Stitched across contract rolls, left as-traded (roll gaps visible in the series).
276    StitchedUnadjusted,
277    /// Stitched across contract rolls, back-adjusted to remove roll gaps — historical price
278    /// levels are shifted and no longer the levels that were actually traded.
279    StitchedBackAdjusted,
280}
281
282/// Whether and how a price series was adjusted for corporate actions.
283///
284/// Structure/extreme detectors that reference historical price levels (pivots, Elliott rule
285/// checks, ...) assume those levels are the ones that were actually traded. Split/dividend
286/// adjustment shifts historical marks to different numbers than what traded at the time.
287#[derive(Debug, Clone, Copy, PartialEq, Eq)]
288#[cfg_attr(
289    feature = "serde",
290    derive(Serialize, Deserialize),
291    serde(rename_all = "snake_case")
292)]
293pub enum PriceAdjustment {
294    /// As-traded, unadjusted prices.
295    Raw,
296    /// Adjusted for stock splits only.
297    Split,
298    /// Adjusted for both splits and dividends.
299    SplitAndDividend,
300}
301
302/// Where a bar series originates.
303///
304/// `Synthetic` is a deliberate, distinct origin (not lumped in with `Broker`) so
305/// deterministically generated series (e.g. this crate's own synthetic-bar generator, or a public
306/// learning-path project built entirely on synthetic data) are recognizable as such rather than
307/// mistaken for a real feed.
308#[derive(Debug, Clone, Copy, PartialEq, Eq)]
309#[cfg_attr(
310    feature = "serde",
311    derive(Serialize, Deserialize),
312    serde(rename_all = "snake_case")
313)]
314pub enum Provenance {
315    /// Directly from an exchange feed.
316    Exchange,
317    /// From a broker (e.g. CFD/FX market maker) rather than the exchange itself.
318    Broker,
319    /// Deterministically generated, not observed from any real market.
320    Synthetic,
321}
322
323/// Coarse liquidity classification of a series.
324///
325/// Indicators that need market depth to be meaningful (e.g. liquidity-pool detection) degrade on
326/// thin markets: the same calculation runs, but the result carries less information.
327#[derive(Debug, Clone, Copy, PartialEq, Eq)]
328#[cfg_attr(
329    feature = "serde",
330    derive(Serialize, Deserialize),
331    serde(rename_all = "snake_case")
332)]
333pub enum LiquidityTier {
334    Deep,
335    Normal,
336    Thin,
337    /// Not classified/estimated.
338    Unknown,
339}
340
341/// Describes what a bar series *is* and where it came from, for the plausibility check in
342/// [`crate::applicability`]. Accompanies a series as metadata (not per-[`Bar`], to avoid
343/// per-bar storage/cache cost) — see that module's `check_applicability` for how this is matched
344/// against an indicator's [`crate::applicability::DataRequirements`].
345///
346/// Deliberately has no `Default` impl: every field materially changes the applicability verdict,
347/// and there is no single "neutral" combination that would not silently misrepresent a real
348/// series. Construct it explicitly for the series at hand.
349#[derive(Debug, Clone, Copy, PartialEq, Eq)]
350#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
351pub struct SeriesCapabilities {
352    pub volume: VolumeKind,
353    /// Whether classified individual trades (buy/sell direction) are available, as opposed to
354    /// only aggregate OHLCV bars.
355    pub trade_direction: bool,
356    pub session: SessionKind,
357    pub continuity: ContinuityKind,
358    pub price_adjustment: PriceAdjustment,
359    pub provenance: Provenance,
360    pub liquidity_tier: LiquidityTier,
361}
362
363/// Comprehensive, provider-neutral provenance and identity specification for a time series.
364///
365/// Ties together instrument symbol, timeframe, session type, contract continuity, and price adjustment.
366/// Can be attached to stored artifacts or downstream results to ensure that price-level findings
367/// (e.g. Elliott Waves, pivots, S/R zones) are never evaluated against mismatched data feeds.
368#[derive(Debug, Clone, PartialEq, Eq)]
369#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
370pub struct SeriesIdentity {
371    pub symbol: String,
372    pub timeframe: String,
373    pub session: SessionKind,
374    pub continuity: ContinuityKind,
375    pub price_adjustment: PriceAdjustment,
376    pub provenance: Provenance,
377    pub contract_code: Option<String>,
378}
379
380impl SeriesIdentity {
381    pub fn new(symbol: impl Into<String>, timeframe: impl Into<String>) -> Self {
382        Self {
383            symbol: symbol.into(),
384            timeframe: timeframe.into(),
385            session: SessionKind::Regular,
386            continuity: ContinuityKind::Spot,
387            price_adjustment: PriceAdjustment::Raw,
388            provenance: Provenance::Exchange,
389            contract_code: None,
390        }
391    }
392
393    pub fn with_session(mut self, session: SessionKind) -> Self {
394        self.session = session;
395        self
396    }
397
398    pub fn with_continuity(mut self, continuity: ContinuityKind) -> Self {
399        self.continuity = continuity;
400        self
401    }
402
403    pub fn with_price_adjustment(mut self, adj: PriceAdjustment) -> Self {
404        self.price_adjustment = adj;
405        self
406    }
407
408    pub fn with_provenance(mut self, prov: Provenance) -> Self {
409        self.provenance = prov;
410        self
411    }
412
413    pub fn with_contract_code(mut self, code: impl Into<String>) -> Self {
414        self.contract_code = Some(code.into());
415        self
416    }
417
418    /// Returns true if this series is compatible with another series in terms of symbol,
419    /// session boundaries, contract continuity, and price adjustment.
420    pub fn is_compatible(&self, other: &Self) -> bool {
421        self.symbol == other.symbol
422            && self.session == other.session
423            && self.continuity == other.continuity
424            && self.price_adjustment == other.price_adjustment
425            && self.contract_code == other.contract_code
426    }
427}
428
429/// Generic OHLCV Bar data point.
430#[derive(Debug, Clone, PartialEq)]
431#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
432pub struct Bar {
433    pub timestamp: i64,
434    pub open: f64,
435    pub high: f64,
436    pub low: f64,
437    pub close: f64,
438    pub volume: f64,
439}
440
441/// Reason why an OHLCV bar violates the public input contract.
442#[derive(Debug, Clone, Copy, PartialEq, Eq)]
443pub enum BarValidationError {
444    NonFiniteValue,
445    NonPositivePrice,
446    NegativeVolume,
447    InvalidPriceRange,
448}
449
450impl fmt::Display for BarValidationError {
451    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
452        let message = match self {
453            Self::NonFiniteValue => "OHLCV values must be finite",
454            Self::NonPositivePrice => "OHLC prices must be greater than zero",
455            Self::NegativeVolume => "volume must be non-negative",
456            Self::InvalidPriceRange => "low/high must contain open and close",
457        };
458        f.write_str(message)
459    }
460}
461
462impl std::error::Error for BarValidationError {}
463
464impl Bar {
465    /// Creates a bar without validation.
466    ///
467    /// Use [`Bar::try_new`] at data-ingestion boundaries. This unchecked constructor is retained
468    /// for trusted feeds and compatibility with existing consumers.
469    pub fn new(timestamp: i64, open: f64, high: f64, low: f64, close: f64, volume: f64) -> Self {
470        Self {
471            timestamp,
472            open,
473            high,
474            low,
475            close,
476            volume,
477        }
478    }
479
480    /// Creates a bar after validating the OHLCV input contract.
481    pub fn try_new(
482        timestamp: i64,
483        open: f64,
484        high: f64,
485        low: f64,
486        close: f64,
487        volume: f64,
488    ) -> Result<Self, BarValidationError> {
489        let bar = Self::new(timestamp, open, high, low, close, volume);
490        bar.validate()?;
491        Ok(bar)
492    }
493
494    pub fn typical_price(&self) -> f64 {
495        (self.high + self.low + self.close) / 3.0
496    }
497
498    /// Verifies that OHLCV prices and volumes satisfy mathematical and physical domain requirements:
499    /// - All price values are finite and strictly positive (> 0.0)
500    /// - Volume is finite and non-negative (>= 0.0)
501    /// - Structural inequality holds: `low <= min(open, close)` and `high >= max(open, close)`
502    pub fn validate(&self) -> Result<(), BarValidationError> {
503        if !self.open.is_finite()
504            || !self.high.is_finite()
505            || !self.low.is_finite()
506            || !self.close.is_finite()
507            || !self.volume.is_finite()
508        {
509            return Err(BarValidationError::NonFiniteValue);
510        }
511
512        if self.open <= 0.0 || self.high <= 0.0 || self.low <= 0.0 || self.close <= 0.0 {
513            return Err(BarValidationError::NonPositivePrice);
514        }
515        if self.volume < 0.0 {
516            return Err(BarValidationError::NegativeVolume);
517        }
518
519        let min_oc = self.open.min(self.close);
520        let max_oc = self.open.max(self.close);
521
522        if self.low > min_oc || self.high < max_oc || self.low > self.high {
523            return Err(BarValidationError::InvalidPriceRange);
524        }
525
526        Ok(())
527    }
528
529    /// Returns whether the bar satisfies [`Bar::validate`].
530    pub fn is_valid(&self) -> bool {
531        self.validate().is_ok()
532    }
533}
534
535#[cfg(test)]
536mod tests {
537    use super::*;
538
539    #[test]
540    fn test_bar_validation_contract() {
541        let valid = Bar::new(1000, 100.0, 105.0, 95.0, 104.0, 1000.0);
542        assert!(valid.is_valid());
543        assert_eq!(
544            Bar::try_new(1000, 100.0, 105.0, 95.0, 104.0, 1000.0),
545            Ok(valid)
546        );
547
548        // Negative price
549        let neg_price = Bar::new(1000, -100.0, 105.0, 95.0, 104.0, 1000.0);
550        assert!(!neg_price.is_valid());
551
552        // Negative volume
553        let neg_vol = Bar::new(1000, 100.0, 105.0, 95.0, 104.0, -10.0);
554        assert!(!neg_vol.is_valid());
555
556        // High lower than open/close
557        let bad_high = Bar::new(1000, 100.0, 90.0, 80.0, 95.0, 1000.0);
558        assert!(!bad_high.is_valid());
559
560        // NaN price
561        let nan_price = Bar::new(1000, f64::NAN, 105.0, 95.0, 104.0, 1000.0);
562        assert!(!nan_price.is_valid());
563        assert_eq!(
564            nan_price.validate(),
565            Err(BarValidationError::NonFiniteValue)
566        );
567    }
568
569    #[test]
570    fn test_instrument_meta_validate() {
571        assert_eq!(InstrumentMeta::default().validate(), Ok(()));
572
573        let bad_tick = InstrumentMeta {
574            tick_size: 0.0,
575            ..InstrumentMeta::default()
576        };
577        assert_eq!(
578            bad_tick.validate(),
579            Err(InstrumentMetaError::NonPositiveTickSize)
580        );
581
582        let bad_precision = InstrumentMeta {
583            price_precision: 13,
584            ..InstrumentMeta::default()
585        };
586        assert_eq!(
587            bad_precision.validate(),
588            Err(InstrumentMetaError::ExcessivePricePrecision)
589        );
590
591        let empty_symbol = InstrumentMeta {
592            symbol: "".to_string(),
593            ..InstrumentMeta::default()
594        };
595        assert_eq!(
596            empty_symbol.validate(),
597            Err(InstrumentMetaError::EmptySymbol)
598        );
599    }
600
601    #[test]
602    fn test_instrument_meta_round_to_tick() {
603        let meta = InstrumentMeta {
604            tick_size: 0.25,
605            ..InstrumentMeta::default()
606        };
607        assert_eq!(meta.round_to_tick(100.10), 100.0);
608        assert_eq!(meta.round_to_tick(100.13), 100.25);
609        assert_eq!(meta.round_to_tick(100.125), 100.25);
610
611        let invalid_tick = InstrumentMeta {
612            tick_size: 0.0,
613            ..InstrumentMeta::default()
614        };
615        // Falls back to the unrounded price rather than dividing by zero.
616        assert_eq!(invalid_tick.round_to_tick(100.10), 100.10);
617    }
618
619    #[test]
620    fn test_bar_quality_defaults() {
621        let default_quality = BarQuality::default();
622        assert!(!default_quality.volume_available);
623        assert!(!default_quality.is_synthetic);
624
625        let observed = BarQuality::observed();
626        assert!(observed.volume_available);
627        assert!(!observed.is_synthetic);
628        assert!(!observed.is_forward_filled);
629        assert!(!observed.has_gap);
630
631        let bar = Bar::new(1000, 100.0, 105.0, 95.0, 104.0, 1000.0);
632        let qualified = QualifiedBar::observed(bar.clone());
633        assert_eq!(qualified.bar, bar);
634        assert_eq!(qualified.quality, BarQuality::observed());
635    }
636}
637
638/// Classification of current market regime.
639#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
640#[cfg_attr(
641    feature = "serde",
642    derive(Serialize, Deserialize),
643    serde(rename_all = "snake_case")
644)]
645pub enum MarketRegime {
646    BullishExpansion,
647    BearishExpansion,
648    #[default]
649    Consolidation,
650    Transition,
651}
652
653impl fmt::Display for MarketRegime {
654    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
655        match self {
656            MarketRegime::BullishExpansion => write!(f, "Bullish Expansion"),
657            MarketRegime::BearishExpansion => write!(f, "Bearish Expansion"),
658            MarketRegime::Consolidation => write!(f, "Consolidation / Range"),
659            MarketRegime::Transition => write!(f, "Regime Transition"),
660        }
661    }
662}
663
664#[derive(Debug, Clone, Copy, PartialEq, Eq)]
665#[cfg_attr(
666    feature = "serde",
667    derive(Serialize, Deserialize),
668    serde(rename_all = "snake_case")
669)]
670pub enum ZoneKind {
671    Support,
672    Resistance,
673}
674
675#[derive(Debug, Clone, PartialEq)]
676#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
677pub struct SupportResistanceZone {
678    pub kind: ZoneKind,
679    pub price: f64,
680    pub price_top: f64,
681    pub price_bottom: f64,
682    pub strength: f64, // 0.0 ..= 1.0
683    pub distance_pct: f64,
684    pub touches: u32,
685}
686
687#[derive(Debug, Clone, PartialEq)]
688#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
689pub struct RiskPlan {
690    pub entry: f64,
691    pub stop_loss: f64,
692    pub target_1: f64,
693    pub target_2: f64,
694    pub risk_reward_ratio: f64,
695}
696
697impl RiskPlan {
698    /// Rounds `entry`/`stop_loss`/`target_1`/`target_2` to `instrument`'s tick size and
699    /// recomputes `risk_reward_ratio` from the rounded prices, so a plan built from raw
700    /// ATR-derived math becomes tradable at the instrument's actual price granularity.
701    pub fn rounded_to(&self, instrument: &InstrumentMeta) -> Self {
702        let entry = instrument.round_to_tick(self.entry);
703        let stop_loss = instrument.round_to_tick(self.stop_loss);
704        let target_1 = instrument.round_to_tick(self.target_1);
705        let target_2 = instrument.round_to_tick(self.target_2);
706
707        let risk = (entry - stop_loss).abs();
708        let reward = (target_2 - entry).abs();
709        let risk_reward_ratio = if risk > 0.0 {
710            reward / risk
711        } else {
712            self.risk_reward_ratio
713        };
714
715        Self {
716            entry,
717            stop_loss,
718            target_1,
719            target_2,
720            risk_reward_ratio,
721        }
722    }
723}
724
725#[cfg(test)]
726mod risk_plan_tests {
727    use super::*;
728
729    #[test]
730    fn test_risk_plan_rounded_to_tick() {
731        let plan = RiskPlan {
732            entry: 100.13,
733            stop_loss: 98.77,
734            target_1: 101.5,
735            target_2: 103.02,
736            risk_reward_ratio: 2.11,
737        };
738        let instrument = InstrumentMeta {
739            tick_size: 0.25,
740            ..InstrumentMeta::default()
741        };
742        let rounded = plan.rounded_to(&instrument);
743
744        assert_eq!(rounded.entry, 100.25);
745        assert_eq!(rounded.stop_loss, 98.75);
746        assert_eq!(rounded.target_1, 101.5);
747        assert_eq!(rounded.target_2, 103.0);
748
749        let expected_rrr = (103.0f64 - 100.25).abs() / (100.25f64 - 98.75).abs();
750        assert!((rounded.risk_reward_ratio - expected_rrr).abs() < 1e-9);
751    }
752}