Files
QuanTAlib/lib/channels/aberr/Aberr.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

708 lines
23 KiB
C#

using System.Buffers;
using System.Numerics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// Aberr: Aberration Bands
/// </summary>
/// <remarks>
/// Aberration Bands measure price deviation from a central moving average using absolute
/// deviation rather than standard deviation. This approach provides more intuitive and
/// outlier-resistant bands compared to Bollinger Bands.
///
/// Calculation:
/// Middle Band = SMA(Source, Period)
/// Deviation = |Source - Middle|
/// Average Deviation = SMA(Deviation, Period)
/// Upper Band = Middle + (Multiplier x Average Deviation)
/// Lower Band = Middle - (Multiplier x Average Deviation)
///
/// Key characteristics:
/// - Uses absolute deviation instead of standard deviation
/// - Less sensitive to extreme outliers than Bollinger Bands
/// - Provides intuitive measure of typical price dispersion
/// - Bands expand during volatile periods and contract during consolidation
///
/// Sources:
/// Pine Script implementation: https://github.com/mihakralj/pinescript/blob/main/indicators/channels/aberr.pine
/// </remarks>
[SkipLocalsInit]
public sealed class Aberr : ITValuePublisher, IDisposable
{
private readonly int _period;
private readonly double _multiplier;
private readonly RingBuffer _sourceBuffer;
private readonly RingBuffer _deviationBuffer;
private readonly TValuePublishedHandler _handler;
private ITValuePublisher? _source;
private bool _disposed;
private const int ResyncInterval = 1000;
[StructLayout(LayoutKind.Auto)]
private record struct State(
double SumSource,
double SumDeviation,
double LastValidValue,
int TickCount
);
private State _state;
private State _pState;
/// <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 (SMA of source).
/// </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 enough data to produce valid results.
/// </summary>
public bool IsHot => _sourceBuffer.IsFull;
/// <summary>
/// Event triggered when a new TValue is available.
/// </summary>
public event TValuePublishedHandler? Pub;
/// <summary>
/// Creates Aberr with specified period and multiplier.
/// </summary>
/// <param name="period">Lookback period for SMA and deviation calculations (must be > 0)</param>
/// <param name="multiplier">Multiplier for band width (must be > 0, default: 2.0)</param>
public Aberr(int period, double multiplier = 2.0)
{
if (period <= 0)
{
throw new ArgumentOutOfRangeException(nameof(period), "Period must be greater than 0");
}
if (multiplier <= 0)
{
throw new ArgumentOutOfRangeException(nameof(multiplier), "Multiplier must be greater than 0");
}
_period = period;
_multiplier = multiplier;
_sourceBuffer = new RingBuffer(period);
_deviationBuffer = new RingBuffer(period);
Name = $"Aberr({period},{multiplier:F2})";
WarmupPeriod = period;
_handler = HandleValue;
}
/// <summary>
/// Creates Aberr with TSeries source.
/// </summary>
public Aberr(TSeries source, int period, double multiplier = 2.0) : this(period, multiplier)
{
_source = source ?? throw new ArgumentNullException(nameof(source));
Prime(source);
_source.Pub += _handler;
}
/// <summary>
/// Creates Aberr with ITValuePublisher source.
/// </summary>
public Aberr(ITValuePublisher source, int period, double multiplier = 2.0) : this(period, multiplier)
{
_source = source ?? throw new ArgumentNullException(nameof(source));
_source.Pub += _handler;
}
private void HandleValue(object? _, in TValueEventArgs e) => Update(e.Value, e.IsNew);
/// <summary>
/// Helper to invoke the Pub event.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void PubEvent(TValue value, bool isNew)
{
Pub?.Invoke(this, new TValueEventArgs { Value = value, IsNew = isNew });
}
/// <summary>
/// Gets a valid input value, using last-value substitution for non-finite inputs.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private double GetValidValue(double input)
{
if (double.IsFinite(input))
{
_state.LastValidValue = input;
return input;
}
return _state.LastValidValue;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void UpdateState(double value, double deviation)
{
double removedSource = _sourceBuffer.Count == _sourceBuffer.Capacity ? _sourceBuffer.Oldest : 0.0;
double removedDeviation = _deviationBuffer.Count == _deviationBuffer.Capacity ? _deviationBuffer.Oldest : 0.0;
_state.SumSource = _state.SumSource - removedSource + value;
_state.SumDeviation = _state.SumDeviation - removedDeviation + deviation;
_sourceBuffer.Add(value);
_deviationBuffer.Add(deviation);
_state.TickCount++;
if (_sourceBuffer.IsFull && _state.TickCount >= ResyncInterval)
{
_state.TickCount = 0;
_state.SumSource = _sourceBuffer.RecalculateSum();
_state.SumDeviation = _deviationBuffer.RecalculateSum();
}
}
/// <summary>
/// Updates the indicator with a TValue input.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TValue Update(TValue input, bool isNew = true)
{
double value = GetValidValue(input.Value);
if (isNew)
{
_pState = _state;
// Compute SMA including the new value with correct divisor (matches batch ProcessMainLoop)
int count = _sourceBuffer.Count;
double removedSource = count == _sourceBuffer.Capacity ? _sourceBuffer.Oldest : 0.0;
double newSum = _state.SumSource - removedSource + value;
int newCount = count < _sourceBuffer.Capacity ? count + 1 : count;
double sma = newSum / newCount;
double deviation = Math.Abs(value - sma);
UpdateState(value, deviation);
}
else
{
_state = _pState;
// Replace newest source value and recompute sum for current-bar SMA (matches Pine)
_sourceBuffer.UpdateNewest(value);
double currentSum = _sourceBuffer.Sum;
int corrCount = _sourceBuffer.Count;
double corrSma = corrCount > 0 ? currentSum / corrCount : value;
double corrDeviation = Math.Abs(value - corrSma);
_deviationBuffer.UpdateNewest(corrDeviation);
_state = _state with
{
SumSource = currentSum,
SumDeviation = _deviationBuffer.Sum,
};
}
int currentCount = _sourceBuffer.Count;
if (currentCount == 0)
{
Last = new TValue(input.Time, double.NaN);
Upper = new TValue(input.Time, double.NaN);
Lower = new TValue(input.Time, double.NaN);
}
else
{
double middle = _state.SumSource / currentCount;
double avgDeviation = _state.SumDeviation / currentCount;
double bandWidth = _multiplier * avgDeviation;
Last = new TValue(input.Time, middle);
Upper = new TValue(input.Time, middle + bandWidth);
Lower = new TValue(input.Time, middle - bandWidth);
}
PubEvent(Last, isNew);
return Last;
}
/// <summary>
/// Updates the indicator with a TSeries.
/// </summary>
public (TSeries Middle, TSeries Upper, TSeries Lower) Update(TSeries 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
Batch(source.Values, vMiddleSpan, vUpperSpan, vLowerSpan, _period, _multiplier);
source.Times.CopyTo(tSpan);
// Copy timestamps to upper and lower (same time series)
tSpan.CopyTo(CollectionsMarshal.AsSpan(tUpper));
tSpan.CopyTo(CollectionsMarshal.AsSpan(tLower));
// Prime the state for continued streaming
Prime(source);
return (new TSeries(tMiddle, vMiddle), new TSeries(tUpper, vUpper), new TSeries(tLower, vLower));
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void ResyncSums(int period, ref WorkBuffers buffers, ref ScalarState state)
{
state.TickCount = 0;
if (Vector.IsHardwareAccelerated && period >= Vector<double>.Count)
{
ReadOnlySpan<double> sourceSpan = buffers.Source;
ReadOnlySpan<double> deviationSpan = buffers.Deviation;
state.SumSource = sourceSpan.SumSIMD();
state.SumDeviation = deviationSpan.SumSIMD();
}
else
{
double recalcSumSource = 0;
double recalcSumDeviation = 0;
for (int k = 0; k < period; k++)
{
recalcSumSource += buffers.Source[k];
recalcSumDeviation += buffers.Deviation[k];
}
state.SumSource = recalcSumSource;
state.SumDeviation = recalcSumDeviation;
}
}
/// <summary>
/// Initializes the indicator state using the provided TSeries history.
/// </summary>
public void Prime(TSeries source)
{
if (source.Count == 0)
{
return;
}
// Reset state
_sourceBuffer.Clear();
_deviationBuffer.Clear();
_state = default;
_pState = default;
int warmupLength = Math.Min(source.Count, WarmupPeriod);
int startIndex = source.Count - warmupLength;
// Seed LastValidValue
_state.LastValidValue = double.NaN;
for (int i = startIndex - 1; i >= 0; i--)
{
if (double.IsFinite(source[i].Value))
{
_state.LastValidValue = source[i].Value;
break;
}
}
// Find valid value in warmup window if not found
if (double.IsNaN(_state.LastValidValue))
{
for (int i = startIndex; i < source.Count; i++)
{
if (double.IsFinite(source[i].Value))
{
_state.LastValidValue = source[i].Value;
break;
}
}
}
// Feed the buffers
for (int i = startIndex; i < source.Count; i++)
{
double value = GetValidValue(source[i].Value);
// Compute SMA including the new value with correct divisor (matches batch ProcessMainLoop)
int count = _sourceBuffer.Count;
double removedSource = count == _sourceBuffer.Capacity ? _sourceBuffer.Oldest : 0.0;
double newSum = _state.SumSource - removedSource + value;
int newCount = count < _sourceBuffer.Capacity ? count + 1 : count;
double sma = newSum / newCount;
double deviation = Math.Abs(value - sma);
UpdateState(value, deviation);
}
// Finalize state
int currentCount = _sourceBuffer.Count;
if (currentCount > 0)
{
var lastItem = source.Last;
double middle = _state.SumSource / currentCount;
double avgDeviation = _state.SumDeviation / currentCount;
double bandWidth = _multiplier * avgDeviation;
Last = new TValue(lastItem.Time, middle);
Upper = new TValue(lastItem.Time, middle + bandWidth);
Lower = new TValue(lastItem.Time, middle - bandWidth);
}
_pState = _state;
}
/// <summary>
/// Resets the indicator state.
/// </summary>
public void Reset()
{
_sourceBuffer.Clear();
_deviationBuffer.Clear();
_state = default;
_pState = default;
Last = default;
Upper = default;
Lower = default;
}
/////////////////////////////////////////////////////////////////////////////////////////////////
// Static Batch Methods
/////////////////////////////////////////////////////////////////////////////////////////////////
/// <summary>
/// Output buffers for batch Aberr calculation.
/// </summary>
[StructLayout(LayoutKind.Auto)]
#pragma warning disable S1104 // Fields should not have public accessibility
public ref struct BatchOutputs
{
/// <summary>Output middle band (SMA of source)</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 SumSource;
internal double SumDeviation;
internal double LastValidValue;
internal int BufferIndex;
internal int TickCount;
}
/// <summary>
/// Working buffers for batch calculation.
/// </summary>
[StructLayout(LayoutKind.Auto)]
private readonly ref struct WorkBuffers(Span<double> source, Span<double> deviation)
{
internal readonly Span<double> Source = source;
internal readonly Span<double> Deviation = deviation;
}
/// <summary>
/// Calculates Aberr for the entire TSeries using a new instance.
/// </summary>
public static (TSeries Middle, TSeries Upper, TSeries Lower) Batch(TSeries source, int period, double multiplier = 2.0)
{
var aberr = new Aberr(period, multiplier);
return aberr.Update(source);
}
/// <summary>
/// Calculates Aberr in-place using spans for maximum performance.
/// Zero-allocation method.
/// </summary>
/// <param name="source">Source price values</param>
/// <param name="outputs">Output buffers for middle, upper, and lower bands</param>
/// <param name="period">Lookback period</param>
/// <param name="multiplier">Band width multiplier</param>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Batch(
ReadOnlySpan<double> source,
BatchOutputs outputs,
int period,
double multiplier = 2.0)
{
Batch(source, outputs.Middle, outputs.Upper, outputs.Lower, period, multiplier);
}
/// <summary>
/// Calculates Aberr in-place using spans for maximum performance.
/// Zero-allocation method.
/// </summary>
/// <param name="source">Source price values</param>
/// <param name="middle">Output middle band (SMA of source)</param>
/// <param name="upper">Output upper band</param>
/// <param name="lower">Output lower band</param>
/// <param name="period">Lookback period</param>
/// <param name="multiplier">Band width multiplier</param>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Batch(
ReadOnlySpan<double> source,
Span<double> middle,
Span<double> upper,
Span<double> lower,
int period,
double multiplier = 2.0)
{
int len = source.Length;
if (middle.Length < len || upper.Length < len || lower.Length < len)
{
throw new ArgumentException("Output buffers must be at least as long as input", nameof(middle));
}
if (period <= 0)
{
throw new ArgumentOutOfRangeException(nameof(period), "Period must be greater than 0");
}
if (multiplier <= 0)
{
throw new ArgumentOutOfRangeException(nameof(multiplier), "Multiplier must be greater than 0");
}
if (len == 0)
{
return;
}
// Scalar implementation with NaN handling
var outputs = new BatchOutputs(middle, upper, lower);
CalculateScalarCore(source, outputs, period, multiplier);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void CalculateScalarCore(
ReadOnlySpan<double> source,
scoped BatchOutputs outputs,
int period,
double multiplier)
{
int len = source.Length;
// Always use ArrayPool to avoid span scope safety issues with stackalloc + ref structs
double[] rentedSource = ArrayPool<double>.Shared.Rent(period);
double[] rentedDeviation = ArrayPool<double>.Shared.Rent(period);
try
{
var buffers = new WorkBuffers(
rentedSource.AsSpan(0, period),
rentedDeviation.AsSpan(0, period));
var state = new ScalarState
{
LastValidValue = double.NaN,
};
SeedFirstValidValue(source, ref state);
int warmupEnd = Math.Min(period, len);
ProcessWarmupPhase(source, outputs, warmupEnd, multiplier, ref buffers, ref state);
ProcessMainLoop(source, outputs, warmupEnd, period, multiplier, ref buffers, ref state);
}
finally
{
ArrayPool<double>.Shared.Return(rentedSource);
ArrayPool<double>.Shared.Return(rentedDeviation);
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void SeedFirstValidValue(ReadOnlySpan<double> source, ref ScalarState state)
{
int len = source.Length;
for (int k = 0; k < len; k++)
{
if (double.IsFinite(source[k]))
{
state.LastValidValue = source[k];
break;
}
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static double GetValidValue(ReadOnlySpan<double> source, int i, ref ScalarState state)
{
double v = source[i];
if (double.IsFinite(v))
{
state.LastValidValue = v;
return v;
}
return state.LastValidValue;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void WriteBandOutputs(scoped BatchOutputs outputs, int i, double middle, double avgDeviation, double multiplier)
{
double bandWidth = multiplier * avgDeviation;
outputs.Middle[i] = middle;
outputs.Upper[i] = middle + bandWidth;
outputs.Lower[i] = middle - bandWidth;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void ProcessWarmupPhase(
ReadOnlySpan<double> source,
scoped BatchOutputs outputs,
int warmupEnd,
double multiplier,
ref WorkBuffers buffers,
ref ScalarState state)
{
for (int i = 0; i < warmupEnd; i++)
{
double v = GetValidValue(source, i, ref state);
// Compute SMA including the new value to get same-bar deviation (matches Pine)
int newCount = i + 1;
double newSum = state.SumSource + v;
double sma = newSum / newCount;
double deviation = Math.Abs(v - sma);
state.SumSource = newSum;
state.SumDeviation += deviation;
buffers.Source[i] = v;
buffers.Deviation[i] = deviation;
double middle = state.SumSource / newCount;
double avgDeviation = state.SumDeviation / newCount;
WriteBandOutputs(outputs, i, middle, avgDeviation, multiplier);
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void ProcessMainLoop(
ReadOnlySpan<double> source,
scoped BatchOutputs outputs,
int startIndex,
int period,
double multiplier,
ref WorkBuffers buffers,
ref ScalarState state)
{
int len = source.Length;
for (int i = startIndex; i < len; i++)
{
double v = GetValidValue(source, i, ref state);
// Compute SMA including the new value to get same-bar deviation (matches Pine)
double newSumSource = state.SumSource - buffers.Source[state.BufferIndex] + v;
double sma = newSumSource / period;
double deviation = Math.Abs(v - sma);
// Update running sums using single buffer index
state.SumSource = newSumSource;
buffers.Source[state.BufferIndex] = v;
state.SumDeviation = state.SumDeviation - buffers.Deviation[state.BufferIndex] + deviation;
buffers.Deviation[state.BufferIndex] = deviation;
state.BufferIndex++;
if (state.BufferIndex >= period)
{
state.BufferIndex = 0;
}
double middle = state.SumSource / period;
double avgDeviation = state.SumDeviation / period;
WriteBandOutputs(outputs, i, middle, avgDeviation, multiplier);
state.TickCount++;
if (state.TickCount >= ResyncInterval)
{
ResyncSums(period, ref buffers, ref state);
}
}
}
/// <summary>
/// Runs a high-performance batch calculation and returns a "Hot" Aberr instance.
/// </summary>
public static ((TSeries Middle, TSeries Upper, TSeries Lower) Results, Aberr Indicator) Calculate(TSeries source, int period, double multiplier = 2.0)
{
var aberr = new Aberr(period, multiplier);
var results = aberr.Update(source);
return (results, aberr);
}
/// <summary>
/// Disposes the Aberr instance, unsubscribing from the source publisher.
/// This method is idempotent.
/// </summary>
public void Dispose()
{
if (!_disposed)
{
if (_source != null)
{
_source.Pub -= _handler;
_source = null;
}
_disposed = true;
}
GC.SuppressFinalize(this);
}
}