using System.Runtime.CompilerServices; using System.Runtime.InteropServices; namespace QuanTAlib; /// /// MAVP: Moving Average Variable Period /// /// /// EMA-based moving average where the smoothing period changes per bar. /// Each bar receives its own period value, clamped to [minPeriod, maxPeriod], /// producing alpha = 2/(period+1). Adaptive warmup compensator tracks the /// cumulative product of per-bar (1-alpha) for bias correction. /// /// Calculation: alpha = 2/(clamp(period)+1); EMA += alpha*(P-EMA); result = EMA/(1-E). /// O(1) per bar, zero allocation, no buffer required. /// /// Detailed documentation /// Reference Pine Script implementation [SkipLocalsInit] public sealed class Mavp : AbstractBase { private readonly int _minPeriod; private readonly int _maxPeriod; private readonly TValuePublishedHandler _handler; [StructLayout(LayoutKind.Auto)] private record struct State(double Ema, double E, bool IsHot, bool IsCompensated, double LastValidValue); private State _state; private State _p_state; /// /// Per-bar effective period. Set this before calling Update(TValue) to control /// the smoothing factor for the current bar. Automatically clamped to [minPeriod, maxPeriod]. /// public double Period { get; set; } /// /// Minimum allowed period for clamping. /// public int MinPeriod => _minPeriod; /// /// Maximum allowed period for clamping. /// public int MaxPeriod => _maxPeriod; public override bool IsHot => _state.IsHot; private const double COVERAGE_THRESHOLD = 0.05; private const double COMPENSATOR_THRESHOLD = 1e-10; /// /// Creates MAVP with specified period bounds. /// /// Minimum allowed period (default 2, must be >= 1). /// Maximum allowed period (default 30, must be >= minPeriod). public Mavp(int minPeriod = 2, int maxPeriod = 30) { if (minPeriod < 1) { throw new ArgumentException("Minimum period must be >= 1", nameof(minPeriod)); } if (maxPeriod < minPeriod) { throw new ArgumentException("Maximum period must be >= minimum period", nameof(maxPeriod)); } _minPeriod = minPeriod; _maxPeriod = maxPeriod; Period = minPeriod; _handler = Handle; Name = $"Mavp({minPeriod}, {maxPeriod})"; WarmupPeriod = maxPeriod; _state = new State(0, 1.0, false, false, double.NaN); _p_state = _state; } /// /// Creates MAVP subscribed to a source publisher. /// /// Source to subscribe to. /// Minimum allowed period (default 2). /// Maximum allowed period (default 30). public Mavp(ITValuePublisher source, int minPeriod = 2, int maxPeriod = 30) : this(minPeriod, maxPeriod) { source.Pub += _handler; } [MethodImpl(MethodImplOptions.AggressiveInlining)] private double GetValidValue(double input) { if (double.IsFinite(input)) { _state.LastValidValue = input; return input; } return _state.LastValidValue; } /// /// Updates MAVP with a value and explicit per-bar period. /// /// Price input. /// Per-bar effective period (clamped to [minPeriod, maxPeriod]). /// True if this is a new bar, false for bar correction. /// Updated MAVP value. [MethodImpl(MethodImplOptions.AggressiveInlining)] public TValue Update(TValue input, double period, bool isNew = true) { Period = period; return Update(input, isNew); } [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] public override TValue Update(TValue input, bool isNew = true) { if (isNew) { _p_state = _state; } else { _state = _p_state; } double val = GetValidValue(input.Value); if (double.IsNaN(val)) { Last = new TValue(input.Time, double.NaN); PubEvent(Last, isNew); return Last; } // Clamp period and compute alpha double p = Math.Clamp(Period, _minPeriod, _maxPeriod); double alpha = 2.0 / (p + 1.0); double beta = 1.0 - alpha; // Local copy for JIT struct promotion var s = _state; // EMA update: ema = ema * beta + alpha * input s.Ema = Math.FusedMultiplyAdd(s.Ema, beta, alpha * val); double result; if (!s.IsCompensated) { s.E *= beta; if (!s.IsHot && s.E <= COVERAGE_THRESHOLD) { s.IsHot = true; } if (s.E <= COMPENSATOR_THRESHOLD) { s.IsCompensated = true; result = s.Ema; } else { result = s.Ema / (1.0 - s.E); } } else { result = s.Ema; } _state = s; Last = new TValue(input.Time, result); PubEvent(Last, isNew); return Last; } public override TSeries Update(TSeries source) { if (source.Count == 0) { return new TSeries([], []); } int len = source.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); source.Times.CopyTo(tSpan); double savedPeriod = Period; Reset(); Period = savedPeriod; for (int i = 0; i < len; i++) { vSpan[i] = Update(new TValue(source.Times[i], source.Values[i])).Value; } return new TSeries(t, v); } /// /// Batch update with separate period series. /// /// Price series. /// Per-bar period series (same length as source). /// Smoothed output series. public TSeries Update(TSeries source, TSeries periods) { if (source.Count == 0) { return new TSeries([], []); } if (source.Count != periods.Count) { throw new ArgumentException("Source and periods must have the same length", nameof(periods)); } int len = source.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); source.Times.CopyTo(tSpan); Reset(); for (int i = 0; i < len; i++) { Period = periods.Values[i]; vSpan[i] = Update(new TValue(source.Times[i], source.Values[i])).Value; } return new TSeries(t, v); } private void Handle(object? sender, in TValueEventArgs args) { Update(args.Value, args.IsNew); } public override void Prime(ReadOnlySpan source, TimeSpan? step = null) { foreach (var value in source) { Update(new TValue(DateTime.MinValue, value)); } } /// /// Batch with fixed period for all bars. /// public static TSeries Batch(TSeries source, int minPeriod = 2, int maxPeriod = 30, double fixedPeriod = double.NaN) { var mavp = new Mavp(minPeriod, maxPeriod); if (!double.IsNaN(fixedPeriod)) { mavp.Period = fixedPeriod; } return mavp.Update(source); } /// /// Batch with per-bar period series (TSeries). /// public static TSeries Batch(TSeries source, TSeries periods, int minPeriod = 2, int maxPeriod = 30) { var mavp = new Mavp(minPeriod, maxPeriod); return mavp.Update(source, periods); } /// /// High-performance span-based batch with per-bar periods. /// /// Input prices. /// Per-bar periods (same length as source). /// Output span (same length as source). /// Minimum allowed period. /// Maximum allowed period. public static void Batch(ReadOnlySpan source, ReadOnlySpan periods, Span output, int minPeriod = 2, int maxPeriod = 30) { if (minPeriod < 1) { throw new ArgumentException("Minimum period must be >= 1", nameof(minPeriod)); } if (maxPeriod < minPeriod) { throw new ArgumentException("Maximum period must be >= minimum period", nameof(maxPeriod)); } if (source.Length != output.Length) { throw new ArgumentException("Source and output must have the same length", nameof(output)); } if (source.Length != periods.Length) { throw new ArgumentException("Source and periods must have the same length", nameof(periods)); } double ema = 0; double e = 1.0; bool isCompensated = false; double lastValid = double.NaN; for (int i = 0; i < source.Length; i++) { double val = source[i]; if (double.IsFinite(val)) { lastValid = val; } else { val = lastValid; } if (double.IsNaN(val)) { output[i] = double.NaN; continue; } double p = Math.Clamp(periods[i], minPeriod, maxPeriod); double alpha = 2.0 / (p + 1.0); double beta = 1.0 - alpha; // ema = ema * beta + alpha * val ema = Math.FusedMultiplyAdd(ema, beta, alpha * val); if (!isCompensated) { e *= beta; if (e <= COMPENSATOR_THRESHOLD) { isCompensated = true; output[i] = ema; } else { output[i] = ema / (1.0 - e); } } else { output[i] = ema; } } } /// /// High-performance span-based batch with fixed period. /// public static void Batch(ReadOnlySpan source, Span output, double fixedPeriod, int minPeriod = 2, int maxPeriod = 30) { if (minPeriod < 1) { throw new ArgumentException("Minimum period must be >= 1", nameof(minPeriod)); } if (maxPeriod < minPeriod) { throw new ArgumentException("Maximum period must be >= minimum period", nameof(maxPeriod)); } if (source.Length != output.Length) { throw new ArgumentException("Source and output must have the same length", nameof(output)); } double p = Math.Clamp(fixedPeriod, minPeriod, maxPeriod); double alpha = 2.0 / (p + 1.0); double beta = 1.0 - alpha; double ema = 0; double e = 1.0; bool isCompensated = false; double lastValid = double.NaN; for (int i = 0; i < source.Length; i++) { double val = source[i]; if (double.IsFinite(val)) { lastValid = val; } else { val = lastValid; } if (double.IsNaN(val)) { output[i] = double.NaN; continue; } ema = Math.FusedMultiplyAdd(ema, beta, alpha * val); if (!isCompensated) { e *= beta; if (e <= COMPENSATOR_THRESHOLD) { isCompensated = true; output[i] = ema; } else { output[i] = ema / (1.0 - e); } } else { output[i] = ema; } } } public static (TSeries Results, Mavp Indicator) Calculate(TSeries source, TSeries periods, int minPeriod = 2, int maxPeriod = 30) { var indicator = new Mavp(minPeriod, maxPeriod); TSeries results = indicator.Update(source, periods); return (results, indicator); } public override void Reset() { _state = new State(0, 1.0, false, false, double.NaN); _p_state = _state; Period = _minPeriod; Last = default; } }