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;