quant-indicators 0.7.0

Pure indicator math library for trading — MA, RSI, Bollinger, MACD, ATR, HRP
Documentation
//! Synthetic ratio candles — OHLC from two candle series.
//!
//! Given two candle series (A, B), produces synthetic candles representing
//! the ratio A/B as a tradable asset. Used for ratio trend-following
//! strategies (e.g., Supertrend on SOL/BTC, ETH/BTC).
//!
//! # Formula
//!
//! ```text
//! ratio.open   = A.open  / B.open
//! ratio.close  = A.close / B.close
//! ratio.high   = A.high  / B.low     (max-spread: A peaks while B troughs)
//! ratio.low    = A.low   / B.high    (min-spread: A troughs while B peaks)
//! ratio.volume = min(A.volume, B.volume)  (tradable on both legs)
//! ```
//!
//! The naive approach (`A.high / B.high`, `A.low / B.low`) is wrong because
//! the ratio A/B reaches its intraday maximum when A peaks AND B troughs
//! (worst-case spread). The conservative formula above produces wider ratio
//! bars, which is correct for ATR-based filters like Supertrend.
//!
//! # Alignment
//!
//! Input series must be pre-aligned on timestamps. If `a[i].timestamp() !=
//! b[i].timestamp()` for any i, returns `MismatchedTimestamps{index}`.

use quant_primitives::{Candle, CandleError};
use rust_decimal::Decimal;
use thiserror::Error;

/// Errors from ratio candle synthesis.
#[derive(Debug, Error, PartialEq, Eq)]
pub enum RatioCandleError {
    /// One or both input series are empty.
    #[error("ratio candle synthesis requires non-empty series")]
    EmptySeries,

    /// Input series have different lengths.
    #[error("series length mismatch: a={len_a}, b={len_b}")]
    LengthMismatch { len_a: usize, len_b: usize },

    /// Timestamps don't match at index i.
    #[error("timestamp mismatch at index {index}")]
    MismatchedTimestamps { index: usize },

    /// A candle in series B has zero high/low/open/close — cannot divide.
    #[error("division by zero at index {index} (B candle has zero price)")]
    DivisionByZero { index: usize },

    /// Underlying Candle construction failed (e.g., synthetic high < low after
    /// unusual inputs — should not happen with positive price data).
    #[error("candle construction failed at index {index}: {source}")]
    CandleConstruction { index: usize, source: CandleError },
}

/// Synthesize ratio candles A/B from two aligned candle series.
///
/// # Arguments
///
/// - `a`: Numerator candle series
/// - `b`: Denominator candle series (same length, same timestamps as `a`)
///
/// # Returns
///
/// A new candle series where each OHLC represents the ratio A/B.
///
/// # Errors
///
/// - `EmptySeries` if either input is empty
/// - `LengthMismatch` if lengths differ
/// - `MismatchedTimestamps{index}` if timestamps don't match at any index
/// - `DivisionByZero{index}` if any B candle has zero in OHLC
///
/// # Example
///
/// ```
/// use quant_indicators::synthesize_ratio_candles;
/// use quant_primitives::Candle;
/// use chrono::Utc;
/// use rust_decimal_macros::dec;
///
/// let ts = Utc::now();
/// let a = vec![Candle::new(dec!(100), dec!(110), dec!(90), dec!(105), dec!(1000), ts).unwrap()];
/// let b = vec![Candle::new(dec!(50), dec!(55), dec!(45), dec!(52), dec!(500), ts).unwrap()];
/// let ratio = synthesize_ratio_candles(&a, &b).unwrap();
/// assert_eq!(ratio.len(), 1);
/// ```
pub fn synthesize_ratio_candles(
    a: &[Candle],
    b: &[Candle],
) -> Result<Vec<Candle>, RatioCandleError> {
    if a.is_empty() || b.is_empty() {
        return Err(RatioCandleError::EmptySeries);
    }
    if a.len() != b.len() {
        return Err(RatioCandleError::LengthMismatch {
            len_a: a.len(),
            len_b: b.len(),
        });
    }

    let mut out = Vec::with_capacity(a.len());

    for (i, (ca, cb)) in a.iter().zip(b.iter()).enumerate() {
        if ca.timestamp() != cb.timestamp() {
            return Err(RatioCandleError::MismatchedTimestamps { index: i });
        }

        // Guard against division by zero on any B OHLC field.
        if cb.open().is_zero() || cb.high().is_zero() || cb.low().is_zero() || cb.close().is_zero()
        {
            return Err(RatioCandleError::DivisionByZero { index: i });
        }

        let open = ca.open() / cb.open();
        let close = ca.close() / cb.close();
        // Max-spread formula: ratio high when A peaks while B troughs.
        let high = ca.high() / cb.low();
        // Ratio low when A troughs while B peaks.
        let low = ca.low() / cb.high();

        // Tradable volume = min of both legs.
        let volume = ca.volume().min(cb.volume());

        let candle = Candle::new(open, high, low, close, volume, ca.timestamp())
            .map_err(|source| RatioCandleError::CandleConstruction { index: i, source })?;
        out.push(candle);
    }

    // Guard against pathological computed high < low (shouldn't happen with
    // positive prices, but Candle::new already enforces high >= low).
    // If it did happen, we'd have swapped max/min in the formula.
    debug_assert!(out.iter().all(|c| c.high() >= c.low()));

    Ok(out)
}

/// Return only the closes of a synthetic ratio series — convenience for
/// callers that want a `(timestamp, ratio_close)` series for statistical
/// analysis (Hurst, Variance Ratio, autocorrelation).
///
/// Equivalent to `synthesize_ratio_candles(a, b)` then mapping to close values,
/// but slightly cheaper (no high/low computation, no min volume).
pub fn ratio_close_series(
    a: &[Candle],
    b: &[Candle],
) -> Result<Vec<(chrono::DateTime<chrono::Utc>, Decimal)>, RatioCandleError> {
    if a.is_empty() || b.is_empty() {
        return Err(RatioCandleError::EmptySeries);
    }
    if a.len() != b.len() {
        return Err(RatioCandleError::LengthMismatch {
            len_a: a.len(),
            len_b: b.len(),
        });
    }

    let mut out = Vec::with_capacity(a.len());
    for (i, (ca, cb)) in a.iter().zip(b.iter()).enumerate() {
        if ca.timestamp() != cb.timestamp() {
            return Err(RatioCandleError::MismatchedTimestamps { index: i });
        }
        if cb.close().is_zero() {
            return Err(RatioCandleError::DivisionByZero { index: i });
        }
        out.push((ca.timestamp(), ca.close() / cb.close()));
    }
    Ok(out)
}

#[cfg(test)]
#[path = "ratio_candles_tests.rs"]
mod tests;