SIMD Refactor: Merge simd-dev into dev (#55)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
Co-authored-by: Warp <agent@warp.dev>
This commit is contained in:
Miha Kralj
2026-01-18 19:02:03 -08:00
committed by GitHub
co-authored by Claude Opus 4.5 aider Warp
parent 5bcdf8d614
commit 86fe32a682
1750 changed files with 198235 additions and 80539 deletions
+169
View File
@@ -0,0 +1,169 @@
using TradingPlatform.BusinessLayer;
namespace QuanTAlib.Tests;
public class AlmaIndicatorTests
{
[Fact]
public void AlmaIndicator_Constructor_SetsDefaults()
{
var indicator = new AlmaIndicator();
Assert.Equal(9, indicator.Period);
Assert.Equal(0.85, indicator.Offset);
Assert.Equal(6.0, indicator.Sigma);
Assert.Equal(SourceType.Close, indicator.Source);
Assert.True(indicator.ShowColdValues);
Assert.Equal("ALMA - Arnaud Legoux Moving Average", indicator.Name);
Assert.False(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void AlmaIndicator_MinHistoryDepths_EqualsPeriod()
{
var indicator = new AlmaIndicator { Period = 20 };
Assert.Equal(0, AlmaIndicator.MinHistoryDepths);
Assert.Equal(0, ((IWatchlistIndicator)indicator).MinHistoryDepths);
}
[Fact]
public void AlmaIndicator_ShortName_IncludesPeriodAndSource()
{
var indicator = new AlmaIndicator { Period = 15 };
Assert.Contains("ALMA", indicator.ShortName, StringComparison.Ordinal);
Assert.Contains("15", indicator.ShortName, StringComparison.Ordinal);
}
[Fact]
public void AlmaIndicator_Initialize_CreatesInternalAlma()
{
var indicator = new AlmaIndicator { Period = 10 };
// Initialize should not throw
indicator.Initialize();
// After init, line series should exist
Assert.Single(indicator.LinesSeries);
}
[Fact]
public void AlmaIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new AlmaIndicator { Period = 3 };
indicator.Initialize();
// Add historical data
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
// Process update
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
// Line series should have a value
Assert.Equal(1, indicator.LinesSeries[0].Count);
Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0)));
}
[Fact]
public void AlmaIndicator_ProcessUpdate_NewBar_ComputesValue()
{
var indicator = new AlmaIndicator { Period = 3 };
indicator.Initialize();
// Add historical data
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
indicator.HistoricalData.AddBar(now.AddMinutes(1), 102, 108, 100, 106);
// Process first update
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
// Line series should have values
Assert.Equal(2, indicator.LinesSeries[0].Count);
}
[Fact]
public void AlmaIndicator_ProcessUpdate_NewTick_ProcessesWithoutError()
{
var indicator = new AlmaIndicator { Period = 3 };
indicator.Initialize();
// Add historical data
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
// Process historical bar first
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
double firstValue = indicator.LinesSeries[0].GetValue(0);
// Update with new tick (same bar data - simulates intrabar update)
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick));
double secondValue = indicator.LinesSeries[0].GetValue(0);
// Both values should be finite
Assert.True(double.IsFinite(firstValue));
Assert.True(double.IsFinite(secondValue));
}
[Fact]
public void AlmaIndicator_MultipleUpdates_ProducesCorrectAlmaSequence()
{
var indicator = new AlmaIndicator { Period = 3 };
indicator.Initialize();
var now = DateTime.UtcNow;
double[] closes = { 100, 102, 104, 103, 105, 107, 106 };
foreach (var close in closes)
{
indicator.HistoricalData.AddBar(now, close, close + 2, close - 2, close);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
now = now.AddMinutes(1);
}
// All values should be finite
for (int i = 0; i < closes.Length; i++)
{
Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(closes.Length - 1 - i)));
}
// ALMA should be smoothing the values
double lastAlma = indicator.LinesSeries[0].GetValue(0);
Assert.True(lastAlma >= 100 && lastAlma <= 110);
}
[Fact]
public void AlmaIndicator_DifferentSourceTypes_Work()
{
var sources = new[] { SourceType.Open, SourceType.High, SourceType.Low, SourceType.Close, SourceType.HL2, SourceType.HLC3 };
foreach (var source in sources)
{
var indicator = new AlmaIndicator { Period = 3, Source = source };
indicator.Initialize();
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 110, 90, 105);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0)),
$"Source {source} should produce finite value");
}
}
[Fact]
public void AlmaIndicator_Period_CanBeChanged()
{
var indicator = new AlmaIndicator { Period = 5 };
Assert.Equal(5, indicator.Period);
indicator.Period = 20;
Assert.Equal(20, indicator.Period);
Assert.Equal(0, AlmaIndicator.MinHistoryDepths);
}
}
+64
View File
@@ -0,0 +1,64 @@
using System.Drawing;
using System.Runtime.CompilerServices;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
[SkipLocalsInit]
public class AlmaIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Period", sortIndex: 1, 1, 1000, 1, 0)]
public int Period { get; set; } = 9;
[InputParameter("Offset", sortIndex: 2, 0.0, 1.0, 0.01, 2)]
public double Offset { get; set; } = 0.85;
[InputParameter("Sigma", sortIndex: 3, 0.1, 100.0, 0.1, 1)]
public double Sigma { get; set; } = 6.0;
[IndicatorExtensions.DataSourceInput]
public SourceType Source { get; set; } = SourceType.Close;
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private Alma ma = null!;
protected LineSeries Series;
protected string SourceName = null!;
private Func<IHistoryItem, double> _priceSelector = null!;
public static int MinHistoryDepths => 0;
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => $"ALMA {Period}:{SourceName}";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/trends/alma/Alma.Quantower.cs";
public AlmaIndicator()
{
OnBackGround = true;
SeparateWindow = false;
SourceName = Source.ToString();
Name = "ALMA - Arnaud Legoux Moving Average";
Description = "Arnaud Legoux Moving Average";
Series = new LineSeries(name: $"ALMA {Period}", color: IndicatorExtensions.Averages, width: 2, style: LineStyle.Solid);
AddLineSeries(Series);
}
protected override void OnInit()
{
ma = new Alma(Period, Offset, Sigma);
SourceName = Source.ToString();
_priceSelector = Source.GetPriceSelector();
base.OnInit();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnUpdate(UpdateArgs args)
{
var item = HistoricalData[Count - 1, SeekOriginHistory.Begin];
TValue result = ma.Update(new TValue(item.TimeLeft.Ticks, _priceSelector(item)), isNew: args.IsNewBar());
Series.SetValue(result.Value, ma.IsHot, ShowColdValues);
}
}
+341
View File
@@ -0,0 +1,341 @@
namespace QuanTAlib.Tests;
public class AlmaTests
{
[Fact]
public void Alma_Constructor_ValidatesInput()
{
var ex1 = Assert.Throws<ArgumentException>(() => new Alma(0));
Assert.Equal("period", ex1.ParamName);
var ex2 = Assert.Throws<ArgumentException>(() => new Alma(10, sigma: 0));
Assert.Equal("sigma", ex2.ParamName);
var ex3 = Assert.Throws<ArgumentOutOfRangeException>(() => new Alma(10, offset: -0.1));
Assert.Equal("offset", ex3.ParamName);
var ex4 = Assert.Throws<ArgumentOutOfRangeException>(() => new Alma(10, offset: 1.1));
Assert.Equal("offset", ex4.ParamName);
var alma = new Alma(10);
Assert.NotNull(alma);
}
[Fact]
public void Alma_Calc_ReturnsValue()
{
var alma = new Alma(10);
TValue result = alma.Update(new TValue(DateTime.UtcNow, 100));
Assert.True(result.Value > 0);
}
[Fact]
public void Alma_IsHot_BecomesTrueWhenBufferFull()
{
var alma = new Alma(5);
Assert.False(alma.IsHot);
for (int i = 0; i < 4; i++)
{
alma.Update(new TValue(DateTime.UtcNow, 100));
Assert.False(alma.IsHot);
}
alma.Update(new TValue(DateTime.UtcNow, 100));
Assert.True(alma.IsHot);
}
[Fact]
public void Alma_StreamingMatchesBatch()
{
var almaStreaming = new Alma(10);
var almaBatch = new Alma(10);
var gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.2, seed: 42);
var series = new TSeries();
for (int i = 0; i < 100; i++)
{
var bar = gbm.Next(isNew: true);
series.Add(new TValue(bar.Time, bar.Close));
}
// Streaming
var streamingResults = new TSeries();
Assert.True(series.Count > 0);
foreach (var item in series)
{
streamingResults.Add(almaStreaming.Update(item));
}
// Batch
var batchResults = almaBatch.Update(series);
Assert.Equal(streamingResults.Count, batchResults.Count);
for (int i = 0; i < batchResults.Count; i++)
{
Assert.Equal(streamingResults[i].Value, batchResults[i].Value, 1e-9);
}
}
[Fact]
public void Alma_StaticCalculate_MatchesInstance()
{
var series = new TSeries();
var gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.2, seed: 42);
for (int i = 0; i < 100; i++)
{
var bar = gbm.Next(isNew: true);
series.Add(bar.Time, bar.Close);
}
var instanceResults = new Alma(10).Update(series);
var staticResults = Alma.Batch(series, 10);
for (int i = 0; i < instanceResults.Count; i++)
{
Assert.Equal(instanceResults[i].Value, staticResults[i].Value, 1e-9);
}
}
[Fact]
public void Alma_SpanCalculate_MatchesSeries()
{
var series = new TSeries();
var gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.2, seed: 42);
for (int i = 0; i < 100; i++)
{
var bar = gbm.Next(isNew: true);
series.Add(bar.Time, bar.Close);
}
var seriesResults = Alma.Batch(series, 10);
double[] input = series.Values.ToArray();
double[] output = new double[input.Length];
Alma.Calculate(input.AsSpan(), output.AsSpan(), 10);
for (int i = 0; i < input.Length; i++)
{
Assert.Equal(seriesResults[i].Value, output[i], 1e-9);
}
}
[Fact]
public void Alma_Update_IsNewFalse_CorrectsValue()
{
var alma = new Alma(10);
var gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.2, seed: 42);
// Feed initial data
for (int i = 0; i < 20; i++)
{
var bar = gbm.Next(isNew: true);
alma.Update(new TValue(bar.Time, bar.Close), isNew: true);
}
// Update with isNew=false (correction)
var newBar = gbm.Next(isNew: true);
alma.Update(new TValue(newBar.Time, newBar.Close), isNew: true);
double valueAfterCommit = alma.Last.Value;
// Now update the SAME bar with a different value
alma.Update(new TValue(newBar.Time, newBar.Close + 10.0), isNew: false);
double valueAfterCorrection = alma.Last.Value;
Assert.NotEqual(valueAfterCommit, valueAfterCorrection);
// Now restore original value
alma.Update(new TValue(newBar.Time, newBar.Close), isNew: false);
Assert.Equal(valueAfterCommit, alma.Last.Value, 1e-9);
}
[Fact]
public void Alma_NaN_Input_UsesLastValidValue()
{
var alma = new Alma(5);
alma.Update(new TValue(DateTime.UtcNow, 100));
alma.Update(new TValue(DateTime.UtcNow, 110));
var resultAfterNaN = alma.Update(new TValue(DateTime.UtcNow, double.NaN));
Assert.True(double.IsFinite(resultAfterNaN.Value));
Assert.NotEqual(0, resultAfterNaN.Value);
}
[Fact]
public void Alma_Reset_ClearsState()
{
var alma = new Alma(10);
alma.Update(new TValue(DateTime.UtcNow, 100));
alma.Update(new TValue(DateTime.UtcNow, 110));
Assert.True(alma.Last.Value > 0);
alma.Reset();
Assert.Equal(0, alma.Last.Value);
Assert.False(alma.IsHot);
}
[Fact]
public void Alma_FirstValue_ReturnsExpected()
{
var alma = new Alma(10);
TValue result = alma.Update(new TValue(DateTime.UtcNow, 100));
Assert.Equal(100.0, result.Value, 1e-9);
}
[Fact]
public void Alma_Properties_Accessible()
{
var alma = new Alma(10);
Assert.False(alma.IsHot);
Assert.Equal(0, alma.Last.Value);
}
[Fact]
public void Alma_Calc_IsNew_AcceptsParameter()
{
var alma = new Alma(10);
alma.Update(new TValue(DateTime.UtcNow, 100), isNew: true);
Assert.Equal(100, alma.Last.Value);
}
[Fact]
public void Alma_IterativeCorrections_RestoreToOriginalState()
{
var alma = new Alma(10);
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1);
// Feed 10 new values
TValue tenthInput = default;
for (int i = 0; i < 10; i++)
{
var bar = gbm.Next(isNew: true);
tenthInput = new TValue(bar.Time, bar.Close);
alma.Update(tenthInput, isNew: true);
}
// Remember state after 10 values
double valueAfterTen = alma.Last.Value;
// Generate 9 corrections with isNew=false (different values)
for (int i = 0; i < 9; i++)
{
var bar = gbm.Next(isNew: false);
alma.Update(new TValue(bar.Time, bar.Close), isNew: false);
}
// Feed the remembered 10th input again with isNew=false
TValue finalValue = alma.Update(tenthInput, isNew: false);
// Should match the original state after 10 values
Assert.Equal(valueAfterTen, finalValue.Value, 1e-9);
}
[Fact]
public void Alma_Infinity_Input_UsesLastValidValue()
{
var alma = new Alma(10);
alma.Update(new TValue(DateTime.UtcNow, 100));
alma.Update(new TValue(DateTime.UtcNow, 110));
var resultPosInf = alma.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity));
Assert.True(double.IsFinite(resultPosInf.Value));
var resultNegInf = alma.Update(new TValue(DateTime.UtcNow, double.NegativeInfinity));
Assert.True(double.IsFinite(resultNegInf.Value));
}
[Fact]
public void Alma_MultipleNaN_ContinuesWithLastValid()
{
var alma = new Alma(10);
alma.Update(new TValue(DateTime.UtcNow, 100));
var r1 = alma.Update(new TValue(DateTime.UtcNow, double.NaN));
var r2 = alma.Update(new TValue(DateTime.UtcNow, double.NaN));
Assert.True(double.IsFinite(r1.Value));
Assert.True(double.IsFinite(r2.Value));
}
[Fact]
public void Alma_AllModes_ProduceSameResult()
{
// Arrange
const int period = 10;
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 123);
var bars = gbm.Fetch(1000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var series = bars.Close;
// 1. Batch Mode
var batchSeries = Alma.Batch(series, period);
double expected = batchSeries.Last.Value;
// 2. Span Mode
var tValues = series.Values.ToArray();
var spanInput = new ReadOnlySpan<double>(tValues);
var spanOutput = new double[tValues.Length];
Alma.Calculate(spanInput, spanOutput, period);
double spanResult = spanOutput[^1];
// 3. Streaming Mode
var streamingInd = new Alma(period);
for (int i = 0; i < series.Count; i++)
{
streamingInd.Update(series[i]);
}
double streamingResult = streamingInd.Last.Value;
// 4. Eventing Mode
var pubSource = new TSeries();
var eventingInd = new Alma(pubSource, period);
for (int i = 0; i < series.Count; i++)
{
pubSource.Add(series[i]);
}
double eventingResult = eventingInd.Last.Value;
// Assert
Assert.Equal(expected, spanResult, 1e-9);
Assert.Equal(expected, streamingResult, 1e-9);
Assert.Equal(expected, eventingResult, 1e-9);
}
[Fact]
public void Alma_SpanCalc_ValidatesInput()
{
double[] source = [1, 2, 3, 4, 5];
double[] output = new double[5];
double[] wrongSizeOutput = new double[3];
Assert.Throws<ArgumentException>(() => Alma.Calculate(source.AsSpan(), output.AsSpan(), 0));
Assert.Throws<ArgumentException>(() => Alma.Calculate(source.AsSpan(), output.AsSpan(), 3, sigma: 0));
Assert.Throws<ArgumentException>(() => Alma.Calculate(source.AsSpan(), output.AsSpan(), 3, sigma: -1));
Assert.Throws<ArgumentOutOfRangeException>(() => Alma.Calculate(source.AsSpan(), output.AsSpan(), 3, offset: -0.1));
Assert.Throws<ArgumentOutOfRangeException>(() => Alma.Calculate(source.AsSpan(), output.AsSpan(), 3, offset: 1.1));
Assert.Throws<ArgumentException>(() => Alma.Calculate(source.AsSpan(), wrongSizeOutput.AsSpan(), 3));
}
[Fact]
public void Alma_SpanCalc_HandlesNaN()
{
double[] source = [100, 110, double.NaN, 120, 130];
double[] output = new double[5];
Alma.Calculate(source.AsSpan(), output.AsSpan(), 3);
foreach (var val in output)
{
Assert.True(double.IsFinite(val));
}
}
}
@@ -0,0 +1,150 @@
using Skender.Stock.Indicators;
using OoplesFinance.StockIndicators;
using OoplesFinance.StockIndicators.Models;
using Xunit.Abstractions;
namespace QuanTAlib.Tests;
public sealed class AlmaValidationTests : IDisposable
{
// Note: ALMA is not available in TA-Lib or Tulip,
// validation is limited to Skender.Stock.Indicators and OoplesFinance.StockIndicators.
private readonly ValidationTestData _testData;
private readonly ITestOutputHelper _output;
private bool _disposed;
public AlmaValidationTests(ITestOutputHelper output)
{
_output = output;
_testData = new ValidationTestData(count: 1000, seed: 42);
}
public void Dispose()
{
Dispose(true);
}
private void Dispose(bool disposing)
{
if (_disposed)
{
return;
}
_disposed = true;
if (disposing)
{
_testData?.Dispose();
}
}
[Fact]
public void Validate_Skender_Batch()
{
int[] periods = { 9, 14, 20, 50 };
const double offset = 0.85;
double sigma = 6.0;
foreach (var period in periods)
{
// Calculate QuanTAlib ALMA (batch TSeries)
var alma = new global::QuanTAlib.Alma(period, offset, sigma);
var qResult = alma.Update(_testData.Data);
// Calculate Skender ALMA
var sResult = _testData.SkenderQuotes.GetAlma(period, offset, sigma).ToList();
// Compare last 100 records
ValidationHelper.VerifyData(qResult, sResult, (s) => s.Alma);
}
_output.WriteLine("ALMA Batch(TSeries) validated successfully against Skender");
}
[Fact]
public void Validate_Skender_Streaming()
{
int[] periods = { 9, 14, 20, 50 };
double offset = 0.85;
double sigma = 6.0;
foreach (var period in periods)
{
// Calculate QuanTAlib ALMA (streaming)
var alma = new global::QuanTAlib.Alma(period, offset, sigma);
var qResults = new List<double>();
foreach (var item in _testData.Data)
{
qResults.Add(alma.Update(item).Value);
}
// Calculate Skender ALMA
var sResult = _testData.SkenderQuotes.GetAlma(period, offset, sigma).ToList();
// Compare last 100 records
ValidationHelper.VerifyData(qResults, sResult, (s) => s.Alma);
}
_output.WriteLine("ALMA Streaming validated successfully against Skender");
}
[Fact]
public void Validate_Skender_Span()
{
int[] periods = { 9, 14, 20, 50 };
double offset = 0.85;
double sigma = 6.0;
// Prepare data for Span API
ReadOnlySpan<double> sourceData = _testData.RawData.Span;
foreach (var period in periods)
{
// Calculate QuanTAlib ALMA (Span API)
double[] qOutput = new double[sourceData.Length];
global::QuanTAlib.Alma.Calculate(sourceData, qOutput.AsSpan(), period, offset, sigma);
// Calculate Skender ALMA
var sResult = _testData.SkenderQuotes.GetAlma(period, offset, sigma).ToList();
// Compare last 100 records
ValidationHelper.VerifyData(qOutput, sResult, (s) => s.Alma);
}
_output.WriteLine("ALMA Span validated successfully against Skender");
}
[Fact]
public void Validate_Ooples_Batch()
{
int[] periods = { 9, 14, 20, 50 };
double offset = 0.85;
double sigma = 6.0;
// Prepare data for Ooples
var ooplesData = _testData.SkenderQuotes.Select(q => new TickerData
{
Date = q.Date,
Open = (double)q.Open,
High = (double)q.High,
Low = (double)q.Low,
Close = (double)q.Close,
Volume = (double)q.Volume
}).ToList();
foreach (var period in periods)
{
// 1. Calculate Ooples ALMA
var stockData = new StockData(ooplesData);
var oResult = stockData.CalculateArnaudLegouxMovingAverage(period, offset, (int)sigma);
var oAlma = oResult.OutputValues["Alma"];
// 2. Calculate QuanTAlib ALMA
var alma = new global::QuanTAlib.Alma(period, offset, sigma);
var qResult = alma.Update(_testData.Data);
// 3. Verify
ValidationHelper.VerifyData(qResult, oAlma, x => x, skip: 100, tolerance: ValidationHelper.OoplesTolerance);
}
_output.WriteLine("ALMA Batch validated successfully against Ooples");
}
}
+367
View File
@@ -0,0 +1,367 @@
using System.Buffers;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// ALMA: Arnaud Legoux Moving Average
/// </summary>
/// <remarks>
/// ALMA uses a Gaussian distribution to determine weights for the moving average.
/// Definition:
/// m = offset * (period - 1)
/// s = period / sigma
/// W_i = exp( - (i - m)^2 / (2 * s^2) )
///
/// The final ALMA is the weighted sum of the price window divided by the sum of weights.
/// </remarks>
[SkipLocalsInit]
public sealed class Alma : AbstractBase
{
private readonly int _period;
private readonly double _offset;
private readonly double _sigma;
private readonly double[] _weights;
private readonly double _invWeightSum;
private readonly RingBuffer _buffer;
private readonly ITValuePublisher? _source;
private readonly TValuePublishedHandler? _pubHandler;
private bool _isNew = true;
[StructLayout(LayoutKind.Auto)]
private record struct State(double LastValidValue, bool IsInitialized);
private State _state;
private State _p_state;
public bool IsNew => _isNew;
public override bool IsHot => _buffer.IsFull;
/// <summary>
/// Creates ALMA with specified parameters.
/// </summary>
/// <param name="period">Window size (must be > 0)</param>
/// <param name="offset">Gaussian offset (0-1, default 0.85). Closer to 1 makes it more responsive.</param>
/// <param name="sigma">Standard deviation (default 6). Higher values make it sharper.</param>
public Alma(int period, double offset = 0.85, double sigma = 6.0)
{
if (period <= 0)
throw new ArgumentException("Period must be greater than 0", nameof(period));
if (sigma <= 0)
throw new ArgumentException("Sigma must be greater than 0", nameof(sigma));
if (offset < 0 || offset > 1)
throw new ArgumentOutOfRangeException(nameof(offset), "Offset must be between 0 and 1");
_period = period;
_offset = offset;
_sigma = sigma;
_buffer = new RingBuffer(period);
_weights = new double[period];
Name = $"Alma({period}, {offset:F2}, {sigma:F2})";
WarmupPeriod = period;
ComputeWeights(_weights, period, offset, sigma, out _invWeightSum);
_state = new State(double.NaN, IsInitialized: false);
}
public Alma(ITValuePublisher source, int period, double offset = 0.85, double sigma = 6.0)
: this(period, offset, sigma)
{
_source = source;
_pubHandler = Handle;
_source.Pub += _pubHandler;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void Handle(object? sender, in TValueEventArgs e) => Update(e.Value, e.IsNew);
protected override void Dispose(bool disposing)
{
if (disposing && _source != null && _pubHandler != null)
{
_source.Pub -= _pubHandler;
}
base.Dispose(disposing);
}
/// <summary>
/// Computes Gaussian weights for ALMA.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void ComputeWeights(Span<double> weights, int period, double offset, double sigma, out double invWeightSum)
{
double m = offset * (period - 1);
double s = period / sigma;
double s2 = 2 * s * s;
double sum = 0;
for (int i = 0; i < period; i++)
{
double v = i - m;
double w = Math.Exp(-(v * v) / s2);
weights[i] = w;
sum += w;
}
invWeightSum = 1.0 / sum;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private double GetValidValue(double input)
{
if (double.IsFinite(input))
{
return input;
}
return _state.IsInitialized ? _state.LastValidValue : double.NaN;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override TValue Update(TValue input, bool isNew = true)
{
_isNew = isNew;
return Update(input, isNew, publish: true);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private TValue Update(TValue input, bool isNew, bool publish)
{
if (isNew)
{
_p_state = _state;
}
else
{
_state = _p_state;
}
if (double.IsFinite(input.Value))
{
_state = _state with { LastValidValue = input.Value, IsInitialized = true };
}
// Retrieve valid value (handles NaN propagation prevention)
double val = GetValidValue(input.Value);
_buffer.Add(val, isNew);
double result = 0;
if (_buffer.Count > 0)
{
result = CalculateWeightedSum();
}
Last = new TValue(input.Time, result);
if (publish)
{
PubEvent(Last);
}
return Last;
}
public override TSeries Update(TSeries source)
{
if (source.Count == 0) return new TSeries([], []);
int len = source.Count;
var t = new List<long>(len);
var v = new List<double>(len);
CollectionsMarshal.SetCount(t, len);
CollectionsMarshal.SetCount(v, len);
var tSpan = CollectionsMarshal.AsSpan(t);
var vSpan = CollectionsMarshal.AsSpan(v);
Calculate(source.Values, vSpan, _period, _offset, _sigma);
source.Times.CopyTo(tSpan);
// Restore state
_buffer.Clear();
_state = default;
// Replay last part to restore buffer state
int startIndex = Math.Max(0, len - _period);
for (int i = startIndex; i < len; i++)
{
Update(source[i], isNew: true, publish: false);
}
return new TSeries(t, v);
}
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
{
foreach (var value in source)
{
Update(new TValue(DateTime.MinValue, value));
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private double CalculateWeightedSum()
{
int count = _buffer.Count;
if (count == 0) return 0;
if (count < _period)
{
// Partial buffer: align newest with newest
// Buffer[0] (oldest) -> Weights[period - count]
ReadOnlySpan<double> bufferSpan = _buffer.GetSpan();
int weightOffset = _period - count;
// Use DotProduct for partial sum
double sum = bufferSpan.DotProduct(_weights.AsSpan(weightOffset, count));
// Calculate weightSum for this subset
double wSum = 0;
for (int i = 0; i < count; i++)
{
wSum += _weights[weightOffset + i];
}
return wSum > 0 ? sum / wSum : 0;
}
// Full buffer: use precomputed _weightSum and SIMD DotProduct
// We use InternalBuffer and StartIndex to avoid allocation and handle wrapping
ReadOnlySpan<double> internalBuf = _buffer.InternalBuffer;
int head = _buffer.StartIndex;
// Part 1: Oldest to End of Buffer -> InternalBuffer[Head ... Cap-1]
// Matches Weights[0 ... Cap-Head-1]
int part1Len = _period - head;
double sum1 = internalBuf.Slice(head, part1Len).DotProduct(_weights.AsSpan(0, part1Len));
// Part 2: Start of Buffer to Newest -> InternalBuffer[0 ... Head-1]
// Matches Weights[Cap-Head ... Cap-1]
double sum2 = internalBuf[..head].DotProduct(_weights.AsSpan(part1Len));
return (sum1 + sum2) * _invWeightSum;
}
public static TSeries Batch(TSeries source, int period, double offset = 0.85, double sigma = 6.0)
{
var alma = new Alma(period, offset, sigma);
return alma.Update(source);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Calculate(ReadOnlySpan<double> source, Span<double> output, int period, double offset = 0.85, double sigma = 6.0)
{
if (period <= 0)
throw new ArgumentException("Period must be greater than 0", nameof(period));
if (sigma <= 0)
throw new ArgumentException("Sigma must be greater than 0", nameof(sigma));
if (offset < 0 || offset > 1)
throw new ArgumentOutOfRangeException(nameof(offset), "Offset must be between 0 and 1");
if (source.Length != output.Length)
throw new ArgumentException("Source and output must have the same length", nameof(output));
// Allocation Strategy: Stack for small periods, Pool for large
double[]? weightsArray = period > 256 ? ArrayPool<double>.Shared.Rent(period) : null;
Span<double> weights = period <= 256
? stackalloc double[period]
: weightsArray!.AsSpan(0, period);
double[]? bufferArray = period > 256 ? ArrayPool<double>.Shared.Rent(period) : null;
Span<double> buffer = period <= 256
? stackalloc double[period]
: bufferArray!.AsSpan(0, period);
// Precompute weights using shared helper
ComputeWeights(weights, period, offset, sigma, out double invWeightSum);
int bufferIdx = 0;
int count = 0;
double lastValid = double.NaN; // Start with NaN to detect first valid value
double currentWeightSum = 0;
try
{
for (int i = 0; i < source.Length; i++)
{
double val = source[i];
// Strict NaN handling: maintain NaN until first valid value
if (double.IsFinite(val))
{
lastValid = val;
}
else if (double.IsFinite(lastValid))
{
val = lastValid;
}
else
{
val = 0.0; // Fallback if series starts with NaN
}
// Add to circular buffer
buffer[bufferIdx] = val;
bufferIdx = (bufferIdx + 1) % period;
if (count < period)
{
count++;
// Incremental weight sum update for warmup
currentWeightSum += weights[period - count];
}
double sum = 0;
if (count == period)
{
// Buffer is full. bufferIdx points to the oldest element (next write position)
// Split the dot product to handle circular buffer wrap-around
int part1Len = period - bufferIdx;
// Part 1: Oldest data (at bufferIdx..End) * Start of Weights
sum += buffer.Slice(bufferIdx, part1Len).DotProduct(weights.Slice(0, part1Len));
// Part 2: Newest data (at 0..bufferIdx) * End of Weights
sum += buffer.Slice(0, bufferIdx).DotProduct(weights.Slice(part1Len));
output[i] = sum * invWeightSum;
}
else
{
// Partial buffer
int startIdx = (bufferIdx - count + period) % period;
int weightOffset = period - count;
if (startIdx + count <= period)
{
// Contiguous in buffer
sum = buffer.Slice(startIdx, count).DotProduct(weights.Slice(weightOffset, count));
}
else
{
// Wrapped in buffer
int part1Len = period - startIdx;
int part2Len = count - part1Len;
sum = buffer.Slice(startIdx, part1Len).DotProduct(weights.Slice(weightOffset, part1Len));
sum += buffer.Slice(0, part2Len).DotProduct(weights.Slice(weightOffset + part1Len, part2Len));
}
output[i] = currentWeightSum > 0 ? sum / currentWeightSum : 0;
}
}
}
finally
{
if (weightsArray != null) ArrayPool<double>.Shared.Return(weightsArray);
if (bufferArray != null) ArrayPool<double>.Shared.Return(bufferArray);
}
}
public override void Reset()
{
_buffer.Clear();
_state = new State(double.NaN, IsInitialized: false);
_p_state = _state;
Last = default;
}
}
+282
View File
@@ -0,0 +1,282 @@
# ALMA: Arnaud Legoux Moving Average
> "Gaussian distributions govern everything from particle diffusion to the distribution of shoe sizes. Applying them to price action isn't 'technical analysis'; it's just physics with a profit motive."
ALMA is a Finite Impulse Response (FIR) filter that applies a Gaussian window to price data. Unlike the Simple Moving Average (which treats 10-minute-old data with the same reverence as 1-minute-old data) or the Exponential Moving Average (which holds onto history like a hoarder), ALMA allows you to shape the weight distribution precisely. It lets you define the trade-off between smoothness and lag using standard deviation ($\sigma$) and offset, rather than arbitrary periods.
## Historical Context / The Standard
Arnaud Legoux and Dimitris Kouzis-Loukas published ALMA in 2009. The context was a trading world drowning in "adaptive" moving averages (KAMA, FRAMA) that often adapted too late or overshot the turn.
While Hull (HMA) attempted to solve lag through algebraic subtraction (and created overshoot), and Jurik (JMA) hid behind proprietary black-box math, Legoux returned to first principles: Signal Processing. He applied the Gaussian filter—standard in electrical engineering for noise reduction—to financial time series. It is not a "modern" invention so much as the correct application of established math to a messy domain.
## Architecture & Physics
ALMA is a weighted moving average where weights follow a normal distribution (bell curve).
The physics of ALMA rely on shifting the "center of gravity" of the window.
* **SMA:** Center of gravity is always the middle ($0.5$). Lag is fixed.
* **EMA:** Center of gravity is front-loaded but has an infinite tail.
* **ALMA:** You move the center. An offset of $0.85$ pushes the bulk of the weight to the most recent 15% of the window.
This shift allows the indicator to capture momentum (high responsiveness) while the Gaussian decay kills high-frequency noise (smoothness). It behaves less like a lagging indicator and more like a mass-dampener system.
### The Compute Challenge
Naive implementations recalculate the Gaussian weights on every tick. This is CPU suicide.
QuanTAlib precomputes the weight vector $\mathbf{W}$ upon initialization. The runtime operation effectively becomes a dot product of the price buffer and the weight vector.
$$ \text{Runtime Cost} = O(N) \text{ multiplications} $$
While heavier than the recursive EMA ($O(1)$), the memory locality of the arrays allows modern CPUs to vectorise these operations (SIMD), making the penalty negligible for typical window sizes (< 100).
## Mathematical Foundation
The weight calculation relies on three inputs:
1. **Window ($L$)**: The lookback period.
2. **Offset ($o$)**: Where the Gaussian peak sits (0.0 to 1.0). Default is 0.85.
3. **Sigma ($\sigma$)**: The width of the bell curve. Default is 6.0.
### 1. Center and Width Calculation
First, QuanTAlib defines the peak index ($m$) and the spread ($s$):
$$ m = o \cdot (L - 1) $$
$$ s = \frac{L}{\sigma} $$
### 2. Weight Generation
For each index $i$ from $0$ to $L-1$, the unnormalized weight is calculated:
$$ w_i = \exp \left( - \frac{(i - m)^2}{2s^2} \right) $$
### 3. Normalization
The final ALMA value is the weighted sum. The weights are not normalized to sum to 1.0 beforehand; instead, division by the total sum of weights $W_{sum}$ happens at the end.
$$ \text{ALMA}_t = \frac{\sum_{i=0}^{L-1} P_{t-i} \cdot w_{L-1-i}}{W_{sum}} $$
*Note: The weights vector is reversed relative to the price history buffer (most recent price gets the weight at the offset index).*
## Performance Profile
### Operation Count (Streaming Mode, Scalar)
**Constructor (one-time precomputation):**
| Operation | Count | Cost (cycles) | Subtotal |
| :--- | :---: | :---: | :---: |
| MUL | 2N + 2 | 3 | 6N + 6 |
| DIV | N | 15 | 15N |
| EXP | N | 50 | 50N |
| ADD/SUB | 2N | 1 | 2N |
| **Total (init)** | — | — | **~73N cycles** |
For period=20: ~1,460 cycles (one-time).
**Hot path (per bar):**
| Operation | Count | Cost (cycles) | Subtotal |
| :--- | :---: | :---: | :---: |
| MUL | N | 3 | 3N |
| ADD | N | 1 | N |
| DIV | 1 | 15 | 15 |
| **Total** | **2N + 1** | — | **~4N + 15 cycles** |
For period=20: ~95 cycles per bar.
**Hot path breakdown:**
- Weighted sum: `∑(buffer[i] × weights[i])` → N MUL + N ADD
- Normalization: `sum / wSum` → 1 DIV (wSum precomputed)
### Batch Mode (SIMD)
The dot product `∑(buffer[i] × weights[i])` is highly vectorizable:
| Operation | Scalar Ops | SIMD Ops (AVX2) | Speedup |
| :--- | :---: | :---: | :---: |
| Weighted products | N | N/8 | 8× |
| Horizontal sum | N | log₂(8) | ~N/3× |
**Batch efficiency (512 bars, period=20):**
| Mode | Cycles/bar | Total | Notes |
| :--- | :---: | :---: | :--- |
| Scalar streaming | ~95 | ~48,640 | O(N) per bar |
| SIMD batch | ~25 | ~12,800 | Vectorized dot product |
| **Improvement** | **~4×** | **~36K saved** | — |
### Quality Metrics
| Metric | Score | Notes |
| :--- | :---: | :--- |
| **Accuracy** | 10/10 | Matches Gaussian definition to `double` precision |
| **Timeliness** | 9/10 | Tunable offset (0.85) minimizes group delay |
| **Overshoot** | 9/10 | Gaussian decay prevents the "whip" effect of HMA |
| **Smoothness** | 8/10 | Dependent on σ; higher σ = sharper filter |
### Implementation Details
```csharp
// Precomputation (Constructor)
double m = offset * (period - 1);
double s = period / sigma;
double wSum = 0;
for (int i = 0; i < period; i++) {
double weight = Math.Exp(-((i - m) * (i - m)) / (2 * s * s));
_weights[i] = weight;
wSum += weight;
}
// Runtime (Update)
double numerator = 0;
// Note: _buffer holds prices. _weights are pre-aligned.
// Modern JIT unrolls this loop efficiently.
for (int i = 0; i < period; i++) {
numerator += _buffer[i] * _weights[i];
}
return numerator / wSum;
```
## Validation
QuanTAlib validates against reference implementations that respect the Gaussian math, ignoring those that approximate for speed.
| Library | Status | Notes |
| :--- | :--- | :--- |
| **QuanTAlib** | ✅ | Validated against math definition. |
| **Skender** | ✅ | Matches `GetAlma`. |
| **Ooples** | ✅ | Matches `CalculateArnaudLegouxMovingAverage`. |
| **Pandas-TA** | ✅ | Python reference implementation matches. |
| **TA-Lib** | ❌ | Not included in standard C distribution. |
| **Tulip** | ❌ | Not included. |
## C# Implementation Considerations
### Precomputed Gaussian Weights
Weights are computed once in the constructor and stored in a `double[]` array:
```csharp
_weights = new double[period];
ComputeWeights(_weights, period, offset, sigma, out _invWeightSum);
```
The inverse of the weight sum is precomputed for multiplication instead of division in the hot path.
### State Record Struct with Auto Layout
Minimal state for bar correction:
```csharp
[StructLayout(LayoutKind.Auto)]
private record struct State(double LastValidValue, bool IsInitialized);
```
The `LayoutKind.Auto` lets the JIT optimize field placement for cache efficiency.
### SIMD-Optimized Dot Product
The weighted sum calculation delegates to a SIMD-optimized `DotProduct` extension method:
```csharp
double sum1 = internalBuf.Slice(head, part1Len).DotProduct(_weights.AsSpan(0, part1Len));
double sum2 = internalBuf[..head].DotProduct(_weights.AsSpan(part1Len));
return (sum1 + sum2) * _invWeightSum;
```
The dot product leverages AVX2/AVX-512/NEON intrinsics internally, achieving up to 8× speedup.
### Circular Buffer Handling
The RingBuffer's internal array is accessed directly to split the dot product across the wrap boundary:
```csharp
ReadOnlySpan<double> internalBuf = _buffer.InternalBuffer;
int head = _buffer.StartIndex;
int part1Len = _period - head;
// Part 1: head..end with weights[0..part1Len]
// Part 2: 0..head with weights[part1Len..period]
```
This avoids copying the buffer into a contiguous array.
### Stackalloc/ArrayPool Allocation Strategy
The static `Calculate` method uses stackalloc for small periods and ArrayPool for large:
```csharp
double[]? weightsArray = period > 256 ? ArrayPool<double>.Shared.Rent(period) : null;
Span<double> weights = period <= 256
? stackalloc double[period]
: weightsArray!.AsSpan(0, period);
```
The 256-element threshold balances stack safety with allocation overhead.
### NaN Handling with Initialization Tracking
Non-finite inputs are replaced with the last valid value, with explicit tracking for uninitialized state:
```csharp
private double GetValidValue(double input)
{
if (double.IsFinite(input))
return input;
return _state.IsInitialized ? _state.LastValidValue : double.NaN;
}
```
This prevents NaN propagation while correctly handling series that start with invalid values.
### Incremental Weight Sum for Warmup
During the warmup period, the weight sum is computed incrementally:
```csharp
if (count < period)
{
count++;
currentWeightSum += weights[period - count];
}
```
This avoids recalculating the partial sum on each bar during convergence.
### Separate Internal Update Method
The `Update` method has a private overload with a `publish` parameter:
```csharp
private TValue Update(TValue input, bool isNew, bool publish)
```
This allows state restoration after batch processing without firing events.
### Memory Layout
| Component | Size | Purpose |
| :--- | :--- | :--- |
| `_weights` | 8×period bytes | Precomputed Gaussian weights |
| `_buffer` (RingBuffer) | 32 + 8×period bytes | Sliding window history |
| `_state` | ~16 bytes | LastValidValue, IsInitialized |
| `_p_state` | ~16 bytes | Previous state for rollback |
| Scalars | ~40 bytes | Period, offset, sigma, invWeightSum |
| **Total** | **~104 + 16N bytes** | Per-instance footprint |
For ALMA(50), total memory is approximately 900 bytes per instance.
## Common Pitfalls
1. **Offset Abuse**: Setting offset to `0.99` creates a filter that barely filters. It tracks price so closely you might as well use `Price[0]`. Setting it to `0.5` makes it a centered moving average (great for smoothing, terrible for trading due to repainting if used as such, but ALMA does not repaint). The magic is in the `0.85` region.
2. **Sigma Confusion**:
* $\sigma = 1$: The curve is flat. You have reinvented the Simple Moving Average (badly).
* $\sigma = 10$: The curve is a needle. You are sampling one specific bar in history.
3. **Cold Start**: ALMA requires a full window ($L$) to be mathematically valid. First $L-1$ bars are convergence noise. Ignore them.
+50
View File
@@ -0,0 +1,50 @@
// The MIT License (MIT)
// © mihakralj
//@version=6
indicator("Arnaud Legoux Moving Average (ALMA)", "ALMA", overlay=true)
//@function Calculates ALMA using Gaussian distribution weights
//@param source Series to calculate ALMA from
//@param period Lookback period - window size
//@param offset Controls the Gaussian peak location (0 to 1)
//@param sigma Controls the Gaussian distribution width/curve shape
//@returns ALMA value, calculates from first bar using available data
//@optimized Uses Gaussian weighting with O(n) complexity per bar due to lookback loop
alma(series float source, simple int period, simple float offset=0.85, simple float sigma=6.0) =>
if period <= 0
runtime.error("Period must be greater than 0")
if offset < 0.0 or offset > 1.0
runtime.error("Offset must be between 0 and 1")
if sigma <= 0.0
runtime.error("Sigma must be greater than 0")
int p = math.min(bar_index + 1, period)
if p <= 1
source
else
float m = (1.0 - offset) * (p - 1)
float s = p / sigma
float s2 = 2.0 * (s * s)
float sum = 0.0
float weight_sum = 0.0
for i = 0 to p - 1
float price = source[i]
if not na(price)
float diff = i - m
float weight = math.exp(-(diff * diff) / s2)
sum += price * weight
weight_sum += weight
nz(sum / weight_sum, source)
// ---------- Main loop ----------
// Inputs
i_period = input.int(50, "Period", minval=1, tooltip="Number of bars used in the calculation")
i_offset = input.float(0.85, "Offset", minval=0.0, maxval=1.0, step=0.01)
i_sigma = input.float(6.0, "Sigma", minval=0.1, maxval=20.0, step=0.1)
i_source = input.source(close, "Source")
// Calculation
alma_value = alma(i_source, i_period, i_offset, i_sigma)
// Plot
plot(alma_value, "ALMA", color=color.yellow, linewidth=2)