mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-23 21:18:04 +00:00
adding missing validations
This commit is contained in:
@@ -409,7 +409,9 @@ public sealed class Bessel : AbstractBase
|
||||
state.F2 = state.LastValidValue;
|
||||
output[i] = state.LastValidValue;
|
||||
state.Count = 1;
|
||||
#pragma warning disable S127 // Warmup init: advance past first valid to seed state machine
|
||||
i++;
|
||||
#pragma warning restore S127
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
using TradingPlatform.BusinessLayer;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public class SakIndicatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void SakIndicator_Constructor_SetsDefaults()
|
||||
{
|
||||
var indicator = new SakIndicator();
|
||||
|
||||
Assert.Equal("BP", indicator.FilterType);
|
||||
Assert.Equal(20, indicator.Period);
|
||||
Assert.Equal(10, indicator.N);
|
||||
Assert.Equal(0.1, indicator.Delta);
|
||||
Assert.Equal(SourceType.Close, indicator.Source);
|
||||
Assert.True(indicator.ShowColdValues);
|
||||
Assert.Equal("SAK - Swiss Army Knife Filter", indicator.Name);
|
||||
Assert.False(indicator.SeparateWindow);
|
||||
Assert.True(indicator.OnBackGround);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SakIndicator_MinHistoryDepths_IsZero()
|
||||
{
|
||||
var indicator = new SakIndicator { Period = 20 };
|
||||
|
||||
Assert.Equal(0, SakIndicator.MinHistoryDepths);
|
||||
Assert.Equal(0, ((IWatchlistIndicator)indicator).MinHistoryDepths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SakIndicator_ShortName_IncludesFilterTypeAndPeriod()
|
||||
{
|
||||
var indicator = new SakIndicator { FilterType = "EMA", Period = 15 };
|
||||
|
||||
Assert.Contains("SAK", indicator.ShortName, StringComparison.Ordinal);
|
||||
Assert.Contains("EMA", indicator.ShortName, StringComparison.Ordinal);
|
||||
Assert.Contains("15", indicator.ShortName, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SakIndicator_Initialize_CreatesInternalSak()
|
||||
{
|
||||
var indicator = new SakIndicator { Period = 10 };
|
||||
|
||||
// Initialize should not throw
|
||||
indicator.Initialize();
|
||||
|
||||
// After init, line series should exist
|
||||
Assert.Single(indicator.LinesSeries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SakIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
|
||||
{
|
||||
var indicator = new SakIndicator { FilterType = "EMA", Period = 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 SakIndicator_ProcessUpdate_NewBar_ComputesValue()
|
||||
{
|
||||
var indicator = new SakIndicator { FilterType = "EMA", Period = 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 SakIndicator_ProcessUpdate_NewTick_ProcessesWithoutError()
|
||||
{
|
||||
var indicator = new SakIndicator { FilterType = "EMA", Period = 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 SakIndicator_MultipleUpdates_ProducesCorrectSequence()
|
||||
{
|
||||
var indicator = new SakIndicator { FilterType = "EMA", 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);
|
||||
}
|
||||
|
||||
for (int i = 0; i < closes.Length; i++)
|
||||
{
|
||||
Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(closes.Length - 1 - i)));
|
||||
}
|
||||
|
||||
double lastSak = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.True(lastSak >= 99 && lastSak <= 111);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SakIndicator_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 SakIndicator { FilterType = "EMA", 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 SakIndicator_AllFilterTypes_InitializeAndCompute()
|
||||
{
|
||||
string[] filterTypes = { "EMA", "HP", "Smooth", "Gauss", "Butter", "2PHP", "BP", "BS", "SMA" };
|
||||
|
||||
foreach (var filterType in filterTypes)
|
||||
{
|
||||
var indicator = new SakIndicator { FilterType = filterType, Period = 5, N = 3 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
double[] closes = { 100, 101, 102, 103, 104, 105 };
|
||||
foreach (var close in closes)
|
||||
{
|
||||
indicator.HistoricalData.AddBar(now, close, close + 1, close - 1, close);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
now = now.AddMinutes(1);
|
||||
}
|
||||
|
||||
double lastVal = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.True(double.IsFinite(lastVal),
|
||||
$"FilterType {filterType} should produce finite value, got {lastVal}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SakIndicator_Period_CanBeChanged()
|
||||
{
|
||||
var indicator = new SakIndicator { Period = 5 };
|
||||
Assert.Equal(5, indicator.Period);
|
||||
|
||||
indicator.Period = 25;
|
||||
Assert.Equal(25, indicator.Period);
|
||||
Assert.Equal(0, SakIndicator.MinHistoryDepths);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Drawing;
|
||||
using System.Runtime.CompilerServices;
|
||||
using TradingPlatform.BusinessLayer;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
[SkipLocalsInit]
|
||||
public sealed class SakIndicator : Indicator, IWatchlistIndicator
|
||||
{
|
||||
[InputParameter("Filter Type", sortIndex: 0)]
|
||||
public string FilterType { get; set; } = "BP";
|
||||
|
||||
[InputParameter("Period", sortIndex: 1, 3, 9999, 1, 0)]
|
||||
public int Period { get; set; } = 20;
|
||||
|
||||
[InputParameter("N (order/length)", sortIndex: 2, 1, 9999, 1, 0)]
|
||||
public int N { get; set; } = 10;
|
||||
|
||||
[InputParameter("Delta (BP/BS bandwidth)", sortIndex: 3, 0.01, 1.0, 0.01, 2)]
|
||||
public double Delta { get; set; } = 0.1;
|
||||
|
||||
[IndicatorExtensions.DataSourceInput]
|
||||
public SourceType Source { get; set; } = SourceType.Close;
|
||||
|
||||
[InputParameter("Show cold values", sortIndex: 21)]
|
||||
public bool ShowColdValues { get; set; } = true;
|
||||
|
||||
private Sak _sak = null!;
|
||||
private readonly LineSeries _series;
|
||||
private string _sourceName = null!;
|
||||
private Func<IHistoryItem, double> _priceSelector = null!;
|
||||
|
||||
public static int MinHistoryDepths => 0;
|
||||
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
|
||||
|
||||
public override string ShortName => $"SAK {FilterType}:{Period}:{_sourceName}";
|
||||
|
||||
public SakIndicator()
|
||||
{
|
||||
OnBackGround = true;
|
||||
SeparateWindow = false;
|
||||
Name = "SAK - Swiss Army Knife Filter";
|
||||
Description = "Swiss Army Knife: 9-mode IIR/FIR filter (EMA, EHP, SMOOTH, GAUSS, BUTTER, 2PHP, BP, BS, SMA)";
|
||||
_series = new LineSeries(name: $"SAK {FilterType}:{Period}", color: IndicatorExtensions.Averages, width: 2, style: LineStyle.Solid);
|
||||
AddLineSeries(_series);
|
||||
}
|
||||
|
||||
protected override void OnInit()
|
||||
{
|
||||
_priceSelector = Source.GetPriceSelector();
|
||||
_sourceName = Source.ToString();
|
||||
_sak = new Sak(FilterType, Period, N, Delta);
|
||||
base.OnInit();
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
protected override void OnUpdate(UpdateArgs args)
|
||||
{
|
||||
bool isNew = args.IsNewBar();
|
||||
var item = HistoricalData[Count - 1, SeekOriginHistory.Begin];
|
||||
double value = _sak.Update(new TValue(item.TimeLeft.Ticks, _priceSelector(item)), isNew).Value;
|
||||
_series.SetValue(value, _sak.IsHot, ShowColdValues);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public sealed class SakTests
|
||||
{
|
||||
// ── A) Constructor validation ─────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Sak_Constructor_Period_NonSma_ThrowsIfTooSmall()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new Sak("EMA", period: 2));
|
||||
Assert.Equal("period", ex.ParamName);
|
||||
|
||||
var ex2 = Assert.Throws<ArgumentException>(() => new Sak("Butter", period: 1));
|
||||
Assert.Equal("period", ex2.ParamName);
|
||||
|
||||
// period == 3 should be fine
|
||||
var sak = new Sak("EMA", period: 3);
|
||||
Assert.NotNull(sak);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sak_Constructor_N_ThrowsIfLessThanOne()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new Sak("SMA", period: 20, n: 0));
|
||||
Assert.Equal("n", ex.ParamName);
|
||||
|
||||
var ex2 = Assert.Throws<ArgumentException>(() => new Sak("EMA", period: 20, n: 0));
|
||||
Assert.Equal("n", ex2.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sak_Constructor_Delta_ThrowsIfBandwidthTooLarge()
|
||||
{
|
||||
// delta/period > 0.25 → invalid for BP/BS
|
||||
var ex = Assert.Throws<ArgumentException>(() => new Sak("BP", period: 20, delta: 6.0));
|
||||
Assert.Equal("delta", ex.ParamName);
|
||||
|
||||
var ex2 = Assert.Throws<ArgumentException>(() => new Sak("BS", period: 20, delta: 6.0));
|
||||
Assert.Equal("delta", ex2.ParamName);
|
||||
|
||||
// delta = 0.1, period = 20 → 0.1/20 = 0.005 ≤ 0.25 → fine
|
||||
var sak = new Sak("BP", period: 20, delta: 0.1);
|
||||
Assert.NotNull(sak);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sak_Constructor_UnknownFilterType_Throws()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new Sak("UNKNOWN", period: 20));
|
||||
Assert.Equal("filterType", ex.ParamName);
|
||||
}
|
||||
|
||||
// ── B) Basic calculation ──────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("EMA")]
|
||||
[InlineData("SMA")]
|
||||
[InlineData("Gauss")]
|
||||
[InlineData("Butter")]
|
||||
[InlineData("Smooth")]
|
||||
[InlineData("HP")]
|
||||
[InlineData("2PHP")]
|
||||
[InlineData("BP")]
|
||||
[InlineData("BS")]
|
||||
public void Sak_AllModes_ReturnsFiniteValue(string mode)
|
||||
{
|
||||
var sak = new Sak(mode, period: 10, n: 5, delta: 0.1);
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
var result = sak.Update(new TValue(now.AddSeconds(i), 100.0 + i));
|
||||
Assert.True(double.IsFinite(result.Value), $"Mode={mode} bar={i} produced non-finite value");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sak_EMA_KnownValueCheck()
|
||||
{
|
||||
// EMA SAK: alpha = (cos(2π/10) + sin(2π/10) - 1) / cos(2π/10)
|
||||
// Constant input 100 → should converge to 100
|
||||
var sak = new Sak("EMA", period: 10);
|
||||
var now = DateTime.UtcNow;
|
||||
TValue result = default;
|
||||
for (int i = 0; i < 200; i++)
|
||||
{
|
||||
result = sak.Update(new TValue(now.AddSeconds(i), 100.0));
|
||||
}
|
||||
Assert.Equal(100.0, result.Value, 1e-6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sak_BP_KnownValueCheck()
|
||||
{
|
||||
// Default BP (period=20, delta=0.1): constant input → output should converge toward 0
|
||||
var sak = new Sak("BP", period: 20, delta: 0.1);
|
||||
var now = DateTime.UtcNow;
|
||||
TValue result = default;
|
||||
for (int i = 0; i < 500; i++)
|
||||
{
|
||||
result = sak.Update(new TValue(now.AddSeconds(i), 100.0));
|
||||
}
|
||||
// BP is a band-pass; constant DC should be attenuated toward 0
|
||||
Assert.True(Math.Abs(result.Value) < 1.0, $"BP constant input did not converge near 0 (got {result.Value})");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sak_SMA_CorrectAverage()
|
||||
{
|
||||
var sak = new Sak("SMA", period: 20, n: 3);
|
||||
var now = DateTime.UtcNow;
|
||||
sak.Update(new TValue(now, 10.0));
|
||||
sak.Update(new TValue(now.AddSeconds(1), 20.0));
|
||||
var result = sak.Update(new TValue(now.AddSeconds(2), 30.0));
|
||||
// SMA(3) of 10,20,30 = 20
|
||||
Assert.Equal(20.0, result.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sak_Name_IsCorrect()
|
||||
{
|
||||
var sak = new Sak("BP", period: 20);
|
||||
Assert.Equal("Sak(BP,20)", sak.Name);
|
||||
}
|
||||
|
||||
// ── C) State + bar correction ─────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Sak_IsNew_True_AdvancesState()
|
||||
{
|
||||
var sak = new Sak("EMA", period: 10);
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
sak.Update(new TValue(now, 100.0), isNew: true);
|
||||
double v1 = sak.Last.Value;
|
||||
|
||||
sak.Update(new TValue(now.AddSeconds(1), 110.0), isNew: true);
|
||||
double v2 = sak.Last.Value;
|
||||
|
||||
Assert.NotEqual(v1, v2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sak_IsNew_False_RewritesLastBar()
|
||||
{
|
||||
var sak = new Sak("EMA", period: 10);
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
sak.Update(new TValue(now, 100.0), isNew: true);
|
||||
sak.Update(new TValue(now.AddSeconds(1), 110.0), isNew: true);
|
||||
double beforeUpdate = sak.Last.Value;
|
||||
|
||||
sak.Update(new TValue(now.AddSeconds(1), 120.0), isNew: false);
|
||||
double afterUpdate = sak.Last.Value;
|
||||
|
||||
Assert.NotEqual(beforeUpdate, afterUpdate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sak_IterativeCorrections_RestoreOriginalValue()
|
||||
{
|
||||
var sak = new Sak("Butter", period: 10);
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1);
|
||||
|
||||
TValue tenthInput = default;
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
var bar = gbm.Next(isNew: true);
|
||||
tenthInput = new TValue(bar.Time, bar.Close);
|
||||
sak.Update(tenthInput, isNew: true);
|
||||
}
|
||||
double afterTen = sak.Last.Value;
|
||||
|
||||
for (int i = 0; i < 9; i++)
|
||||
{
|
||||
var bar = gbm.Next(isNew: false);
|
||||
sak.Update(new TValue(bar.Time, bar.Close), isNew: false);
|
||||
}
|
||||
|
||||
double restored = sak.Update(tenthInput, isNew: false).Value;
|
||||
Assert.Equal(afterTen, restored, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sak_Reset_ClearsState()
|
||||
{
|
||||
var sak = new Sak("EMA", period: 10);
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
sak.Update(new TValue(now, 100.0));
|
||||
sak.Update(new TValue(now.AddSeconds(1), 105.0));
|
||||
|
||||
sak.Reset();
|
||||
|
||||
Assert.Equal(0.0, sak.Last.Value);
|
||||
Assert.False(sak.IsHot);
|
||||
|
||||
sak.Update(new TValue(now.AddSeconds(2), 50.0));
|
||||
Assert.NotEqual(0.0, sak.Last.Value);
|
||||
}
|
||||
|
||||
// ── D) Warmup / IsHot ────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Sak_IIR_IsHot_AfterThreeBars()
|
||||
{
|
||||
var sak = new Sak("EMA", period: 10);
|
||||
Assert.False(sak.IsHot);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
sak.Update(new TValue(now, 100.0));
|
||||
sak.Update(new TValue(now.AddSeconds(1), 100.0));
|
||||
Assert.False(sak.IsHot);
|
||||
|
||||
sak.Update(new TValue(now.AddSeconds(2), 100.0));
|
||||
Assert.True(sak.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sak_SMA_IsHot_AfterNBars()
|
||||
{
|
||||
const int n = 5;
|
||||
var sak = new Sak("SMA", period: 20, n: n);
|
||||
Assert.False(sak.IsHot);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < n - 1; i++)
|
||||
{
|
||||
sak.Update(new TValue(now.AddSeconds(i), 100.0));
|
||||
Assert.False(sak.IsHot);
|
||||
}
|
||||
|
||||
sak.Update(new TValue(now.AddSeconds(n - 1), 100.0));
|
||||
Assert.True(sak.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sak_WarmupPeriod_IIR_IsThree()
|
||||
{
|
||||
var sak = new Sak("Gauss", period: 20);
|
||||
Assert.Equal(3, sak.WarmupPeriod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sak_WarmupPeriod_SMA_IsN()
|
||||
{
|
||||
var sak = new Sak("SMA", period: 20, n: 7);
|
||||
Assert.Equal(7, sak.WarmupPeriod);
|
||||
}
|
||||
|
||||
// ── E) Robustness ────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Sak_NaN_Input_UsesLastValidValue()
|
||||
{
|
||||
var sak = new Sak("EMA", period: 10);
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
sak.Update(new TValue(now, 100.0));
|
||||
sak.Update(new TValue(now.AddSeconds(1), 110.0));
|
||||
|
||||
var result = sak.Update(new TValue(now.AddSeconds(2), double.NaN));
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sak_Infinity_Input_UsesLastValidValue()
|
||||
{
|
||||
var sak = new Sak("Butter", period: 10);
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
sak.Update(new TValue(now, 100.0));
|
||||
sak.Update(new TValue(now.AddSeconds(1), 110.0));
|
||||
|
||||
var result = sak.Update(new TValue(now.AddSeconds(2), double.PositiveInfinity));
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
|
||||
result = sak.Update(new TValue(now.AddSeconds(3), double.NegativeInfinity));
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sak_BatchNaN_IsFiniteOutput()
|
||||
{
|
||||
var sak = new Sak("EMA", period: 10);
|
||||
var data = new double[] { 100, double.NaN, 102, double.NaN, double.NaN, 105 };
|
||||
var now = DateTime.UtcNow;
|
||||
foreach (var d in data)
|
||||
{
|
||||
var r = sak.Update(new TValue(now, d));
|
||||
Assert.True(double.IsFinite(r.Value));
|
||||
}
|
||||
}
|
||||
|
||||
// ── F) Consistency (batch == streaming == span == eventing) ──────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("EMA")]
|
||||
[InlineData("SMA")]
|
||||
[InlineData("Gauss")]
|
||||
[InlineData("Butter")]
|
||||
[InlineData("Smooth")]
|
||||
[InlineData("HP")]
|
||||
[InlineData("2PHP")]
|
||||
[InlineData("BP")]
|
||||
[InlineData("BS")]
|
||||
public void Sak_AllModes_AllApiModes_Match(string mode)
|
||||
{
|
||||
const int period = 10;
|
||||
const int n = 5;
|
||||
const double delta = 0.1;
|
||||
|
||||
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 123);
|
||||
var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
var series = bars.Close;
|
||||
|
||||
// 1. Batch (TSeries)
|
||||
var batchResult = Sak.Calculate(series, mode, period, n, delta).Results;
|
||||
double expected = batchResult.Last.Value;
|
||||
|
||||
// 2. Span
|
||||
var srcArray = series.Values.ToArray();
|
||||
var outArray = new double[srcArray.Length];
|
||||
Sak.Calculate(srcArray.AsSpan(), outArray.AsSpan(), mode, period, n, delta);
|
||||
double spanResult = outArray[^1];
|
||||
|
||||
// 3. Streaming
|
||||
var streaming = new Sak(mode, period, n, delta);
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
streaming.Update(series[i]);
|
||||
}
|
||||
double streamingResult = streaming.Last.Value;
|
||||
|
||||
// 4. Eventing
|
||||
var pubSource = new TSeries();
|
||||
var eventing = new Sak(pubSource, mode, period, n, delta);
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
pubSource.Add(series[i]);
|
||||
}
|
||||
double eventingResult = eventing.Last.Value;
|
||||
|
||||
Assert.Equal(expected, spanResult, precision: 9);
|
||||
Assert.Equal(expected, streamingResult, precision: 9);
|
||||
Assert.Equal(expected, eventingResult, precision: 9);
|
||||
}
|
||||
|
||||
// ── G) Span API ───────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Sak_Span_ThrowsOnLengthMismatch()
|
||||
{
|
||||
var src = new double[10];
|
||||
var out_ = new double[9];
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
Sak.Calculate(src.AsSpan(), out_.AsSpan(), "EMA", 10));
|
||||
Assert.Equal("output", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sak_Span_HandlesEmpty()
|
||||
{
|
||||
// Should not throw
|
||||
Sak.Calculate(ReadOnlySpan<double>.Empty, Span<double>.Empty, "EMA", 10);
|
||||
Assert.True(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sak_Span_HandlesNaN()
|
||||
{
|
||||
var src = new double[] { 100, double.NaN, 102, 103, 104 };
|
||||
var output = new double[5];
|
||||
Sak.Calculate(src.AsSpan(), output.AsSpan(), "EMA", 3);
|
||||
foreach (var v in output)
|
||||
{
|
||||
Assert.True(double.IsFinite(v));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sak_Span_LargeData_NoStackOverflow()
|
||||
{
|
||||
const int size = 10_000;
|
||||
var src = new double[size];
|
||||
var output = new double[size];
|
||||
for (int i = 0; i < size; i++)
|
||||
{
|
||||
src[i] = 100.0 + i * 0.01;
|
||||
}
|
||||
|
||||
// Should not throw StackOverflowException
|
||||
Sak.Calculate(src.AsSpan(), output.AsSpan(), "BP", 20);
|
||||
Assert.True(double.IsFinite(output[^1]));
|
||||
}
|
||||
|
||||
// ── H) Chainability ───────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Sak_Pub_Fires_OnUpdate()
|
||||
{
|
||||
var sak = new Sak("EMA", period: 10);
|
||||
int fireCount = 0;
|
||||
sak.Pub += (_, in _) => fireCount++;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
sak.Update(new TValue(now, 100.0));
|
||||
sak.Update(new TValue(now.AddSeconds(1), 105.0));
|
||||
|
||||
Assert.Equal(2, fireCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sak_EventChaining_Works()
|
||||
{
|
||||
var source = new TSeries();
|
||||
var sakEma = new Sak(source, "EMA", period: 10);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
source.Add(new TValue(now.AddSeconds(i), 100.0 + i));
|
||||
}
|
||||
|
||||
Assert.True(double.IsFinite(sakEma.Last.Value));
|
||||
Assert.True(sakEma.IsHot);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public sealed class SakValidationTests
|
||||
{
|
||||
// ── EMA cross-validation ──────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Sak_EMA_BatchEqualsStreaming()
|
||||
{
|
||||
// SAK "EMA" uses Ehlers' trig alpha (cos+sin-1)/cos, which differs from
|
||||
// the classic 2/(P+1) formula used by standalone Ema. Cross-library
|
||||
// comparison is not valid. Verify internal self-consistency instead.
|
||||
const int period = 14;
|
||||
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 42);
|
||||
var bars = gbm.Fetch(300, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
var series = bars.Close;
|
||||
|
||||
var sakStreaming = new Sak("EMA", period: period);
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
sakStreaming.Update(series[i]);
|
||||
}
|
||||
|
||||
var (batchResult, _) = Sak.Calculate(series, "EMA", period);
|
||||
|
||||
Assert.Equal(batchResult.Last.Value, sakStreaming.Last.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sak_EMA_BatchMatchesStreaming()
|
||||
{
|
||||
const int period = 10;
|
||||
var gbm = new GBM(startPrice: 100, mu: 0.02, sigma: 0.15, seed: 99);
|
||||
var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
var series = bars.Close;
|
||||
|
||||
var batchResult = Sak.Calculate(series, "EMA", period).Results;
|
||||
|
||||
var streaming = new Sak("EMA", period);
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
streaming.Update(series[i]);
|
||||
}
|
||||
|
||||
Assert.Equal(batchResult.Last.Value, streaming.Last.Value, 1e-13);
|
||||
}
|
||||
|
||||
// ── Gauss cross-validation ────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Sak_Gauss_BatchMatchesStreaming()
|
||||
{
|
||||
const int period = 20;
|
||||
var gbm = new GBM(startPrice: 100, mu: 0.03, sigma: 0.2, seed: 77);
|
||||
var bars = gbm.Fetch(300, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
var series = bars.Close;
|
||||
|
||||
var batchResult = Sak.Calculate(series, "Gauss", period).Results;
|
||||
|
||||
var streaming = new Sak("Gauss", period);
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
streaming.Update(series[i]);
|
||||
}
|
||||
|
||||
Assert.Equal(batchResult.Last.Value, streaming.Last.Value, 1e-13);
|
||||
}
|
||||
|
||||
// ── Smooth mode FIR verification ──────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Sak_Smooth_FIR_VerifyThreeBar()
|
||||
{
|
||||
// Smooth: a1=a2=0, so y[t] = c0 * (b0*x[t] + b1*x[t-1] + b2*x[t-2])
|
||||
// = (alpha^2/4) * (x[t] + 2*x[t-1] + x[t-2])
|
||||
// For period=10:
|
||||
// theta = 2π/10, alpha = (cos(theta)+sin(theta)-1)/cos(theta)
|
||||
const int period = 10;
|
||||
double theta = 2.0 * Math.PI / period;
|
||||
double cosT = Math.Cos(theta);
|
||||
double sinT = Math.Sin(theta);
|
||||
double alpha = (cosT + sinT - 1.0) / cosT;
|
||||
double c0 = alpha * alpha / 4.0;
|
||||
|
||||
double x0 = 10.0, x1 = 20.0, x2 = 30.0;
|
||||
double expectedY = c0 * (x0 + 2.0 * x1 + x2); // pure FIR formula
|
||||
|
||||
var sak = new Sak("Smooth", period: period);
|
||||
var now = DateTime.UtcNow;
|
||||
sak.Update(new TValue(now, x2)); // oldest first
|
||||
sak.Update(new TValue(now.AddSeconds(1), x1));
|
||||
var result = sak.Update(new TValue(now.AddSeconds(2), x0));
|
||||
|
||||
Assert.Equal(expectedY, result.Value, 1e-12);
|
||||
}
|
||||
|
||||
// ── SMA cross-validation ──────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Sak_SMA_MatchesStandaloneSma()
|
||||
{
|
||||
const int n = 15;
|
||||
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 55);
|
||||
var bars = gbm.Fetch(300, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
var series = bars.Close;
|
||||
|
||||
var sakSma = new Sak("SMA", period: 20, n: n);
|
||||
var standaloneSma = new Sma(n);
|
||||
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
sakSma.Update(series[i]);
|
||||
standaloneSma.Update(series[i]);
|
||||
}
|
||||
|
||||
// SAK SMA uses RingBuffer exact windowed sum; standalone Sma uses
|
||||
// compensated running-sum — both are O(1) but accumulate FP error
|
||||
// differently. Tolerance 1e-10 covers the rounding gap.
|
||||
Assert.Equal(standaloneSma.Last.Value, sakSma.Last.Value, 1e-10);
|
||||
}
|
||||
|
||||
// ── Internal consistency: constant input ─────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("EMA")]
|
||||
[InlineData("Gauss")]
|
||||
[InlineData("Butter")]
|
||||
[InlineData("SMA")]
|
||||
public void Sak_LowPassModes_ConstantInput_ConvergesToConstant(string mode)
|
||||
{
|
||||
const double constVal = 123.456;
|
||||
int n = string.Equals(mode, "SMA", StringComparison.Ordinal) ? 10 : 5;
|
||||
var sak = new Sak(mode, period: 10, n: n, delta: 0.1);
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
TValue last = default;
|
||||
for (int i = 0; i < 500; i++)
|
||||
{
|
||||
last = sak.Update(new TValue(now.AddSeconds(i), constVal));
|
||||
}
|
||||
|
||||
Assert.Equal(constVal, last.Value, 1e-4);
|
||||
}
|
||||
|
||||
// ── BP/BS: DC rejection ───────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Sak_BP_ConstantInput_ConvergesToZero()
|
||||
{
|
||||
// BP is a bandpass filter: DC (zero-frequency) input is in the stop-band.
|
||||
// Ehlers' BP IIR needs ~5*period bars to fully attenuate the DC transient.
|
||||
var sak = new Sak("BP", period: 20, delta: 0.1);
|
||||
var now = DateTime.UtcNow;
|
||||
TValue last = default;
|
||||
for (int i = 0; i < 2000; i++)
|
||||
{
|
||||
last = sak.Update(new TValue(now.AddSeconds(i), 100.0));
|
||||
}
|
||||
Assert.True(Math.Abs(last.Value) < 1e-3, $"BP DC not rejected after 2000 bars: {last.Value}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sak_HP_ConstantInput_ConvergesToZero()
|
||||
{
|
||||
var sak = new Sak("HP", period: 20);
|
||||
var now = DateTime.UtcNow;
|
||||
TValue last = default;
|
||||
for (int i = 0; i < 500; i++)
|
||||
{
|
||||
last = sak.Update(new TValue(now.AddSeconds(i), 100.0));
|
||||
}
|
||||
Assert.True(Math.Abs(last.Value) < 1e-3, $"HP DC not rejected: {last.Value}");
|
||||
}
|
||||
|
||||
// ── Span == Streaming consistency ─────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("EMA")]
|
||||
[InlineData("BP")]
|
||||
[InlineData("Butter")]
|
||||
[InlineData("SMA")]
|
||||
public void Sak_Span_MatchesStreaming(string mode)
|
||||
{
|
||||
const int period = 12;
|
||||
const int n = 6;
|
||||
var gbm = new GBM(startPrice: 100, mu: 0.04, sigma: 0.18, seed: 333);
|
||||
var bars = gbm.Fetch(150, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
var series = bars.Close;
|
||||
|
||||
var srcArr = series.Values.ToArray();
|
||||
var outArr = new double[srcArr.Length];
|
||||
Sak.Calculate(srcArr.AsSpan(), outArr.AsSpan(), mode, period, n);
|
||||
|
||||
var streaming = new Sak(mode, period, n);
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
streaming.Update(series[i]);
|
||||
}
|
||||
|
||||
Assert.Equal(streaming.Last.Value, outArr[^1], 1e-9);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
[SkipLocalsInit]
|
||||
public sealed class Sak : AbstractBase
|
||||
{
|
||||
// ── coefficient fields (precomputed, readonly) ─────────────────────────
|
||||
private readonly double _c0, _b0, _b1, _b2, _a1, _a2;
|
||||
|
||||
// ── SMA-mode fields ────────────────────────────────────────────────────
|
||||
private readonly RingBuffer? _smaBuf; // null for non-SMA modes
|
||||
private readonly double _oneDivN; // 1/n, precomputed for SMA
|
||||
|
||||
// ── publisher / handler ────────────────────────────────────────────────
|
||||
private readonly ITValuePublisher? _publisher;
|
||||
private readonly TValuePublishedHandler? _handler;
|
||||
|
||||
// ── mode flag ──────────────────────────────────────────────────────────
|
||||
private readonly bool _isSma;
|
||||
|
||||
// ── scalar state ──────────────────────────────────────────────────────
|
||||
// IIR path: x1=x[t-1], x2=x[t-2], y1=y[t-1], y2=y[t-2]
|
||||
// SMA path: y1 = running sum (replaces the standard y1 slot)
|
||||
[StructLayout(LayoutKind.Auto)]
|
||||
private record struct State(
|
||||
double X1, double X2,
|
||||
double Y1, double Y2,
|
||||
double LastValidValue,
|
||||
int Count,
|
||||
bool IsHot)
|
||||
{
|
||||
public static State New() => new(0, 0, 0, 0, 0, 0, false);
|
||||
}
|
||||
|
||||
private State _state = State.New();
|
||||
private State _p_state = State.New();
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Constructors
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
public Sak(string filterType = "BP", int period = 20, int n = 10, double delta = 0.1)
|
||||
{
|
||||
if (n < 1)
|
||||
{
|
||||
throw new ArgumentException("n must be >= 1", nameof(n));
|
||||
}
|
||||
|
||||
string mode = filterType.Trim().ToUpperInvariant();
|
||||
|
||||
// Period validation: SMA only requires n; non-SMA modes need period > 2
|
||||
if (!string.Equals(mode, "SMA", StringComparison.Ordinal) && period <= 2)
|
||||
{
|
||||
throw new ArgumentException("Period must be > 2 for non-SMA modes", nameof(period));
|
||||
}
|
||||
|
||||
_isSma = string.Equals(mode, "SMA", StringComparison.Ordinal);
|
||||
|
||||
if (_isSma)
|
||||
{
|
||||
// SMA special path: coefficients unused; RingBuffer drives computation
|
||||
_c0 = 0; _b0 = 0; _b1 = 0; _b2 = 0; _a1 = 0; _a2 = 0;
|
||||
_oneDivN = 1.0 / n;
|
||||
_smaBuf = new RingBuffer(n);
|
||||
Name = $"Sak({filterType},{period})";
|
||||
WarmupPeriod = n;
|
||||
_handler = Handle;
|
||||
return;
|
||||
}
|
||||
|
||||
// ── alpha / coefficient derivation ───────────────────────────────
|
||||
double theta = 2.0 * Math.PI / period;
|
||||
double cosTheta = Math.Cos(theta);
|
||||
double sinTheta = Math.Sin(theta);
|
||||
double alpha, beta = 0;
|
||||
|
||||
switch (mode)
|
||||
{
|
||||
case "EMA":
|
||||
case "HP":
|
||||
case "SMOOTH":
|
||||
{
|
||||
// Group 1
|
||||
alpha = (cosTheta + sinTheta - 1.0) / cosTheta;
|
||||
break;
|
||||
}
|
||||
|
||||
case "GAUSS":
|
||||
case "BUTTER":
|
||||
case "2PHP":
|
||||
{
|
||||
// Group 2
|
||||
double betaG = 2.415 * (1.0 - cosTheta);
|
||||
alpha = -betaG + Math.Sqrt(Math.FusedMultiplyAdd(betaG, betaG, 2.0 * betaG));
|
||||
break;
|
||||
}
|
||||
|
||||
case "BP":
|
||||
case "BS":
|
||||
{
|
||||
// Group 3: validate delta/period <= 0.25
|
||||
if (delta / period > 0.25)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"delta/period must be <= 0.25 for BP/BS modes (got {delta / period:G4})",
|
||||
nameof(delta));
|
||||
}
|
||||
|
||||
double gamma = 1.0 / Math.Cos(2.0 * Math.PI * delta / period);
|
||||
double gammaSquaredMinus1 = Math.FusedMultiplyAdd(gamma, gamma, -1.0);
|
||||
if (gammaSquaredMinus1 < 0)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"BP/BS: gamma^2 - 1 < 0 (delta/period = {delta / period:G4}). Reduce delta.",
|
||||
nameof(delta));
|
||||
}
|
||||
|
||||
alpha = gamma - Math.Sqrt(gammaSquaredMinus1);
|
||||
beta = cosTheta; // used in BP/BS coefficient table as β
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
throw new ArgumentException(
|
||||
$"Unknown filterType '{filterType}'. Valid: EMA, SMA, Gauss, Butter, Smooth, HP, 2PHP, BP, BS",
|
||||
nameof(filterType));
|
||||
}
|
||||
|
||||
// ── build coefficient table ───────────────────────────────────────
|
||||
double decay = 1.0 - alpha; // (1-α)
|
||||
double decaySq = decay * decay; // (1-α)²
|
||||
double alphaSq = alpha * alpha; // α²
|
||||
|
||||
switch (mode)
|
||||
{
|
||||
case "EMA":
|
||||
_c0 = 1.0; _b0 = alpha; _b1 = 0; _b2 = 0;
|
||||
_a1 = decay; _a2 = 0;
|
||||
break;
|
||||
|
||||
case "GAUSS":
|
||||
_c0 = alphaSq; _b0 = 1; _b1 = 0; _b2 = 0;
|
||||
_a1 = 2.0 * decay; _a2 = -decaySq;
|
||||
break;
|
||||
|
||||
case "BUTTER":
|
||||
_c0 = alphaSq / 4.0; _b0 = 1; _b1 = 2; _b2 = 1;
|
||||
_a1 = 2.0 * decay; _a2 = -decaySq;
|
||||
break;
|
||||
|
||||
case "SMOOTH":
|
||||
_c0 = alphaSq / 4.0; _b0 = 1; _b1 = 2; _b2 = 1;
|
||||
_a1 = 0; _a2 = 0;
|
||||
break;
|
||||
|
||||
case "HP":
|
||||
_c0 = 1.0 - alpha / 2.0; _b0 = 1; _b1 = -1; _b2 = 0;
|
||||
_a1 = decay; _a2 = 0;
|
||||
break;
|
||||
|
||||
case "2PHP":
|
||||
{
|
||||
double halfAlpha = alpha / 2.0;
|
||||
_c0 = (1.0 - halfAlpha) * (1.0 - halfAlpha);
|
||||
_b0 = 1; _b1 = -2; _b2 = 1;
|
||||
_a1 = 2.0 * decay; _a2 = -decaySq;
|
||||
break;
|
||||
}
|
||||
|
||||
case "BP":
|
||||
// β (beta) = cos(2π/P) — named 'beta' here, stored in local 'beta'
|
||||
_c0 = (1.0 - alpha) / 2.0;
|
||||
_b0 = 1; _b1 = 0; _b2 = -1;
|
||||
_a1 = beta * (1.0 + alpha); _a2 = -alpha;
|
||||
break;
|
||||
|
||||
case "BS":
|
||||
_c0 = (1.0 + alpha) / 2.0;
|
||||
_b0 = 1; _b1 = -2.0 * beta; _b2 = 1;
|
||||
_a1 = beta * (1.0 + alpha); _a2 = -alpha;
|
||||
break;
|
||||
}
|
||||
|
||||
Name = $"Sak({filterType},{period})";
|
||||
WarmupPeriod = 3; // 2nd-order IIR transient clears after 3 bars
|
||||
_oneDivN = 0;
|
||||
_handler = Handle;
|
||||
}
|
||||
|
||||
public Sak(ITValuePublisher src, string filterType = "BP", int period = 20, int n = 10, double delta = 0.1)
|
||||
: this(filterType, period, n, delta)
|
||||
{
|
||||
_publisher = src;
|
||||
src.Pub += _handler;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Event handler
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
private void Handle(object? sender, in TValueEventArgs e) => Update(e.Value, e.IsNew);
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Properties
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
public override bool IsHot => _isSma ? (_smaBuf!.IsFull) : _state.IsHot;
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Update (TValue) — hot path
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public override TValue Update(TValue input, bool isNew = true)
|
||||
{
|
||||
if (isNew)
|
||||
{
|
||||
_p_state = _state;
|
||||
_smaBuf?.Snapshot();
|
||||
}
|
||||
else
|
||||
{
|
||||
_state = _p_state;
|
||||
_smaBuf?.Restore();
|
||||
}
|
||||
|
||||
double val = input.Value;
|
||||
if (!double.IsFinite(val))
|
||||
{
|
||||
val = _state.LastValidValue;
|
||||
}
|
||||
else
|
||||
{
|
||||
_state.LastValidValue = val;
|
||||
}
|
||||
|
||||
double y;
|
||||
|
||||
if (_isSma)
|
||||
{
|
||||
// SMA running-sum path — O(1) per bar
|
||||
// y[t] = (1/n)*x[t] + y[t-1] - (1/n)*x[t-n]
|
||||
double oldest = _smaBuf!.IsFull ? _smaBuf.Oldest : 0.0;
|
||||
_smaBuf.Add(val, isNew);
|
||||
// _state.Y1 holds the running sum
|
||||
y = Math.FusedMultiplyAdd(_oneDivN, val, _state.Y1 - _oneDivN * oldest);
|
||||
_state.Y1 = y;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Standard IIR path — use local copy for JIT register promotion
|
||||
var s = _state;
|
||||
|
||||
// feedforward: c0 * (b0*x + b1*x1 + b2*x2)
|
||||
double ff = _c0 * Math.FusedMultiplyAdd(_b0, val,
|
||||
Math.FusedMultiplyAdd(_b1, s.X1, _b2 * s.X2));
|
||||
|
||||
// feedback: a1*y1 + a2*y2
|
||||
double fb = Math.FusedMultiplyAdd(_a1, s.Y1, _a2 * s.Y2);
|
||||
|
||||
y = ff + fb;
|
||||
|
||||
s.X2 = s.X1;
|
||||
s.X1 = val;
|
||||
s.Y2 = s.Y1;
|
||||
s.Y1 = y;
|
||||
|
||||
_state = s;
|
||||
}
|
||||
|
||||
if (isNew)
|
||||
{
|
||||
_state.Count++;
|
||||
}
|
||||
|
||||
if (!_state.IsHot && _state.Count >= WarmupPeriod)
|
||||
{
|
||||
_state.IsHot = true;
|
||||
}
|
||||
|
||||
Last = new TValue(input.Time, y);
|
||||
PubEvent(Last, isNew);
|
||||
return Last;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Batch via TSeries
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
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;
|
||||
|
||||
CalculateCore(sourceValues, vSpan, _c0, _b0, _b1, _b2, _a1, _a2,
|
||||
_isSma, _oneDivN, _smaBuf?.Capacity ?? 0, WarmupPeriod, ref _state, _smaBuf);
|
||||
|
||||
sourceTimes.CopyTo(tSpan);
|
||||
_p_state = _state;
|
||||
Last = new TValue(tSpan[len - 1], vSpan[len - 1]);
|
||||
return new TSeries(t, v);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Static Calculate (TSeries)
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
public static (TSeries Results, Sak Indicator) Calculate(
|
||||
TSeries source, string filterType = "BP", int period = 20, int n = 10, double delta = 0.1)
|
||||
{
|
||||
var sak = new Sak(filterType, period, n, delta);
|
||||
TSeries results = sak.Update(source);
|
||||
return (results, sak);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Static Calculate (Span)
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
public static void Calculate(
|
||||
ReadOnlySpan<double> src, Span<double> output,
|
||||
string filterType = "BP", int period = 20, int n = 10, double delta = 0.1)
|
||||
{
|
||||
if (src.Length != output.Length)
|
||||
{
|
||||
throw new ArgumentException("src and output must have the same length", nameof(output));
|
||||
}
|
||||
|
||||
if (src.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Build a temporary instance to compute coefficients, then run the core loop
|
||||
var tmp = new Sak(filterType, period, n, delta);
|
||||
var state = State.New();
|
||||
RingBuffer? smaBuf = tmp._isSma ? new RingBuffer(n) : null;
|
||||
|
||||
CalculateCore(src, output, tmp._c0, tmp._b0, tmp._b1, tmp._b2, tmp._a1, tmp._a2,
|
||||
tmp._isSma, tmp._oneDivN, n, tmp.WarmupPeriod, ref state, smaBuf);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// CalculateCore — shared by Update(TSeries) and Calculate(Span)
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static void CalculateCore(
|
||||
ReadOnlySpan<double> source,
|
||||
Span<double> output,
|
||||
double c0, double b0, double b1, double b2, double a1, double a2,
|
||||
bool isSma, double oneDivN, int smaN, int warmupPeriod,
|
||||
ref State state,
|
||||
RingBuffer? smaBuf)
|
||||
{
|
||||
int len = source.Length;
|
||||
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
double val = source[i];
|
||||
if (!double.IsFinite(val))
|
||||
{
|
||||
val = state.LastValidValue;
|
||||
}
|
||||
else
|
||||
{
|
||||
state.LastValidValue = val;
|
||||
}
|
||||
|
||||
double y;
|
||||
|
||||
if (isSma)
|
||||
{
|
||||
double oldest = (smaBuf != null && smaBuf.IsFull) ? smaBuf.Oldest : 0.0;
|
||||
smaBuf?.Add(val);
|
||||
y = Math.FusedMultiplyAdd(oneDivN, val, state.Y1 - oneDivN * oldest);
|
||||
state.Y1 = y;
|
||||
}
|
||||
else
|
||||
{
|
||||
double ff = c0 * Math.FusedMultiplyAdd(b0, val,
|
||||
Math.FusedMultiplyAdd(b1, state.X1, b2 * state.X2));
|
||||
double fb = Math.FusedMultiplyAdd(a1, state.Y1, a2 * state.Y2);
|
||||
y = ff + fb;
|
||||
|
||||
state.X2 = state.X1;
|
||||
state.X1 = val;
|
||||
state.Y2 = state.Y1;
|
||||
state.Y1 = y;
|
||||
}
|
||||
|
||||
output[i] = y;
|
||||
state.Count++;
|
||||
}
|
||||
|
||||
if (!state.IsHot && state.Count >= warmupPeriod)
|
||||
{
|
||||
state.IsHot = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Prime
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
|
||||
{
|
||||
if (source.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Reset();
|
||||
foreach (double v in source)
|
||||
{
|
||||
Update(new TValue(DateTime.UtcNow, v), isNew: true);
|
||||
}
|
||||
_p_state = _state;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Reset
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
public override void Reset()
|
||||
{
|
||||
_state = State.New();
|
||||
_p_state = State.New();
|
||||
_smaBuf?.Clear();
|
||||
Last = default;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Dispose
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && _publisher != null && _handler != null)
|
||||
{
|
||||
_publisher.Pub -= _handler;
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
+348
-57
@@ -1,92 +1,383 @@
|
||||
# SAK: Swiss Army Knife Indicator
|
||||
# SAK: Swiss Army Knife
|
||||
|
||||
> "John Ehlers unified nine filter types into one second-order IIR framework. Change the coefficients and you get EMA, SMA, Gaussian, Butterworth, smoother, high-pass, 2-pole high-pass, band-pass, or band-stop. One formula to implement them all."
|
||||
> "Nine filters walk into a bar. The bartender says, 'What'll it be?' They answer in unison: 'Same equation, different coefficients.'"
|
||||
|
||||
SAK is a unified second-order IIR filter framework where five coefficient sets ($c_0$, $b_0$, $b_1$, $b_2$, $a_1$, $a_2$) determine the filter type. The general form $\text{Filt} = c_0(b_0 x + b_1 x_{t-1} + b_2 x_{t-2}) + a_1 \text{Filt}_{t-1} + a_2 \text{Filt}_{t-2}$ can instantiate nine different filters by selecting the appropriate coefficient derivation. Published by John Ehlers in "Swiss Army Knife Indicator" (*Technical Analysis of Stocks & Commodities*, January 2006).
|
||||
SAK is John Ehlers' unified second-order IIR filter framework that collapses nine distinct filter types into a single difference equation. Change five coefficients and the same code path produces EMA, SMA, Gaussian, Butterworth, FIR smoother, high-pass, two-pole high-pass, band-pass, or band-stop output. One transfer function. Nine behaviors. Zero code duplication.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Property | Value |
|
||||
| :--- | :--- |
|
||||
| **Category** | Filters |
|
||||
| **Inputs** | `src` (price series) |
|
||||
| **Parameters** | `filterType` (string, default `"BP"`), `period` (int, default 20), `n` (int, default 10, SMA only), `delta` (float, default 0.1, BP/BS only) |
|
||||
| **Outputs** | Single `double` per bar |
|
||||
| **Warmup** | 3 bars (2nd-order IIR), except SMA which needs `n` bars |
|
||||
| **Range** | Overlay (EMA, SMA, Gauss, Butter, Smooth) or oscillator around zero (HP, 2PHP, BP, BS) |
|
||||
|
||||
## Key Takeaways
|
||||
|
||||
- **One equation, nine filters.** The unified transfer function $H(z) = c_0(b_0 + b_1 z^{-1} + b_2 z^{-2}) / (1 - a_1 z^{-1} - a_2 z^{-2})$ covers all nine modes through coefficient substitution alone.
|
||||
- **Three alpha families.** EMA/HP/SMA/Smooth share one alpha derivation; Gauss/Butter/2PHP share another; BP/BS use a third with bandwidth parameter $\delta$.
|
||||
- **SMA takes the back door.** While every other mode flows through the standard IIR path, SMA uses a running-sum recurrence that skips the feedforward section entirely.
|
||||
- **Stable for $P > 2$.** All modes produce bounded output when the period exceeds two bars. Below that, poles escape the unit circle and the filter diverges.
|
||||
- **DFT mode deliberately excluded.** Ehlers recommended MESA and Hilbert Transform methods for spectral estimation; the DFT mode from the original framework adds complexity without matching those dedicated tools.
|
||||
|
||||
## Historical Context
|
||||
|
||||
John F. Ehlers published the Swiss Army Knife indicator in TASC (January 2006), motivated by the observation that most common technical analysis filters (EMA, SMA, Gaussian, Butterworth, high-pass, band-pass) share the same second-order difference equation structure. Only the coefficients differ. By parameterizing the coefficient derivation, a single implementation can serve as any of nine filter types.
|
||||
In May 2004, Richard Lyons and Amy Bell published "The Swiss Army Knife of Digital Networks" in *IEEE Signal Processing Magazine* (pp. 90-100). Their observation: a second-order IIR structure with configurable coefficients could implement low-pass, high-pass, band-pass, and band-stop filters from a single code path. Elegant, but aimed at electrical engineers processing radio signals.
|
||||
|
||||
This unification has both practical and theoretical value. Practically, it reduces code duplication: one function with a mode selector replaces nine separate implementations. Theoretically, it reveals the deep connection between seemingly different filters: they are all members of the same family of second-order IIR filters, differing only in their pole and zero placements in the z-plane.
|
||||
John F. Ehlers read that paper and recognized its relevance to market data. Eight months later, in January 2006, he published "Swiss Army Knife Indicator" in *Technical Analysis of Stocks & Commodities*, translating the Lyons-Bell framework into trading-specific terms. Where Lyons and Bell dealt with sampling rates and Hertz, Ehlers parameterized everything in terms of cycle period $P$ (bars per cycle) and derived alpha coefficients using trigonometric identities that map period to pole/zero placement.
|
||||
|
||||
Ehlers derives the coefficients from the cycle period $P$ using trigonometric formulas that place poles/zeros at specific frequencies, ensuring each filter type has its cutoff or center frequency aligned with the user-specified period.
|
||||
The practical value is immediate. Before SAK, implementing nine filter types meant maintaining nine separate functions with independent alpha computations, state management, and test suites. After SAK, one function with a mode selector replaces all nine. The theoretical value is subtler but equally important: SAK reveals that EMA, Butterworth, Gaussian, high-pass, and band-pass filters are not fundamentally different algorithms. They are the same second-order recursive structure with different pole and zero placements in the z-plane.
|
||||
|
||||
## Architecture & Physics
|
||||
Most implementations in the wild reproduce the TASC article verbatim, including the DFT mode. QuanTAlib follows Ehlers' own later recommendation to exclude DFT, since MESA and Hilbert Transform approaches (available as separate indicators) handle spectral estimation with phase-locked precision that DFT cannot match over short windows.
|
||||
|
||||
### 1. Unified Second-Order IIR
|
||||
## What It Measures and Why It Matters
|
||||
|
||||
$$
|
||||
\text{Filt}_t = c_0(b_0 x_t + b_1 x_{t-1} + b_2 x_{t-2}) + a_1 \text{Filt}_{t-1} + a_2 \text{Filt}_{t-2}
|
||||
$$
|
||||
SAK does not measure one thing. It measures nine things, depending on the mode. That is the point.
|
||||
|
||||
### 2. Coefficient Derivation by Mode
|
||||
In low-pass modes (EMA, SMA, Gauss, Butter, Smooth), SAK extracts the trend component by attenuating frequencies above the cutoff period. These outputs overlay the price chart. In high-pass modes (HP, 2PHP), SAK isolates the cyclic component by removing the trend. In band-pass mode (BP), it isolates a specific frequency band centered on the period, with bandwidth controlled by $\delta$. In band-stop mode (BS), it does the opposite: removes a specific frequency band and passes everything else.
|
||||
|
||||
Three smoothing parameters are computed from the period:
|
||||
- **EMA/HP/SMA/Smooth modes:** $\alpha = (\cos\theta + \sin\theta - 1)/\cos\theta$, $\theta = 2\pi/P$
|
||||
- **Gauss/Butter/2PHP modes:** $\beta = 2.415(1 - \cos\theta)$, $\alpha = -\beta + \sqrt{\beta^2 + 2\beta}$
|
||||
- **BP/BS modes:** $\gamma = 1/\cos(2\pi\delta/P)$, $\beta = \cos(2\pi/P)$, $\alpha = \gamma - \sqrt{\gamma^2 - 1}$
|
||||
The practical benefit is not that SAK computes any single filter better than a dedicated implementation. A standalone Butterworth filter will produce identical output. The benefit is that SAK provides a unified interface for switching between filter behaviors at runtime, comparing filter responses on identical data, and understanding the relationships between filter types. When a researcher needs to test whether EMA, Gaussian, or Butterworth smoothing produces better signals for a particular strategy, SAK lets them change a string parameter instead of rewiring their indicator chain.
|
||||
|
||||
### 3. Nine Filter Types
|
||||
|
||||
| Mode | Type | Overlay? |
|
||||
| :--- | :--- | :---: |
|
||||
| EMA | Low-pass (1-pole) | Yes |
|
||||
| SMA | Low-pass (running sum) | Yes |
|
||||
| Gauss | Low-pass (2-pole Gaussian) | Yes |
|
||||
| Butter | Low-pass (2-pole Butterworth) | Yes |
|
||||
| Smooth | Low-pass (FIR-like) | Yes |
|
||||
| HP | High-pass (1-pole) | No |
|
||||
| 2PHP | High-pass (2-pole) | No |
|
||||
| BP | Band-pass | No |
|
||||
| BS | Band-stop (notch) | No |
|
||||
For adaptive systems, SAK enables dynamic filter selection: use Butterworth during trending markets for its flat passband, switch to band-pass during ranging markets to isolate the dominant cycle, and apply high-pass filtering to detrend before feeding into an oscillator. One indicator instance, multiple behaviors, zero recompilation.
|
||||
|
||||
## Mathematical Foundation
|
||||
|
||||
**Unified transfer function (z-domain):**
|
||||
### Unified Transfer Function
|
||||
|
||||
The z-domain transfer function for all nine modes:
|
||||
|
||||
$$
|
||||
H(z) = \frac{c_0(b_0 + b_1 z^{-1} + b_2 z^{-2})}{1 - a_1 z^{-1} - a_2 z^{-2}}
|
||||
$$
|
||||
|
||||
**Coefficient table:**
|
||||
The corresponding time-domain difference equation:
|
||||
|
||||
$$
|
||||
y_t = c_0(b_0 x_t + b_1 x_{t-1} + b_2 x_{t-2}) + a_1 y_{t-1} + a_2 y_{t-2}
|
||||
$$
|
||||
|
||||
where $x_t$ is the input (price) and $y_t$ is the filtered output.
|
||||
|
||||
### Alpha Derivations by Mode Group
|
||||
|
||||
**Group 1: EMA, HP, SMA, Smooth**
|
||||
|
||||
$$
|
||||
\theta = \frac{2\pi}{P}
|
||||
$$
|
||||
|
||||
$$
|
||||
\alpha = \frac{\cos\theta + \sin\theta - 1}{\cos\theta}
|
||||
$$
|
||||
|
||||
**Group 2: Gauss, Butter, 2PHP**
|
||||
|
||||
$$
|
||||
\theta = \frac{2\pi}{P}
|
||||
$$
|
||||
|
||||
$$
|
||||
\beta = 2.415(1 - \cos\theta)
|
||||
$$
|
||||
|
||||
$$
|
||||
\alpha = -\beta + \sqrt{\beta^2 + 2\beta}
|
||||
$$
|
||||
|
||||
The constant 2.415 ensures Gaussian roll-off at -3 dB at the cutoff frequency.
|
||||
|
||||
**Group 3: BP, BS**
|
||||
|
||||
$$
|
||||
\beta = \cos\left(\frac{2\pi}{P}\right)
|
||||
$$
|
||||
|
||||
$$
|
||||
\gamma = \frac{1}{\cos(2\pi\delta / P)}
|
||||
$$
|
||||
|
||||
$$
|
||||
\alpha = \gamma - \sqrt{\gamma^2 - 1}
|
||||
$$
|
||||
|
||||
where $\delta$ controls bandwidth. Larger $\delta$ widens the pass/stop band; smaller $\delta$ narrows it.
|
||||
|
||||
### Coefficient Table
|
||||
|
||||
| Mode | $c_0$ | $b_0$ | $b_1$ | $b_2$ | $a_1$ | $a_2$ |
|
||||
| :--- | :--- | :---: | :---: | :---: | :--- | :--- |
|
||||
| EMA | 1 | $\alpha$ | 0 | 0 | $1-\alpha$ | 0 |
|
||||
| SMA | $1/n$ | 1 | 0 | 0 | 1 | 0 |
|
||||
| Gauss | $\alpha^2$ | 1 | 0 | 0 | $2(1-\alpha)$ | $-(1-\alpha)^2$ |
|
||||
| Butter | $\alpha^2/4$ | 1 | 2 | 1 | $2(1-\alpha)$ | $-(1-\alpha)^2$ |
|
||||
| Smooth | $\alpha^2/4$ | 1 | 2 | 1 | 0 | 0 |
|
||||
| HP | $1-\alpha/2$ | 1 | $-1$ | 0 | $1-\alpha$ | 0 |
|
||||
| 2PHP | $(1-\alpha/2)^2$ | 1 | $-2$ | 1 | $2(1-\alpha)$ | $-(1-\alpha)^2$ |
|
||||
| BP | $(1-\alpha)/2$ | 1 | 0 | $-1$ | $\beta(1+\alpha)$ | $-\alpha$ |
|
||||
| BS | $(1+\alpha)/2$ | 1 | $-2\beta$ | 1 | $\beta(1+\alpha)$ | $-\alpha$ |
|
||||
| EMA | $1$ | $\alpha$ | $0$ | $0$ | $1-\alpha$ | $0$ |
|
||||
| SMA | $1/n$ | $1$ | $0$ | $0$ | $1$ | $0$ |
|
||||
| Gauss | $\alpha^2$ | $1$ | $0$ | $0$ | $2(1-\alpha)$ | $-(1-\alpha)^2$ |
|
||||
| Butter | $\alpha^2/4$ | $1$ | $2$ | $1$ | $2(1-\alpha)$ | $-(1-\alpha)^2$ |
|
||||
| Smooth | $\alpha^2/4$ | $1$ | $2$ | $1$ | $0$ | $0$ |
|
||||
| HP | $1-\alpha/2$ | $1$ | $-1$ | $0$ | $1-\alpha$ | $0$ |
|
||||
| 2PHP | $(1-\alpha/2)^2$ | $1$ | $-2$ | $1$ | $2(1-\alpha)$ | $-(1-\alpha)^2$ |
|
||||
| BP | $(1-\alpha)/2$ | $1$ | $0$ | $-1$ | $\beta(1+\alpha)$ | $-\alpha$ |
|
||||
| BS | $(1+\alpha)/2$ | $1$ | $-2\beta$ | $1$ | $\beta(1+\alpha)$ | $-\alpha$ |
|
||||
|
||||
**SMA special path:** Uses $\text{Filt} = \frac{1}{n}x_t + \text{Filt}_{t-1} - \frac{1}{n}x_{t-n}$ (running sum).
|
||||
### SMA Special Path
|
||||
|
||||
**Stability:** All modes produce stable filters for $P > 2$. The Gauss and Butter modes have conjugate poles inside the unit circle; BP/BS modes have poles on the real axis for the specified bandwidth.
|
||||
SMA does not use the standard feedforward section. Instead it uses a running-sum recurrence:
|
||||
|
||||
**Default parameters:** `filterType = "BP"`, `period = 20`, `n = 10` (SMA only), `delta = 0.1` (BP/BS), `minPeriod = 2`.
|
||||
$$
|
||||
y_t = \frac{1}{n} x_t + y_{t-1} - \frac{1}{n} x_{t-n}
|
||||
$$
|
||||
|
||||
**Pseudo-code (streaming):**
|
||||
This is O(1) per bar regardless of window length $n$, since it adds the newest sample and subtracts the oldest rather than recomputing the full sum.
|
||||
|
||||
### Smooth Mode: FIR in IIR Clothing
|
||||
|
||||
Smooth mode sets $a_1 = a_2 = 0$, eliminating all feedback. The result is a purely feedforward (FIR) filter:
|
||||
|
||||
$$
|
||||
y_t = \frac{\alpha^2}{4}(x_t + 2x_{t-1} + x_{t-2})
|
||||
$$
|
||||
|
||||
This is a 3-tap triangular window with prescribed gain. No recursion, no stability concerns, no ringing. The trade-off: it provides only modest smoothing compared to genuine IIR modes.
|
||||
|
||||
## Architecture and Physics
|
||||
|
||||
### 1. Unified IIR Engine
|
||||
|
||||
Every mode except SMA flows through the same computation:
|
||||
|
||||
```
|
||||
// Compute alpha, beta, gamma from period and mode
|
||||
[alpha, beta, gamma] = derive_params(filterType, period, delta)
|
||||
|
||||
// Select coefficients by mode
|
||||
[c0, b0, b1, b2, a1, a2] = select_coeffs(filterType, alpha, beta, gamma, n)
|
||||
|
||||
// Apply unified 2nd-order IIR
|
||||
if filterType == "SMA":
|
||||
result = (1/n)*src + result[1] - (1/n)*src[n]
|
||||
else:
|
||||
result = c0*(b0*src + b1*src[1] + b2*src[2]) + a1*result[1] + a2*result[2]
|
||||
y[t] = c0 * (b0*x[t] + b1*x[t-1] + b2*x[t-2]) + a1*y[t-1] + a2*y[t-2]
|
||||
```
|
||||
|
||||
## Resources
|
||||
The engine stores two previous inputs ($x_{t-1}$, $x_{t-2}$) and two previous outputs ($y_{t-1}$, $y_{t-2}$). Total state: four doubles plus the coefficient set. This is the minimal state for any second-order IIR filter.
|
||||
|
||||
### 2. Coefficient Derivation Per Mode Group
|
||||
|
||||
The nine modes divide into three groups based on how $\alpha$ is computed:
|
||||
|
||||
**Group 1 (EMA/HP/SMA/Smooth):** Uses the EMA alpha formula $\alpha = (\cos\theta + \sin\theta - 1)/\cos\theta$. This places a single real pole at distance $(1-\alpha)$ from the origin. For HP mode, the zero at $z = 1$ blocks the DC component. For Smooth mode, the lack of feedback poles makes it FIR.
|
||||
|
||||
**Group 2 (Gauss/Butter/2PHP):** Uses the Gaussian alpha via $\beta = 2.415(1 - \cos\theta)$. This places conjugate complex poles that produce a smoother roll-off than the EMA formula. Butterworth adds feedforward zeros at $z = -1$ to flatten the passband. 2PHP inverts the numerator to create a second-order high-pass response.
|
||||
|
||||
**Group 3 (BP/BS):** Uses a bandwidth-dependent alpha with parameter $\delta$. The poles sit on a circle of radius $\alpha$, placed at angle $\beta$ (the center frequency). Band-pass zeros at $z = \pm 1$ create the band-pass shape. Band-stop zeros at angle $\beta$ create the notch.
|
||||
|
||||
### 3. SMA Special Path
|
||||
|
||||
SMA bypasses the IIR engine entirely. The running-sum recurrence $y_t = (1/n)x_t + y_{t-1} - (1/n)x_{t-n}$ requires a circular buffer of length $n$ to store past inputs. This makes SMA the only mode with O(n) memory rather than O(1).
|
||||
|
||||
The reason for the special path: expressing SMA as a pure IIR filter would require $n$ feedback taps (an $n$th-order IIR), which defeats the purpose of a second-order framework. The running-sum trick achieves O(1) computation per bar while keeping SMA within the SAK interface.
|
||||
|
||||
### 4. Stability Analysis
|
||||
|
||||
For a second-order IIR filter to be stable, all poles of $1 - a_1 z^{-1} - a_2 z^{-2} = 0$ must lie inside the unit circle ($|z| < 1$).
|
||||
|
||||
**LP modes (Gauss, Butter):** Poles at $z = (1-\alpha) \pm j\epsilon$. Since $0 < \alpha < 1$ for $P > 2$, the pole modulus $|1-\alpha| < 1$. Stable.
|
||||
|
||||
**HP modes (HP, 2PHP):** Same pole placement as LP counterparts. The zeros change (high-pass vs low-pass), but poles remain inside the unit circle. Stable.
|
||||
|
||||
**BP/BS modes:** Poles at modulus $\alpha < 1$ for $P > 2$ and valid $\delta$. The condition $\gamma^2 - 1 \geq 0$ requires $\delta/P \leq 0.25$, which is satisfied for all practical bandwidth settings. Stable.
|
||||
|
||||
**EMA:** Single pole at $(1-\alpha)$. Since $\alpha \in (0, 1)$ for $P > 2$, the pole is inside the unit circle. Stable.
|
||||
|
||||
**SMA:** The running-sum recurrence has a pole at $z = 1$ (marginally stable), but the subtraction of $x_{t-n}$ acts as implicit stabilization. Numerically stable for finite-precision arithmetic.
|
||||
|
||||
**Smooth:** No poles (FIR). Always stable.
|
||||
|
||||
**Critical boundary:** At $P = 2$, the EMA alpha formula yields $\alpha = 1$ and the filter degenerates. The constraint $P > 2$ must be enforced at the API level.
|
||||
|
||||
### 5. Frequency Response Characteristics
|
||||
|
||||
| Mode | Passband | Stopband | Roll-off | Phase |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| EMA | $[0, f_c]$ | $(f_c, f_N]$ | -6 dB/oct | Non-linear |
|
||||
| SMA | $[0, f_c]$ | $(f_c, f_N]$ | -6 dB/oct (approx) | Linear |
|
||||
| Gauss | $[0, f_c]$ | $(f_c, f_N]$ | -12 dB/oct | Non-linear |
|
||||
| Butter | $[0, f_c]$ | $(f_c, f_N]$ | -12 dB/oct | Maximally flat |
|
||||
| Smooth | $[0, f_c]$ | $(f_c, f_N]$ | -6 dB/oct | Linear (FIR) |
|
||||
| HP | $(f_c, f_N]$ | $[0, f_c]$ | -6 dB/oct | Non-linear |
|
||||
| 2PHP | $(f_c, f_N]$ | $[0, f_c]$ | -12 dB/oct | Non-linear |
|
||||
| BP | $[f_c-\Delta, f_c+\Delta]$ | Outside band | -6 dB/oct per side | Non-linear |
|
||||
| BS | Outside notch | $[f_c-\Delta, f_c+\Delta]$ | -6 dB/oct per side | Non-linear |
|
||||
|
||||
where $f_c = 1/P$ is the cutoff frequency and $f_N$ is the Nyquist frequency.
|
||||
|
||||
## Interpretation and Signals
|
||||
|
||||
### Overlay Modes (EMA, SMA, Gauss, Butter, Smooth)
|
||||
|
||||
These modes output values on the same scale as price. Standard usage:
|
||||
|
||||
- **Trend identification:** Price above the filter output suggests uptrend; below suggests downtrend.
|
||||
- **Support/resistance:** The filter output acts as dynamic support in uptrends, resistance in downtrends.
|
||||
- **Crossover systems:** Fast SAK(shorter period) crossing slow SAK(longer period) generates signals.
|
||||
- **Mode comparison:** Run Gauss and Butter on identical data to compare roll-off. Butterworth preserves more passband detail; Gaussian rolls off more gradually.
|
||||
|
||||
**Choosing between LP modes:** EMA has more lag than Gauss for the same period but less overshoot. Butterworth provides the flattest passband response (least distortion of low-frequency components). Smooth mode is the cheapest computationally but provides the least attenuation.
|
||||
|
||||
### Oscillator Modes (HP, 2PHP, BP, BS)
|
||||
|
||||
These modes output values centered around zero.
|
||||
|
||||
- **HP/2PHP (detrending):** Removes the trend component, isolating cycles. Useful as a pre-processor before feeding into oscillator indicators. 2PHP provides sharper trend removal (-12 dB/oct vs -6 dB/oct).
|
||||
- **BP (cycle isolation):** Extracts the component at period $P$ with bandwidth $\delta$. When the dominant market cycle matches $P$, the BP output shows clean sinusoidal swings. Zero-crossings indicate cycle turning points.
|
||||
- **BS (notch rejection):** Removes a specific frequency while passing everything else. Useful for eliminating known periodic noise (e.g., a daily settlement artifact at a known period).
|
||||
|
||||
### Bandwidth Parameter ($\delta$)
|
||||
|
||||
For BP and BS modes, $\delta$ controls the width of the pass/stop band:
|
||||
|
||||
- $\delta = 0.1$ (default): Narrow band, high selectivity, more ringing
|
||||
- $\delta = 0.3$: Moderate band, balanced response
|
||||
- $\delta = 0.5$: Wide band, low selectivity, less ringing
|
||||
|
||||
Wider bandwidth trades frequency selectivity for time-domain responsiveness. Narrow bandwidth isolates the target frequency more precisely but introduces more transient ringing when the input changes abruptly.
|
||||
|
||||
## Quality Metrics
|
||||
|
||||
Quality scores vary by mode. Representative scores for the most commonly used modes:
|
||||
|
||||
### Low-Pass Modes
|
||||
|
||||
| Metric | EMA | Gauss | Butter | Score Basis |
|
||||
| :--- | :---: | :---: | :---: | :--- |
|
||||
| **Lag** | 5/10 | 6/10 | 7/10 | Bars of delay at cutoff |
|
||||
| **Smoothness** | 6/10 | 8/10 | 9/10 | Stopband attenuation |
|
||||
| **Overshoot** | 8/10 | 7/10 | 6/10 | Step response ringing |
|
||||
| **Passband Flatness** | 5/10 | 7/10 | 10/10 | Gain variation in passband |
|
||||
| **Computational Cost** | 10/10 | 9/10 | 9/10 | Ops per bar (lower = better score) |
|
||||
|
||||
### High-Pass and Band-Pass Modes
|
||||
|
||||
| Metric | HP | 2PHP | BP | Score Basis |
|
||||
| :--- | :---: | :---: | :---: | :--- |
|
||||
| **Trend Rejection** | 6/10 | 9/10 | 8/10 | DC attenuation |
|
||||
| **Cycle Clarity** | 5/10 | 7/10 | 9/10 | Signal-to-noise at target frequency |
|
||||
| **Transient Response** | 8/10 | 6/10 | 5/10 | Settling time after step input |
|
||||
| **Ringing** | 9/10 | 7/10 | 5/10 | Oscillation after impulse |
|
||||
| **Computational Cost** | 10/10 | 9/10 | 9/10 | Ops per bar |
|
||||
|
||||
## Related Indicators
|
||||
|
||||
SAK subsumes or closely relates to several standalone indicators in QuanTAlib:
|
||||
|
||||
| Indicator | Relationship | Path |
|
||||
| :--- | :--- | :--- |
|
||||
| [EMA](../../trends_IIR/ema/Ema.md) | Identical to SAK EMA mode | `lib/trends_IIR/ema/` |
|
||||
| [SMA](../../trends_FIR/sma/Sma.md) | Identical to SAK SMA mode | `lib/trends_FIR/sma/` |
|
||||
| [Gauss](../gauss/Gauss.md) | Identical to SAK Gauss mode | `lib/filters/gauss/` |
|
||||
| [Butter2](../butter2/Butter2.md) | Identical to SAK Butter mode | `lib/filters/butter2/` |
|
||||
| [Hp](../hp/Hp.md) | Related to SAK HP mode | `lib/filters/hp/` |
|
||||
| [Hpf](../hpf/Hpf.md) | Related to SAK 2PHP mode | `lib/filters/hpf/` |
|
||||
| [Bpf](../bpf/Bpf.md) | Related to SAK BP mode | `lib/filters/bpf/` |
|
||||
| [SSF2](../ssf2/Ssf2.md) | 2-pole super smoother, similar to Butter | `lib/filters/ssf2/` |
|
||||
| [Notch](../notch/Notch.md) | Related to SAK BS mode | `lib/filters/notch/` |
|
||||
|
||||
The standalone implementations may differ slightly in alpha derivation or normalization, but the core IIR structure is identical. SAK's value is the unified interface, not algorithmic novelty.
|
||||
|
||||
## Validation
|
||||
|
||||
SAK is a multi-mode indicator. Validation must cover each mode independently.
|
||||
|
||||
| Mode | Batch | Streaming | Span | Reference |
|
||||
| :--- | :---: | :---: | :---: | :--- |
|
||||
| EMA | pending | pending | pending | EMA standalone |
|
||||
| SMA | pending | pending | pending | SMA standalone |
|
||||
| Gauss | pending | pending | pending | Gauss standalone |
|
||||
| Butter | pending | pending | pending | Butter2 standalone |
|
||||
| Smooth | pending | pending | pending | PineScript reference |
|
||||
| HP | pending | pending | pending | HP standalone |
|
||||
| 2PHP | pending | pending | pending | Hpf standalone |
|
||||
| BP | pending | pending | pending | Bpf standalone |
|
||||
| BS | pending | pending | pending | PineScript reference |
|
||||
|
||||
**Tolerance targets:**
|
||||
|
||||
| Reference | Tolerance |
|
||||
| :--- | :--- |
|
||||
| QuanTAlib standalone equivalents | $1 \times 10^{-13}$ (bit-exact expected) |
|
||||
| PineScript reference | $1 \times 10^{-9}$ |
|
||||
|
||||
## Performance Profile
|
||||
|
||||
### Operation Count (Streaming Mode, Per Bar)
|
||||
|
||||
For the standard IIR path (all modes except SMA):
|
||||
|
||||
| Operation | Count | Cost (cycles) | Subtotal |
|
||||
| :--- | :---: | :---: | :---: |
|
||||
| MUL | 5 | 3 | 15 |
|
||||
| ADD/SUB | 4 | 1 | 4 |
|
||||
| **Total (IIR path)** | **9** | | **~19 cycles** |
|
||||
|
||||
For SMA mode (running-sum path):
|
||||
|
||||
| Operation | Count | Cost (cycles) | Subtotal |
|
||||
| :--- | :---: | :---: | :---: |
|
||||
| MUL | 2 | 3 | 6 |
|
||||
| ADD/SUB | 2 | 1 | 2 |
|
||||
| Memory (ring buffer) | 1 | ~4 | 4 |
|
||||
| **Total (SMA path)** | **5** | | **~12 cycles** |
|
||||
|
||||
Coefficient derivation (once per instance, not per bar):
|
||||
|
||||
| Operation | Count | Cost (cycles) | Subtotal |
|
||||
| :--- | :---: | :---: | :---: |
|
||||
| COS | 1-2 | 50 | 50-100 |
|
||||
| SIN | 0-1 | 50 | 0-50 |
|
||||
| SQRT | 0-1 | 15 | 0-15 |
|
||||
| MUL/DIV | 3-6 | 3-15 | 9-90 |
|
||||
| **Total (init)** | | | **~60-255 cycles** |
|
||||
|
||||
### SIMD Analysis
|
||||
|
||||
The IIR path is inherently recursive: each bar depends on the previous bar's output. Cross-bar SIMD parallelization is not possible.
|
||||
|
||||
Within-bar SIMD is also limited because the IIR computation involves only 9 scalar operations. The overhead of loading/storing SIMD registers exceeds any gain from vectorizing 5 multiplications.
|
||||
|
||||
**Batch `Calculate(Span)` optimization:** For LP modes that do not use the IIR path's $y_{t-2}$ term (EMA, SMA), the recurrence reduces to first-order, potentially enabling loop unrolling with FMA:
|
||||
|
||||
```
|
||||
y[t] = FMA(y[t-1], decay, alpha * x[t])
|
||||
```
|
||||
|
||||
where `decay = 1 - alpha`. This is a single FMA instruction per bar.
|
||||
|
||||
**SIMD-friendly modes:** Smooth mode (FIR, no feedback) can be fully vectorized across 4 bars simultaneously using AVX2 `VFMADD` instructions, yielding ~4x throughput improvement for batch computation.
|
||||
|
||||
### Memory Profile
|
||||
|
||||
| Component | Size | Notes |
|
||||
| :--- | :---: | :--- |
|
||||
| Coefficients ($c_0$, $b_0-b_2$, $a_1$, $a_2$) | 48 bytes | 6 doubles, computed once |
|
||||
| Input history ($x_{t-1}$, $x_{t-2}$) | 16 bytes | 2 doubles |
|
||||
| Output history ($y_{t-1}$, $y_{t-2}$) | 16 bytes | 2 doubles |
|
||||
| State struct overhead | ~8 bytes | Alignment padding |
|
||||
| **Total (IIR modes)** | **~88 bytes** | |
|
||||
| Ring buffer (SMA only) | $8n$ bytes | 80 bytes for $n=10$ |
|
||||
| **Total (SMA mode)** | **~168 bytes** | |
|
||||
|
||||
Per-instance memory is minimal. Running 10,000 concurrent SAK instances requires ~860 KB for IIR modes or ~1.6 MB for SMA mode.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Period must exceed 2.** At $P = 2$, the EMA alpha formula yields $\alpha = 1$ (division by $\cos(\pi) = -1$ produces a sign flip that breaks the derivation). The Gauss/Butter beta formula also degenerates. Enforce $P \geq 3$ in practice, or at minimum validate $P > 2$ at construction. Impact: filter divergence producing `NaN` or `Infinity` output.
|
||||
|
||||
2. **SMA mode needs the `n` parameter, not `period`.** The `period` parameter controls the alpha derivation for IIR modes. For SMA, the window length comes from `n`. Setting `period = 50` with `n = 10` produces a 10-bar SMA, not a 50-bar SMA. Confusing these two is the single most common SAK misconfiguration.
|
||||
|
||||
3. **BP/BS bandwidth ($\delta$) must satisfy $\delta/P \leq 0.25$.** When $\delta$ is too large relative to $P$, the gamma computation $\gamma = 1/\cos(2\pi\delta/P)$ produces $\gamma < 1$, making $\gamma^2 - 1 < 0$ and the square root undefined. The filter falls back to $\alpha = 0$, producing zero output. Impact: silent failure with no error, just flat-line at zero.
|
||||
|
||||
4. **Smooth mode provides minimal smoothing.** Because it has no feedback ($a_1 = a_2 = 0$), Smooth mode is a 3-tap FIR filter with weights $[1, 2, 1]/4$ scaled by $\alpha^2$. Its attenuation at the stopband is roughly -6 dB, compared to -12 dB for Butterworth. Traders expecting strong noise rejection will be disappointed. Use Gauss or Butter for serious smoothing.
|
||||
|
||||
5. **HP and 2PHP are pre-processors, not standalone signals.** High-pass output oscillates around zero and contains all market noise above the cutoff frequency. Using raw HP output as a trading signal produces excessive whipsaws. Feed HP output into a secondary smoother or oscillator (band-pass, zero-crossing detector) for actionable signals.
|
||||
|
||||
6. **Initial transient corrupts first 2-3 bars.** All IIR modes produce unreliable output until the filter state stabilizes. For two-pole modes (Gauss, Butter, 2PHP, BP, BS), allow at least 3 bars of warmup. For SMA mode, allow $n$ bars. Signals from the transient period have no analytical meaning.
|
||||
|
||||
7. **Band-stop mode is not a trend filter.** BS (notch) removes a narrow frequency band and passes everything else, including high-frequency noise. It is not equivalent to a low-pass filter. Traders who want trend extraction should use EMA, Gauss, or Butter modes instead. BS is for removing known periodic interference from a signal that will receive further processing.
|
||||
|
||||
## References
|
||||
|
||||
- Ehlers, J.F. (2006). "Swiss Army Knife Indicator." *Technical Analysis of Stocks & Commodities*, January 2006.
|
||||
- Lyons, R. and Bell, A. (2004). "The Swiss Army Knife of Digital Networks." *IEEE Signal Processing Magazine*, May 2004, pp. 90-100.
|
||||
- Ehlers, J.F. (2001). *Rocket Science for Traders*. Wiley. Chapters 3-4: IIR and FIR filter design.
|
||||
- Ehlers, J.F. (2004). *Cybernetic Analysis for Stocks and Futures*. Wiley. Chapter 2: Filters.
|
||||
- Ehlers, J.F. (2004). *Cybernetic Analysis for Stocks and Futures*. Wiley. Chapter 2: digital filter fundamentals.
|
||||
- Ehlers, J.F. (2013). *Cycle Analytics for Traders*. Wiley. Chapter 4: filter comparison and selection criteria.
|
||||
|
||||
@@ -268,7 +268,9 @@ public sealed class Ssf2 : AbstractBase
|
||||
state.PrevInput = state.LastValidValue;
|
||||
output[i] = state.LastValidValue;
|
||||
state.Count = 1;
|
||||
#pragma warning disable S127 // Warmup init: advance past first valid to seed state machine
|
||||
i++;
|
||||
#pragma warning restore S127
|
||||
break;
|
||||
}
|
||||
output[i] = double.NaN;
|
||||
|
||||
@@ -270,7 +270,9 @@ public sealed class Usf : AbstractBase
|
||||
state.PrevInput2 = state.LastValidValue;
|
||||
output[i] = state.LastValidValue;
|
||||
state.Count = 1;
|
||||
#pragma warning disable S127 // Warmup init: advance past first valid to seed state machine
|
||||
i++;
|
||||
#pragma warning restore S127
|
||||
break;
|
||||
}
|
||||
output[i] = double.NaN;
|
||||
|
||||
Reference in New Issue
Block a user