// RS: Price Relative Strength // Compares the performance of one asset to another by calculating the ratio // and optionally applying EMA smoothing for trend identification. using System.Runtime.CompilerServices; using static System.Math; namespace QuanTAlib; /// /// RS: Price Relative Strength /// /// /// Measures relative performance between two assets by calculating their price ratio. /// A rising RS indicates the base asset is outperforming the comparison asset. /// A falling RS indicates underperformance. Optional EMA smoothing reduces noise. /// /// Key characteristics: /// - Ratio-based: RS = Base / Comparison /// - Trend indicator: Rising = outperformance, Falling = underperformance /// - Smoothing: Optional EMA with bias compensation for warmup /// - Division by zero: Returns NaN when comparison is zero /// /// Calculation: /// /// Raw Ratio = Base / Comparison /// Smoothed = EMA(Raw Ratio, smoothPeriod) with bias compensation /// /// /// Interpretation: /// - RS > 1.0: Base asset is worth more per unit /// - RS increasing: Base outperforming comparison /// - RS decreasing: Base underperforming comparison /// - Use with baseline (1.0 or initial ratio) for normalized view /// /// Detailed documentation [SkipLocalsInit] public sealed class Rs : AbstractBase { private const double Epsilon = 1e-10; private readonly int _smoothPeriod; private readonly double _alpha; // EMA state with bias compensation private double _ema; private double _e; // Bias compensation factor private bool _isEmaInitialized; private bool _isWarmup; // State for bar correction private double _lastValidBase; private double _lastValidComp; private double _p_ema; private double _p_e; private bool _p_isEmaInitialized; private bool _p_isWarmup; private double _p_lastValidBase; private double _p_lastValidComp; private int _count; /// /// Gets the raw (unsmoothed) ratio from the last update. /// public double RawRatio { get; private set; } /// /// Gets the smoothing period for the EMA. /// public int SmoothPeriod => _smoothPeriod; public override bool IsHot => _count >= _smoothPeriod; /// /// Creates a new Price Relative Strength indicator. /// /// Smoothing period for EMA (1 = no smoothing) public Rs(int smoothPeriod = 1) { if (smoothPeriod < 1) { throw new ArgumentException("Smoothing period must be >= 1", nameof(smoothPeriod)); } _smoothPeriod = smoothPeriod; _alpha = 2.0 / Max(smoothPeriod, 1); _isWarmup = true; _e = 1.0; Name = smoothPeriod == 1 ? "Rs" : $"Rs({smoothPeriod})"; WarmupPeriod = smoothPeriod; } /// /// Updates the RS indicator with new values from both series. /// /// Base asset price /// Comparison asset price /// Whether this is a new bar /// The smoothed relative strength ratio [MethodImpl(MethodImplOptions.AggressiveInlining)] public TValue Update(TValue baseValue, TValue compValue, bool isNew = true) { double basePrice = SanitizeBase(baseValue.Value); double compPrice = SanitizeComp(compValue.Value); if (isNew) { SaveState(); } else { RestoreState(); } double result; if (Abs(compPrice) < Epsilon) { // Division by zero - return NaN RawRatio = double.NaN; result = double.NaN; } else { double ratio = basePrice / compPrice; RawRatio = ratio; result = CalculateSmoothedRatio(ratio); } if (isNew) { _count++; } Last = new TValue(baseValue.Time, result); PubEvent(Last); return Last; } /// /// Updates with raw double values. /// /// /// Stamps both inputs with DateTime.UtcNow as their timestamp. For /// deterministic or replay-safe sequences use /// with explicit timestamps instead. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public TValue Update(double baseValue, double compValue, bool isNew = true) { DateTime now = DateTime.UtcNow; return Update(new TValue(now, baseValue), new TValue(now, compValue), isNew); } /// Not supported for bi-input indicator. Use Update(baseValue, compValue) instead. /// RS requires paired base/comparison inputs; single-input updates are invalid. public override TValue Update(TValue input, bool isNew = true) { throw new NotSupportedException("RS requires two inputs (base and comparison). Use Update(baseValue, compValue)."); } /// Not supported for bi-input indicator. Use Calculate(baseSeries, compSeries, period) instead. /// RS requires paired base/comparison series; single-series updates are invalid. public override TSeries Update(TSeries source) { throw new NotSupportedException("RS requires two inputs. Use Batch(baseSeries, compSeries, period)."); } [MethodImpl(MethodImplOptions.AggressiveInlining)] private double SanitizeBase(double value) { if (double.IsFinite(value)) { _lastValidBase = value; return value; } return double.IsFinite(_lastValidBase) ? _lastValidBase : 0.0; } [MethodImpl(MethodImplOptions.AggressiveInlining)] private double SanitizeComp(double value) { if (double.IsFinite(value)) { _lastValidComp = value; return value; } return double.IsFinite(_lastValidComp) ? _lastValidComp : 0.0; } [MethodImpl(MethodImplOptions.AggressiveInlining)] private void SaveState() { _p_ema = _ema; _p_e = _e; _p_isEmaInitialized = _isEmaInitialized; _p_isWarmup = _isWarmup; _p_lastValidBase = _lastValidBase; _p_lastValidComp = _lastValidComp; } [MethodImpl(MethodImplOptions.AggressiveInlining)] private void RestoreState() { _ema = _p_ema; _e = _p_e; _isEmaInitialized = _p_isEmaInitialized; _isWarmup = _p_isWarmup; _lastValidBase = _p_lastValidBase; _lastValidComp = _p_lastValidComp; } [MethodImpl(MethodImplOptions.AggressiveInlining)] private double CalculateSmoothedRatio(double ratio) { if (_smoothPeriod == 1) { // No smoothing return ratio; } if (!_isEmaInitialized) { // First value: initialize EMA with the first ratio _ema = ratio; _isEmaInitialized = true; return ratio; } // EMA calculation with bias compensation _ema = FusedMultiplyAdd(_alpha, ratio - _ema, _ema); if (_isWarmup) { _e *= (1 - _alpha); double compensation = 1.0 / (1.0 - _e); double result = compensation * _ema; if (_e <= 1e-10) { _isWarmup = false; } return result; } return _ema; } public override void Prime(ReadOnlySpan source, TimeSpan? step = null) { throw new NotSupportedException("RS requires two inputs. Use Prime(baseSource, compSource)."); } /// /// Primes the indicator with historical data from both series. /// public void Prime(ReadOnlySpan baseSource, ReadOnlySpan compSource, TimeSpan? step = null) { if (baseSource.Length != compSource.Length) { throw new ArgumentException("Source arrays must have the same length", nameof(compSource)); } TimeSpan interval = step ?? TimeSpan.FromSeconds(1); DateTime time = DateTime.UtcNow - (interval * baseSource.Length); for (int i = 0; i < baseSource.Length; i++) { Update(new TValue(time, baseSource[i]), new TValue(time, compSource[i]), true); time += interval; } } public override void Reset() { _ema = 0; _e = 1.0; _isEmaInitialized = false; _isWarmup = true; _lastValidBase = 0; _lastValidComp = 0; _count = 0; RawRatio = 0; Last = default; _p_ema = 0; _p_e = 1.0; _p_isEmaInitialized = false; _p_isWarmup = true; _p_lastValidBase = 0; _p_lastValidComp = 0; } /// /// Calculates RS for two time series. /// public static TSeries Batch(TSeries baseSeries, TSeries compSeries, int smoothPeriod = 1) { if (baseSeries.Count != compSeries.Count) { throw new ArgumentException("Series must have the same length", nameof(compSeries)); } var indicator = new Rs(smoothPeriod); var result = new TSeries(baseSeries.Count); var times = baseSeries.Times; var baseValues = baseSeries.Values; var compValues = compSeries.Values; for (int i = 0; i < baseSeries.Count; i++) { var tvalBase = new TValue(times[i], baseValues[i]); var tvalComp = new TValue(times[i], compValues[i]); result.Add(indicator.Update(tvalBase, tvalComp, isNew: true)); } return result; } /// /// Static batch calculation for span-based processing. /// public static void Batch( ReadOnlySpan baseSeries, ReadOnlySpan compSeries, Span output, int smoothPeriod = 1) { if (baseSeries.Length != compSeries.Length) { throw new ArgumentException("Series must have the same length", nameof(compSeries)); } if (baseSeries.Length != output.Length) { throw new ArgumentException("Output must have the same length as input", nameof(output)); } if (smoothPeriod < 1) { throw new ArgumentException("Smoothing period must be >= 1", nameof(smoothPeriod)); } var indicator = new Rs(smoothPeriod); for (int i = 0; i < baseSeries.Length; i++) { var result = indicator.Update(baseSeries[i], compSeries[i], isNew: true); output[i] = result.Value; } } public static (TSeries Results, Rs Indicator) Calculate(TSeries baseSeries, TSeries compSeries, int smoothPeriod = 1) { var indicator = new Rs(smoothPeriod); TSeries results = Batch(baseSeries, compSeries, smoothPeriod); return (results, indicator); } }