mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-22 04:28:04 +00:00
Refactor documentation to remove "Zero-Allocation Design" sections across various trend indicators and implement a PowerShell script for automated cleanup
- Updated mathematical foundations and performance profiles where necessary to maintain clarity and coherence.
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
using Xunit;
|
||||
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(20, indicator.MinHistoryDepths);
|
||||
Assert.Equal(20, ((IWatchlistIndicator)indicator).MinHistoryDepths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BesselIndicator_ShortName_IncludesLengthAndSource()
|
||||
{
|
||||
var indicator = new BesselIndicator { Length = 15 };
|
||||
|
||||
Assert.Contains("BESSEL", indicator.ShortName);
|
||||
Assert.Contains("15", indicator.ShortName);
|
||||
}
|
||||
|
||||
[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(20, indicator.MinHistoryDepths);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Drawing;
|
||||
using TradingPlatform.BusinessLayer;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
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;
|
||||
protected LineSeries? Series;
|
||||
protected string? SourceName;
|
||||
private int _warmupBarIndex = -1;
|
||||
|
||||
public int MinHistoryDepths => Length;
|
||||
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(name: $"BESSEL {Length}", color: IndicatorExtensions.Averages, width: 2, style: LineStyle.Solid);
|
||||
AddLineSeries(Series);
|
||||
}
|
||||
|
||||
protected override void OnInit()
|
||||
{
|
||||
_filter = new Bessel(Length);
|
||||
SourceName = Source.ToString();
|
||||
_warmupBarIndex = -1;
|
||||
base.OnInit();
|
||||
}
|
||||
|
||||
protected override void OnUpdate(UpdateArgs args)
|
||||
{
|
||||
TValue input = this.GetInputValue(args, Source);
|
||||
bool isNew = args.Reason == UpdateReason.NewBar || args.Reason == UpdateReason.HistoricalBar;
|
||||
TValue result = _filter!.Update(input, isNew);
|
||||
|
||||
Series!.SetValue(result.Value);
|
||||
Series!.SetMarker(0, Color.Transparent);
|
||||
|
||||
if (_warmupBarIndex < 0 && _filter!.IsHot)
|
||||
_warmupBarIndex = Count;
|
||||
}
|
||||
|
||||
public override void OnPaintChart(PaintChartEventArgs args)
|
||||
{
|
||||
base.OnPaintChart(args);
|
||||
int warmupPeriod = _warmupBarIndex > 0 ? _warmupBarIndex : Count;
|
||||
this.PaintSmoothCurve(args, Series!, warmupPeriod, showColdValues: ShowColdValues, tension: 0.2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
#pragma warning disable S2245 // Random is acceptable for simulation/testing purposes
|
||||
public class BesselTests
|
||||
{
|
||||
[Fact]
|
||||
public void Bessel_Constructor_Length_ValidatesInput()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new Bessel(0));
|
||||
Assert.Throws<ArgumentException>(() => new Bessel(-1));
|
||||
|
||||
var bessel = new Bessel(14);
|
||||
Assert.NotNull(bessel);
|
||||
}
|
||||
|
||||
[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()
|
||||
{
|
||||
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,53 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public class BesselValidationTests : IDisposable
|
||||
{
|
||||
private readonly ValidationTestData _testData;
|
||||
private readonly ITestOutputHelper _output;
|
||||
|
||||
public BesselValidationTests(ITestOutputHelper output)
|
||||
{
|
||||
_output = output;
|
||||
_testData = new ValidationTestData();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
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: 1e-9);
|
||||
}
|
||||
|
||||
_output.WriteLine("Bessel validated internally: Span vs TSeries are consistent.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// BESSEL: 2nd-order Bessel Low-pass Filter
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Bessel filter is a 2nd-order IIR low-pass filter with maximally flat group delay,
|
||||
/// adapted from John Ehlers' work for financial time series.
|
||||
///
|
||||
/// Coefficients for a given length L:
|
||||
/// a = exp(-PI / L)
|
||||
/// b = 2 * a * cos(1.738 * PI / L)
|
||||
/// c2 = b
|
||||
/// c3 = -a * a
|
||||
/// c1 = 1 - c2 - c3
|
||||
///
|
||||
/// Recursive form:
|
||||
/// F[n] = c1 * Src[n] + c2 * F[n-1] + c3 * F[n-2]
|
||||
/// </remarks>
|
||||
[SkipLocalsInit]
|
||||
public sealed class Bessel : AbstractBase
|
||||
{
|
||||
private record struct State(double F1, double F2, double LastValidValue, int Count, bool IsHot)
|
||||
{
|
||||
public static State New() => new()
|
||||
{
|
||||
F1 = 0,
|
||||
F2 = 0,
|
||||
LastValidValue = 0,
|
||||
Count = 0,
|
||||
IsHot = false
|
||||
};
|
||||
}
|
||||
|
||||
private readonly double _c1, _c2, _c3;
|
||||
private State _state = State.New();
|
||||
private State _p_state = State.New();
|
||||
|
||||
/// <summary>
|
||||
/// Creates Bessel filter with specified length.
|
||||
/// </summary>
|
||||
/// <param name="length">Cutoff length (must be > 0, internally clamped to at least 2).</param>
|
||||
public Bessel(int length)
|
||||
{
|
||||
if (length <= 0)
|
||||
throw new ArgumentException("Length must be greater than 0", nameof(length));
|
||||
|
||||
int safeLength = Math.Max(length, 2);
|
||||
|
||||
double a = Math.Exp(-Math.PI / safeLength);
|
||||
double b = 2.0 * a * Math.Cos(1.738 * Math.PI / safeLength);
|
||||
_c2 = b;
|
||||
_c3 = -a * a;
|
||||
_c1 = 1.0 - _c2 - _c3;
|
||||
|
||||
Name = $"Bessel({length})";
|
||||
WarmupPeriod = length;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates Bessel filter subscribed to a source publisher.
|
||||
/// </summary>
|
||||
public Bessel(ITValuePublisher source, int length) : this(length)
|
||||
{
|
||||
source.Pub += item => Update(item);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates Bessel filter pre-primed with an existing TSeries and subscribed for future updates.
|
||||
/// </summary>
|
||||
public Bessel(TSeries source, int length) : this(length)
|
||||
{
|
||||
Prime(source.Values);
|
||||
if (source.Count > 0)
|
||||
{
|
||||
Last = new TValue(source.LastTime, Last.Value);
|
||||
}
|
||||
|
||||
source.Pub += item => Update(item);
|
||||
}
|
||||
|
||||
public override bool IsHot => _state.IsHot;
|
||||
|
||||
public override void Prime(ReadOnlySpan<double> source)
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
for (; i < len; i++)
|
||||
{
|
||||
double val = source[i];
|
||||
if (double.IsFinite(val))
|
||||
_state.LastValidValue = val;
|
||||
else
|
||||
val = _state.LastValidValue;
|
||||
|
||||
double filt = _state.Count < 3
|
||||
? val
|
||||
: (_c1 * val) + (_c2 * _state.F1) + (_c3 * _state.F2);
|
||||
|
||||
_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;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private double GetValidValue(double input)
|
||||
{
|
||||
if (double.IsFinite(input))
|
||||
{
|
||||
_state.LastValidValue = input;
|
||||
return input;
|
||||
}
|
||||
|
||||
return _state.LastValidValue;
|
||||
}
|
||||
|
||||
[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;
|
||||
}
|
||||
|
||||
double filt = _state.Count < 3
|
||||
? val
|
||||
: (_c1 * val) + (_c2 * _state.F1) + (_c3 * _state.F2);
|
||||
|
||||
_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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
[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;
|
||||
}
|
||||
}
|
||||
|
||||
for (; i < len; i++)
|
||||
{
|
||||
double val = source[i];
|
||||
if (double.IsFinite(val))
|
||||
state.LastValidValue = val;
|
||||
else
|
||||
val = state.LastValidValue;
|
||||
|
||||
double filt = state.Count < 3
|
||||
? val
|
||||
: (c1 * val) + (c2 * state.F1) + (c3 * state.F2);
|
||||
|
||||
state.F2 = state.F1;
|
||||
state.F1 = filt;
|
||||
output[i] = filt;
|
||||
state.Count++;
|
||||
}
|
||||
|
||||
if (!state.IsHot && state.Count >= warmupPeriod)
|
||||
state.IsHot = true;
|
||||
}
|
||||
|
||||
public static (TSeries Results, Bessel Indicator) Calculate(TSeries source, int length)
|
||||
{
|
||||
var bessel = new Bessel(length);
|
||||
TSeries results = bessel.Update(source);
|
||||
return (results, bessel);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static void Calculate(ReadOnlySpan<double> source, Span<double> output, int length)
|
||||
{
|
||||
if (length <= 0)
|
||||
throw new ArgumentException("Length must be greater than 0", nameof(length));
|
||||
|
||||
if (source.Length != output.Length)
|
||||
throw new ArgumentException("Source and output must have the same length");
|
||||
|
||||
if (source.Length == 0)
|
||||
return;
|
||||
|
||||
int safeLength = Math.Max(length, 2);
|
||||
|
||||
double a = Math.Exp(-Math.PI / safeLength);
|
||||
double b = 2.0 * a * Math.Cos(1.738 * Math.PI / safeLength);
|
||||
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);
|
||||
}
|
||||
|
||||
public override void Reset()
|
||||
{
|
||||
_state = State.New();
|
||||
_p_state = _state;
|
||||
Last = default;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
# 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 Ehlers–style adaptations for financial time series and is tuned for O(1) updates and zero heap allocations in QuanTAlib.
|
||||
|
||||
## The Standard
|
||||
|
||||
Originally derived from Friedrich Bessel’s 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:
|
||||
|
||||
$$
|
||||
\begin{aligned}
|
||||
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
|
||||
\end{aligned}
|
||||
$$
|
||||
|
||||
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.
|
||||
|
||||
## Usage
|
||||
|
||||
### Object API (streaming)
|
||||
|
||||
```csharp
|
||||
var bessel = new Bessel(length: 14);
|
||||
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
var value = new TValue(bar.Time, bar.Close);
|
||||
TValue result = bessel.Update(value, isNew: true);
|
||||
// use result.Value
|
||||
}
|
||||
```
|
||||
|
||||
### TSeries API (batch)
|
||||
|
||||
```csharp
|
||||
var (seriesOut, indicator) = Bessel.Calculate(inputSeries, length: 14);
|
||||
double last = seriesOut.Last.Value;
|
||||
```
|
||||
|
||||
### Span API (high-performance batch)
|
||||
|
||||
```csharp
|
||||
double[] src = /* prices */;
|
||||
double[] dst = new double[src.Length];
|
||||
|
||||
Bessel.Calculate(src.AsSpan(), dst.AsSpan(), length: 14);
|
||||
```
|
||||
|
||||
All three modes (streaming, `TSeries`, `Span`) are tested to produce numerically consistent results.
|
||||
|
||||
## Validation
|
||||
|
||||
Current validation focuses on **internal consistency**:
|
||||
|
||||
- `TSeries` vs Span API:
|
||||
- Same GBM-based dataset, multiple lengths (5, 14, 20, 50).
|
||||
- Last $N$ outputs compared with tolerance $10^{-9}$.
|
||||
- Warmup and hot-state behavior verified via unit tests:
|
||||
- `IsHot` flips after `Length` bars.
|
||||
- `isNew=true/false` behaves as expected for bar corrections.
|
||||
- Robustness:
|
||||
- Inputs with `NaN`, `+∞`, `-∞` are forced to last valid value.
|
||||
- Streaming and batch APIs remain finite and stable.
|
||||
|
||||
External library cross-checks can be added later (e.g. via Python or DSP toolkits) if you want independent frequency-domain confirmation; the internal tests already guarantee implementation consistency.
|
||||
|
||||
## 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.
|
||||
Reference in New Issue
Block a user