From b79b56dc6505bc2864694aa801dec4133040e6cd Mon Sep 17 00:00:00 2001 From: Miha Kralj Date: Wed, 18 Mar 2026 19:10:48 -0700 Subject: [PATCH] feat: add TBF (Ehlers Truncated BandPass Filter) + fix all 64 warnings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TBF indicator: - Sealed class with RingBuffer, stackalloc scratch, O(Length) per bar - 7 core files: Tbf.cs, Tbf.Quantower.cs, Tbf.md, tbf.pine, 3 test files - 67 tests (48 lib + 19 Quantower) all passing - Full integration: sidebar, indexes, docs, Python bridge, exports AMFM fix: - Added envBuf.Clear()/smaBuf.Clear() after stackalloc in Batch (SkipLocalsInit garbage values caused 8.97e+65 blowup) Warning fixes (64 → 0): - Amfm.cs: S125 commented code removed, 11× IDE0011 braces - Pta.cs: 11× IDE0011 braces on if/else/for/foreach - Pta.Tests.cs: 14× IDE0011, S1481 unused var, S2699 assertion, 2× MA0074 - Lpf.Quantower.Tests.cs: 2× MA0074 StringComparison Build: 0 warnings, 0 errors, 20,048 tests passing --- README.md | 12 + _sidebar.md | 1 + docs/indicators.md | 1 + docs/pinescript.md | 1 + lib/_index.md | 1 + lib/cycles/amfm/Amfm.cs | 59 ++- lib/cycles/lpf/tests/Lpf.Quantower.Tests.cs | 4 +- lib/dynamics/pta/Pta.cs | 31 +- lib/dynamics/pta/tests/Pta.Tests.cs | 58 ++- lib/filters/_index.md | 1 + lib/filters/tbf/Tbf.Quantower.cs | 72 +++ lib/filters/tbf/Tbf.cs | 410 ++++++++++++++++ lib/filters/tbf/Tbf.md | 146 ++++++ lib/filters/tbf/tbf.pine | 57 +++ lib/filters/tbf/tests/Tbf.Quantower.Tests.cs | 285 +++++++++++ lib/filters/tbf/tests/Tbf.Tests.cs | 449 ++++++++++++++++++ lib/filters/tbf/tests/Tbf.Validation.Tests.cs | 398 ++++++++++++++++ python/quantalib/_bridge.py | 1 + python/quantalib/channels.py | 8 +- python/quantalib/filters.py | 14 + python/src/Exports.Generated.cs | 16 + 21 files changed, 1992 insertions(+), 33 deletions(-) create mode 100644 lib/filters/tbf/Tbf.Quantower.cs create mode 100644 lib/filters/tbf/Tbf.cs create mode 100644 lib/filters/tbf/Tbf.md create mode 100644 lib/filters/tbf/tbf.pine create mode 100644 lib/filters/tbf/tests/Tbf.Quantower.Tests.cs create mode 100644 lib/filters/tbf/tests/Tbf.Tests.cs create mode 100644 lib/filters/tbf/tests/Tbf.Validation.Tests.cs diff --git a/README.md b/README.md index a5f60b60..1e886a31 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,18 @@ [![CodeFactor](https://www.codefactor.io/repository/github/mihakralj/quantalib/badge/main)](https://www.codefactor.io/repository/github/mihakralj/quantalib/overview/main) [![Nuget](https://img.shields.io/nuget/v/QuanTAlib?style=flat-square)](https://www.nuget.org/packages/QuanTAlib/) ![GitHub last commit](https://img.shields.io/github/last-commit/mihakralj/QuanTAlib) +[![Nuget](https://img.shields.io/nuget/dt/QuanTAlib?style=flat-sq[![Codacy grade](https://app.codacy.com/project/badge/Grade/c8be6c08f5514e95b84d37e661a6ec27)](https://app.codacy.com/gh/mihakralj/QuanTAlib/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) +[![codecov](https://codecov.io/gh/mihakralj/QuanTAlib/branch/main/graph/badge.svg?style=flat-square&token=YNMJRGKMTJ?style=flat-square)](https://codecov.io/gh/mihakralj/QuanTAlib) +[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=mihakralj_QuanTAlib&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=mihakralj_QuanTAlib) +[![CodeFactor](https://www.codefactor.io/repository/github/mihakralj/quantalib/badge/main)](https://www.codefactor.io/repository/github/mihakralj/quantalib/overview/main) +[![Nuget](https://img.shields.io/nuget/v/QuanTAlib?style=flat-square)](https://www.nuget.org/packages/QuanTAlib/) +![GitHub last commit](https://img.shields.io/github/last-commit/mihakralj/QuanTAlib) +[![Nuget](https://img.shields.io/nuget/dt/QuanTAlib?style=flat-sq[![Codacy grade](https://app.codacy.com/project/badge/Grade/c8be6c08f5514e95b84d37e661a6ec27)](https://app.codacy.com/gh/mihakralj/QuanTAlib/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) +[![codecov](https://codecov.io/gh/mihakralj/QuanTAlib/branch/main/graph/badge.svg?style=flat-square&token=YNMJRGKMTJ?style=flat-square)](https://codecov.io/gh/mihakralj/QuanTAlib) +[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=mihakralj_QuanTAlib&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=mihakralj_QuanTAlib) +[![CodeFactor](https://www.codefactor.io/repository/github/mihakralj/quantalib/badge/main)](https://www.codefactor.io/repository/github/mihakralj/quantalib/overview/main) +[![Nuget](https://img.shields.io/nuget/v/QuanTAlib?style=flat-square)](https://www.nuget.org/packages/QuanTAlib/) +![GitHub last commit](https://img.shields.io/github/last-commit/mihakralj/QuanTAlib) [![Nuget](https://img.shields.io/nuget/dt/QuanTAlib?style=flat-square)](https://www.nuget.org/packages/QuanTAlib/) [![.NET](https://img.shields.io/badge/.NET-10.0-blue?style=flat-square)](https://dotnet.microsoft.com/en-us/download/dotnet) diff --git a/_sidebar.md b/_sidebar.md index 9bbb7486..7fc1d028 100644 --- a/_sidebar.md +++ b/_sidebar.md @@ -116,6 +116,7 @@ * [SAK - Ehlers Swiss Army Knife](/lib/filters/sak/Sak.md) * [SGF - Savitzky-Golay Filter](/lib/filters/sgf/Sgf.md) * [SPBF - Ehlers Super Passband Filter](/lib/filters/spbf/Spbf.md) + * [TBF - Ehlers Truncated Bandpass Filter](/lib/filters/tbf/Tbf.md) * [SSF2 - Ehlers 2-Pole Super Smoother Filter](/lib/filters/ssf2/Ssf2.md) * [SSF3 - Ehlers 3-Pole Super Smoother Filter](/lib/filters/ssf3/Ssf3.md) * [USF - Ehlers Ultimate Smoother Filter](/lib/filters/usf/Usf.md) diff --git a/docs/indicators.md b/docs/indicators.md index 19c63a36..336f5ef1 100644 --- a/docs/indicators.md +++ b/docs/indicators.md @@ -154,6 +154,7 @@ Signal processing filters adapted for financial time series. Designed to separat | [**SAK**](../lib/filters/sak/Sak.md) | Ehlers Swiss Army Knife | Configurable multi-mode filter (LP, HP, BP, BS) | | [**SGF**](../lib/filters/sgf/Sgf.md) | Savitzky-Golay Filter | Polynomial least-squares fitting | | [**SPBF**](../lib/filters/spbf/Spbf.md) | Ehlers Super Passband Filter | Ehlers wide-band bandpass with RMS envelope | +| [**TBF**](../lib/filters/tbf/Tbf.md) | Ehlers Truncated Bandpass Filter | Truncated IIR bandpass, eliminates initialization errors | | [**SSF2**](../lib/filters/ssf2/Ssf2.md) | Ehlers 2-Pole Super Smoother Filter | Ehlers two-pole design | | [**SSF3**](../lib/filters/ssf3/Ssf3.md) | Ehlers 3-Pole Super Smoother Filter | Ehlers three-pole, single-sample feedforward | | [**USF**](../lib/filters/usf/Usf.md) | Ehlers Ultimate Smoother | Ehlers high-fidelity filter | diff --git a/docs/pinescript.md b/docs/pinescript.md index 2dee8743..f65efd23 100644 --- a/docs/pinescript.md +++ b/docs/pinescript.md @@ -182,6 +182,7 @@ These are the heavy artillery. Kalman filters, Butterworth filters, wavelets. If | SAK | Ehlers Swiss Army Knife | [sak.pine](../lib/filters/sak/sak.pine) | | SGF | Savitzky-Golay Filter | [sgf.pine](../lib/filters/sgf/sgf.pine) | | SPBF | Ehlers Super Passband Filter | [spbf.pine](../lib/filters/spbf/spbf.pine) | +| TBF | Ehlers Truncated Bandpass Filter | [tbf.pine](../lib/filters/tbf/tbf.pine) | | SSF2 | Ehlers 2-Pole Super Smoother Filter | [ssf2.pine](../lib/filters/ssf2/ssf2.pine) | | SSF3 | Ehlers 3-Pole Super Smoother Filter | [ssf3.pine](../lib/filters/ssf3/ssf3.pine) | | USF | Ehlers Ultimate Smoother Filter | [usf.pine](../lib/filters/usf/usf.pine) | diff --git a/lib/_index.md b/lib/_index.md index 481964e0..1d2bafbc 100644 --- a/lib/_index.md +++ b/lib/_index.md @@ -356,6 +356,7 @@ | [SWINGS](reversals/swings/Swings.md) | Swing High/Low Detection | Reversals | | [SWMA](trends_FIR/swma/Swma.md) | Symmetric Weighted MA | Trends (FIR) | | [T3](trends_IIR/t3/T3.md) | Tillson T3 MA | Trends (IIR) | +| [TBF](filters/tbf/Tbf.md) | Ehlers Truncated Bandpass Filter | Filters | | [TD_SEQ](oscillators/td_seq/Td_seq.md) | TD Sequential | Oscillators | | [TDIST](numerics/tdist/Tdist.md) | Student's t-Distribution | Numerics | | [TEMA](trends_IIR/tema/Tema.md) | Triple Exponential MA | Trends (IIR) | diff --git a/lib/cycles/amfm/Amfm.cs b/lib/cycles/amfm/Amfm.cs index 6e7872df..d105bf7b 100644 --- a/lib/cycles/amfm/Amfm.cs +++ b/lib/cycles/amfm/Amfm.cs @@ -109,8 +109,6 @@ public sealed class Amfm : ITValuePublisher _s = default; _ps = default; - // Warmup: need 4 bars for envelope + 8 bars for SMA = 12; - // also need 'period' bars for SSF convergence WarmupPeriod = Math.Max(12, period); Name = $"Amfm({period})"; _barHandler = HandleBar; @@ -201,9 +199,18 @@ public sealed class Amfm : ITValuePublisher // Find max of the 4-element envelope buffer double envel = _amEnvBuf[0]; - if (_amEnvBuf[1] > envel) envel = _amEnvBuf[1]; - if (_amEnvBuf[2] > envel) envel = _amEnvBuf[2]; - if (_amEnvBuf[3] > envel) envel = _amEnvBuf[3]; + if (_amEnvBuf[1] > envel) + { + envel = _amEnvBuf[1]; + } + if (_amEnvBuf[2] > envel) + { + envel = _amEnvBuf[2]; + } + if (_amEnvBuf[3] > envel) + { + envel = _amEnvBuf[3]; + } // Step 2: AM = SMA(envelope, 8) int smaIdx = _s.SmaIdx; @@ -224,8 +231,14 @@ public sealed class Amfm : ITValuePublisher // ── FM Demodulator ─────────────────────────────────────────── // Step 1: Hard limiter (10x gain, clamp to ±1) double hl = 10.0 * deriv; - if (hl > 1.0) hl = 1.0; - else if (hl < -1.0) hl = -1.0; + if (hl > 1.0) + { + hl = 1.0; + } + else if (hl < -1.0) + { + hl = -1.0; + } // Step 2: Super Smoother (2-pole Butterworth IIR) double fm; @@ -301,7 +314,10 @@ public sealed class Amfm : ITValuePublisher { throw new ArgumentException("Period must be greater than 0", nameof(period)); } - if (len == 0) return; + if (len == 0) + { + return; + } // Super Smoother coefficients double a1 = Math.Exp(-1.414 * Math.PI / period); @@ -312,7 +328,9 @@ public sealed class Amfm : ITValuePublisher // AM state Span envBuf = stackalloc double[4]; + envBuf.Clear(); Span smaBuf = stackalloc double[8]; + smaBuf.Clear(); double smaSum = 0.0; int envIdx = 0; int smaIdx = 0; @@ -332,9 +350,18 @@ public sealed class Amfm : ITValuePublisher envIdx = (envIdx + 1) & 3; double envel = envBuf[0]; - if (envBuf[1] > envel) envel = envBuf[1]; - if (envBuf[2] > envel) envel = envBuf[2]; - if (envBuf[3] > envel) envel = envBuf[3]; + if (envBuf[1] > envel) + { + envel = envBuf[1]; + } + if (envBuf[2] > envel) + { + envel = envBuf[2]; + } + if (envBuf[3] > envel) + { + envel = envBuf[3]; + } // AM: SMA(envelope, 8) double oldSma = smaBuf[smaIdx]; @@ -346,8 +373,14 @@ public sealed class Amfm : ITValuePublisher // FM: hard limiter double hl = 10.0 * deriv; - if (hl > 1.0) hl = 1.0; - else if (hl < -1.0) hl = -1.0; + if (hl > 1.0) + { + hl = 1.0; + } + else if (hl < -1.0) + { + hl = -1.0; + } // FM: Super Smoother double fm; diff --git a/lib/cycles/lpf/tests/Lpf.Quantower.Tests.cs b/lib/cycles/lpf/tests/Lpf.Quantower.Tests.cs index 361f2e16..8852161d 100644 --- a/lib/cycles/lpf/tests/Lpf.Quantower.Tests.cs +++ b/lib/cycles/lpf/tests/Lpf.Quantower.Tests.cs @@ -283,7 +283,7 @@ public class LpfIndicatorTests public void LpfIndicator_SourceCodeLink_IsValid() { var indicator = new LpfIndicator(); - Assert.Contains("github.com", indicator.SourceCodeLink); - Assert.Contains("Lpf.Quantower.cs", indicator.SourceCodeLink); + Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal); + Assert.Contains("Lpf.Quantower.cs", indicator.SourceCodeLink, StringComparison.Ordinal); } } diff --git a/lib/dynamics/pta/Pta.cs b/lib/dynamics/pta/Pta.cs index 8ce925db..052d9d62 100644 --- a/lib/dynamics/pta/Pta.cs +++ b/lib/dynamics/pta/Pta.cs @@ -74,8 +74,10 @@ public sealed class Pta : AbstractBase ArgumentOutOfRangeException.ThrowIfLessThan(longPeriod, 3, nameof(longPeriod)); ArgumentOutOfRangeException.ThrowIfLessThan(shortPeriod, 2, nameof(shortPeriod)); if (longPeriod <= shortPeriod) + { throw new ArgumentOutOfRangeException(nameof(longPeriod), $"longPeriod ({longPeriod}) must be greater than shortPeriod ({shortPeriod})."); + } LongPeriod = longPeriod; ShortPeriod = shortPeriod; @@ -120,16 +122,22 @@ public sealed class Pta : AbstractBase public override void Prime(ReadOnlySpan source, TimeSpan? step = null) { foreach (double v in source) + { Update(new TValue(DateTime.MinValue, v), isNew: true); + } } [MethodImpl(MethodImplOptions.AggressiveInlining)] public override TValue Update(TValue input, bool isNew = true) { if (isNew) + { _p_state = _state; + } else + { _state = _p_state; + } double src = input.Value; ref State s = ref _state; @@ -188,7 +196,10 @@ public sealed class Pta : AbstractBase [MethodImpl(MethodImplOptions.AggressiveOptimization)] public override TSeries Update(TSeries source) { - if (source.Count == 0) return []; + if (source.Count == 0) + { + return []; + } var resultValues = new double[source.Count]; Batch(source.Values, resultValues, LongPeriod, ShortPeriod); @@ -196,7 +207,9 @@ public sealed class Pta : AbstractBase var result = new TSeries(); var times = source.Times; for (int i = 0; i < source.Count; i++) + { result.Add(new TValue(times[i], resultValues[i])); + } // Sync internal state int len = source.Count; @@ -204,7 +217,9 @@ public sealed class Pta : AbstractBase { var replay = new Pta(LongPeriod, ShortPeriod); for (int i = 0; i < len; i++) + { replay.Update(new TValue(times[i], source.Values[i])); + } _state = replay._state; } _p_state = _state; @@ -226,14 +241,21 @@ public sealed class Pta : AbstractBase int longPeriod = 250, int shortPeriod = 40) { if (source.Length != output.Length) + { throw new ArgumentException("Source and output spans must be of equal length.", nameof(output)); - if (source.Length == 0) return; + } + if (source.Length == 0) + { + return; + } ArgumentOutOfRangeException.ThrowIfLessThan(longPeriod, 3, nameof(longPeriod)); ArgumentOutOfRangeException.ThrowIfLessThan(shortPeriod, 2, nameof(shortPeriod)); if (longPeriod <= shortPeriod) + { throw new ArgumentOutOfRangeException(nameof(longPeriod), $"longPeriod ({longPeriod}) must be greater than shortPeriod ({shortPeriod})."); + } // Precompute coefficients ComputeHpCoefficients(longPeriod, out double c1L, out double c2L, out double c3L); @@ -241,7 +263,10 @@ public sealed class Pta : AbstractBase // Bar 0 and 1: output = 0 (not enough history for 2nd-order diff) output[0] = 0.0; - if (source.Length < 2) return; + if (source.Length < 2) + { + return; + } output[1] = 0.0; double hp1 = 0, hp1_1 = 0; diff --git a/lib/dynamics/pta/tests/Pta.Tests.cs b/lib/dynamics/pta/tests/Pta.Tests.cs index 44114477..dce00721 100644 --- a/lib/dynamics/pta/tests/Pta.Tests.cs +++ b/lib/dynamics/pta/tests/Pta.Tests.cs @@ -95,7 +95,10 @@ public class PtaTests { var pta = new Pta(50, 10); var series = MakeSeries(100); - foreach (var bar in series) pta.Update(bar); + foreach (var bar in series) + { + pta.Update(bar); + } double val1 = pta.Update(new TValue(DateTime.UtcNow, 105.0), isNew: true).Value; double val2 = pta.Update(new TValue(DateTime.UtcNow.AddMinutes(1), 110.0), isNew: true).Value; Assert.NotEqual(val1, val2); @@ -106,10 +109,13 @@ public class PtaTests { var pta = new Pta(50, 10); var series = MakeSeries(100); - foreach (var bar in series) pta.Update(bar); + foreach (var bar in series) + { + pta.Update(bar); + } double v1 = pta.Update(new TValue(DateTime.UtcNow, 105.0), isNew: true).Value; - double v2 = pta.Update(new TValue(DateTime.UtcNow, 108.0), isNew: false).Value; + _ = pta.Update(new TValue(DateTime.UtcNow, 108.0), isNew: false).Value; double v3 = pta.Update(new TValue(DateTime.UtcNow, 105.0), isNew: false).Value; Assert.Equal(v1, v3, 10); } @@ -119,7 +125,10 @@ public class PtaTests { var pta = new Pta(50, 10); var series = MakeSeries(100); - foreach (var bar in series) pta.Update(bar); + foreach (var bar in series) + { + pta.Update(bar); + } pta.Reset(); Assert.False(pta.IsHot); Assert.Equal(0.0, pta.Update(new TValue(DateTime.UtcNow, 100.0)).Value); @@ -163,7 +172,10 @@ public class PtaTests { var pta = new Pta(50, 10); var series = MakeSeries(5000); - foreach (var bar in series) pta.Update(bar); + foreach (var bar in series) + { + pta.Update(bar); + } Assert.True(double.IsFinite(pta.Last.Value)); } @@ -192,7 +204,10 @@ public class PtaTests // Mode 1: Streaming var streaming = new Pta(lp, sp); - foreach (var bar in series) streaming.Update(bar); + foreach (var bar in series) + { + streaming.Update(bar); + } // Mode 2: Batch TSeries var batchResult = Pta.Batch(series, lp, sp); @@ -228,19 +243,24 @@ public class PtaTests var streaming = new Pta(lp, sp); var streamResults = new double[series.Count]; for (int i = 0; i < series.Count; i++) + { streamResults[i] = streaming.Update(series[i]).Value; + } var spanResults = new double[series.Count]; Pta.Batch(series.Values, spanResults, lp, sp); for (int i = 0; i < series.Count; i++) + { Assert.Equal(streamResults[i], spanResults[i], 10); + } } [Fact] public void SpanBatch_EmptyInput_NoThrow() { - Pta.Batch(ReadOnlySpan.Empty, Span.Empty, 50, 10); + var exception = Record.Exception(() => Pta.Batch(ReadOnlySpan.Empty, Span.Empty, 50, 10)); + Assert.Null(exception); } [Fact] @@ -261,7 +281,9 @@ public class PtaTests var source = new TSeries(); var pta = new Pta(source, longPeriod: 50, shortPeriod: 10); for (int i = 0; i < 100; i++) + { source.Add(new TValue(DateTime.UtcNow.AddMinutes(i), 100.0 + i * 0.1)); + } Assert.True(double.IsFinite(pta.Last.Value)); } @@ -274,7 +296,9 @@ public class PtaTests { var pta = new Pta(50, 10); for (int i = 0; i < 300; i++) + { pta.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100.0)); + } // Constant price → zero 2nd-order difference → both HP = 0 → PTA = 0 Assert.Equal(0.0, pta.Last.Value, 10); @@ -286,7 +310,9 @@ public class PtaTests // A perfectly linear trend has zero 2nd derivative → HP outputs approach 0 var pta = new Pta(50, 10); for (int i = 0; i < 500; i++) + { pta.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100.0 + i * 0.5)); + } // Both HP filters output 0 for pure linear → PTA ≈ 0 Assert.True(Math.Abs(pta.Last.Value) < 1.0, @@ -303,7 +329,10 @@ public class PtaTests { double price = 100.0 + 10.0 * Math.Sin(2.0 * Math.PI * i / 100.0); pta.Update(new TValue(DateTime.UtcNow.AddMinutes(i), price)); - if (i > 300) lastAbsMax = Math.Max(lastAbsMax, Math.Abs(pta.Last.Value)); + if (i > 300) + { + lastAbsMax = Math.Max(lastAbsMax, Math.Abs(pta.Last.Value)); + } } Assert.True(lastAbsMax > 0.1, $"Expected significant output for in-band sine, got max={lastAbsMax}"); @@ -315,10 +344,14 @@ public class PtaTests var pta = new Pta(50, 10); // Uptrend for (int i = 0; i < 200; i++) + { pta.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100.0 + i * 0.5)); + } // Transition to downtrend for (int i = 0; i < 200; i++) + { pta.Update(new TValue(DateTime.UtcNow.AddMinutes(200 + i), 200.0 - i * 0.5)); + } // After sustained downtrend, PTA should detect the reversal // (the sign change may take some bars due to the bandpass filter) @@ -343,8 +376,8 @@ public class PtaTests public void Name_IncludesBothPeriods() { var pta = new Pta(300, 60); - Assert.Contains("300", pta.Name); - Assert.Contains("60", pta.Name); + Assert.Contains("300", pta.Name, StringComparison.Ordinal); + Assert.Contains("60", pta.Name, StringComparison.Ordinal); } [Fact] @@ -361,7 +394,10 @@ public class PtaTests { var pta = new Pta(50, 10); var values = new double[100]; - for (int i = 0; i < 100; i++) values[i] = 100.0 + i * 0.1; + for (int i = 0; i < 100; i++) + { + values[i] = 100.0 + i * 0.1; + } pta.Prime(values); Assert.True(pta.IsHot); } diff --git a/lib/filters/_index.md b/lib/filters/_index.md index 6e804906..29993e0e 100644 --- a/lib/filters/_index.md +++ b/lib/filters/_index.md @@ -38,6 +38,7 @@ Signal processing filters adapted for financial time series. These are not indic | [SAK](sak/Sak.md) | Ehlers Swiss Army Knife | Configurable multi-mode filter (LP, HP, BP, BS). Single framework. | | [SGF](sgf/Sgf.md) | Savitzky-Golay | Polynomial smoothing. Preserves higher moments (derivatives). | | [SPBF](spbf/Spbf.md) | Ehlers Super Passband Filter | Ehlers. Wide-band bandpass via differenced EMAs with RMS envelope. | +| [TBF](tbf/Tbf.md) | Ehlers Truncated Bandpass Filter | Ehlers. 2-pole bandpass with finite truncation window. Eliminates initialization errors. | | [SSF2](ssf2/Ssf2.md) | Ehlers 2-Pole Super Smoother Filter | Ehlers. 2-pole Butterworth variant. Standard cycle pre-filter. | | [SSF3](ssf3/Ssf3.md) | Ehlers 3-Pole Super Smoother Filter | Ehlers. 3-pole single-sample feedforward. Steeper rolloff than SSF2. | | [USF](usf/Usf.md) | Ehlers Ultimate Smoother Filter | Ehlers. 3-pole variant. More smoothing than SSF. | diff --git a/lib/filters/tbf/Tbf.Quantower.cs b/lib/filters/tbf/Tbf.Quantower.cs new file mode 100644 index 00000000..1004579f --- /dev/null +++ b/lib/filters/tbf/Tbf.Quantower.cs @@ -0,0 +1,72 @@ +using System.Drawing; +using System.Runtime.CompilerServices; +using TradingPlatform.BusinessLayer; + +namespace QuanTAlib; + +[SkipLocalsInit] +public class TbfIndicator : Indicator, IWatchlistIndicator +{ + [InputParameter("Period", sortIndex: 1, minimum: 2, maximum: 1000, increment: 1, decimalPlaces: 0)] + public int Period { get; set; } = 20; + + [InputParameter("Bandwidth", sortIndex: 2, minimum: 0.001, maximum: 1.0, increment: 0.01, decimalPlaces: 3)] + public double Bandwidth { get; set; } = 0.1; + + [InputParameter("Length", sortIndex: 3, minimum: 1, maximum: 200, increment: 1, decimalPlaces: 0)] + public int Length { get; set; } = 10; + + [IndicatorExtensions.DataSourceInput(sortIndex: 4)] + public SourceType Source { get; set; } = SourceType.Close; + + [InputParameter("Show cold values", sortIndex: 21)] + public bool ShowColdValues { get; set; } = true; + + private Tbf? tbf; + protected LineSeries? TbfSeries; + protected LineSeries? BpSeries; + protected LineSeries? ZeroSeries; + + public int MinHistoryDepths => Length + 2; + int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths; + + public override string ShortName => $"TBF ({Period},{Bandwidth:F2},{Length})"; + public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/filters/tbf/Tbf.cs"; + + public TbfIndicator() + { + Name = "TBF - Ehlers Truncated Bandpass Filter"; + Description = "Modified bandpass filter with truncated IIR memory for improved time-domain response"; + + TbfSeries = new("TBF", Color.Blue, 2, LineStyle.Solid); + BpSeries = new("BP", Color.Red, 1, LineStyle.Solid); + ZeroSeries = new("Zero", Color.Gray, 1, LineStyle.Dot); + + AddLineSeries(TbfSeries); + AddLineSeries(BpSeries); + AddLineSeries(ZeroSeries); + + SeparateWindow = true; + } + + protected override void OnInit() + { + tbf = new(Period, Bandwidth, Length); + base.OnInit(); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + protected override void OnUpdate(UpdateArgs args) + { + var priceSelector = Source.GetPriceSelector(); + var item = HistoricalData[0, SeekOriginHistory.End]; + double price = priceSelector(item); + + TValue input = new(item.TimeLeft, price); + TValue result = tbf!.Update(input, args.IsNewBar()); + + TbfSeries!.SetValue(result.Value, tbf.IsHot, ShowColdValues); + BpSeries!.SetValue(tbf.Bp.Value, tbf.IsHot, ShowColdValues); + ZeroSeries!.SetValue(0.0, true, true); + } +} diff --git a/lib/filters/tbf/Tbf.cs b/lib/filters/tbf/Tbf.cs new file mode 100644 index 00000000..e7f529c2 --- /dev/null +++ b/lib/filters/tbf/Tbf.cs @@ -0,0 +1,410 @@ +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace QuanTAlib; + +/// +/// TBF: Ehlers Truncated Bandpass Filter +/// A modified bandpass filter that limits the IIR filter's infinite memory to a fixed +/// number of bars, eliminating initialization errors and dampening transient responses +/// for more reliable cycle indication. +/// +/// +/// The TBF calculation process: +/// 1. Compute standard Ehlers bandpass coefficients from Period and Bandwidth +/// 2. Each bar, recompute the entire filter forward over the truncation window +/// 3. Initialize the tail of the window to zero (no memory beyond Length bars) +/// 4. Run the 2-pole IIR recursion from oldest to newest within the window +/// 5. Output the value at the current bar position +/// +/// Key characteristics: +/// - Eliminates initialization errors inherent in IIR filters +/// - Dampened transient response reduces false cycle triggers from price shocks +/// - O(Length) computation per bar (brute-force recomputation) +/// - Also computes standard (non-truncated) bandpass for comparison +/// +/// Sources: +/// John F. Ehlers - "Truncated Indicators" TASC July 2020 +/// https://www.mesasoftware.com/papers/TRUNCATED%20INDICATORS.pdf +/// +[SkipLocalsInit] +public sealed class Tbf : AbstractBase +{ + private readonly int _length; + + // Precomputed bandpass coefficients + private readonly double _a0; // 0.5 * (1 - S1) — input difference coefficient + private readonly double _a1; // L1 * (1 + S1) — first feedback coefficient + private readonly double _a2; // -S1 — second feedback coefficient + + // Price history buffer: stores last (length + 2) prices + private readonly RingBuffer _priceBuffer; + + private const int DefaultPeriod = 20; + private const double DefaultBandwidth = 0.1; + private const int DefaultLength = 10; + private const int MinPeriod = 2; + private const double MinBandwidth = 0.001; + private const int MinLength = 1; + + [StructLayout(LayoutKind.Auto)] + private record struct State( + double Bp1, + double Bp2, + double Src1, + double Src2, + double LastValid, + int Bars); + + private State _state; + private State _p_state; + + public override bool IsHot => _state.Bars >= WarmupPeriod; + + /// + /// Standard (non-truncated) bandpass filter value for comparison. + /// + public TValue Bp { get; private set; } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Tbf(int period = DefaultPeriod, double bandwidth = DefaultBandwidth, int length = DefaultLength) + { + if (period < MinPeriod) + { + throw new ArgumentOutOfRangeException(nameof(period), + $"Period must be at least {MinPeriod}."); + } + if (bandwidth < MinBandwidth) + { + throw new ArgumentOutOfRangeException(nameof(bandwidth), + $"Bandwidth must be at least {MinBandwidth}."); + } + if (length < MinLength) + { + throw new ArgumentOutOfRangeException(nameof(length), + $"Length must be at least {MinLength}."); + } + + _length = length; + + // Ehlers bandpass coefficients + double twoPi = 2.0 * Math.PI; + double l1 = Math.Cos(twoPi / period); + double g1 = Math.Cos(bandwidth * twoPi / period); + double s1 = (1.0 / g1) - Math.Sqrt((1.0 / (g1 * g1)) - 1.0); + + _a0 = 0.5 * (1.0 - s1); + _a1 = l1 * (1.0 + s1); + _a2 = -s1; + + // Buffer needs length + 2 prices (indices 0..length+1) + _priceBuffer = new RingBuffer(length + 2); + + WarmupPeriod = length + 2; + Name = $"TBF({period},{bandwidth:F2},{length})"; + Init(); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void Init() + { + _state = default; + _p_state = default; + _priceBuffer.Clear(); + Bp = new TValue(DateTime.UtcNow, double.NaN); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public override TValue Update(TValue input, bool isNew = true) + { + // State management for bar correction + if (isNew) + { + _p_state = _state; + _priceBuffer.Snapshot(); + } + else + { + _state = _p_state; + _priceBuffer.Restore(); + } + + // Handle NaN/Infinity input + double val = input.Value; + if (!double.IsFinite(val)) + { + val = double.IsFinite(_state.LastValid) ? _state.LastValid : 0.0; + } + else + { + _state.LastValid = val; + } + + _state.Bars++; + + // Add price to buffer + _priceBuffer.Add(val); + + // === Standard Bandpass (IIR) === + double bpStd; + if (_state.Bars <= 3) + { + bpStd = 0.0; + } + else + { + bpStd = Math.FusedMultiplyAdd(_a0, val - _state.Src2, + Math.FusedMultiplyAdd(_a1, _state.Bp1, + _a2 * _state.Bp2)); + } + + // Update standard BP state + _state.Bp2 = _state.Bp1; + _state.Bp1 = bpStd; + _state.Src2 = _state.Src1; + _state.Src1 = val; + + Bp = new TValue(input.Time, bpStd); + + // === Truncated Bandpass === + double tbfValue; + if (_state.Bars < _length + 2) + { + // Not enough history for full truncation window + tbfValue = 0.0; + } + else + { + tbfValue = ComputeTruncated(); + } + + Last = new TValue(input.Time, tbfValue); + PubEvent(Last, isNew); + return Last; + } + + public override TSeries Update(TSeries source) + { + if (source == null) + { + throw new ArgumentNullException(nameof(source)); + } + + int len = source.Count; + TSeries result = new(capacity: len); + + for (int i = 0; i < len; i++) + { + var item = source[i]; + Update(item, isNew: true); + result.Add(Last.Time, Last.Value, isNew: true); + } + + return result; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private double ComputeTruncated() + { + int len = _length; + int bufCount = _priceBuffer.Count; // should be length + 2 when full + + // Scratch array for the truncated filter computation + // Need indices 1..length+2, so allocate length+3 elements + Span trunc = len + 3 <= 256 + ? stackalloc double[len + 3] + : new double[len + 3]; + + // Initialize tail to zero (no memory beyond truncation window) + trunc[len + 2] = 0.0; + trunc[len + 1] = 0.0; + + // Run the IIR recursion forward from oldest to newest within window + // Ehlers: for count = Length downto 1 + // Trunc[count] = a0*(Close[count-1] - Close[count+1]) + a1*Trunc[count+1] + a2*Trunc[count+2] + // Where Close[k] = price k bars ago (Close[0] = current) + // RingBuffer uses [0]=oldest, [Count-1]=newest, so Close[k bars ago] = [Count-1-k] + + for (int count = len; count >= 1; count--) + { + double pricePrev = _priceBuffer[bufCount - count]; // Close[count-1] = [Count-1-(count-1)] + double priceNext = _priceBuffer[bufCount - count - 2]; // Close[count+1] = [Count-1-(count+1)] + trunc[count] = Math.FusedMultiplyAdd(_a0, pricePrev - priceNext, + Math.FusedMultiplyAdd(_a1, trunc[count + 1], + _a2 * trunc[count + 2])); + } + + return trunc[1]; + } + + public override void Reset() + { + _priceBuffer.Clear(); + Init(); + } + + public override void Prime(ReadOnlySpan source, TimeSpan? step = null) + { + step ??= TimeSpan.FromSeconds(1); + DateTime startTime = DateTime.UtcNow; + + for (int i = 0; i < source.Length; i++) + { + Update(new TValue(startTime + i * step.Value, source[i]), isNew: true); + } + } + + /// + /// Static batch computation for TSeries. + /// + public static TSeries Batch(TSeries source, int period = DefaultPeriod, + double bandwidth = DefaultBandwidth, int length = DefaultLength) + { + Tbf tbf = new(period, bandwidth, length); + return tbf.Update(source); + } + + /// + /// Static span-based batch computation. + /// Outputs both truncated bandpass and standard bandpass. + /// + public static void Batch( + ReadOnlySpan source, + Span tbfOut, + Span bpOut, + int period = DefaultPeriod, + double bandwidth = DefaultBandwidth, + int length = DefaultLength) + { + int n = source.Length; + if (n != tbfOut.Length || n != bpOut.Length) + { + throw new ArgumentException("All spans must have the same length.", nameof(source)); + } + if (period < MinPeriod) + { + throw new ArgumentOutOfRangeException(nameof(period), + $"Period must be at least {MinPeriod}."); + } + if (bandwidth < MinBandwidth) + { + throw new ArgumentOutOfRangeException(nameof(bandwidth), + $"Bandwidth must be at least {MinBandwidth}."); + } + if (length < MinLength) + { + throw new ArgumentOutOfRangeException(nameof(length), + $"Length must be at least {MinLength}."); + } + + if (n == 0) + { + return; + } + + // Compute coefficients + double twoPi = 2.0 * Math.PI; + double l1 = Math.Cos(twoPi / period); + double g1 = Math.Cos(bandwidth * twoPi / period); + double s1 = (1.0 / g1) - Math.Sqrt((1.0 / (g1 * g1)) - 1.0); + + double a0 = 0.5 * (1.0 - s1); + double a1 = l1 * (1.0 + s1); + double a2 = -s1; + + int bufSize = length + 2; + + // Price buffer (circular) + Span priceBuf = bufSize <= 256 + ? stackalloc double[bufSize] + : new double[bufSize]; + int head = 0; + int count = 0; + + // Standard BP state + double bp1 = 0, bp2 = 0; + double src1 = 0, src2 = 0; + double lastValid = 0; + int bars = 0; + + // Scratch for truncated computation + Span trunc = length + 3 <= 256 + ? stackalloc double[length + 3] + : new double[length + 3]; + + for (int i = 0; i < n; i++) + { + double val = source[i]; + if (!double.IsFinite(val)) + { + val = double.IsFinite(lastValid) ? lastValid : 0.0; + } + else + { + lastValid = val; + } + + bars++; + + // Add to circular buffer + int writeIdx = head; + priceBuf[writeIdx] = val; + head = (head + 1) % bufSize; + if (count < bufSize) + { + count++; + } + + // Standard BP + double bpStd; + if (bars <= 3) + { + bpStd = 0.0; + } + else + { + bpStd = Math.FusedMultiplyAdd(a0, val - src2, + Math.FusedMultiplyAdd(a1, bp1, a2 * bp2)); + } + + bp2 = bp1; + bp1 = bpStd; + src2 = src1; + src1 = val; + bpOut[i] = bpStd; + + // Truncated BP + if (count < bufSize) + { + tbfOut[i] = 0.0; + continue; + } + + // Recompute truncated filter + trunc[length + 2] = 0.0; + trunc[length + 1] = 0.0; + + for (int c = length; c >= 1; c--) + { + // price k bars ago: most recent is at (head-1) mod bufSize, + // k bars ago is at (head-1-k) mod bufSize + int idxPrev = ((writeIdx - (c - 1)) % bufSize + bufSize) % bufSize; + int idxNext = ((writeIdx - (c + 1)) % bufSize + bufSize) % bufSize; + double pPrev = priceBuf[idxPrev]; + double pNext = priceBuf[idxNext]; + trunc[c] = Math.FusedMultiplyAdd(a0, pPrev - pNext, + Math.FusedMultiplyAdd(a1, trunc[c + 1], a2 * trunc[c + 2])); + } + + tbfOut[i] = trunc[1]; + } + } + + public static (TSeries Results, Tbf Indicator) Calculate(TSeries source, + int period = DefaultPeriod, double bandwidth = DefaultBandwidth, int length = DefaultLength) + { + var indicator = new Tbf(period, bandwidth, length); + TSeries results = indicator.Update(source); + return (results, indicator); + } +} diff --git a/lib/filters/tbf/Tbf.md b/lib/filters/tbf/Tbf.md new file mode 100644 index 00000000..ca1a27d4 --- /dev/null +++ b/lib/filters/tbf/Tbf.md @@ -0,0 +1,146 @@ +# TBF: Ehlers Truncated Bandpass Filter + +> *Truncation tames the infinite memory of IIR filters, revealing only the cycles that matter now.* + +| Property | Value | +| ---------------- | -------------------------------- | +| **Category** | Filter | +| **Inputs** | Source (close) | +| **Parameters** | `period` (default 20), `bandwidth` (default 0.1), `length` (default 10) | +| **Outputs** | TBF (primary), BP (standard bandpass for comparison) | +| **Output range** | Oscillates around zero | +| **Warmup** | `length + 2` bars | +| **PineScript** | [tbf.pine](tbf.pine) | + +- The Truncated Bandpass Filter limits the IIR bandpass filter's infinite memory to a fixed window, eliminating initialization transients and dampening false cycle triggers from price shocks. +- **Similar:** [BPF](../bpf/bpf.md), [SPBF](../spbf/Spbf.md) | **Complementary:** Dominant cycle detection for adaptive period | **Trading note:** Oscillator around zero; rising above zero indicates upward cycle phase. +- Based on John F. Ehlers' "Truncated Indicators" — TASC July 2020. + +## Historical Context + +John F. Ehlers introduced the Truncated Bandpass Filter in his July 2020 article "Truncated Indicators" for *Stocks & Commodities* magazine. The key insight: IIR (Infinite Impulse Response) filters carry a theoretically infinite history of past prices in their feedback terms. In finite backtests this causes two problems: + +1. **Initialization errors**: The filter output depends on how far back the data starts, producing different results for the same time window depending on available history. +2. **Transient ringing**: Price shocks propagate indefinitely through the filter's feedback, causing artificial cycles that can trigger false signals. + +Ehlers' solution: instead of using the filter's own past outputs (which encode infinite history), recompute the entire filter from scratch each bar over a fixed-length window. By initializing the tail of the window to zero, the filter's "memory" is explicitly bounded to exactly `Length` bars. + +## Architecture & Physics + +### 1. Bandpass Filter Coefficients + +The filter is parameterized by a center period $P$ and fractional bandwidth $\beta$: + +$$ +L_1 = \cos\!\left(\frac{2\pi}{P}\right), \qquad +G_1 = \cos\!\left(\frac{\beta \cdot 2\pi}{P}\right) +$$ + +$$ +S_1 = \frac{1}{G_1} - \sqrt{\frac{1}{G_1^2} - 1} +$$ + +Derived recursion coefficients: + +$$ +a_0 = \tfrac{1}{2}(1 - S_1), \qquad +a_1 = L_1(1 + S_1), \qquad +a_2 = -S_1 +$$ + +### 2. Standard Bandpass (IIR) + +The standard 2-pole Ehlers bandpass filter: + +$$ +BP_t = a_0(P_t - P_{t-2}) + a_1 \cdot BP_{t-1} + a_2 \cdot BP_{t-2} +$$ + +This is an IIR filter — each output depends on all previous outputs, creating infinite memory. + +### 3. Truncated Bandpass + +The truncated version limits memory to exactly $L$ bars by recomputing the IIR recursion from scratch each bar: + +**Step 1:** Initialize the tail: +$$ +T_{L+2} = 0, \qquad T_{L+1} = 0 +$$ + +**Step 2:** Run the recursion forward from oldest to newest: +$$ +T_k = a_0(P_{k-1} - P_{k+1}) + a_1 \cdot T_{k+1} + a_2 \cdot T_{k+2}, \qquad k = L, L{-}1, \ldots, 1 +$$ + +where $P_k$ denotes the close price $k$ bars ago. + +**Step 3:** Output: +$$ +\text{TBF}_t = T_1 +$$ + +### 4. Complexity + +Each bar requires $O(L)$ work to recompute the truncated filter. The standard BP is $O(1)$ per bar. Memory: a circular buffer of $L+2$ prices plus a scratch array of $L+3$ doubles (stack-allocated when $L \leq 253$). + +## Mathematical Foundation + +### Parameters + +| Symbol | Name | Default | Constraint | Description | +|--------|------|---------|------------|-------------| +| $P$ | period | 20 | $\geq 2$ | Center cycle period | +| $\beta$ | bandwidth | 0.1 | $> 0$ | Fractional bandwidth (0.1 = 10%) | +| $L$ | length | 10 | $\geq 1$ | Truncation window (bars of memory) | + +### Transfer Function + +The standard bandpass has transfer function: + +$$ +H(z) = \frac{a_0(1 - z^{-2})}{1 - a_1 z^{-1} - a_2 z^{-2}} +$$ + +with poles at $z = \frac{a_1 \pm \sqrt{a_1^2 + 4a_2}}{2}$ and zeros at $z = \pm 1$. The truncated version has the same frequency selectivity but finite impulse response over the window, trading spectral resolution for temporal precision. + +### Bandwidth Interpretation + +The bandwidth parameter $\beta$ controls how narrow the passband is: +- $\beta = 0.1$ (10%): passes cycles from period 18 to 22 (for $P=20$) +- $\beta = 0.3$ (30%): passes cycles from period 14 to 26 +- Smaller $\beta$ = narrower band = more ringing in standard BP (but truncation dampens this) + +## Performance Profile + +### Operation Count (Streaming Mode) + +Each bar recomputes the truncated filter over $L$ positions: + +| Operation | Count | Cost (cycles) | Subtotal | +| :--- | :---: | :---: | :---: | +| MUL (a0 × diff) | $L$ | 3 | $3L$ | +| ADD (src diff) | $L$ | 1 | $L$ | +| FMA (a1 × T[k+1]) | $L$ | 4 | $4L$ | +| FMA (a2 × T[k+2]) | $L$ | 4 | $4L$ | +| Standard BP (O(1)) | 3 | 4 | 12 | +| Buffer add | 1 | 2 | 2 | +| **Total** | **~$4L + 4$** | — | **~$12L + 14$ cycles** | + +For default $L = 10$: ~134 cycles/bar. For $L = 50$: ~614 cycles/bar. + +### Batch Mode (SIMD Analysis) + +The truncated recursion is inherently sequential (each $T_k$ depends on $T_{k+1}$ and $T_{k+2}$), preventing SIMD within a single bar's computation: + +| Optimization | Benefit | +| :--- | :--- | +| Truncated IIR recursion | Sequential per bar; O(L) per bar | +| Standard IIR | Sequential; O(1) per bar | +| stackalloc scratch | Zero heap allocation for L ≤ 253 | +| Coefficient precomputation | Amortized once at construction | + +## Resources + +- Ehlers, J. F. (2020). "Truncated Indicators." *Technical Analysis of Stocks & Commodities*, July 2020. +- [Ehlers' Paper (PDF)](https://www.mesasoftware.com/papers/TRUNCATED%20INDICATORS.pdf) +- Ehlers, J. F. (2013). *Cycle Analytics for Traders*. Wiley. diff --git a/lib/filters/tbf/tbf.pine b/lib/filters/tbf/tbf.pine new file mode 100644 index 00000000..d22fb931 --- /dev/null +++ b/lib/filters/tbf/tbf.pine @@ -0,0 +1,57 @@ +// Licensed under the Apache License, Version 2.0 +// © mihakralj +//@version=6 +// Truncated Bandpass Filter logic based on work by John F. Ehlers (c) 2020 +indicator("Ehlers Truncated Bandpass Filter (TBF)", "TBF", overlay=false) + +//@function Calculates Truncated Bandpass Filter alongside standard Bandpass +//@param src Source series +//@param period Center cycle period +//@param bw Fractional bandwidth (e.g. 0.1 = 10%) +//@param length Truncation window length +//@returns tuple [truncatedBP, standardBP] +tbf(series float src, simple int period, simple float bw, simple int length) => + // Ehlers bandpass coefficients + float twoPi = 2.0 * math.pi + float L1 = math.cos(twoPi / period) + float G1 = math.cos(bw * twoPi / period) + float S1 = 1.0 / G1 - math.sqrt(1.0 / (G1 * G1) - 1.0) + float a0 = 0.5 * (1.0 - S1) + float a1 = L1 * (1.0 + S1) + float a2 = -S1 + + // Standard Bandpass (IIR) + var float bp = 0.0 + float s = nz(src, src[1]) + if bar_index >= 3 + bp := a0 * (s - nz(src[2], s)) + a1 * nz(bp[1], 0.0) + a2 * nz(bp[2], 0.0) + else + bp := 0.0 + + // Truncated Bandpass using array + float tbfVal = 0.0 + if bar_index >= length + 1 + trunc = array.new_float(length + 3, 0.0) + for count = length to 1 + float pPrev = nz(src[count - 1], s) + float pNext = nz(src[count + 1], s) + float val = a0 * (pPrev - pNext) + a1 * array.get(trunc, count + 1) + a2 * array.get(trunc, count + 2) + array.set(trunc, count, val) + tbfVal := array.get(trunc, 1) + [tbfVal, bp] + +// ---------- Main loop ---------- + +// Inputs +i_source = input.source(close, "Source") +i_period = input.int(20, "Period", minval=2, tooltip="Center cycle period for the bandpass filter.") +i_bw = input.float(0.1, "Bandwidth", minval=0.001, maxval=1.0, step=0.01, tooltip="Fractional bandwidth (0.1 = 10%).") +i_length = input.int(10, "Length", minval=1, tooltip="Truncation window length in bars.") + +// Calculation +[tbfVal, bpVal] = tbf(i_source, i_period, i_bw, i_length) + +// Plot +plot(tbfVal, "TBF", color=color.blue, linewidth=2) +plot(bpVal, "BP", color=color.red, linewidth=1) +hline(0, "Zero", color=color.gray, linestyle=hline.style_dotted) diff --git a/lib/filters/tbf/tests/Tbf.Quantower.Tests.cs b/lib/filters/tbf/tests/Tbf.Quantower.Tests.cs new file mode 100644 index 00000000..dc9d47d0 --- /dev/null +++ b/lib/filters/tbf/tests/Tbf.Quantower.Tests.cs @@ -0,0 +1,285 @@ +using TradingPlatform.BusinessLayer; + +namespace QuanTAlib.Tests; + +public class TbfIndicatorTests +{ + [Fact] + public void TbfIndicator_Constructor_SetsDefaults() + { + var indicator = new TbfIndicator(); + + Assert.Equal(20, indicator.Period); + Assert.Equal(0.1, indicator.Bandwidth); + Assert.Equal(10, indicator.Length); + Assert.Equal(SourceType.Close, indicator.Source); + Assert.True(indicator.ShowColdValues); + Assert.Equal("TBF - Ehlers Truncated Bandpass Filter", indicator.Name); + Assert.True(indicator.SeparateWindow); + } + + [Fact] + public void TbfIndicator_MinHistoryDepths_EqualsLengthPlus2() + { + var indicator = new TbfIndicator { Period = 20, Bandwidth = 0.1, Length = 10 }; + + Assert.Equal(12, indicator.MinHistoryDepths); // 10 + 2 + Assert.Equal(12, ((IWatchlistIndicator)indicator).MinHistoryDepths); + } + + [Fact] + public void TbfIndicator_MinHistoryDepths_ChangesWithLength() + { + var indicator = new TbfIndicator { Length = 20 }; + Assert.Equal(22, indicator.MinHistoryDepths); // 20 + 2 + + indicator.Length = 5; + Assert.Equal(7, indicator.MinHistoryDepths); // 5 + 2 + } + + [Fact] + public void TbfIndicator_ShortName_IncludesParameters() + { + var indicator = new TbfIndicator { Period = 20, Bandwidth = 0.1, Length = 10 }; + + Assert.Contains("TBF", indicator.ShortName, StringComparison.Ordinal); + Assert.Contains("20", indicator.ShortName, StringComparison.Ordinal); + Assert.Contains("0.10", indicator.ShortName, StringComparison.Ordinal); + Assert.Contains("10", indicator.ShortName, StringComparison.Ordinal); + } + + [Fact] + public void TbfIndicator_Initialize_CreatesInternalTbf() + { + var indicator = new TbfIndicator { Period = 20, Bandwidth = 0.1, Length = 10 }; + + indicator.Initialize(); + + Assert.Equal(3, indicator.LinesSeries.Count); // TBF, BP, Zero + } + + [Fact] + public void TbfIndicator_ThreeLineSeries_CorrectNames() + { + var indicator = new TbfIndicator(); + + Assert.Equal(3, indicator.LinesSeries.Count); + // LineSeries[0] = TBF, [1] = BP, [2] = Zero + } + + [Fact] + public void TbfIndicator_ProcessUpdate_HistoricalBar_ComputesValue() + { + var indicator = new TbfIndicator { Period = 20, Bandwidth = 0.1, Length = 10 }; + 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 TbfIndicator_ProcessUpdate_NewBar_ComputesValue() + { + var indicator = new TbfIndicator { Period = 20, Bandwidth = 0.1, Length = 10 }; + 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 TbfIndicator_ProcessUpdate_NewTick_ProcessesWithoutError() + { + var indicator = new TbfIndicator { Period = 20, Bandwidth = 0.1, Length = 10 }; + 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 TbfIndicator_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 TbfIndicator { Period = 20, Bandwidth = 0.1, Length = 10, 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 TbfIndicator_Parameters_CanBeChanged() + { + var indicator = new TbfIndicator { Period = 20, Bandwidth = 0.1, Length = 10 }; + Assert.Equal(20, indicator.Period); + Assert.Equal(0.1, indicator.Bandwidth); + Assert.Equal(10, indicator.Length); + + indicator.Period = 30; + indicator.Bandwidth = 0.2; + indicator.Length = 15; + Assert.Equal(30, indicator.Period); + Assert.Equal(0.2, indicator.Bandwidth); + Assert.Equal(15, indicator.Length); + } + + [Fact] + public void TbfIndicator_MultipleBars_ProducesFiniteOutput() + { + var indicator = new TbfIndicator { Period = 20, Bandwidth = 0.1, Length = 10 }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + for (int i = 0; i < 50; i++) + { + double price = 100 + Math.Sin(i * 0.3) * 10; + indicator.HistoricalData.AddBar(now.AddMinutes(i), price, price + 5, price - 5, price + 1); + indicator.ProcessUpdate(new UpdateArgs(i == 0 ? UpdateReason.HistoricalBar : UpdateReason.NewBar)); + } + + Assert.Equal(50, indicator.LinesSeries[0].Count); + Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0))); + } + + [Fact] + public void TbfIndicator_ZeroLine_AlwaysShowsValue() + { + var indicator = new TbfIndicator { Period = 20, Bandwidth = 0.1, Length = 10 }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + indicator.HistoricalData.AddBar(now, 100, 105, 95, 102); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + + // Zero line (index 2) should always be 0.0 + Assert.Equal(0.0, indicator.LinesSeries[2].GetValue(0)); + } + + [Fact] + public void TbfIndicator_BpSeries_ProducesFiniteOutput() + { + var indicator = new TbfIndicator { Period = 20, Bandwidth = 0.1, Length = 10 }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + for (int i = 0; i < 20; i++) + { + double price = 100 + i * 2; + indicator.HistoricalData.AddBar(now.AddMinutes(i), price, price + 5, price - 5, price + 1); + indicator.ProcessUpdate(new UpdateArgs(i == 0 ? UpdateReason.HistoricalBar : UpdateReason.NewBar)); + } + + // BP line (index 1) should produce finite values + Assert.True(double.IsFinite(indicator.LinesSeries[1].GetValue(0)), + "BP series should produce finite output"); + } + + [Fact] + public void TbfIndicator_Reinitialize_ClearsState() + { + var indicator = new TbfIndicator { Period = 20, Bandwidth = 0.1, Length = 10 }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + for (int i = 0; i < 20; i++) + { + double price = 100 + i; + indicator.HistoricalData.AddBar(now.AddMinutes(i), price, price + 5, price - 5, price); + indicator.ProcessUpdate(new UpdateArgs(i == 0 ? UpdateReason.HistoricalBar : UpdateReason.NewBar)); + } + + int countBefore = indicator.LinesSeries[0].Count; + Assert.True(countBefore > 0); + + // Re-initialize should reset + indicator.Initialize(); + Assert.Equal(3, indicator.LinesSeries.Count); + } + + [Fact] + public void TbfIndicator_SourceCodeLink_IsValid() + { + var indicator = new TbfIndicator(); + Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal); + Assert.Contains("Tbf.cs", indicator.SourceCodeLink, StringComparison.Ordinal); + } + + [Fact] + public void TbfIndicator_ShowColdValues_AffectsDisplay() + { + var indicator1 = new TbfIndicator { ShowColdValues = true }; + indicator1.Initialize(); + + var indicator2 = new TbfIndicator { ShowColdValues = false }; + indicator2.Initialize(); + + var now = DateTime.UtcNow; + indicator1.HistoricalData.AddBar(now, 100, 105, 95, 102); + indicator2.HistoricalData.AddBar(now, 100, 105, 95, 102); + + indicator1.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + indicator2.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + + // Both should process without error + Assert.Equal(1, indicator1.LinesSeries[0].Count); + Assert.Equal(1, indicator2.LinesSeries[0].Count); + } + + [Fact] + public void TbfIndicator_CustomParameters_PropagateToShortName() + { + var indicator = new TbfIndicator { Period = 30, Bandwidth = 0.25, Length = 15 }; + + Assert.Contains("30", indicator.ShortName, StringComparison.Ordinal); + Assert.Contains("0.25", indicator.ShortName, StringComparison.Ordinal); + Assert.Contains("15", indicator.ShortName, StringComparison.Ordinal); + } + + [Fact] + public void TbfIndicator_ConstantPrices_ProducesZeroTbfValue() + { + var indicator = new TbfIndicator { Period = 20, Bandwidth = 0.1, Length = 10 }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + for (int i = 0; i < 30; i++) + { + indicator.HistoricalData.AddBar(now.AddMinutes(i), 100, 100, 100, 100); + indicator.ProcessUpdate(new UpdateArgs(i == 0 ? UpdateReason.HistoricalBar : UpdateReason.NewBar)); + } + + // Constant input → bandpass should be near zero + double tbfValue = indicator.LinesSeries[0].GetValue(0); + Assert.True(Math.Abs(tbfValue) < 1e-3, $"Constant input should produce TBF near zero, got {tbfValue}"); + } +} diff --git a/lib/filters/tbf/tests/Tbf.Tests.cs b/lib/filters/tbf/tests/Tbf.Tests.cs new file mode 100644 index 00000000..4dd4d6e3 --- /dev/null +++ b/lib/filters/tbf/tests/Tbf.Tests.cs @@ -0,0 +1,449 @@ +namespace QuanTAlib.Tests; + +public class TbfTests +{ + private static TSeries MakeSeries(int count = 500) + { + var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.5, seed: 42); + var bars = gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + return bars.Close; + } + + // ========== A. Constructor & Validation ========== + + [Fact] + public void Tbf_Constructor_ValidatesInput() + { + Assert.Throws(() => new Tbf(1)); // period < 2 + Assert.Throws(() => new Tbf(-1)); // period < 2 + Assert.Throws(() => new Tbf(20, 0)); // bandwidth < MinBandwidth + Assert.Throws(() => new Tbf(20, -0.1)); // bandwidth < 0 + Assert.Throws(() => new Tbf(20, 0.1, 0)); // length < 1 + } + + [Fact] + public void Tbf_Constructor_DefaultParameters() + { + var tbf = new Tbf(); + Assert.Contains("20", tbf.Name, StringComparison.Ordinal); + Assert.Contains("0.10", tbf.Name, StringComparison.Ordinal); + Assert.Contains("10", tbf.Name, StringComparison.Ordinal); + } + + [Fact] + public void Tbf_Constructor_CustomParameters() + { + var tbf = new Tbf(30, 0.2, 15); + Assert.Contains("30", tbf.Name, StringComparison.Ordinal); + Assert.Contains("0.20", tbf.Name, StringComparison.Ordinal); + Assert.Contains("15", tbf.Name, StringComparison.Ordinal); + } + + // ========== B. Basic Functionality ========== + + [Fact] + public void Tbf_Update_ProducesFiniteOutput() + { + var tbf = new Tbf(); + TSeries series = MakeSeries(200); + + for (int i = 0; i < series.Count; i++) + { + tbf.Update(series[i], isNew: true); + } + + Assert.True(double.IsFinite(tbf.Last.Value)); + Assert.True(double.IsFinite(tbf.Bp.Value)); + } + + [Fact] + public void Tbf_ConstantInput_ProducesZero() + { + var tbf = new Tbf(20, 0.1, 10); + double constant = 100.0; + + for (int i = 0; i < 100; i++) + { + tbf.Update(new TValue(DateTime.UtcNow, constant), isNew: true); + } + + // Bandpass of constant = 0 (no oscillation) + Assert.True(Math.Abs(tbf.Last.Value) < 1e-6); + Assert.True(Math.Abs(tbf.Bp.Value) < 1e-6); + } + + [Fact] + public void Tbf_OutputOscillatesAroundZero() + { + var tbf = new Tbf(); + TSeries series = MakeSeries(500); + int positive = 0, negative = 0; + + for (int i = 0; i < series.Count; i++) + { + tbf.Update(series[i], isNew: true); + if (tbf.IsHot) + { + if (tbf.Last.Value > 0) + { + positive++; + } + else if (tbf.Last.Value < 0) + { + negative++; + } + } + } + + // Both positive and negative values should exist + Assert.True(positive > 0, "Should have positive values"); + Assert.True(negative > 0, "Should have negative values"); + } + + // ========== C. Warmup & IsHot ========== + + [Fact] + public void Tbf_IsHot_WhenBarsReachWarmupPeriod() + { + var tbf = new Tbf(20, 0.1, 10); + int warmup = 10 + 2; // length + 2 + + for (int i = 0; i < warmup - 1; i++) + { + tbf.Update(new TValue(DateTime.UtcNow, 100.0 + i), isNew: true); + Assert.False(tbf.IsHot, $"Should be cold at bar {i}"); + } + + tbf.Update(new TValue(DateTime.UtcNow, 200.0), isNew: true); + Assert.True(tbf.IsHot, "Should be hot after warmup period"); + } + + [Fact] + public void Tbf_WarmupPeriod_EqualsLengthPlus2() + { + var tbf = new Tbf(20, 0.1, 15); + Assert.Equal(17, tbf.WarmupPeriod); // 15 + 2 + + var tbf2 = new Tbf(20, 0.1, 5); + Assert.Equal(7, tbf2.WarmupPeriod); // 5 + 2 + } + + // ========== D. Bar Correction ========== + + [Fact] + public void Tbf_BarCorrection_RestoresState() + { + var tbf = new Tbf(); + TSeries series = MakeSeries(50); + + // Feed first 40 bars + for (int i = 0; i < 40; i++) + { + tbf.Update(series[i], isNew: true); + } + + // Feed bar 41 as new + tbf.Update(new TValue(DateTime.UtcNow, 999.0), isNew: true); + double valueBadBar = tbf.Last.Value; + + // Correct bar 41 with proper value + tbf.Update(series[40], isNew: false); + double valueCorrected = tbf.Last.Value; + + // The bad bar should differ from the corrected one + Assert.NotEqual(valueBadBar, valueCorrected); + } + + [Fact] + public void Tbf_BarCorrection_MultipleCorrectionsSameResult() + { + var tbf = new Tbf(); + TSeries series = MakeSeries(50); + + for (int i = 0; i < 40; i++) + { + tbf.Update(series[i], isNew: true); + } + + // Correct the same bar multiple times + tbf.Update(series[40], isNew: true); + tbf.Update(new TValue(DateTime.UtcNow, 123.0), isNew: false); + tbf.Update(new TValue(DateTime.UtcNow, 456.0), isNew: false); + tbf.Update(series[40], isNew: false); + double finalValue = tbf.Last.Value; + + // Fresh indicator for comparison + var tbf2 = new Tbf(); + for (int i = 0; i <= 40; i++) + { + tbf2.Update(series[i], isNew: true); + } + + Assert.Equal(tbf2.Last.Value, finalValue, 10); + } + + // ========== E. Reset ========== + + [Fact] + public void Tbf_Reset_ClearsState() + { + var tbf = new Tbf(); + TSeries series = MakeSeries(50); + + for (int i = 0; i < 50; i++) + { + tbf.Update(series[i], isNew: true); + } + + tbf.Reset(); + Assert.False(tbf.IsHot); + } + + [Fact] + public void Tbf_Reset_ProducesSameResultsOnReplay() + { + var tbf = new Tbf(); + TSeries series = MakeSeries(100); + + for (int i = 0; i < series.Count; i++) + { + tbf.Update(series[i], isNew: true); + } + double firstRun = tbf.Last.Value; + + tbf.Reset(); + for (int i = 0; i < series.Count; i++) + { + tbf.Update(series[i], isNew: true); + } + double secondRun = tbf.Last.Value; + + Assert.Equal(firstRun, secondRun, 10); + } + + // ========== F. NaN / Infinity Handling ========== + + [Fact] + public void Tbf_NaN_UsesLastValidValue() + { + var tbf = new Tbf(20, 0.1, 5); + + for (int i = 0; i < 20; i++) + { + tbf.Update(new TValue(DateTime.UtcNow, 100.0 + i), isNew: true); + } + + tbf.Update(new TValue(DateTime.UtcNow, double.NaN), isNew: true); + Assert.True(double.IsFinite(tbf.Last.Value)); + } + + [Fact] + public void Tbf_Infinity_UsesLastValidValue() + { + var tbf = new Tbf(20, 0.1, 5); + + for (int i = 0; i < 20; i++) + { + tbf.Update(new TValue(DateTime.UtcNow, 100.0 + i), isNew: true); + } + + tbf.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity), isNew: true); + Assert.True(double.IsFinite(tbf.Last.Value)); + } + + // ========== G. All-Modes Consistency ========== + + [Fact] + public void Tbf_AllModes_ProduceSameResult() + { + int period = 20; + double bandwidth = 0.1; + int length = 10; + TSeries series = MakeSeries(200); + + // Mode 1: Streaming + var streaming = new Tbf(period, bandwidth, length); + for (int i = 0; i < series.Count; i++) + { + streaming.Update(series[i], isNew: true); + } + + // Mode 2: Batch TSeries + var batchResult = Tbf.Batch(series, period, bandwidth, length); + + // Mode 3: Span-based + double[] source = new double[series.Count]; + for (int i = 0; i < series.Count; i++) + { + source[i] = series[i].Value; + } + double[] spanTbf = new double[series.Count]; + double[] spanBp = new double[series.Count]; + Tbf.Batch(source.AsSpan(), spanTbf.AsSpan(), spanBp.AsSpan(), period, bandwidth, length); + + // Compare all modes + Assert.Equal(streaming.Last.Value, batchResult[^1].Value, 8); + Assert.Equal(streaming.Last.Value, spanTbf[^1], 8); + } + + [Fact] + public void Tbf_SpanBatch_ValidatesInputs() + { + double[] source = new double[10]; + double[] tbfOut = new double[10]; + double[] bpOut = new double[10]; + double[] wrongSize = new double[5]; + + Assert.Throws(() => + Tbf.Batch(source.AsSpan(), wrongSize.AsSpan(), bpOut.AsSpan())); + Assert.Throws(() => + Tbf.Batch(source.AsSpan(), tbfOut.AsSpan(), bpOut.AsSpan(), period: 1)); + Assert.Throws(() => + Tbf.Batch(source.AsSpan(), tbfOut.AsSpan(), bpOut.AsSpan(), bandwidth: 0)); + Assert.Throws(() => + Tbf.Batch(source.AsSpan(), tbfOut.AsSpan(), bpOut.AsSpan(), length: 0)); + } + + // ========== H. Static Calculate Method ========== + + [Fact] + public void Tbf_StaticCalculate_Works() + { + TSeries series = MakeSeries(200); + var (results, indicator) = Tbf.Calculate(series, 20, 0.1, 10); + + Assert.Equal(series.Count, results.Count); + Assert.True(indicator.IsHot); + } + + // ========== Behavioral Tests ========== + + [Fact] + public void Tbf_TruncatedVsStandard_DifferAfterShock() + { + var tbf = new Tbf(20, 0.1, 10); + + // Feed stable prices + for (int i = 0; i < 50; i++) + { + tbf.Update(new TValue(DateTime.UtcNow, 100.0), isNew: true); + } + + // Price shock + tbf.Update(new TValue(DateTime.UtcNow, 200.0), isNew: true); + + // Feed more stable prices - the truncated version should recover faster + for (int i = 0; i < 30; i++) + { + tbf.Update(new TValue(DateTime.UtcNow, 100.0), isNew: true); + } + + // After 30 bars of constant price post-shock, truncated should be dampened + // (closer to zero than standard) because the shock is beyond the truncation window + Assert.True(Math.Abs(tbf.Last.Value) < Math.Abs(tbf.Bp.Value) + 1e-3, + "Truncated BP should recover from shock faster than standard BP"); + } + + [Theory] + [InlineData(5)] + [InlineData(10)] + [InlineData(20)] + [InlineData(50)] + public void Tbf_DifferentLengths_AllProduceValidOutput(int length) + { + var tbf = new Tbf(20, 0.1, length); + TSeries series = MakeSeries(200); + + for (int i = 0; i < series.Count; i++) + { + tbf.Update(series[i], isNew: true); + } + + Assert.True(double.IsFinite(tbf.Last.Value)); + Assert.True(tbf.IsHot); + } + + [Theory] + [InlineData(10, 0.1)] + [InlineData(20, 0.1)] + [InlineData(30, 0.2)] + [InlineData(50, 0.3)] + public void Tbf_DifferentPeriodBandwidth_AllProduceValidOutput(int period, double bandwidth) + { + var tbf = new Tbf(period, bandwidth, 10); + TSeries series = MakeSeries(200); + + for (int i = 0; i < series.Count; i++) + { + tbf.Update(series[i], isNew: true); + } + + Assert.True(double.IsFinite(tbf.Last.Value)); + } + + [Fact] + public void Tbf_SineInput_DetectsCycle() + { + var tbf = new Tbf(20, 0.3, 25); // wider bandwidth to capture + double period = 20.0; + int totalBars = 200; + + for (int i = 0; i < totalBars; i++) + { + double value = Math.Sin(2.0 * Math.PI * i / period); + tbf.Update(new TValue(DateTime.UtcNow, value), isNew: true); + } + + // The filter should produce non-trivial output for a sine at the center period + // After warmup, the output should be significant + Assert.True(Math.Abs(tbf.Last.Value) > 0.01, + "TBF should detect cycle at center period"); + } + + [Fact] + public void Tbf_UpdateTSeries_ReturnsCorrectCount() + { + var tbf = new Tbf(); + TSeries series = MakeSeries(100); + TSeries result = tbf.Update(series); + + Assert.Equal(series.Count, result.Count); + } + + [Fact] + public void Tbf_NullSource_Throws() + { + var tbf = new Tbf(); + Assert.Throws(() => tbf.Update((TSeries)null!)); + } + + [Fact] + public void Tbf_Prime_InitializesState() + { + var tbf = new Tbf(20, 0.1, 10); + double[] primeData = new double[50]; + for (int i = 0; i < 50; i++) + { + primeData[i] = 100.0 + Math.Sin(i * 0.3); + } + + tbf.Prime(primeData); + Assert.True(tbf.IsHot); + Assert.True(double.IsFinite(tbf.Last.Value)); + } + + [Fact] + public void Tbf_Bp_AlsoProducesOutput() + { + var tbf = new Tbf(); + TSeries series = MakeSeries(100); + + for (int i = 0; i < series.Count; i++) + { + tbf.Update(series[i], isNew: true); + } + + // Standard BP should also have finite output + Assert.True(double.IsFinite(tbf.Bp.Value)); + } +} diff --git a/lib/filters/tbf/tests/Tbf.Validation.Tests.cs b/lib/filters/tbf/tests/Tbf.Validation.Tests.cs new file mode 100644 index 00000000..632bd11e --- /dev/null +++ b/lib/filters/tbf/tests/Tbf.Validation.Tests.cs @@ -0,0 +1,398 @@ +namespace QuanTAlib.Tests; + +/// +/// Validation tests for the Ehlers Truncated Bandpass Filter. +/// Since TBF is a proprietary Ehlers indicator, no external library implementations exist. +/// Validation uses self-consistency: bandpass behavior, DC rejection, truncation properties, +/// mode consistency, and determinism. +/// +public class TbfValidationTests +{ + [Fact] + public void Validate_BandpassBehavior_InBandPassesOutBandAttenuated() + { + // TBF with period=20, bandwidth=0.3 should pass ~20-bar cycles and attenuate others + const int T = 1000; + double[] sine5 = new double[T]; // Period 5: too fast, should be attenuated + double[] sine20 = new double[T]; // Period 20: in-band, should pass + double[] sine200 = new double[T]; // Period 200: too slow, should be attenuated + + for (int i = 0; i < T; i++) + { + sine5[i] = Math.Sin(2.0 * Math.PI * i / 5.0); + sine20[i] = Math.Sin(2.0 * Math.PI * i / 20.0); + sine200[i] = Math.Sin(2.0 * Math.PI * i / 200.0); + } + + double[] out5 = new double[T]; + double[] out20 = new double[T]; + double[] out200 = new double[T]; + double[] bp5 = new double[T]; + double[] bp20 = new double[T]; + double[] bp200 = new double[T]; + + Tbf.Batch(sine5, out5, bp5, 20, 0.3, 25); + Tbf.Batch(sine20, out20, bp20, 20, 0.3, 25); + Tbf.Batch(sine200, out200, bp200, 20, 0.3, 25); + + double amp5 = GetAmplitude(out5); + double amp20 = GetAmplitude(out20); + double amp200 = GetAmplitude(out200); + + // In-band signal should have larger amplitude than out-of-band + Assert.True(amp20 > amp200, $"In-band (P=20, amp={amp20:E3}) should exceed trend (P=200, amp={amp200:E3})"); + Assert.True(amp20 > amp5, $"In-band (P=20, amp={amp20:E3}) should exceed noise (P=5, amp={amp5:E3})"); + } + + [Fact] + public void Validate_StreamingMatchesSpan() + { + var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 42); + var data = gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + double[] input = data.Close.Values.ToArray(); + + // Span path + double[] spanTbf = new double[input.Length]; + double[] spanBp = new double[input.Length]; + Tbf.Batch(input, spanTbf, spanBp, 20, 0.1, 10); + + // Streaming path + var ind = new Tbf(20, 0.1, 10); + double[] streamTbf = new double[input.Length]; + double[] streamBp = new double[input.Length]; + for (int i = 0; i < input.Length; i++) + { + ind.Update(new TValue(DateTime.UtcNow, input[i])); + streamTbf[i] = ind.Last.Value; + streamBp[i] = ind.Bp.Value; + } + + for (int i = 0; i < input.Length; i++) + { + Assert.Equal(spanTbf[i], streamTbf[i], 1e-9); + Assert.Equal(spanBp[i], streamBp[i], 1e-9); + } + } + + [Fact] + public void Validate_ConstantInput_OutputZero() + { + double[] input = Enumerable.Repeat(50.0, 1000).ToArray(); + double[] tbfOut = new double[1000]; + double[] bpOut = new double[1000]; + + Tbf.Batch(input, tbfOut, bpOut, 20, 0.1, 10); + + // Bandpass on constant → zero (DC rejection) + Assert.True(Math.Abs(tbfOut[^1]) < 1e-10, $"Expected TBF 0 for constant, got {tbfOut[^1]}"); + Assert.True(Math.Abs(bpOut[^1]) < 1e-10, $"Expected BP 0 for constant, got {bpOut[^1]}"); + } + + [Fact] + public void Validate_Deterministic() + { + var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 99); + var data = gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + double[] input = data.Close.Values.ToArray(); + + double[] out1 = new double[input.Length]; + double[] out2 = new double[input.Length]; + double[] bp1 = new double[input.Length]; + double[] bp2 = new double[input.Length]; + + Tbf.Batch(input, out1, bp1, 20, 0.1, 10); + Tbf.Batch(input, out2, bp2, 20, 0.1, 10); + + for (int i = 0; i < input.Length; i++) + { + Assert.Equal(out1[i], out2[i], 15); + Assert.Equal(bp1[i], bp2[i], 15); + } + } + + [Fact] + public void Validate_OutputOscillatesAroundZero() + { + var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 77); + var data = gbm.Fetch(1000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + double[] input = data.Close.Values.ToArray(); + double[] output = new double[input.Length]; + double[] bp = new double[input.Length]; + + Tbf.Batch(input, output, bp, 20, 0.1, 10); + + bool hasPositive = false, hasNegative = false; + for (int i = 50; i < output.Length; i++) + { + if (output[i] > 0) + { + hasPositive = true; + } + + if (output[i] < 0) + { + hasNegative = true; + } + } + + Assert.True(hasPositive, "TBF output should have positive values"); + Assert.True(hasNegative, "TBF output should have negative values"); + } + + [Fact] + public void Validate_LargeDataset_Stable() + { + var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 55); + var data = gbm.Fetch(10000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + double[] input = data.Close.Values.ToArray(); + double[] tbfOut = new double[input.Length]; + double[] bpOut = new double[input.Length]; + + Tbf.Batch(input, tbfOut, bpOut, 20, 0.1, 10); + + for (int i = 0; i < tbfOut.Length; i++) + { + Assert.True(double.IsFinite(tbfOut[i]), $"TBF[{i}] is not finite: {tbfOut[i]}"); + Assert.True(double.IsFinite(bpOut[i]), $"BP[{i}] is not finite: {bpOut[i]}"); + } + } + + [Fact] + public void Validate_NaN_Batch_Safe() + { + double[] input = new double[100]; + for (int i = 0; i < 100; i++) + { + input[i] = i % 7 == 0 ? double.NaN : 100.0 + Math.Sin(i * 0.1); + } + double[] tbfOut = new double[100]; + double[] bpOut = new double[100]; + + Tbf.Batch(input, tbfOut, bpOut, 20, 0.1, 10); + + for (int i = 0; i < tbfOut.Length; i++) + { + Assert.True(double.IsFinite(tbfOut[i]), $"TBF[{i}] should be finite with NaN input"); + Assert.True(double.IsFinite(bpOut[i]), $"BP[{i}] should be finite with NaN input"); + } + } + + [Fact] + public void Validate_DifferentPeriods_ProduceDifferentOutput() + { + var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 33); + var data = gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + double[] input = data.Close.Values.ToArray(); + + double[] out1 = new double[input.Length]; + double[] out2 = new double[input.Length]; + double[] bp1 = new double[input.Length]; + double[] bp2 = new double[input.Length]; + + Tbf.Batch(input, out1, bp1, 20, 0.1, 10); + Tbf.Batch(input, out2, bp2, 40, 0.2, 15); + + bool anyDifferent = false; + for (int i = 20; i < input.Length; i++) + { + if (Math.Abs(out1[i] - out2[i]) > 1e-12) + { + anyDifferent = true; + break; + } + } + + Assert.True(anyDifferent, "Different parameters should produce different output"); + } + + [Fact] + public void Validate_TruncatedHasSmallerTransientThanStandard() + { + // After a price shock, the truncated filter should recover faster + // because it has finite memory (limited to Length bars) + const int total = 200; + double[] input = new double[total]; + + // Flat → shock → flat pattern + for (int i = 0; i < total; i++) + { + input[i] = (i == 50) ? 200.0 : 100.0; + } + + double[] tbfOut = new double[total]; + double[] bpOut = new double[total]; + Tbf.Batch(input, tbfOut, bpOut, 20, 0.1, 10); + + // After Length+2 bars past the shock (i > 62), TBF should be back to ~0 + // but standard BP still has residual transient + double tbfPostShock = 0; + double bpPostShock = 0; + int startCheck = 50 + 10 + 5; // shock bar + length + margin + for (int i = startCheck; i < Math.Min(startCheck + 20, total); i++) + { + tbfPostShock += Math.Abs(tbfOut[i]); + bpPostShock += Math.Abs(bpOut[i]); + } + + // TBF should have less accumulated transient energy after truncation window passes + Assert.True(tbfPostShock <= bpPostShock + 1e-6, + $"Truncated ({tbfPostShock:E3}) should have ≤ transient energy than standard ({bpPostShock:E3})"); + } + + [Fact] + public void Validate_LongerTruncation_ApproachesStandardBP() + { + // As length increases, the truncated version should approach the standard BP + var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 44); + var data = gbm.Fetch(300, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + double[] input = data.Close.Values.ToArray(); + + double[] tbfShort = new double[input.Length]; + double[] tbfLong = new double[input.Length]; + double[] bpShort = new double[input.Length]; + double[] bpLong = new double[input.Length]; + + Tbf.Batch(input, tbfShort, bpShort, 20, 0.1, 5); + Tbf.Batch(input, tbfLong, bpLong, 20, 0.1, 50); + + // The longer truncation should be closer to its matching standard BP + double diffShort = 0, diffLong = 0; + int start = 60; // after both warmup + for (int i = start; i < input.Length; i++) + { + diffShort += Math.Abs(tbfShort[i] - bpShort[i]); + diffLong += Math.Abs(tbfLong[i] - bpLong[i]); + } + + Assert.True(diffLong < diffShort, + $"Longer truncation ({diffLong:E3}) should be closer to standard BP than shorter ({diffShort:E3})"); + } + + [Fact] + public void Validate_EmptySpan_NoOp() + { + double[] empty = Array.Empty(); + double[] tbfOut = Array.Empty(); + double[] bpOut = Array.Empty(); + + // Should not throw and produce no output + Tbf.Batch(empty, tbfOut, bpOut, 20, 0.1, 10); + Assert.Empty(tbfOut); + } + + [Fact] + public void Validate_ResetAndReplay_Identical() + { + var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 66); + var data = gbm.Fetch(300, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + double[] input = data.Close.Values.ToArray(); + + var ind = new Tbf(20, 0.1, 10); + double[] run1 = new double[input.Length]; + for (int i = 0; i < input.Length; i++) + { + run1[i] = ind.Update(new TValue(DateTime.UtcNow, input[i])).Value; + } + + ind.Reset(); + double[] run2 = new double[input.Length]; + for (int i = 0; i < input.Length; i++) + { + run2[i] = ind.Update(new TValue(DateTime.UtcNow, input[i])).Value; + } + + for (int i = 0; i < input.Length; i++) + { + Assert.Equal(run1[i], run2[i], 12); + } + } + + [Fact] + public void Validate_StandardBP_MatchesDirect2PoleRecurrence() + { + // Verify the standard BP output matches a direct implementation + // of Ehlers 2-pole bandpass: BP = a0*(Close - Close[2]) + a1*BP[1] - s1*BP[2] + int period = 20; + double bandwidth = 0.1; + int length = 10; + + var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 11); + var data = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + double[] input = data.Close.Values.ToArray(); + + // Compute using TBF class (get BP output) + double[] tbfOut = new double[input.Length]; + double[] bpOut = new double[input.Length]; + Tbf.Batch(input, tbfOut, bpOut, period, bandwidth, length); + + // Compute direct 2-pole bandpass + double twoPi = 2.0 * Math.PI; + double l1 = Math.Cos(twoPi / period); + double g1 = Math.Cos(bandwidth * twoPi / period); + double s1 = (1.0 / g1) - Math.Sqrt((1.0 / (g1 * g1)) - 1.0); + double a0 = 0.5 * (1.0 - s1); + double a1 = l1 * (1.0 + s1); + + double[] directBp = new double[input.Length]; + for (int i = 0; i < input.Length; i++) + { + if (i <= 2) + { + directBp[i] = 0.0; + } + else + { + directBp[i] = a0 * (input[i] - input[i - 2]) + + a1 * directBp[i - 1] + - s1 * directBp[i - 2]; + } + } + + // The standard BP from TBF should match the direct computation + for (int i = 0; i < input.Length; i++) + { + Assert.Equal(directBp[i], bpOut[i], 1e-9); + } + } + + [Theory] + [InlineData(10, 0.1, 5)] + [InlineData(20, 0.1, 10)] + [InlineData(30, 0.2, 15)] + [InlineData(50, 0.3, 25)] + public void Validate_AllParameterCombos_ProduceFiniteOutput(int period, double bandwidth, int length) + { + var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 22); + var data = gbm.Fetch(300, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + double[] input = data.Close.Values.ToArray(); + + double[] tbfOut = new double[input.Length]; + double[] bpOut = new double[input.Length]; + Tbf.Batch(input, tbfOut, bpOut, period, bandwidth, length); + + for (int i = 0; i < input.Length; i++) + { + Assert.True(double.IsFinite(tbfOut[i]), $"TBF[{i}] not finite for p={period},bw={bandwidth},len={length}"); + } + } + + private static double GetAmplitude(double[] data) + { + // Measure peak-to-peak amplitude in last half (after warmup) + int start = data.Length / 2; + double max = double.MinValue, min = double.MaxValue; + for (int i = start; i < data.Length; i++) + { + if (data[i] > max) + { + max = data[i]; + } + + if (data[i] < min) + { + min = data[i]; + } + } + return (max - min) / 2.0; + } +} diff --git a/python/quantalib/_bridge.py b/python/quantalib/_bridge.py index c8893529..7fe6e430 100644 --- a/python/quantalib/_bridge.py +++ b/python/quantalib/_bridge.py @@ -331,6 +331,7 @@ HAS_RMED = _bind("qtl_rmed", [_dp, _dp, _ci, _ci]) HAS_ROOFING = _bind("qtl_roofing", [_dp, _dp, _ci, _ci, _ci]) HAS_SGF = _bind("qtl_sgf", [_dp, _dp, _ci, _ci, _ci]) HAS_SPBF = _bind("qtl_spbf", [_dp, _dp, _ci, _ci, _ci, _ci]) +HAS_TBF = _bind("qtl_tbf", [_dp, _dp, _ci, _ci, _cd, _ci]) HAS_SSF2 = _bind("qtl_ssf2", [_dp, _dp, _ci, _ci]) HAS_SSF3 = _bind("qtl_ssf3", [_dp, _dp, _ci, _ci, _cd]) HAS_USF = _bind("qtl_usf", [_dp, _dp, _ci, _ci]) diff --git a/python/quantalib/channels.py b/python/quantalib/channels.py index 7e02dadb..9e89bde9 100644 --- a/python/quantalib/channels.py +++ b/python/quantalib/channels.py @@ -257,8 +257,8 @@ def ttm_lrc(close: object, period: int = 14, offset: int = 0, **kwargs) -> objec return _wrap_multi({"midline": midline, "upper1": upper1, "lower1": lower1, "upper2": upper2, "lower2": lower2}, idx, "channels", offset) -def ubands(close: object, period: int = 14, multiplier: float = 2.0, offset: int = 0, **kwargs) -> object: - """Upper/Lower Bands.""" +def ubands(close: object, period: int = 20, multiplier: float = 1.0, offset: int = 0, **kwargs) -> object: + """Ehlers Ultimate Bands.""" period = int(kwargs.get("length", period)) multiplier = float(multiplier) offset = int(offset) @@ -271,8 +271,8 @@ def ubands(close: object, period: int = 14, multiplier: float = 2.0, offset: int return _wrap_multi({"upper": upper, "middle": middle, "lower": lower}, idx, "channels", offset) -def uchannel(high: object, low: object, close: object, strPeriod: int = 14, centerPeriod: int = 20, multiplier: float = 2.0, offset: int = 0, **kwargs) -> object: - """Ulcer Channel.""" +def uchannel(high: object, low: object, close: object, strPeriod: int = 20, centerPeriod: int = 20, multiplier: float = 1.0, offset: int = 0, **kwargs) -> object: + """Ehlers Ultimate Channel.""" strPeriod = int(strPeriod) centerPeriod = int(centerPeriod) multiplier = float(multiplier) diff --git a/python/quantalib/filters.py b/python/quantalib/filters.py index f3da4a06..bc06a241 100644 --- a/python/quantalib/filters.py +++ b/python/quantalib/filters.py @@ -25,6 +25,7 @@ __all__ = [ "roofing", "sgf", "spbf", + "tbf", "ssf2", "ssf3", "usf", @@ -248,6 +249,19 @@ def spbf(close: object, shortPeriod: int = 40, longPeriod: int = 60, rmsPeriod: return _wrap(output, idx, f"SPBF_{shortPeriod}", "filters", offset) +def tbf(close: object, period: int = 20, bandwidth: float = 0.1, length: int = 10, offset: int = 0, **kwargs) -> object: + """Ehlers Truncated Bandpass Filter.""" + period = int(period) + bandwidth = float(bandwidth) + length = int(length) + offset = int(offset) + src, idx = _arr(close) + n = len(src) + output = _out(n) + _check(_lib.qtl_tbf(_ptr(src), _ptr(output), n, period, bandwidth, length)) + return _wrap(output, idx, f"TBF_{period}", "filters", offset) + + def ssf2(close: object, period: int = 14, offset: int = 0, **kwargs) -> object: """Super Smoother (2-pole).""" period = int(kwargs.get("length", period)) diff --git a/python/src/Exports.Generated.cs b/python/src/Exports.Generated.cs index 469cdf9d..7ce80ea8 100644 --- a/python/src/Exports.Generated.cs +++ b/python/src/Exports.Generated.cs @@ -2887,6 +2887,22 @@ public static unsafe partial class Exports catch { return StatusCodes.QTL_ERR_INTERNAL; } } + [UnmanagedCallersOnly(EntryPoint = "qtl_tbf")] + public static int QtlTbf(double* source, double* output, int n, int period, double bandwidth, int length) + { + if (source == null || output == null) return StatusCodes.QTL_ERR_NULL_PTR; + if (n <= 0) return StatusCodes.QTL_ERR_INVALID_LENGTH; + try + { + var src = Src(source, n); + var dst = Dst(output, n); + Span bpScratch = n <= 4096 ? stackalloc double[n] : new double[n]; + Tbf.Batch(src, dst, bpScratch, period, bandwidth, length); + return StatusCodes.QTL_OK; + } + catch { return StatusCodes.QTL_ERR_INTERNAL; } + } + [UnmanagedCallersOnly(EntryPoint = "qtl_spearman")] public static int QtlSpearman(double* seriesX, double* seriesY, double* output, int n, int period) {