using System.Runtime.CompilerServices; using System.Runtime.InteropServices; namespace QuanTAlib; /// /// Delegate for bi-input batch calculation methods. /// This custom delegate is required because Action<T1,T2,T3,T4> cannot accept /// ref struct types (Span, ReadOnlySpan) as generic parameters in .NET 8.0. /// /// Actual values span /// Predicted values span /// Output span for results /// Calculation period public delegate void BiInputBatchDelegate( ReadOnlySpan actual, ReadOnlySpan predicted, Span output, int period); /// /// Abstract base class for error-metric indicators that compare two input series (actual vs predicted). /// Provides common infrastructure for RingBuffer-based sliding window calculations with O(1) updates. /// /// /// This base class is designed specifically for error metrics (MAE, MSE, RMSE, MAPE, SMAPE, etc.) /// where each bar contributes a single scalar error value to a running mean. /// /// It is NOT intended for statistical bi-input indicators (Correlation, Cointegration) which /// maintain multiple running sums (Σx, Σy, Σx², Σy², Σxy) and have different state-restoration /// semantics — those indicators manage their own state directly. /// /// Infrastructure provided: /// - _p_state / _buffer.Snapshot() / _buffer.Restore() for bar correction (isNew semantics) /// - RingBuffer-based sliding window with a single Kahan compensated running sum /// - NaN/Infinity handling with last-valid-value substitution /// - Template Method pattern: subclasses only implement ComputeError and optionally PostProcess /// /// Kahan compensated summation prevents floating-point drift without periodic resync. /// [SkipLocalsInit] public abstract class BiInputIndicatorBase : AbstractBase { protected readonly RingBuffer _buffer; [StructLayout(LayoutKind.Auto)] protected record struct BiInputState(double Sum, double Compensation, double LastValidActual, double LastValidPredicted); protected BiInputState _state; protected BiInputState _p_state; /// /// Creates a bi-input indicator with specified period. /// /// Number of values to average (must be > 0) /// Indicator name protected BiInputIndicatorBase(int period, string name) { if (period <= 0) { throw new ArgumentException("Period must be greater than 0", nameof(period)); } _buffer = new RingBuffer(period); Name = name; WarmupPeriod = period; } /// /// True if the indicator has enough data to produce valid results. /// public override bool IsHot => _buffer.IsFull; /// /// Period of the indicator. /// public int Period => _buffer.Capacity; /// /// Computes the error value from actual and predicted values. /// Subclasses implement this to define their specific error computation. /// /// Actual value /// Predicted value /// Error value to be averaged [MethodImpl(MethodImplOptions.AggressiveInlining)] protected abstract double ComputeError(double actual, double predicted); /// /// Optional post-processing of the mean result. /// Default implementation returns the mean unchanged. /// Override for indicators like RMSE that need sqrt of mean. /// /// The mean of error values /// Post-processed result [MethodImpl(MethodImplOptions.AggressiveInlining)] protected virtual double PostProcess(double mean) => mean; /// /// Sanitizes input value, substituting last valid value for NaN/Infinity. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] private double SanitizeActual(double value) { if (double.IsFinite(value)) { _state.LastValidActual = value; return value; } return double.IsFinite(_state.LastValidActual) ? _state.LastValidActual : 0.0; } /// /// Sanitizes predicted value, substituting last valid value for NaN/Infinity. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] private double SanitizePredicted(double value) { if (double.IsFinite(value)) { _state.LastValidPredicted = value; return value; } return double.IsFinite(_state.LastValidPredicted) ? _state.LastValidPredicted : 0.0; } /// /// Gets the value to be removed from the running sum (oldest value or 0 if buffer not full). /// [MethodImpl(MethodImplOptions.AggressiveInlining)] private double GetRemovedValue() => _buffer.Count == _buffer.Capacity ? _buffer.Oldest : 0.0; /// /// Processes a new bar update. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] private void ProcessNewBar(double error) { _p_state = _state; // Snapshot buffer state BEFORE Add so Restore can undo it _buffer.Snapshot(); // Kahan compensated sliding window update double delta = error - GetRemovedValue(); { double y = delta - _state.Compensation; double t = _state.Sum + y; _state.Compensation = (t - _state.Sum) - y; _state.Sum = t; } _buffer.Add(error); } /// /// Processes a bar correction (same bar update). /// Uses O(1) differential update: restores buffer and scalar state, then applies the new error. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] private void ProcessBarCorrection(double error) { // Restore scalar state _state = _p_state; // Restore buffer to state before last Add (undoes the Add completely) _buffer.Restore(); // Now add the new correction value (this overwrites the same slot) _buffer.Add(error); // Update sum from buffer (Add already updated it correctly) _state.Sum = _buffer.Sum; } /// /// Updates the indicator with new actual and predicted values. /// /// Actual value /// Predicted value /// Whether this is a new bar /// The calculated indicator value [MethodImpl(MethodImplOptions.AggressiveInlining)] public TValue Update(TValue actual, TValue predicted, bool isNew = true) { // Save state BEFORE sanitizers mutate it (for bar correction restore) if (isNew) { _p_state = _state; } else { _state = _p_state; } double actualVal = SanitizeActual(actual.Value); double predictedVal = SanitizePredicted(predicted.Value); double error = ComputeError(actualVal, predictedVal); if (isNew) { ProcessNewBar(error); } else { ProcessBarCorrection(error); } double mean = _buffer.Count > 0 ? _state.Sum / _buffer.Count : error; double result = PostProcess(mean); Last = new TValue(actual.Time, result); PubEvent(Last, isNew); return Last; } /// /// Updates the indicator with raw double values. /// Uses DateTime.MinValue as a sentinel timestamp for performance in high-frequency scenarios. /// For time-sensitive applications, use Update(TValue, TValue, bool) with explicit timestamps. /// /// Actual value /// Predicted value /// Whether this is a new bar /// The calculated indicator value (with DateTime.MinValue as timestamp) [MethodImpl(MethodImplOptions.AggressiveInlining)] public TValue Update(double actual, double predicted, bool isNew = true) { return Update(new TValue(DateTime.MinValue, actual), new TValue(DateTime.MinValue, predicted), isNew); } /// /// Single-input Update is not supported for bi-input indicators. /// public override TValue Update(TValue input, bool isNew = true) { throw new NotSupportedException($"{Name} requires two inputs. Use Update(actual, predicted)."); } /// /// Single-series Update is not supported for bi-input indicators. /// public override TSeries Update(TSeries source) { throw new NotSupportedException($"{Name} requires two inputs. Use Calculate(actualSeries, predictedSeries, period)."); } /// /// Single-series Prime is not supported for bi-input indicators. /// public override void Prime(ReadOnlySpan source, TimeSpan? step = null) { throw new NotSupportedException($"{Name} requires two inputs."); } /// /// Resets the indicator state. /// public override void Reset() { _buffer.Clear(); _state = default; _p_state = default; Last = default; } /// /// Helper method for subclasses to implement static Calculate with TSeries. /// protected static TSeries CalculateImpl( TSeries actual, TSeries predicted, int period, BiInputBatchDelegate batchMethod) { if (actual.Count != predicted.Count) { throw new ArgumentException("Actual and predicted series must have the same length", nameof(predicted)); } int len = actual.Count; var t = new List(len); var v = new List(len); CollectionsMarshal.SetCount(t, len); CollectionsMarshal.SetCount(v, len); var tSpan = CollectionsMarshal.AsSpan(t); var vSpan = CollectionsMarshal.AsSpan(v); batchMethod(actual.Values, predicted.Values, vSpan, period); actual.Times.CopyTo(tSpan); return new TSeries(t, v); } /// /// Common validation for Batch methods. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] protected static void ValidateBatchInputs( ReadOnlySpan actual, ReadOnlySpan predicted, Span output, int period) { if (actual.Length != predicted.Length || actual.Length != output.Length) { throw new ArgumentException("All spans must have the same length", nameof(output)); } if (period <= 0) { throw new ArgumentException("Period must be greater than 0", nameof(period)); } } }