feat: add TBF (Ehlers Truncated BandPass Filter) + fix all 64 warnings

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
This commit is contained in:
Miha Kralj
2026-03-18 19:10:48 -07:00
parent ef00330de3
commit b79b56dc65
21 changed files with 1992 additions and 33 deletions
+12
View File
@@ -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)
+1
View File
@@ -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)
+1
View File
@@ -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 |
+1
View File
@@ -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) |
+1
View File
@@ -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) |
+46 -13
View File
@@ -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<double> envBuf = stackalloc double[4];
envBuf.Clear();
Span<double> 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;
+2 -2
View File
@@ -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);
}
}
+28 -3
View File
@@ -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<double> 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;
+47 -11
View File
@@ -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<double>.Empty, Span<double>.Empty, 50, 10);
var exception = Record.Exception(() => Pta.Batch(ReadOnlySpan<double>.Empty, Span<double>.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);
}
+1
View File
@@ -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. |
+72
View File
@@ -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);
}
}
+410
View File
@@ -0,0 +1,410 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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
/// </remarks>
[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;
/// <summary>
/// Standard (non-truncated) bandpass filter value for comparison.
/// </summary>
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<double> 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<double> 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);
}
}
/// <summary>
/// Static batch computation for TSeries.
/// </summary>
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);
}
/// <summary>
/// Static span-based batch computation.
/// Outputs both truncated bandpass and standard bandpass.
/// </summary>
public static void Batch(
ReadOnlySpan<double> source,
Span<double> tbfOut,
Span<double> 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<double> 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<double> 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);
}
}
+146
View File
@@ -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.
+57
View File
@@ -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)
@@ -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}");
}
}
+449
View File
@@ -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<ArgumentOutOfRangeException>(() => new Tbf(1)); // period < 2
Assert.Throws<ArgumentOutOfRangeException>(() => new Tbf(-1)); // period < 2
Assert.Throws<ArgumentOutOfRangeException>(() => new Tbf(20, 0)); // bandwidth < MinBandwidth
Assert.Throws<ArgumentOutOfRangeException>(() => new Tbf(20, -0.1)); // bandwidth < 0
Assert.Throws<ArgumentOutOfRangeException>(() => 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<ArgumentException>(() =>
Tbf.Batch(source.AsSpan(), wrongSize.AsSpan(), bpOut.AsSpan()));
Assert.Throws<ArgumentOutOfRangeException>(() =>
Tbf.Batch(source.AsSpan(), tbfOut.AsSpan(), bpOut.AsSpan(), period: 1));
Assert.Throws<ArgumentOutOfRangeException>(() =>
Tbf.Batch(source.AsSpan(), tbfOut.AsSpan(), bpOut.AsSpan(), bandwidth: 0));
Assert.Throws<ArgumentOutOfRangeException>(() =>
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<ArgumentNullException>(() => 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));
}
}
@@ -0,0 +1,398 @@
namespace QuanTAlib.Tests;
/// <summary>
/// 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.
/// </summary>
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>();
double[] tbfOut = Array.Empty<double>();
double[] bpOut = Array.Empty<double>();
// 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;
}
}
+1
View File
@@ -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])
+4 -4
View File
@@ -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)
+14
View File
@@ -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))
+16
View File
@@ -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<double> 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)
{