Files
QuanTAlib/lib/channels/apz/Apz.cs
T
Miha Kralj 1910fdca93 chore: repo cleanup and code quality improvements
- Remove global.json (SDK pinning unnecessary)

- Remove nuget.config, move MyGet source to .csproj RestoreAdditionalProjectSources

- Gitignore ndepend/ entirely, move badges to docs/img/

- Update README.md and docs/ndepend.md badge paths

- Add NDepend project property to QuanTAlib.slnx

- Expand .editorconfig ReSharper/diagnostic suppressions

- Use ArgumentOutOfRangeException instead of ArgumentException

- Use discard _ for unused event sender parameters

- Remove quantalib.code-workspace and sonar-suppressions.json

- Add filter signature SVGs
2026-03-03 09:22:55 -08:00

703 lines
22 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// APZ: Adaptive Price Zone
/// </summary>
/// <remarks>
/// 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 &amp; Commodities, Volume 24:9.
/// </remarks>
[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;
/// <summary>
/// Display name for the indicator.
/// </summary>
public string Name { get; }
/// <summary>
/// Number of periods before the indicator is considered "hot" (valid).
/// </summary>
public int WarmupPeriod { get; }
/// <summary>
/// Current middle band value (double-smoothed EMA of price).
/// </summary>
public TValue Last { get; private set; }
/// <summary>
/// Current upper band value.
/// </summary>
public TValue Upper { get; private set; }
/// <summary>
/// Current lower band value.
/// </summary>
public TValue Lower { get; private set; }
/// <summary>
/// True if the indicator has converged (warmup decay below threshold).
/// </summary>
public bool IsHot => _state.IsHot;
/// <summary>
/// Event triggered when a new TValue is available.
/// </summary>
public event TValuePublishedHandler? Pub;
/// <summary>
/// Creates APZ with specified period and multiplier.
/// </summary>
/// <param name="period">Lookback period (sqrt applied internally for smoothing, must be > 0)</param>
/// <param name="multiplier">Multiplier for band width (must be > 0, default: 2.0)</param>
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;
}
/// <summary>
/// Creates APZ with TBarSeries source.
/// </summary>
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);
/// <summary>
/// Helper to invoke the Pub event.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void PubEvent(TValue value, bool isNew = true)
{
Pub?.Invoke(this, new TValueEventArgs { Value = value, IsNew = isNew });
}
/// <summary>
/// Gets valid input values, using last-value substitution for non-finite inputs.
/// </summary>
[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);
}
/// <summary>
/// Core calculation with warmup compensation.
/// </summary>
[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);
}
/// <summary>
/// Updates the indicator with a TBar input.
/// </summary>
[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;
}
/// <summary>
/// Updates the indicator with a TBarSeries.
/// </summary>
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<long>(len);
var vMiddle = new List<double>(len);
var tUpper = new List<long>(len);
var vUpper = new List<double>(len);
var tLower = new List<long>(len);
var vLower = new List<double>(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));
}
/// <summary>
/// Initializes the indicator state using the provided TBarSeries history.
/// </summary>
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;
}
/// <summary>
/// Resets the indicator state.
/// </summary>
public void Reset()
{
_state = State.New();
_p_state = _state;
Last = default;
Upper = default;
Lower = default;
}
/////////////////////////////////////////////////////////////////////////////////////////////////
// Static Batch Methods
/////////////////////////////////////////////////////////////////////////////////////////////////
/// <summary>
/// Output buffers for batch APZ calculation.
/// </summary>
[StructLayout(LayoutKind.Auto)]
#pragma warning disable S1104 // Fields should not have public accessibility
public ref struct BatchOutputs
{
/// <summary>Output middle band (double-smoothed EMA of price)</summary>
public Span<double> Middle;
/// <summary>Output upper band</summary>
public Span<double> Upper;
/// <summary>Output lower band</summary>
public Span<double> Lower;
#pragma warning restore S1104
/// <summary>
/// Creates a new BatchOutputs instance.
/// </summary>
public BatchOutputs(Span<double> middle, Span<double> upper, Span<double> lower)
{
Middle = middle;
Upper = upper;
Lower = lower;
}
}
/// <summary>
/// Internal state for scalar calculation.
/// </summary>
[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;
}
/// <summary>
/// Calculates APZ for the entire TBarSeries using a new instance.
/// </summary>
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);
}
/// <summary>
/// Calculates APZ in-place using spans for maximum performance.
/// Zero-allocation method.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Batch(
ReadOnlySpan<double> high,
ReadOnlySpan<double> low,
ReadOnlySpan<double> 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);
}
/// <summary>
/// Batch calculation that returns final state for continued streaming.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static ScalarState BatchWithState(
ReadOnlySpan<double> high,
ReadOnlySpan<double> low,
ReadOnlySpan<double> 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<double> high,
ReadOnlySpan<double> low,
ReadOnlySpan<double> close,
BatchOutputs outputs,
int period,
double multiplier)
{
_ = CalculateScalarCoreWithState(high, low, close, outputs, period, multiplier);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static ScalarState CalculateScalarCoreWithState(
ReadOnlySpan<double> high,
ReadOnlySpan<double> low,
ReadOnlySpan<double> 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<double> middle = outputs.Middle;
Span<double> upper = outputs.Upper;
Span<double> 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<double> high,
ReadOnlySpan<double> low,
ReadOnlySpan<double> 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;
}
}
}
/// <summary>
/// Runs a high-performance batch calculation and returns a "Hot" APZ instance.
/// </summary>
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);
}
}