mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-03 03:47:42 +00:00
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:
@@ -4,6 +4,18 @@
|
||||
[](https://www.codefactor.io/repository/github/mihakralj/quantalib/overview/main)
|
||||
[](https://www.nuget.org/packages/QuanTAlib/)
|
||||

|
||||
[](https://app.codacy.com/gh/mihakralj/QuanTAlib/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade)
|
||||
[](https://codecov.io/gh/mihakralj/QuanTAlib)
|
||||
[](https://sonarcloud.io/summary/new_code?id=mihakralj_QuanTAlib)
|
||||
[](https://www.codefactor.io/repository/github/mihakralj/quantalib/overview/main)
|
||||
[](https://www.nuget.org/packages/QuanTAlib/)
|
||||

|
||||
[](https://app.codacy.com/gh/mihakralj/QuanTAlib/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade)
|
||||
[](https://codecov.io/gh/mihakralj/QuanTAlib)
|
||||
[](https://sonarcloud.io/summary/new_code?id=mihakralj_QuanTAlib)
|
||||
[](https://www.codefactor.io/repository/github/mihakralj/quantalib/overview/main)
|
||||
[](https://www.nuget.org/packages/QuanTAlib/)
|
||||

|
||||
[](https://www.nuget.org/packages/QuanTAlib/)
|
||||
[](https://dotnet.microsoft.com/en-us/download/dotnet)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user