using System.Runtime.CompilerServices; using System.Runtime.InteropServices; namespace QuanTAlib; /// /// APZ: Adaptive Price Zone /// /// /// The Adaptive Price Zone (APZ) is a volatility-based technical indicator developed by /// Lee Leibfarth. It uses a double-smoothed exponential moving average (EMA) with a /// modified smoothing factor based on sqrt(period) to create adaptive bands around price. /// /// Calculation: /// smoothing_period = sqrt(period) /// alpha = 2 / (smoothing_period + 1) /// EMA1_price = alpha × price + (1 - alpha) × EMA1_price[1] /// EMA2_price = alpha × EMA1_price + (1 - alpha) × EMA2_price[1] (middle line) /// EMA1_range = alpha × (high - low) + (1 - alpha) × EMA1_range[1] /// EMA2_range = alpha × EMA1_range + (1 - alpha) × EMA2_range[1] (adaptive range) /// upper = middle + (multiplier × adaptive_range) /// lower = middle - (multiplier × adaptive_range) /// /// Key characteristics: /// - Uses compound warmup compensation for nested EMAs: compensator = 1/(1-beta²) /// - Faster response than standard EMAs due to sqrt(period) smoothing /// - Bands adapt to volatility via the high-low range /// - O(1) complexity per update /// /// Sources: /// Leibfarth, Lee (2006). "Trading With An Adaptive Price Zone," Technical Analysis of /// Stocks & Commodities, Volume 24:9. /// [SkipLocalsInit] public sealed class Apz : ITValuePublisher { private readonly int _period; private readonly double _multiplier; private readonly double _alpha; private readonly double _beta; private readonly double _betaSquared; private readonly TBarPublishedHandler _barHandler; private const double ConvergenceThreshold = 1e-10; [StructLayout(LayoutKind.Auto)] private record struct State( double Ema1Price, double Ema2Price, double Ema1Range, double Ema2Range, double E, // Warmup decay factor double LastValidPrice, double LastValidHigh, double LastValidLow, bool IsHot ) { public static State New() => new() { Ema1Price = 0, Ema2Price = 0, Ema1Range = 0, Ema2Range = 0, E = 1.0, LastValidPrice = double.NaN, LastValidHigh = double.NaN, LastValidLow = double.NaN, IsHot = false, }; } private State _state; private State _p_state; /// /// Display name for the indicator. /// public string Name { get; } /// /// Number of periods before the indicator is considered "hot" (valid). /// public int WarmupPeriod { get; } /// /// Current middle band value (double-smoothed EMA of price). /// public TValue Last { get; private set; } /// /// Current upper band value. /// public TValue Upper { get; private set; } /// /// Current lower band value. /// public TValue Lower { get; private set; } /// /// True if the indicator has converged (warmup decay below threshold). /// public bool IsHot => _state.IsHot; /// /// Event triggered when a new TValue is available. /// public event TValuePublishedHandler? Pub; /// /// Creates APZ with specified period and multiplier. /// /// Lookback period (sqrt applied internally for smoothing, must be > 0) /// Multiplier for band width (must be > 0, default: 2.0) public Apz(int period, double multiplier = 2.0) { if (period <= 0) { throw new ArgumentException("Period must be greater than 0", nameof(period)); } if (multiplier <= 0) { throw new ArgumentException("Multiplier must be greater than 0", nameof(multiplier)); } _period = period; _multiplier = multiplier; double smoothPeriod = Math.Sqrt(period); _alpha = 2.0 / (smoothPeriod + 1.0); _beta = 1.0 - _alpha; _betaSquared = _beta * _beta; Name = $"Apz({period},{multiplier:F2})"; // Warmup is based on EMA convergence - use period as approximation WarmupPeriod = period; _state = State.New(); _p_state = _state; _barHandler = HandleBar; } /// /// Creates APZ with TBarSeries source. /// public Apz(TBarSeries source, int period, double multiplier = 2.0) : this(period, multiplier) { Prime(source); source.Pub += _barHandler; } private void HandleBar(object? sender, in TBarEventArgs e) => Update(e.Value, e.IsNew); /// /// Helper to invoke the Pub event. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] private void PubEvent(TValue value, bool isNew = true) { Pub?.Invoke(this, new TValueEventArgs { Value = value, IsNew = isNew }); } /// /// Gets valid input values, using last-value substitution for non-finite inputs. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] private (double price, double high, double low) GetValidValues(double price, double high, double low) { if (double.IsFinite(price)) { _state.LastValidPrice = price; } else { price = _state.LastValidPrice; } if (double.IsFinite(high)) { _state.LastValidHigh = high; } else { high = _state.LastValidHigh; } if (double.IsFinite(low)) { _state.LastValidLow = low; } else { low = _state.LastValidLow; } return (price, high, low); } /// /// Core calculation with warmup compensation. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] private (double middle, double upper, double lower) Compute(double price, double range) { // Double-smoothed EMA for price _state.Ema1Price = Math.FusedMultiplyAdd(_state.Ema1Price, _beta, _alpha * price); _state.Ema2Price = Math.FusedMultiplyAdd(_state.Ema2Price, _beta, _alpha * _state.Ema1Price); // Double-smoothed EMA for range _state.Ema1Range = Math.FusedMultiplyAdd(_state.Ema1Range, _beta, _alpha * range); _state.Ema2Range = Math.FusedMultiplyAdd(_state.Ema2Range, _beta, _alpha * _state.Ema1Range); double middle = _state.Ema2Price; double adaptiveRange = _state.Ema2Range; // Apply compound warmup compensation if (!_state.IsHot) { _state.E *= _betaSquared; double compensator = 1.0 / (1.0 - _state.E); middle *= compensator; adaptiveRange *= compensator; if (_state.E <= ConvergenceThreshold) { _state.IsHot = true; } } double bandWidth = _multiplier * adaptiveRange; return (middle, middle + bandWidth, middle - bandWidth); } /// /// Updates the indicator with a TBar input. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public TValue Update(TBar input, bool isNew = true) { if (isNew) { _p_state = _state; } else { _state = _p_state; } var (price, high, low) = GetValidValues(input.Close, input.High, input.Low); // Handle first value initialization if (double.IsNaN(_state.LastValidPrice)) { Last = new TValue(input.Time, double.NaN); Upper = new TValue(input.Time, double.NaN); Lower = new TValue(input.Time, double.NaN); PubEvent(Last, isNew); return Last; } double range = high - low; if (range < 0) { range = 0; // Safety check } var (middle, upper, lower) = Compute(price, range); Last = new TValue(input.Time, middle); Upper = new TValue(input.Time, upper); Lower = new TValue(input.Time, lower); PubEvent(Last, isNew); return Last; } /// /// Updates the indicator with a TBarSeries. /// public (TSeries Middle, TSeries Upper, TSeries Lower) Update(TBarSeries source) { if (source.Count == 0) { return (new TSeries([], []), new TSeries([], []), new TSeries([], [])); } int len = source.Count; var tMiddle = new List(len); var vMiddle = new List(len); var tUpper = new List(len); var vUpper = new List(len); var tLower = new List(len); var vLower = new List(len); CollectionsMarshal.SetCount(tMiddle, len); CollectionsMarshal.SetCount(vMiddle, len); CollectionsMarshal.SetCount(tUpper, len); CollectionsMarshal.SetCount(vUpper, len); CollectionsMarshal.SetCount(tLower, len); CollectionsMarshal.SetCount(vLower, len); var tSpan = CollectionsMarshal.AsSpan(tMiddle); var vMiddleSpan = CollectionsMarshal.AsSpan(vMiddle); var vUpperSpan = CollectionsMarshal.AsSpan(vUpper); var vLowerSpan = CollectionsMarshal.AsSpan(vLower); // Use batch calculation and capture final state for continued streaming var finalState = BatchWithState(source.High.Values, source.Low.Values, source.Close.Values, new BatchOutputs(vMiddleSpan, vUpperSpan, vLowerSpan), _period, _multiplier); source.Times.CopyTo(tSpan); tSpan.CopyTo(CollectionsMarshal.AsSpan(tUpper)); tSpan.CopyTo(CollectionsMarshal.AsSpan(tLower)); // Restore state from batch calculation (no re-processing) _state = new State( Ema1Price: finalState.Ema1Price, Ema2Price: finalState.Ema2Price, Ema1Range: finalState.Ema1Range, Ema2Range: finalState.Ema2Range, E: finalState.E, LastValidPrice: finalState.LastValidPrice, LastValidHigh: finalState.LastValidHigh, LastValidLow: finalState.LastValidLow, IsHot: finalState.IsHot ); _p_state = _state; // Update Last/Upper/Lower from final computed values if (len > 0) { var lastTime = source.Times[len - 1]; Last = new TValue(new DateTime(lastTime, DateTimeKind.Utc), vMiddleSpan[len - 1]); Upper = new TValue(new DateTime(lastTime, DateTimeKind.Utc), vUpperSpan[len - 1]); Lower = new TValue(new DateTime(lastTime, DateTimeKind.Utc), vLowerSpan[len - 1]); } return (new TSeries(tMiddle, vMiddle), new TSeries(tUpper, vUpper), new TSeries(tLower, vLower)); } /// /// Initializes the indicator state using the provided TBarSeries history. /// public void Prime(TBarSeries source) { if (source.Count == 0) { return; } // Reset state _state = State.New(); _p_state = _state; // Use all available data for priming to ensure proper convergence const int startIndex = 0; // Find first valid values in the data if (double.IsNaN(_state.LastValidPrice)) { for (int i = startIndex; i < source.Count; i++) { var bar = source[i]; if (double.IsFinite(bar.Close)) { _state.LastValidPrice = bar.Close; _state.LastValidHigh = bar.High; _state.LastValidLow = bar.Low; break; } } } // Feed the warmup data for (int i = startIndex; i < source.Count; i++) { var bar = source[i]; var (price, high, low) = GetValidValues(bar.Close, bar.High, bar.Low); if (double.IsFinite(price)) { double range = Math.Max(0, high - low); var (middle, upper, lower) = Compute(price, range); Last = new TValue(bar.Time, middle); Upper = new TValue(bar.Time, upper); Lower = new TValue(bar.Time, lower); } } _p_state = _state; } /// /// Resets the indicator state. /// public void Reset() { _state = State.New(); _p_state = _state; Last = default; Upper = default; Lower = default; } ///////////////////////////////////////////////////////////////////////////////////////////////// // Static Batch Methods ///////////////////////////////////////////////////////////////////////////////////////////////// /// /// Output buffers for batch APZ calculation. /// [StructLayout(LayoutKind.Auto)] #pragma warning disable S1104 // Fields should not have public accessibility public ref struct BatchOutputs { /// Output middle band (double-smoothed EMA of price) public Span Middle; /// Output upper band public Span Upper; /// Output lower band public Span Lower; #pragma warning restore S1104 /// /// Creates a new BatchOutputs instance. /// public BatchOutputs(Span middle, Span upper, Span lower) { Middle = middle; Upper = upper; Lower = lower; } } /// /// Internal state for scalar calculation. /// [StructLayout(LayoutKind.Auto)] private ref struct ScalarState { internal double Ema1Price; internal double Ema2Price; internal double Ema1Range; internal double Ema2Range; internal double E; internal double LastValidPrice; internal double LastValidHigh; internal double LastValidLow; internal bool IsHot; } /// /// Calculates APZ for the entire TBarSeries using a new instance. /// public static (TSeries Middle, TSeries Upper, TSeries Lower) Batch(TBarSeries source, int period, double multiplier = 2.0) { var apz = new Apz(period, multiplier); return apz.Update(source); } /// /// Calculates APZ in-place using spans for maximum performance. /// Zero-allocation method. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void Batch( ReadOnlySpan high, ReadOnlySpan low, ReadOnlySpan close, BatchOutputs outputs, int period, double multiplier = 2.0) { int len = close.Length; if (high.Length != len || low.Length != len) { throw new ArgumentException("Input spans must have the same length", nameof(high)); } if (outputs.Middle.Length < len || outputs.Upper.Length < len || outputs.Lower.Length < len) { throw new ArgumentException("Output buffers must be at least as long as input", nameof(outputs)); } if (period <= 0) { throw new ArgumentException("Period must be greater than 0", nameof(period)); } if (multiplier <= 0) { throw new ArgumentException("Multiplier must be greater than 0", nameof(multiplier)); } if (len == 0) { return; } CalculateScalarCore(high, low, close, outputs, period, multiplier); } /// /// Batch calculation that returns final state for continued streaming. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] private static ScalarState BatchWithState( ReadOnlySpan high, ReadOnlySpan low, ReadOnlySpan close, BatchOutputs outputs, int period, double multiplier) { int len = close.Length; if (high.Length != len || low.Length != len) { throw new ArgumentException("Input spans must have the same length", nameof(high)); } if (outputs.Middle.Length < len || outputs.Upper.Length < len || outputs.Lower.Length < len) { throw new ArgumentException("Output buffers must be at least as long as input", nameof(outputs)); } if (period <= 0) { throw new ArgumentException("Period must be greater than 0", nameof(period)); } if (multiplier <= 0) { throw new ArgumentException("Multiplier must be greater than 0", nameof(multiplier)); } if (len == 0) { return new ScalarState(); } return CalculateScalarCoreWithState(high, low, close, outputs, period, multiplier); } [MethodImpl(MethodImplOptions.AggressiveInlining)] private static void CalculateScalarCore( ReadOnlySpan high, ReadOnlySpan low, ReadOnlySpan close, BatchOutputs outputs, int period, double multiplier) { _ = CalculateScalarCoreWithState(high, low, close, outputs, period, multiplier); } [MethodImpl(MethodImplOptions.AggressiveInlining)] private static ScalarState CalculateScalarCoreWithState( ReadOnlySpan high, ReadOnlySpan low, ReadOnlySpan close, BatchOutputs outputs, int period, double multiplier) { int len = close.Length; double smoothPeriod = Math.Sqrt(period); double alpha = 2.0 / (smoothPeriod + 1.0); double beta = 1.0 - alpha; double betaSquared = beta * beta; Span middle = outputs.Middle; Span upper = outputs.Upper; Span lower = outputs.Lower; var state = new ScalarState { Ema1Price = 0, Ema2Price = 0, Ema1Range = 0, Ema2Range = 0, E = 1.0, LastValidPrice = double.NaN, LastValidHigh = double.NaN, LastValidLow = double.NaN, IsHot = false, }; // Seed first valid values SeedFirstValidValues(high, low, close, ref state); for (int i = 0; i < len; i++) { double price = close[i]; double h = high[i]; double l = low[i]; // Get valid values if (double.IsFinite(price)) { state.LastValidPrice = price; } else { price = state.LastValidPrice; } if (double.IsFinite(h)) { state.LastValidHigh = h; } else { h = state.LastValidHigh; } if (double.IsFinite(l)) { state.LastValidLow = l; } else { l = state.LastValidLow; } // Handle first valid value if (double.IsNaN(price)) { middle[i] = double.NaN; upper[i] = double.NaN; lower[i] = double.NaN; continue; } double range = Math.Max(0, h - l); // Double-smoothed EMA for price state.Ema1Price = Math.FusedMultiplyAdd(state.Ema1Price, beta, alpha * price); state.Ema2Price = Math.FusedMultiplyAdd(state.Ema2Price, beta, alpha * state.Ema1Price); // Double-smoothed EMA for range state.Ema1Range = Math.FusedMultiplyAdd(state.Ema1Range, beta, alpha * range); state.Ema2Range = Math.FusedMultiplyAdd(state.Ema2Range, beta, alpha * state.Ema1Range); double mid = state.Ema2Price; double adaptiveRange = state.Ema2Range; // Apply compound warmup compensation if (!state.IsHot) { state.E *= betaSquared; double compensator = 1.0 / (1.0 - state.E); mid *= compensator; adaptiveRange *= compensator; if (state.E <= ConvergenceThreshold) { state.IsHot = true; } } double bandWidth = multiplier * adaptiveRange; middle[i] = mid; upper[i] = mid + bandWidth; lower[i] = mid - bandWidth; } return state; } [MethodImpl(MethodImplOptions.AggressiveInlining)] private static void SeedFirstValidValues( ReadOnlySpan high, ReadOnlySpan low, ReadOnlySpan close, ref ScalarState state) { int len = close.Length; for (int k = 0; k < len; k++) { // Check all three values are finite before assigning state if (double.IsFinite(close[k]) && double.IsFinite(high[k]) && double.IsFinite(low[k])) { state.LastValidPrice = close[k]; state.LastValidHigh = high[k]; state.LastValidLow = low[k]; break; } } } /// /// Runs a high-performance batch calculation and returns a "Hot" APZ instance. /// public static ((TSeries Middle, TSeries Upper, TSeries Lower) Results, Apz Indicator) Calculate(TBarSeries source, int period, double multiplier = 2.0) { var apz = new Apz(period, multiplier); var results = apz.Update(source); return (results, apz); } }