Skip to main content

quant_indicators/
ratio_candles.rs

1//! Synthetic ratio candles — OHLC from two candle series.
2//!
3//! Given two candle series (A, B), produces synthetic candles representing
4//! the ratio A/B as a tradable asset. Used for ratio trend-following
5//! strategies (e.g., Supertrend on SOL/BTC, ETH/BTC).
6//!
7//! # Formula
8//!
9//! ```text
10//! ratio.open   = A.open  / B.open
11//! ratio.close  = A.close / B.close
12//! ratio.high   = A.high  / B.low     (max-spread: A peaks while B troughs)
13//! ratio.low    = A.low   / B.high    (min-spread: A troughs while B peaks)
14//! ratio.volume = min(A.volume, B.volume)  (tradable on both legs)
15//! ```
16//!
17//! The naive approach (`A.high / B.high`, `A.low / B.low`) is wrong because
18//! the ratio A/B reaches its intraday maximum when A peaks AND B troughs
19//! (worst-case spread). The conservative formula above produces wider ratio
20//! bars, which is correct for ATR-based filters like Supertrend.
21//!
22//! # Alignment
23//!
24//! Input series must be pre-aligned on timestamps. If `a[i].timestamp() !=
25//! b[i].timestamp()` for any i, returns `MismatchedTimestamps{index}`.
26
27use quant_primitives::{Candle, CandleError};
28use rust_decimal::Decimal;
29use thiserror::Error;
30
31/// Errors from ratio candle synthesis.
32#[derive(Debug, Error, PartialEq, Eq)]
33pub enum RatioCandleError {
34    /// One or both input series are empty.
35    #[error("ratio candle synthesis requires non-empty series")]
36    EmptySeries,
37
38    /// Input series have different lengths.
39    #[error("series length mismatch: a={len_a}, b={len_b}")]
40    LengthMismatch { len_a: usize, len_b: usize },
41
42    /// Timestamps don't match at index i.
43    #[error("timestamp mismatch at index {index}")]
44    MismatchedTimestamps { index: usize },
45
46    /// A candle in series B has zero high/low/open/close — cannot divide.
47    #[error("division by zero at index {index} (B candle has zero price)")]
48    DivisionByZero { index: usize },
49
50    /// Underlying Candle construction failed (e.g., synthetic high < low after
51    /// unusual inputs — should not happen with positive price data).
52    #[error("candle construction failed at index {index}: {source}")]
53    CandleConstruction { index: usize, source: CandleError },
54}
55
56/// Synthesize ratio candles A/B from two aligned candle series.
57///
58/// # Arguments
59///
60/// - `a`: Numerator candle series
61/// - `b`: Denominator candle series (same length, same timestamps as `a`)
62///
63/// # Returns
64///
65/// A new candle series where each OHLC represents the ratio A/B.
66///
67/// # Errors
68///
69/// - `EmptySeries` if either input is empty
70/// - `LengthMismatch` if lengths differ
71/// - `MismatchedTimestamps{index}` if timestamps don't match at any index
72/// - `DivisionByZero{index}` if any B candle has zero in OHLC
73///
74/// # Example
75///
76/// ```
77/// use quant_indicators::synthesize_ratio_candles;
78/// use quant_primitives::Candle;
79/// use chrono::Utc;
80/// use rust_decimal_macros::dec;
81///
82/// let ts = Utc::now();
83/// let a = vec![Candle::new(dec!(100), dec!(110), dec!(90), dec!(105), dec!(1000), ts).unwrap()];
84/// let b = vec![Candle::new(dec!(50), dec!(55), dec!(45), dec!(52), dec!(500), ts).unwrap()];
85/// let ratio = synthesize_ratio_candles(&a, &b).unwrap();
86/// assert_eq!(ratio.len(), 1);
87/// ```
88pub fn synthesize_ratio_candles(
89    a: &[Candle],
90    b: &[Candle],
91) -> Result<Vec<Candle>, RatioCandleError> {
92    if a.is_empty() || b.is_empty() {
93        return Err(RatioCandleError::EmptySeries);
94    }
95    if a.len() != b.len() {
96        return Err(RatioCandleError::LengthMismatch {
97            len_a: a.len(),
98            len_b: b.len(),
99        });
100    }
101
102    let mut out = Vec::with_capacity(a.len());
103
104    for (i, (ca, cb)) in a.iter().zip(b.iter()).enumerate() {
105        if ca.timestamp() != cb.timestamp() {
106            return Err(RatioCandleError::MismatchedTimestamps { index: i });
107        }
108
109        // Guard against division by zero on any B OHLC field.
110        if cb.open().is_zero() || cb.high().is_zero() || cb.low().is_zero() || cb.close().is_zero()
111        {
112            return Err(RatioCandleError::DivisionByZero { index: i });
113        }
114
115        let open = ca.open() / cb.open();
116        let close = ca.close() / cb.close();
117        // Max-spread formula: ratio high when A peaks while B troughs.
118        let high = ca.high() / cb.low();
119        // Ratio low when A troughs while B peaks.
120        let low = ca.low() / cb.high();
121
122        // Tradable volume = min of both legs.
123        let volume = ca.volume().min(cb.volume());
124
125        let candle = Candle::new(open, high, low, close, volume, ca.timestamp())
126            .map_err(|source| RatioCandleError::CandleConstruction { index: i, source })?;
127        out.push(candle);
128    }
129
130    // Guard against pathological computed high < low (shouldn't happen with
131    // positive prices, but Candle::new already enforces high >= low).
132    // If it did happen, we'd have swapped max/min in the formula.
133    debug_assert!(out.iter().all(|c| c.high() >= c.low()));
134
135    Ok(out)
136}
137
138/// Return only the closes of a synthetic ratio series — convenience for
139/// callers that want a `(timestamp, ratio_close)` series for statistical
140/// analysis (Hurst, Variance Ratio, autocorrelation).
141///
142/// Equivalent to `synthesize_ratio_candles(a, b)` then mapping to close values,
143/// but slightly cheaper (no high/low computation, no min volume).
144pub fn ratio_close_series(
145    a: &[Candle],
146    b: &[Candle],
147) -> Result<Vec<(chrono::DateTime<chrono::Utc>, Decimal)>, RatioCandleError> {
148    if a.is_empty() || b.is_empty() {
149        return Err(RatioCandleError::EmptySeries);
150    }
151    if a.len() != b.len() {
152        return Err(RatioCandleError::LengthMismatch {
153            len_a: a.len(),
154            len_b: b.len(),
155        });
156    }
157
158    let mut out = Vec::with_capacity(a.len());
159    for (i, (ca, cb)) in a.iter().zip(b.iter()).enumerate() {
160        if ca.timestamp() != cb.timestamp() {
161            return Err(RatioCandleError::MismatchedTimestamps { index: i });
162        }
163        if cb.close().is_zero() {
164            return Err(RatioCandleError::DivisionByZero { index: i });
165        }
166        out.push((ca.timestamp(), ca.close() / cb.close()));
167    }
168    Ok(out)
169}
170
171#[cfg(test)]
172#[path = "ratio_candles_tests.rs"]
173mod tests;