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
@@ -0,0 +1,161 @@
using TradingPlatform.BusinessLayer;
namespace QuanTAlib.Tests;
public class BesselIndicatorTests
{
[Fact]
public void BesselIndicator_Constructor_SetsDefaults()
{
var indicator = new BesselIndicator();
Assert.Equal(14, indicator.Length);
Assert.Equal(SourceType.Close, indicator.Source);
Assert.True(indicator.ShowColdValues);
Assert.Equal("BESSEL - Bessel Filter", indicator.Name);
Assert.False(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void BesselIndicator_MinHistoryDepths_EqualsLength()
{
var indicator = new BesselIndicator { Length = 20 };
Assert.Equal(0, BesselIndicator.MinHistoryDepths);
Assert.Equal(0, ((IWatchlistIndicator)indicator).MinHistoryDepths);
}
[Fact]
public void BesselIndicator_ShortName_IncludesLengthAndSource()
{
var indicator = new BesselIndicator { Length = 15 };
Assert.Contains("BESSEL", indicator.ShortName, StringComparison.Ordinal);
Assert.Contains("15", indicator.ShortName, StringComparison.Ordinal);
}
[Fact]
public void BesselIndicator_Initialize_CreatesInternalFilter()
{
var indicator = new BesselIndicator { Length = 14 };
indicator.Initialize();
Assert.Single(indicator.LinesSeries);
}
[Fact]
public void BesselIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new BesselIndicator { Length = 3 };
indicator.Initialize();
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
Assert.Equal(1, indicator.LinesSeries[0].Count);
Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0)));
}
[Fact]
public void BesselIndicator_ProcessUpdate_NewBar_ComputesValue()
{
var indicator = new BesselIndicator { Length = 3 };
indicator.Initialize();
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
indicator.HistoricalData.AddBar(now.AddMinutes(1), 102, 108, 100, 106);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
Assert.Equal(2, indicator.LinesSeries[0].Count);
}
[Fact]
public void BesselIndicator_ProcessUpdate_NewTick_ProcessesWithoutError()
{
var indicator = new BesselIndicator { Length = 3 };
indicator.Initialize();
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
double firstValue = indicator.LinesSeries[0].GetValue(0);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick));
double secondValue = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(firstValue));
Assert.True(double.IsFinite(secondValue));
}
[Fact]
public void BesselIndicator_MultipleUpdates_ProducesSmoothedSequence()
{
var indicator = new BesselIndicator { Length = 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);
}
for (int i = 0; i < closes.Length; i++)
{
Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(closes.Length - 1 - i)));
}
double lastValue = indicator.LinesSeries[0].GetValue(0);
Assert.True(lastValue >= 90 && lastValue <= 120);
}
[Fact]
public void BesselIndicator_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 BesselIndicator { Length = 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 BesselIndicator_Length_CanBeChanged()
{
var indicator = new BesselIndicator { Length = 5 };
Assert.Equal(5, indicator.Length);
indicator.Length = 20;
Assert.Equal(20, indicator.Length);
Assert.Equal(0, BesselIndicator.MinHistoryDepths);
}
}
+57
View File
@@ -0,0 +1,57 @@
using System.Drawing;
using System.Runtime.CompilerServices;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
[SkipLocalsInit]
public class BesselIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Length", sortIndex: 1, 1, 1000, 1, 0)]
public int Length { get; set; } = 14;
[IndicatorExtensions.DataSourceInput]
public SourceType Source { get; set; } = SourceType.Close;
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private Bessel _filter = 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 => $"BESSEL {Length}:{SourceName}";
public BesselIndicator()
{
OnBackGround = true;
SeparateWindow = false;
SourceName = Source.ToString();
Name = "BESSEL - Bessel Filter";
Description = "2nd-order Bessel low-pass filter with maximally flat group delay";
Series = new LineSeries(name: $"BESSEL {Length}", color: IndicatorExtensions.Averages, width: 2, style: LineStyle.Solid);
AddLineSeries(Series);
}
protected override void OnInit()
{
_filter = new Bessel(Length);
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 = _filter.Update(new TValue(item.TimeLeft.Ticks, _priceSelector(item)), isNew: args.IsNewBar());
Series.SetValue(result.Value, _filter.IsHot, ShowColdValues);
}
}
+304
View File
@@ -0,0 +1,304 @@
namespace QuanTAlib.Tests;
#pragma warning disable S2245 // Random is acceptable for simulation/testing purposes
public class BesselTests
{
[Fact]
public void Bessel_Constructor_Length_ValidatesInput()
{
var ex0 = Assert.Throws<ArgumentException>(() => new Bessel(0));
Assert.Equal("length", ex0.ParamName);
var exNeg = Assert.Throws<ArgumentException>(() => new Bessel(-1));
Assert.Equal("length", exNeg.ParamName);
var ex1 = Assert.Throws<ArgumentException>(() => new Bessel(1));
Assert.Equal("length", ex1.ParamName);
var bessel = new Bessel(2);
Assert.NotNull(bessel);
var bessel14 = new Bessel(14);
Assert.NotNull(bessel14);
}
[Fact]
public void Bessel_SpanCalculate_ValidatesLength()
{
double[] source = [1, 2, 3, 4, 5];
double[] output = new double[5];
var exLength = Assert.Throws<ArgumentException>(() =>
Bessel.Calculate(source.AsSpan(), output.AsSpan(), 1));
Assert.Equal("length", exLength.ParamName);
var exLengthZero = Assert.Throws<ArgumentException>(() =>
Bessel.Calculate(source.AsSpan(), output.AsSpan(), 0));
Assert.Equal("length", exLengthZero.ParamName);
}
[Fact]
public void Bessel_SpanCalculate_ValidatesBufferLength()
{
double[] source = [1, 2, 3, 4, 5];
double[] wrongSizeOutput = new double[3];
var ex = Assert.Throws<ArgumentException>(() =>
Bessel.Calculate(source.AsSpan(), wrongSizeOutput.AsSpan(), 14));
Assert.Equal("output", ex.ParamName);
}
[Fact]
public void Bessel_Calc_ReturnsValue()
{
var bessel = new Bessel(14);
Assert.Equal(0, bessel.Last.Value);
TValue result = bessel.Update(new TValue(DateTime.UtcNow, 100));
Assert.True(double.IsFinite(result.Value));
Assert.Equal(result.Value, bessel.Last.Value);
}
[Fact]
public void Bessel_Calc_IsNew_AcceptsParameter()
{
var bessel = new Bessel(14);
bessel.Update(new TValue(DateTime.UtcNow, 100), isNew: true);
double value1 = bessel.Last.Value;
bessel.Update(new TValue(DateTime.UtcNow, 105), isNew: true);
double value2 = bessel.Last.Value;
// Values should change with new bars
Assert.NotEqual(value1, value2);
}
[Fact]
public void Bessel_Calc_IsNew_False_UpdatesValue()
{
var bessel = new Bessel(14);
bessel.Update(new TValue(DateTime.UtcNow, 100));
bessel.Update(new TValue(DateTime.UtcNow, 110), isNew: true);
double beforeUpdate = bessel.Last.Value;
bessel.Update(new TValue(DateTime.UtcNow, 120), isNew: false);
double afterUpdate = bessel.Last.Value;
// Update should change the value
Assert.NotEqual(beforeUpdate, afterUpdate);
}
[Fact]
public void Bessel_Reset_ClearsState()
{
var bessel = new Bessel(14);
bessel.Update(new TValue(DateTime.UtcNow, 100));
bessel.Update(new TValue(DateTime.UtcNow, 105));
double valueBefore = bessel.Last.Value;
bessel.Reset();
Assert.Equal(0, bessel.Last.Value);
// After reset, should accept new values
bessel.Update(new TValue(DateTime.UtcNow, 50));
Assert.NotEqual(0, bessel.Last.Value);
Assert.NotEqual(valueBefore, bessel.Last.Value);
}
[Fact]
public void Bessel_Properties_Accessible()
{
var bessel = new Bessel(14);
Assert.Equal(0, bessel.Last.Value);
Assert.False(bessel.IsHot);
bessel.Update(new TValue(DateTime.UtcNow, 100));
Assert.NotEqual(0, bessel.Last.Value);
}
[Fact]
public void Bessel_IsHot_BecomesTrueAfterWarmup()
{
const int length = 14;
var bessel = new Bessel(length);
// Initially IsHot should be false
Assert.False(bessel.IsHot);
int steps = 0;
while (!bessel.IsHot && steps < 1000)
{
bessel.Update(new TValue(DateTime.UtcNow, 100));
steps++;
}
Assert.True(bessel.IsHot);
Assert.True(steps > 0);
Assert.Equal(length, steps); // WarmupPeriod is length
}
[Fact]
public void Bessel_IterativeCorrections_RestoreToOriginalState()
{
var bessel = new Bessel(14);
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1);
// Feed 14 new values
TValue lastInput = default;
for (int i = 0; i < 14; i++)
{
var bar = gbm.Next(isNew: true);
lastInput = new TValue(bar.Time, bar.Close);
bessel.Update(lastInput, isNew: true);
}
double valueAfterWarmup = bessel.Last.Value;
// Generate corrections with isNew=false (different values)
for (int i = 0; i < 13; i++)
{
var bar = gbm.Next(isNew: false);
bessel.Update(new TValue(bar.Time, bar.Close), isNew: false);
}
// Feed the remembered last input again with isNew=false
TValue finalValue = bessel.Update(lastInput, isNew: false);
Assert.Equal(valueAfterWarmup, finalValue.Value, 1e-10);
}
[Fact]
public void Bessel_BatchCalc_MatchesIterativeCalc()
{
var besselIterative = new Bessel(14);
var besselBatch = new Bessel(14);
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1);
var series = new TSeries();
for (int i = 0; i < 100; i++)
{
var bar = gbm.Next(isNew: true);
series.Add(bar.Time, bar.Close);
}
Assert.True(series.Count > 0);
var iterativeResults = new TSeries();
foreach (var item in series)
{
iterativeResults.Add(besselIterative.Update(item));
}
var batchResults = besselBatch.Update(series);
Assert.Equal(iterativeResults.Count, batchResults.Count);
for (int i = 0; i < iterativeResults.Count; i++)
{
Assert.Equal(iterativeResults[i].Value, batchResults[i].Value, 1e-10);
Assert.Equal(iterativeResults[i].Time, batchResults[i].Time);
}
}
[Fact]
public void Bessel_NaN_Input_UsesLastValidValue()
{
var bessel = new Bessel(14);
bessel.Update(new TValue(DateTime.UtcNow, 100));
bessel.Update(new TValue(DateTime.UtcNow, 110));
var resultAfterNaN = bessel.Update(new TValue(DateTime.UtcNow, double.NaN));
Assert.True(double.IsFinite(resultAfterNaN.Value));
Assert.NotEqual(0, resultAfterNaN.Value);
}
[Fact]
public void Bessel_Infinity_Input_UsesLastValidValue()
{
var bessel = new Bessel(14);
bessel.Update(new TValue(DateTime.UtcNow, 100));
bessel.Update(new TValue(DateTime.UtcNow, 110));
var resultAfterPosInf = bessel.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity));
Assert.True(double.IsFinite(resultAfterPosInf.Value));
var resultAfterNegInf = bessel.Update(new TValue(DateTime.UtcNow, double.NegativeInfinity));
Assert.True(double.IsFinite(resultAfterNegInf.Value));
}
[Fact]
public void Bessel_SpanBatch_MatchesTSeriesBatch()
{
var series = new TSeries();
double[] source = new double[100];
double[] output = new double[100];
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42);
for (int i = 0; i < 100; i++)
{
var bar = gbm.Next(isNew: true);
source[i] = bar.Close;
series.Add(bar.Time, bar.Close);
}
var tseriesResult = Bessel.Calculate(series, 14).Results;
Bessel.Calculate(source.AsSpan(), output.AsSpan(), 14);
for (int i = 0; i < 100; i++)
{
Assert.Equal(tseriesResult[i].Value, output[i], 1e-9);
}
}
[Fact]
public void Bessel_AllModes_ProduceSameResult()
{
int length = 14;
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 = Bessel.Calculate(series, length).Results;
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];
Bessel.Calculate(spanInput, spanOutput, length);
double spanResult = spanOutput[^1];
// 3. Streaming Mode
var streamingInd = new Bessel(length);
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 Bessel(pubSource, length);
for (int i = 0; i < series.Count; i++)
{
pubSource.Add(series[i]);
}
double eventingResult = eventingInd.Last.Value;
Assert.Equal(expected, spanResult, precision: 9);
Assert.Equal(expected, streamingResult, precision: 9);
Assert.Equal(expected, eventingResult, precision: 9);
}
}
@@ -0,0 +1,58 @@
using Xunit.Abstractions;
namespace QuanTAlib.Tests;
public sealed class BesselValidationTests : IDisposable
{
private readonly ValidationTestData _testData;
private readonly ITestOutputHelper _output;
private bool _disposed;
public BesselValidationTests(ITestOutputHelper output)
{
_output = output;
_testData = new ValidationTestData();
}
public void Dispose()
{
Dispose(true);
}
private void Dispose(bool disposing)
{
if (_disposed)
{
return;
}
_disposed = true;
if (disposing)
{
_testData?.Dispose();
}
}
[Fact]
public void Validate_Internal_Span_Against_TSeries()
{
int[] lengths = { 5, 14, 20, 50 };
foreach (int length in lengths)
{
// QuanTAlib Bessel via TSeries API
var (qResult, _) = Bessel.Calculate(_testData.Data, length);
// Same data via Span API
var src = _testData.Data.Values.ToArray();
var outSpan = new double[src.Length];
Bessel.Calculate(src.AsSpan(), outSpan.AsSpan(), length);
// Verify last window for convergence and consistency
ValidationHelper.VerifyData(qResult, outSpan, lookback: 0, skip: length, tolerance: ValidationHelper.DefaultTolerance);
}
_output.WriteLine("Bessel validated internally: Span vs TSeries are consistent.");
}
}
+536
View File
@@ -0,0 +1,536 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// BESSEL: 2nd-order Bessel Low-pass Filter with maximally flat group delay.
/// </summary>
/// <remarks>
/// <para>
/// The Bessel filter is a 2nd-order IIR low-pass filter designed to preserve signal shape
/// and timing. Unlike sharper filters (Butterworth, Chebyshev) that prioritize steep roll-off,
/// the Bessel family is engineered for <b>maximally flat group delay</b>: signals are delayed
/// uniformly across frequencies, preserving waveform integrity without overshoot or ringing.
/// </para>
///
/// <para><b>Coefficient Derivation (for cutoff length L):</b></para>
/// <code>
/// a = exp(-π / L)
/// b = 2 · a · cos(1.738 · π / L) // 1.738 ≈ √3 for 2nd-order Bessel characteristics
/// c₂ = b
/// c₃ = -a²
/// c₁ = 1 - c₂ - c₃
/// </code>
///
/// <para><b>Recursive IIR Form:</b></para>
/// <code>
/// F[n] = c₁ · Src[n] + c₂ · F[n-1] + c₃ · F[n-2]
/// </code>
///
/// <para><b>Complexity:</b></para>
/// <list type="bullet">
/// <item><description>Time: O(1) per update - constant 3 multiplications + 2 additions</description></item>
/// <item><description>Space: O(1) - only 2 previous filter values stored</description></item>
/// <item><description>SIMD: Not applicable due to IIR recursive data dependency</description></item>
/// </list>
///
/// <para><b>Numerical Considerations:</b></para>
/// <list type="bullet">
/// <item><description>Uses <see cref="Math.FusedMultiplyAdd"/> for improved precision and potential performance</description></item>
/// <item><description>NaN/Infinity inputs are substituted with last valid value to prevent state corruption</description></item>
/// <item><description>Minimum length of 2 required for 2nd-order filter numerical stability</description></item>
/// </list>
///
/// <para><b>Sources:</b></para>
/// <list type="bullet">
/// <item><description>John Ehlers - "Cybernetic Analysis for Stocks and Futures"</description></item>
/// <item><description>Friedrich Bessel - Bessel polynomials and filter theory</description></item>
/// </list>
/// </remarks>
[SkipLocalsInit]
public sealed class Bessel : AbstractBase
{
/// <summary>
/// Internal state for the Bessel filter, stored as a value type for performance.
/// </summary>
/// <remarks>
/// Uses <see cref="LayoutKind.Auto"/> for optimal memory layout.
/// Record struct provides value semantics for safe state rollback on bar corrections.
/// </remarks>
[StructLayout(LayoutKind.Auto)]
private record struct State(double F1, double F2, double LastValidValue, int Count, bool IsHot)
{
/// <summary>Creates a new default state instance.</summary>
public static State New() => new()
{
F1 = 0,
F2 = 0,
LastValidValue = 0,
Count = 0,
IsHot = false,
};
}
/// <summary>Filter coefficient for current input: c₁ = 1 - c₂ - c₃.</summary>
private readonly double _c1;
/// <summary>Filter coefficient for F[n-1]: c₂ = 2a·cos(1.738π/L).</summary>
private readonly double _c2;
/// <summary>Filter coefficient for F[n-2]: c₃ = -a².</summary>
private readonly double _c3;
private readonly ITValuePublisher? _publisher;
private readonly TValuePublishedHandler? _handler;
private State _state = State.New();
private State _p_state = State.New();
/// <summary>
/// Initializes a new Bessel filter with the specified cutoff length.
/// </summary>
/// <param name="length">
/// Cutoff period in bars. Larger values produce smoother output with more lag.
/// Must be at least 2 for 2nd-order filter numerical stability.
/// </param>
/// <exception cref="ArgumentException">Thrown when <paramref name="length"/> is less than 2.</exception>
/// <remarks>
/// Coefficient computation: O(1) - performed once at construction using exp/cos.
/// </remarks>
public Bessel(int length)
{
if (length < 2)
throw new ArgumentException("Length must be at least 2 for 2nd-order Bessel filter", nameof(length));
double a = Math.Exp(-Math.PI / length);
double b = 2.0 * a * Math.Cos(1.738 * Math.PI / length);
_c2 = b;
_c3 = -a * a;
_c1 = 1.0 - _c2 - _c3;
Name = $"Bessel({length})";
WarmupPeriod = length;
}
/// <summary>
/// Initializes a Bessel filter subscribed to a source publisher for reactive updates.
/// </summary>
/// <param name="source">The data source to subscribe to. Updates are received via the Pub event.</param>
/// <param name="length">Cutoff period in bars (must be >= 2).</param>
/// <exception cref="ArgumentException">Thrown when <paramref name="length"/> is less than 2.</exception>
/// <remarks>
/// The filter subscribes directly to the source's Pub event for zero-copy reactive updates.
/// Call <see cref="Dispose"/> to unsubscribe when the filter is no longer needed.
/// </remarks>
public Bessel(ITValuePublisher source, int length) : this(length)
{
_publisher = source;
_handler = Handle;
source.Pub += _handler;
}
/// <summary>
/// Initializes a Bessel filter pre-primed with historical data and subscribed for future updates.
/// </summary>
/// <param name="source">
/// The TSeries containing historical data for priming and future updates.
/// All existing values are processed immediately via <see cref="Prime"/>.
/// </param>
/// <param name="length">Cutoff period in bars (must be >= 2).</param>
/// <exception cref="ArgumentException">Thrown when <paramref name="length"/> is less than 2.</exception>
/// <remarks>
/// <para>Complexity: O(n) for initial priming where n = source.Count, then O(1) per update.</para>
/// <para>After construction, the filter is ready to produce valid output if source.Count >= WarmupPeriod.</para>
/// </remarks>
public Bessel(TSeries source, int length) : this(length)
{
Prime(source.Values);
if (source.Count > 0)
{
Last = new TValue(source.LastTime, Last.Value);
}
_publisher = source;
_handler = Handle;
source.Pub += _handler;
}
/// <inheritdoc />
public override bool IsHot => _state.IsHot;
/// <summary>
/// Initializes the filter state using historical data without producing output.
/// </summary>
/// <param name="source">Historical values to process for state initialization.</param>
/// <param name="step">Time interval between values (unused for Bessel, included for API compatibility).</param>
/// <remarks>
/// <para><b>Complexity:</b> O(n) where n = source.Length</para>
/// <para>After priming, the filter's <see cref="IsHot"/> property reflects whether enough data was provided.</para>
/// <para>NaN values in source are handled via last-valid-value substitution.</para>
/// </remarks>
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
{
if (source.Length == 0)
return;
Reset();
int len = source.Length;
int i = 0;
// Find first valid value
for (int k = 0; k < len; k++)
{
if (double.IsFinite(source[k]))
{
_state.LastValidValue = source[k];
_state.F1 = _state.LastValidValue;
_state.F2 = _state.LastValidValue;
_state.Count = 1;
i = k + 1;
break;
}
}
// Handle case where all inputs are NaN
if (_state.Count == 0)
{
_state.LastValidValue = double.NaN;
_state.F1 = double.NaN;
_state.F2 = double.NaN;
_state.IsHot = false;
Last = new TValue(DateTime.MinValue, double.NaN);
_p_state = _state;
return;
}
// Warmup phase: pass-through until enough history (Count >= 2)
for (; i < len && _state.Count < 2; i++)
{
double val = source[i];
if (double.IsFinite(val))
_state.LastValidValue = val;
else
val = _state.LastValidValue;
_state.F2 = _state.F1;
_state.F1 = val;
_state.Count++;
}
// Hot phase: main filtering loop (no warmup check)
for (; i < len; i++)
{
double val = source[i];
if (double.IsFinite(val))
_state.LastValidValue = val;
else
val = _state.LastValidValue;
double filt = Math.FusedMultiplyAdd(_c3, _state.F2,
Math.FusedMultiplyAdd(_c2, _state.F1, _c1 * val));
_state.F2 = _state.F1;
_state.F1 = filt;
_state.Count++;
}
if (_state.Count >= WarmupPeriod)
_state.IsHot = true;
Last = new TValue(DateTime.MinValue, _state.F1);
_p_state = _state;
}
/// <summary>
/// Returns a finite value for calculation, substituting last valid value for NaN/Infinity.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private double GetValidValue(double input)
{
if (double.IsFinite(input))
{
_state.LastValidValue = input;
return input;
}
return _state.LastValidValue;
}
/// <summary>
/// Updates the filter with a single input value.
/// </summary>
/// <param name="input">The input value containing timestamp and price data.</param>
/// <param name="isNew">
/// True if this is a new bar (advances state), False if updating current bar (rolls back then recomputes).
/// </param>
/// <returns>The filtered output value with the same timestamp as input.</returns>
/// <remarks>
/// <para><b>Complexity:</b> O(1) - constant time regardless of filter length or history.</para>
/// <para><b>Operations:</b> 3 multiplications + 2 additions using FMA for precision.</para>
/// <para><b>Allocations:</b> Zero heap allocations on hot path.</para>
/// <para>
/// Bar correction: When <paramref name="isNew"/> is false, the filter rolls back to the
/// previous state before applying the update, enabling intra-bar recalculation.
/// </para>
/// </remarks>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override TValue Update(TValue input, bool isNew = true)
{
if (isNew)
{
_p_state = _state;
}
else
{
_state = _p_state;
}
double val = GetValidValue(input.Value);
if (_state.Count == 0)
{
_state.F1 = val;
_state.F2 = val;
}
// 2nd-order filter needs 2 history points (Count >= 2)
double filt = _state.Count < 2
? val
: Math.FusedMultiplyAdd(_c3, _state.F2,
Math.FusedMultiplyAdd(_c2, _state.F1, _c1 * val));
_state.F2 = _state.F1;
_state.F1 = filt;
if (isNew)
{
_state.Count++;
}
if (!_state.IsHot && _state.Count >= WarmupPeriod)
_state.IsHot = true;
Last = new TValue(input.Time, filt);
PubEvent(Last);
return Last;
}
/// <summary>
/// Processes an entire time series and returns filtered results.
/// </summary>
/// <param name="source">The input time series to filter.</param>
/// <returns>A new TSeries containing filtered values with preserved timestamps.</returns>
/// <remarks>
/// <para><b>Complexity:</b> O(n) where n = source.Count</para>
/// <para>Uses optimized span-based batch processing internally.</para>
/// <para>Updates internal state to match the end of the processed series.</para>
/// </remarks>
public override TSeries Update(TSeries source)
{
if (source.Count == 0)
return [];
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);
var sourceValues = source.Values;
var sourceTimes = source.Times;
State state = _state;
CalculateCore(sourceValues, vSpan, _c1, _c2, _c3, WarmupPeriod, ref state);
_state = state;
sourceTimes.CopyTo(tSpan);
_p_state = _state;
Last = new TValue(tSpan[len - 1], vSpan[len - 1]);
return new TSeries(t, v);
}
/// <summary>
/// Core calculation loop shared by all batch processing methods.
/// </summary>
/// <remarks>
/// <para><b>Complexity:</b> O(n) where n = source.Length</para>
/// <para>Handles warmup, NaN substitution, and state management in a single pass.</para>
/// <para>Uses FMA for the IIR calculation: F = c1*val + c2*F1 + c3*F2</para>
/// </remarks>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void CalculateCore(
ReadOnlySpan<double> source,
Span<double> output,
double c1,
double c2,
double c3,
int warmupPeriod,
ref State state)
{
int len = source.Length;
int i = 0;
// If starting from scratch (count == 0), find first valid value
if (state.Count == 0)
{
for (; i < len; i++)
{
if (double.IsFinite(source[i]))
{
state.LastValidValue = source[i];
state.F1 = state.LastValidValue;
state.F2 = state.LastValidValue;
output[i] = state.LastValidValue;
state.Count = 1;
i++;
break;
}
output[i] = double.NaN;
}
}
// Warmup phase: pass-through until enough history (Count >= 2)
for (; i < len && state.Count < 2; i++)
{
double val = source[i];
if (double.IsFinite(val))
state.LastValidValue = val;
else
val = state.LastValidValue;
state.F2 = state.F1;
state.F1 = val;
output[i] = val;
state.Count++;
}
// Hot phase: main filtering loop (no warmup check)
for (; i < len; i++)
{
double val = source[i];
if (double.IsFinite(val))
state.LastValidValue = val;
else
val = state.LastValidValue;
double filt = Math.FusedMultiplyAdd(c3, state.F2,
Math.FusedMultiplyAdd(c2, state.F1, c1 * val));
state.F2 = state.F1;
state.F1 = filt;
output[i] = filt;
state.Count++;
}
if (!state.IsHot && state.Count >= warmupPeriod)
state.IsHot = true;
}
/// <summary>
/// Calculates filtered values for a time series and returns both results and a primed indicator.
/// </summary>
/// <param name="source">The input time series to filter.</param>
/// <param name="length">Cutoff period in bars (must be >= 2).</param>
/// <returns>
/// A tuple containing:
/// <list type="bullet">
/// <item><description>Results: TSeries with filtered values</description></item>
/// <item><description>Indicator: A primed Bessel instance ready for streaming updates</description></item>
/// </list>
/// </returns>
/// <exception cref="ArgumentException">Thrown when <paramref name="length"/> is less than 2.</exception>
/// <remarks>
/// <para><b>Complexity:</b> O(n) where n = source.Count</para>
/// <para>The returned indicator maintains state and can continue processing new values.</para>
/// </remarks>
public static (TSeries Results, Bessel Indicator) Calculate(TSeries source, int length)
{
var bessel = new Bessel(length);
TSeries results = bessel.Update(source);
return (results, bessel);
}
/// <summary>
/// Calculates filtered values for a span of doubles (stateless batch processing).
/// </summary>
/// <param name="source">Input values to filter.</param>
/// <param name="output">Output span to write filtered values (must be same length as source).</param>
/// <param name="length">Cutoff period in bars (must be >= 2).</param>
/// <exception cref="ArgumentException">
/// Thrown when <paramref name="length"/> is less than 2 or when source and output lengths differ.
/// </exception>
/// <remarks>
/// <para><b>Complexity:</b> O(n) where n = source.Length</para>
/// <para><b>Allocations:</b> Zero heap allocations (state is stack-allocated).</para>
/// <para>This is the highest-performance API for batch processing without state persistence.</para>
/// <para>SIMD optimization is not applicable due to IIR recursive data dependency.</para>
/// </remarks>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Calculate(ReadOnlySpan<double> source, Span<double> output, int length)
{
if (length < 2)
throw new ArgumentException("Length must be at least 2 for 2nd-order Bessel filter", nameof(length));
if (source.Length != output.Length)
throw new ArgumentException("Source and output must have the same length", nameof(output));
if (source.Length == 0)
return;
double a = Math.Exp(-Math.PI / length);
double b = 2.0 * a * Math.Cos(1.738 * Math.PI / length);
double c2 = b;
double c3 = -a * a;
double c1 = 1.0 - c2 - c3;
var state = State.New();
CalculateCore(source, output, c1, c2, c3, length, ref state);
}
/// <summary>
/// Event handler for reactive updates from subscribed publishers.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void Handle(object? sender, in TValueEventArgs e) => Update(e.Value, e.IsNew);
/// <summary>
/// Resets the filter to its initial state, clearing all history.
/// </summary>
/// <remarks>
/// After reset, <see cref="IsHot"/> will be false and the filter will need to
/// reaccumulate warmup data before producing valid filtered output.
/// </remarks>
public override void Reset()
{
_state = State.New();
_p_state = _state;
Last = default;
}
/// <summary>
/// Unsubscribes from the source publisher if one was provided during construction.
/// </summary>
/// <remarks>
/// Call this method when the filter is no longer needed to prevent memory leaks
/// from dangling event subscriptions. Safe to call multiple times.
/// </remarks>
protected override void Dispose(bool disposing)
{
if (disposing && _publisher != null && _handler != null)
{
_publisher.Pub -= _handler;
}
base.Dispose(disposing);
}
}
+119
View File
@@ -0,0 +1,119 @@
# BESSEL: Bessel Filter
> When you care more about *when* the market turns than how aggressively you can torture the noise, you reach for a Bessel.
The Bessel Filter is a 2nd-order low-pass IIR filter designed to preserve the **shape** and **timing** of price moves. Unlike sharper filters that chase steep roll-off at the expense of phase distortion, the Bessel family is engineered for a **maximally flat group delay**: signals are delayed, but not deformed.
This implementation follows John Ehlersstyle adaptations for financial time series and is tuned for O(1) updates and zero heap allocations in QuanTAlib.
## The Standard
Originally derived from Friedrich Bessels work on Bessel polynomials and later adapted to signal processing, the Bessel filter became popular where **waveform integrity** matters more than raw attenuation: control systems, audio, and here, price series.
In trading terms:
* You keep the **relative timing** of swings.
* You avoid overshoot and ringing common in sharper filters.
* You accept a gentler roll-off as the price of cleaner turning points.
QuanTAlib implements the **2nd-order low-pass** variant used in Ehlers-style digital filters.
## Architecture & Physics
BESSEL is implemented as a **2nd-order IIR filter** with a fixed structure:
* State: last two filtered values plus last valid input
* Behavior:
* Short warmup period (a few bars)
* Stable, monotonic smoothing
* Minimal overshoot on sharp transitions
Conceptually:
* High frequencies are attenuated gradually.
* Phase is nearly linear in the passband, so local structures (peaks, troughs, breakout steps) keep their relative timing.
* It runs as an **O(1)** streaming update:
* One input in, one output out, constant work per bar.
### Specific Architectural Challenge
The main tension is:
* The design demands **IIR smoothness** and responsiveness.
* Recursive instability or phase warping in turning zones cannot be tolerated.
BESSEL solves this by:
* Fixing a 2nd-order topology with coefficients derived from the Bessel prototype.
* Using a **safe minimum length** (at least 2) to keep coefficients in a numerically stable region.
* Treating non-finite values via a last-valid-value cache so NaNs and infinities never poison the state.
## Mathematical Foundation
Let $L$ be the user-specified length (cutoff period). Internally it is clamped as
$$ L_{\text{safe}} = \max(L, 2) $$
The coefficients are:
$$ a = e^{-\pi / L_{\text{safe}}} $$
$$ b = 2 a \cos\!\left(1.738 \frac{\pi}{L_{\text{safe}}}\right) $$
$$ c_2 = b $$
$$ c_3 = -a^2 $$
$$ c_1 = 1 - c_2 - c_3 $$
The constant $1.738 \approx \sqrt{3}$ is chosen to match the 2nd-order Bessel group-delay characteristics.
For an input price series $s[n]$, the recursive filter is
$$ \text{BESSEL}[n] = c_1 s[n] + c_2\, \text{BESSEL}[n-1] + c_3\, \text{BESSEL}[n-2] $$
with initialization:
* For the first few bars, the filter output is seeded directly from the price (no recursion) to avoid transient garbage.
### NaN and Infinity Handling
For robustness:
* Maintain a `LastValidValue` cache $v_{\text{last}}$.
* For each input $x$:
* If $x$ is finite, set $v_{\text{last}} = x$.
* If $x$ is `NaN` or infinite, use $x \leftarrow v_{\text{last}}$.
* The recursive update always runs on a finite input.
## Performance Profile
BESSEL is designed for **zero allocations** on the hot path and efficient batch processing for analysis and backtests.
| Metric | Score | Notes |
| :--- | :--- | :--- |
| **Throughput** | ★★★★★ | O(1) streaming update. |
| **Allocations** | ★★★★★ | 0 bytes; hot path is allocation-free. |
| **Complexity** | ★★★★★ | Constant time per update. |
| **Precision** | ★★★★★ | `double` precision critical for recursive stability. |
### Zero-Allocation Design
The filter maintains its state in a small set of scalar variables (`_prev1`, `_prev2`, `_lastValidValue`). No arrays or buffers are allocated during the `Update` cycle.
## Validation
Validation focuses on internal consistency between streaming, TSeries, and Span APIs.
| Library | Status | Notes |
| :--- | :--- | :--- |
| **QuanTAlib** | ✅ | Internal consistency verified (Span vs TSeries). |
| **TA-Lib** | ❌ | Not implemented. |
| **Skender** | ❌ | Not implemented. |
| **Tulip** | ❌ | Not implemented. |
| **Ooples** | ❌ | Not implemented. |
### Common Pitfalls
* **Expecting razor-sharp cutoff:** Bessel is **not** a Chebyshev or elliptic filter. Roll-off is gentler by design to preserve shape and timing. If you want violent attenuation of high-frequency noise, pick Butterworth, Chebyshev, or a band-pass.
* **Over-smoothing with large length:** Very large $L$ values will still preserve shape, but you will delay turning points more than necessary. Typical sweet spot for daily data is $L \in [10, 30]$.
* **Misinterpreting flat response as “weak” filter:** The goal is not to crush all noise. The goal is to keep enough structure that pattern recognition, divergence analysis, and multi-stream alignment still make sense.
* **Ignoring NaN propagation:** If your upstream feed throws `NaN` or infinities and you do not clean it, BESSEL will fall back to the last valid value. This is intentional. If you want gaps instead, preprocess the series and pass explicit masked values.
Used correctly, BESSEL gives you a **shape-faithful trend line** with clean timing and low overshoot, ideal for traders who care more about *when* than *how loudly* the filter shouts.
+40
View File
@@ -0,0 +1,40 @@
// The MIT License (MIT)
// © mihakralj
//@version=6
indicator("Bessel 2nd Order Filter (BESSEL)", "BESSEL", overlay=true)
//@function Calculates 2nd Order Bessel Lowpass Filter
//@doc https://github.com/mihakralj/pinescript/blob/main/indicators/filters/bessel.md
//@param src Series to calculate Bessel filter from
//@param length Cutoff period (related to -3dB frequency)
//@returns Bessel filter value
//@optimized Uses IIR 2nd order filter with O(1) complexity per bar
bessel(series float src, simple int length) =>
float pi = math.pi
int safe_length = math.max(length, 2)
float a = math.exp(-pi / safe_length)
float b = 2.0 * a * math.cos(1.738 * pi / safe_length)
float c2 = b
float c3 = -a * a
float c1 = 1.0 - c2 - c3
var float filt = na
if bar_index < 2
filt := nz(src, 0.0)
else
float ssrc = nz(src, src[1])
float filt1 = nz(filt[1], ssrc)
float filt2 = nz(filt[2], filt1)
filt := c1 * ssrc + c2 * filt1 + c3 * filt2
filt
// ---------- Main loop ----------
// Inputs
i_length = input.int(20, "Length", minval=2)
i_source = input.source(close, "Source")
// Calculation
bessel_val = bessel(i_source, i_length)
// Plot
plot(bessel_val, "Bessel", color=color.yellow, linewidth=2)