Add documentation links for various volatility indicators and channels

- Updated BBWN, BBWP, CCV, CV, CVI, EWMA, GKV, HLV, HV, Jvolty, JVOLTYN, MASSI, NATR, RSV, RV, RVI, TR, UI, VOV, VR, YZV indicators with documentation links.
- Added documentation links for Aberration, Acceleration Bands, Andrews' Pitchfork, Adaptive Price Zone, ATR Bands, Bollinger Bands, Center of Gravity, Donchian Channels, Decay Min-Max Channel, Detrended Synthetic Price, EACP, EBSW, HOMOD, Jurik Volatility Bands, Keltner Channel, MA Envelope, Min-Max Channel, Price Channel, Regression Channels, Standard Deviation Channel, Stoller Average Range Channel, Super Trend Bands, Ultimate Bands, Ultimate Channel, VWAP Bands, and VWAP with Standard Deviation Bands.
This commit is contained in:
Miha Kralj
2026-02-18 11:55:48 -08:00
parent 79c0d72d0a
commit 24e86d762a
332 changed files with 19813 additions and 323 deletions
+142
View File
@@ -0,0 +1,142 @@
using TradingPlatform.BusinessLayer;
namespace QuanTAlib.Tests;
public class VossIndicatorTests
{
[Fact]
public void VossIndicator_Constructor_SetsDefaults()
{
var indicator = new VossIndicator();
Assert.Equal(20, indicator.Period);
Assert.Equal(3, indicator.Predict);
Assert.Equal(0.25, indicator.Bandwidth);
Assert.Equal(SourceType.Close, indicator.Source);
Assert.True(indicator.ShowColdValues);
Assert.Equal("VOSS - Ehlers Voss Predictive Filter", indicator.Name);
Assert.True(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void VossIndicator_MinHistoryDepths_EqualsZero()
{
var indicator = new VossIndicator { Period = 20, Predict = 3 };
Assert.Equal(0, VossIndicator.MinHistoryDepths);
Assert.Equal(0, ((IWatchlistIndicator)indicator).MinHistoryDepths);
}
[Fact]
public void VossIndicator_ShortName_IncludesParameters()
{
var indicator = new VossIndicator { Period = 20, Predict = 3, Bandwidth = 0.25 };
Assert.Contains("VOSS", indicator.ShortName, StringComparison.Ordinal);
Assert.Contains("20", indicator.ShortName, StringComparison.Ordinal);
Assert.Contains("3", indicator.ShortName, StringComparison.Ordinal);
}
[Fact]
public void VossIndicator_Initialize_CreatesInternalVoss()
{
var indicator = new VossIndicator { Period = 20, Predict = 3, Bandwidth = 0.25 };
indicator.Initialize();
// Voss has two line series: Voss predictor + Bandpass (Filt)
Assert.Equal(2, indicator.LinesSeries.Count);
}
[Fact]
public void VossIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new VossIndicator { Period = 20, Predict = 3, Bandwidth = 0.25 };
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)));
Assert.Equal(1, indicator.LinesSeries[1].Count);
Assert.True(double.IsFinite(indicator.LinesSeries[1].GetValue(0)));
}
[Fact]
public void VossIndicator_ProcessUpdate_NewBar_ComputesValue()
{
var indicator = new VossIndicator { Period = 20, Predict = 3, Bandwidth = 0.25 };
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);
Assert.Equal(2, indicator.LinesSeries[1].Count);
}
[Fact]
public void VossIndicator_ProcessUpdate_NewTick_ProcessesWithoutError()
{
var indicator = new VossIndicator { Period = 20, Predict = 3, Bandwidth = 0.25 };
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 VossIndicator_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 VossIndicator { Period = 20, Predict = 3, Bandwidth = 0.25, 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 Voss value");
Assert.True(double.IsFinite(indicator.LinesSeries[1].GetValue(0)),
$"Source {source} should produce finite Filt value");
}
}
[Fact]
public void VossIndicator_Parameters_CanBeChanged()
{
var indicator = new VossIndicator { Period = 20, Predict = 3, Bandwidth = 0.25 };
Assert.Equal(20, indicator.Period);
Assert.Equal(3, indicator.Predict);
Assert.Equal(0.25, indicator.Bandwidth);
indicator.Period = 40;
indicator.Predict = 5;
indicator.Bandwidth = 0.15;
Assert.Equal(40, indicator.Period);
Assert.Equal(5, indicator.Predict);
Assert.Equal(0.15, indicator.Bandwidth);
}
}
+65
View File
@@ -0,0 +1,65 @@
using System.Drawing;
using System.Runtime.CompilerServices;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
[SkipLocalsInit]
public sealed class VossIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Period", sortIndex: 1, 2, 2000, 1, 0)]
public int Period { get; set; } = 20;
[InputParameter("Predict", sortIndex: 2, 1, 100, 1, 0)]
public int Predict { get; set; } = 3;
[InputParameter("Bandwidth", sortIndex: 3, 0.01, 0.99, 0.01, 2)]
public double Bandwidth { get; set; } = 0.25;
[IndicatorExtensions.DataSourceInput]
public SourceType Source { get; set; } = SourceType.Close;
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private Voss _voss = null!;
private readonly LineSeries _vossSeries;
private readonly LineSeries _filtSeries;
private string _sourceName = null!;
private Func<IHistoryItem, double> _priceSelector = null!;
public static int MinHistoryDepths => 0;
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => $"VOSS {Period}:{Predict}:{Bandwidth:F2}:{_sourceName}";
public VossIndicator()
{
OnBackGround = true;
SeparateWindow = true;
Name = "VOSS - Ehlers Voss Predictive Filter";
Description = "Ehlers Voss Predictive Filter: two-pole bandpass + weighted feedback predictor with negative group delay";
_vossSeries = new LineSeries(name: $"Voss {Period}:{Predict}", color: Color.DodgerBlue, width: 2, style: LineStyle.Solid);
_filtSeries = new LineSeries(name: $"Filt {Period}:{Predict}", color: Color.Red, width: 1, style: LineStyle.Solid);
AddLineSeries(_vossSeries);
AddLineSeries(_filtSeries);
}
protected override void OnInit()
{
_priceSelector = Source.GetPriceSelector();
_sourceName = Source.ToString();
_voss = new Voss(Period, Predict, Bandwidth);
base.OnInit();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnUpdate(UpdateArgs args)
{
bool isNew = args.IsNewBar();
var item = HistoricalData[Count - 1, SeekOriginHistory.Begin];
double value = _voss.Update(new TValue(item.TimeLeft.Ticks, _priceSelector(item)), isNew).Value;
_vossSeries.SetValue(value, _voss.IsHot, ShowColdValues);
_filtSeries.SetValue(_voss.LastFilt, _voss.IsHot, ShowColdValues);
}
}
+445
View File
@@ -0,0 +1,445 @@
namespace QuanTAlib;
public class VossTests
{
private readonly GBM _gbm;
public VossTests()
{
_gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 123);
}
// --- A) Constructor Validation ---
[Fact]
public void Constructor_ValidatesPeriod()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new Voss(period: 1));
Assert.Throws<ArgumentOutOfRangeException>(() => new Voss(period: 0));
Assert.Throws<ArgumentOutOfRangeException>(() => new Voss(period: -1));
}
[Fact]
public void Constructor_ValidatesPredict()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new Voss(predict: 0));
Assert.Throws<ArgumentOutOfRangeException>(() => new Voss(predict: -1));
}
[Fact]
public void Constructor_ValidatesBandwidth()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new Voss(bandwidth: 0));
Assert.Throws<ArgumentOutOfRangeException>(() => new Voss(bandwidth: -0.1));
Assert.Throws<ArgumentOutOfRangeException>(() => new Voss(bandwidth: 1.0));
Assert.Throws<ArgumentOutOfRangeException>(() => new Voss(bandwidth: 1.5));
}
[Fact]
public void Constructor_SetsName()
{
var ind = new Voss(20, 3, 0.25);
Assert.Equal("VOSS(20,3,0.25)", ind.Name);
}
[Fact]
public void Constructor_SetsWarmupPeriod()
{
var ind = new Voss(20, 3, 0.25);
Assert.Equal(20, ind.WarmupPeriod);
}
[Fact]
public void Constructor_DefaultParameters()
{
var ind = new Voss();
Assert.Equal(20, ind.Period);
Assert.Equal(3, ind.Predict);
Assert.Equal(0.25, ind.Bandwidth);
Assert.Equal(9, ind.Order); // 3 * 3
}
[Fact]
public void Constructor_OrderIs3TimesPredict()
{
var ind = new Voss(period: 20, predict: 5);
Assert.Equal(15, ind.Order);
}
// --- B) Basic Calculation ---
[Fact]
public void Calc_ReturnsValue()
{
var ind = new Voss(20, 3, 0.25);
var result = ind.Update(new TValue(DateTime.UtcNow, 100));
Assert.True(double.IsFinite(result.Value));
}
[Fact]
public void Calc_PropertiesAccessible()
{
var ind = new Voss(20, 3, 0.25);
// Feed several bars to pass warmup
for (int i = 0; i < 10; i++)
{
ind.Update(new TValue(DateTime.UtcNow, 100 + i));
}
Assert.True(double.IsFinite(ind.Last.Value));
Assert.True(ind.IsHot); // Count > 5 after 10 bars
Assert.Equal("VOSS(20,3,0.25)", ind.Name);
_ = ind.IsNew;
Assert.True(double.IsFinite(ind.LastFilt));
}
[Fact]
public void ConstantInput_ConvergesToZero()
{
// Bandpass filter applied to DC input → output should converge to zero
var ind = new Voss(20, 3, 0.25);
double lastVal = 0;
for (int i = 0; i < 500; i++)
{
lastVal = ind.Update(new TValue(DateTime.UtcNow, 100)).Value;
}
Assert.True(Math.Abs(lastVal) < 1e-6, $"Constant input should yield ~0, got {lastVal}");
}
[Fact]
public void ConstantInput_FiltConvergesToZero()
{
var ind = new Voss(20, 3, 0.25);
for (int i = 0; i < 500; i++)
{
ind.Update(new TValue(DateTime.UtcNow, 100));
}
Assert.True(Math.Abs(ind.LastFilt) < 1e-6, $"Constant input Filt should yield ~0, got {ind.LastFilt}");
}
// --- C) State + Bar Correction ---
[Fact]
public void Calc_IsNew_AcceptsParameter()
{
var ind = new Voss(20, 3, 0.25);
// Feed enough bars past warmup (Count > 5) so Filt is not clamped to 0
for (int i = 0; i < 10; i++)
{
ind.Update(new TValue(DateTime.UtcNow, 100 + i * 2), isNew: true);
}
double val1 = ind.Last.Value;
ind.Update(new TValue(DateTime.UtcNow, 200), isNew: false);
double val2 = ind.Last.Value;
Assert.NotEqual(val1, val2);
}
[Fact]
public void Calc_IsNew_False_UpdatesValue()
{
var ind = new Voss(10, 2, 0.3);
// Feed enough bars past warmup (Count > 5) so Filt is not clamped to 0
for (int i = 0; i < 10; i++)
{
ind.Update(new TValue(DateTime.UtcNow, 100 + i * 3), isNew: true);
}
double val1 = ind.Last.Value;
ind.Update(new TValue(DateTime.UtcNow, 200), isNew: false);
double val2 = ind.Last.Value;
Assert.NotEqual(val1, val2);
}
[Fact]
public void IterativeCorrections_RestoreToOriginalState()
{
var ind = new Voss(10, 2, 0.3);
var data = _gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var series = data.Close;
// Feed N values
for (int i = 0; i < series.Count; i++)
{
ind.Update(series[i]);
}
double originalValue = ind.Last.Value;
// Feed M corrections with isNew=false
ind.Update(new TValue(DateTime.UtcNow, 200), isNew: false);
ind.Update(new TValue(DateTime.UtcNow, 300), isNew: false);
ind.Update(new TValue(DateTime.UtcNow, 400), isNew: false);
// Restore with original last value
ind.Update(series[^1], isNew: false);
double restoredValue = ind.Last.Value;
Assert.Equal(originalValue, restoredValue, 10);
}
[Fact]
public void Reset_ClearsState()
{
var ind = new Voss(10, 2, 0.3);
var data = _gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
foreach (var item in data.Close)
{
ind.Update(item);
}
ind.Reset();
var ind2 = new Voss(10, 2, 0.3);
var result1 = ind.Update(new TValue(DateTime.UtcNow, 100));
var result2 = ind2.Update(new TValue(DateTime.UtcNow, 100));
Assert.Equal(result2.Value, result1.Value, 10);
}
// --- D) Warmup/Convergence ---
[Fact]
public void IsHot_FalseBeforeWarmup()
{
// IsHot => Count > 5
var ind = new Voss(20, 3, 0.25);
Assert.False(ind.IsHot); // No bars yet
// Feed 5 bars → Count = 5, still not hot
for (int i = 0; i < 5; i++)
{
ind.Update(new TValue(DateTime.UtcNow, 100 + i));
}
Assert.False(ind.IsHot); // Count == 5, need > 5
// 6th bar → hot
ind.Update(new TValue(DateTime.UtcNow, 106));
Assert.True(ind.IsHot);
}
[Fact]
public void IsHot_StaysTrueAfterWarmup()
{
var ind = new Voss(20, 3, 0.25);
for (int i = 0; i < 100; i++)
{
ind.Update(new TValue(DateTime.UtcNow, 100 + i));
}
Assert.True(ind.IsHot);
}
// --- E) Robustness ---
[Fact]
public void NaN_Input_UsesLastValidValue()
{
var ind = new Voss(20, 3, 0.25);
ind.Update(new TValue(DateTime.UtcNow, 100));
ind.Update(new TValue(DateTime.UtcNow, 105));
var result = ind.Update(new TValue(DateTime.UtcNow, double.NaN));
Assert.True(double.IsFinite(result.Value));
}
[Fact]
public void Infinity_Input_UsesLastValidValue()
{
var ind = new Voss(20, 3, 0.25);
ind.Update(new TValue(DateTime.UtcNow, 100));
ind.Update(new TValue(DateTime.UtcNow, 105));
var result = ind.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity));
Assert.True(double.IsFinite(result.Value));
var result2 = ind.Update(new TValue(DateTime.UtcNow, double.NegativeInfinity));
Assert.True(double.IsFinite(result2.Value));
}
[Fact]
public void MultipleNaN_ContinuesWithLastValid()
{
var ind = new Voss(20, 3, 0.25);
ind.Update(new TValue(DateTime.UtcNow, 100));
ind.Update(new TValue(DateTime.UtcNow, 105));
for (int i = 0; i < 10; i++)
{
var result = ind.Update(new TValue(DateTime.UtcNow, double.NaN));
Assert.True(double.IsFinite(result.Value));
}
}
[Fact]
public void BatchCalc_HandlesNaN()
{
double[] input = [100, 105, double.NaN, 110, double.NaN, 115, 120, 125, 130, 135];
double[] output = new double[input.Length];
Voss.Batch(input, output, 5, 1, 0.25);
for (int i = 0; i < output.Length; i++)
{
Assert.True(double.IsFinite(output[i]), $"Output[{i}] should be finite");
}
}
// --- F) Consistency ---
[Fact]
public void AllModes_ProduceSameResult()
{
const int period = 20;
const int predict = 3;
const double bandwidth = 0.25;
var data = _gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var series = data.Close;
// 1. Span Mode
double[] spanOutput = new double[series.Count];
Voss.Batch(series.Values.ToArray(), spanOutput, period, predict, bandwidth);
// 2. TSeries Batch Mode
var vossBatch = new Voss(period, predict, bandwidth);
var batchResult = vossBatch.Update(series);
// 3. Streaming Mode
var vossStream = new Voss(period, predict, bandwidth);
var streamResults = new List<double>();
foreach (var item in series)
{
streamResults.Add(vossStream.Update(item).Value);
}
// 4. Eventing Mode
var pubSource = new TSeries();
var vossEvent = new Voss(pubSource, period, predict, bandwidth);
for (int i = 0; i < series.Count; i++)
{
pubSource.Add(series[i]);
}
// Assert all modes match
for (int i = 0; i < series.Count; i++)
{
Assert.Equal(spanOutput[i], batchResult[i].Value, 1e-9);
Assert.Equal(spanOutput[i], streamResults[i], 1e-9);
}
Assert.Equal(spanOutput[^1], vossEvent.Last.Value, 1e-9);
}
// --- G) Span API ---
[Fact]
public void SpanCalc_ValidatesLength()
{
double[] source = new double[10];
double[] output = new double[5]; // Mismatched!
Assert.Throws<ArgumentException>(() => Voss.Batch(source, output));
}
[Fact]
public void SpanCalc_ConstantInput_ConvergesToZero()
{
double[] input = Enumerable.Repeat(100.0, 500).ToArray();
double[] output = new double[500];
Voss.Batch(input, output, 20, 3, 0.25);
Assert.True(Math.Abs(output[^1]) < 1e-6, $"Expected ~0 for constant input, got {output[^1]}");
}
[Fact]
public void SpanCalc_MatchesTSeriesCalc()
{
var data = _gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var series = data.Close;
// Span
double[] spanOutput = new double[series.Count];
Voss.Batch(series.Values.ToArray(), spanOutput, 20, 3, 0.25);
// TSeries
var ind = new Voss(20, 3, 0.25);
var tseriesResult = ind.Update(series);
for (int i = 0; i < series.Count; i++)
{
Assert.Equal(spanOutput[i], tseriesResult[i].Value, 1e-9);
}
}
// --- H) Chainability ---
[Fact]
public void Pub_FiresOnUpdate()
{
var ind = new Voss(20, 3, 0.25);
int fireCount = 0;
ind.Pub += (object? _, in TValueEventArgs _) => fireCount++;
ind.Update(new TValue(DateTime.UtcNow, 100));
ind.Update(new TValue(DateTime.UtcNow, 105));
Assert.Equal(2, fireCount);
}
[Fact]
public void EventChaining_Works()
{
var source = new TSeries();
var ind = new Voss(source, 20, 3, 0.25);
source.Add(new TValue(DateTime.UtcNow, 100));
source.Add(new TValue(DateTime.UtcNow, 105));
Assert.True(double.IsFinite(ind.Last.Value));
}
// --- Additional: Different Parameters ---
[Fact]
public void DifferentParameters_ProduceDifferentResults()
{
var data = _gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var series = data.Close;
var ind1 = new Voss(20, 3, 0.25);
var ind2 = new Voss(10, 5, 0.4);
foreach (var item in series)
{
ind1.Update(item);
ind2.Update(item);
}
Assert.NotEqual(ind1.Last.Value, ind2.Last.Value);
}
[Fact]
public void LargeDataset_DoesNotThrow()
{
var data = _gbm.Fetch(10000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var series = data.Close;
double[] input = series.Values.ToArray();
double[] output = new double[input.Length];
Voss.Batch(input, output, 20, 3, 0.25);
Assert.True(double.IsFinite(output[^1]));
}
[Fact]
public void LastFilt_TracksCurrentBandpassValue()
{
var ind = new Voss(20, 3, 0.25);
var data = _gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
foreach (var item in data.Close)
{
ind.Update(item);
}
// LastFilt should be finite and generally different from Voss output
Assert.True(double.IsFinite(ind.LastFilt));
}
}
+248
View File
@@ -0,0 +1,248 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Xunit;
namespace QuanTAlib.Tests;
/// <summary>
/// Validation tests for the Voss Predictive Filter.
/// Since VOSS is a proprietary Ehlers indicator, no external library implementations exist.
/// Validation uses self-consistency: bandpass behavior, predictor lead, mode consistency, and determinism.
/// </summary>
public class VossValidationTests
{
[Fact]
public void Validate_BandpassBehavior_Synthetic()
{
// Voss BPF stage with period=20 should pass cycles near period 20
// Cycles far from period (very fast or very slow) should be attenuated
const int T = 1000;
double[] sine5 = new double[T]; // Period 5: too fast, attenuated
double[] sine20 = new double[T]; // Period 20: in-band, should pass
double[] sine100 = new double[T]; // Period 100: too slow, attenuated
for (int i = 0; i < T; i++)
{
sine5[i] = Math.Sin(2 * Math.PI * i / 5.0);
sine20[i] = Math.Sin(2 * Math.PI * i / 20.0);
sine100[i] = Math.Sin(2 * Math.PI * i / 100.0);
}
double[] out5 = new double[T];
double[] out20 = new double[T];
double[] out100 = new double[T];
Voss.Batch(sine5, out5, 20, 3, 0.25);
Voss.Batch(sine20, out20, 20, 3, 0.25);
Voss.Batch(sine100, out100, 20, 3, 0.25);
double amp5 = GetAmplitude(out5);
double amp20 = GetAmplitude(out20);
double amp100 = GetAmplitude(out100);
// Voss predictor amplifies the in-band signal
Assert.True(amp20 > 0.5, $"In-band signal (P=20) should pass. Amplitude: {amp20}");
Assert.True(amp5 < amp20, $"Out-of-band fast signal should be smaller. Fast: {amp5}, In-band: {amp20}");
Assert.True(amp100 < amp20, $"Out-of-band slow signal should be smaller. Slow: {amp100}, In-band: {amp20}");
}
[Fact]
public void Validate_VossLeadsFilt()
{
// The Voss predictor should lead (anticipate) the bandpass filter
// Test with a clean sinusoid at the tuned period
const int T = 500;
double[] sine = new double[T];
for (int i = 0; i < T; i++)
{
sine[i] = Math.Sin(2 * Math.PI * i / 20.0);
}
var ind = new Voss(20, 3, 0.25);
var vossVals = new double[T];
var filtVals = new double[T];
for (int i = 0; i < T; i++)
{
ind.Update(new TValue(DateTime.UtcNow, sine[i]));
vossVals[i] = ind.Last.Value;
filtVals[i] = ind.LastFilt;
}
// Find zero crossings of Filt (positive to negative) in the settled region
// Voss should cross zero first (leading)
int vossLeadCount = 0;
int filtLeadCount = 0;
for (int i = 200; i < T - 1; i++)
{
// Filt zero crossing (positive → negative)
if (filtVals[i] > 0 && filtVals[i + 1] <= 0)
{
// Check if Voss already crossed (is negative) nearby
if (vossVals[i] <= 0)
{
vossLeadCount++;
}
else
{
filtLeadCount++;
}
}
}
// Voss should lead more often than filt for an in-band signal
Assert.True(vossLeadCount > 0, "Voss should lead the bandpass at least once");
}
[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();
double[] spanOut = new double[input.Length];
Voss.Batch(input, spanOut, 20, 3, 0.25);
var ind = new Voss(20, 3, 0.25);
var streamResults = new double[input.Length];
for (int i = 0; i < input.Length; i++)
{
streamResults[i] = ind.Update(new TValue(DateTime.UtcNow, input[i])).Value;
}
for (int i = 0; i < input.Length; i++)
{
Assert.Equal(spanOut[i], streamResults[i], 1e-9);
}
}
[Fact]
public void Validate_ConstantInput_OutputZero()
{
double[] input = Enumerable.Repeat(50.0, 1000).ToArray();
double[] output = new double[1000];
Voss.Batch(input, output, 20, 3, 0.25);
// Bandpass on constant → zero (removes DC)
Assert.True(Math.Abs(output[^1]) < 1e-10, $"Expected 0 for constant, got {output[^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];
Voss.Batch(input, out1, 20, 3, 0.25);
Voss.Batch(input, out2, 20, 3, 0.25);
for (int i = 0; i < input.Length; i++)
{
Assert.Equal(out1[i], out2[i], 15); // Exact match
}
}
[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];
Voss.Batch(input, output, 20, 3, 0.25);
bool hasPositive = false, hasNegative = false;
for (int i = 100; i < output.Length; i++)
{
if (output[i] > 0)
{
hasPositive = true;
}
if (output[i] < 0)
{
hasNegative = true;
}
}
Assert.True(hasPositive, "Voss output should have positive values");
Assert.True(hasNegative, "Voss 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[] output = new double[input.Length];
Voss.Batch(input, output, 20, 3, 0.25);
for (int i = 0; i < output.Length; i++)
{
Assert.True(double.IsFinite(output[i]), $"Output[{i}] is not finite: {output[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[] output = new double[100];
Voss.Batch(input, output, 20, 3, 0.25);
for (int i = 0; i < output.Length; i++)
{
Assert.True(double.IsFinite(output[i]), $"Output[{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];
Voss.Batch(input, out1, 20, 3, 0.25);
Voss.Batch(input, out2, 40, 5, 0.15);
bool anyDifferent = false;
for (int i = 100; i < input.Length; i++)
{
if (Math.Abs(out1[i] - out2[i]) > 1e-10)
{
anyDifferent = true;
break;
}
}
Assert.True(anyDifferent, "Different parameters should produce different output");
}
private static double GetAmplitude(double[] signal)
{
double max = 0;
for (int i = signal.Length - 100; i < signal.Length; i++)
{
max = Math.Max(max, Math.Abs(signal[i]));
}
return max;
}
}
+369
View File
@@ -0,0 +1,369 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// VOSS: Ehlers Voss Predictive Filter
/// A predictive bandpass filter using negative group delay via weighted feedback.
/// Stage 1: Two-pole bandpass filter extracts the dominant cycle.
/// Stage 2: Voss predictor applies negative group delay for anticipatory coupling.
/// Both outputs oscillate around zero.
/// </summary>
/// <remarks>
/// The algorithm is based on:
/// John Ehlers, "A Peek Into the Future," TASC August 2019.
/// Based on Henning U. Voss universal negative delay filter.
///
/// Key properties:
/// - BPF stage isolates cycles near the specified period
/// - Voss predictor reduces group delay (leads the bandpass)
/// - Both Filt and Voss oscillate around zero
/// - Crossings between Filt and Voss generate trade signals
///
/// Complexity: O(Order) per bar, where Order = 3 * Predict
/// </remarks>
[SkipLocalsInit]
public sealed class Voss : AbstractBase
{
private readonly double _f1, _s1;
private readonly int _order;
private ITValuePublisher? _publisher;
private TValuePublishedHandler? _handler;
private bool _isNew;
[StructLayout(LayoutKind.Auto)]
private record struct State
{
public double Src1; // src[1] — previous bar value for delay chain
public double Src2; // src[2] — value from two bars ago
public double Filt1; // Filt[1] for BPF recursion
public double Filt2; // Filt[2] for BPF recursion
public double LastFilt; // Current Filt value (exposed as property)
public double LastValid; // Last finite input
public int Count; // Bar count for warmup suppression
}
private State _s;
private State _ps;
// Ring buffer for Voss history (Order+1 elements)
private readonly double[] _vossRing;
private int _vossIdx;
private double[]? _p_vossRing;
private int _p_vossIdx;
/// <summary>Primary cycle period in bars.</summary>
public int Period { get; }
/// <summary>Prediction bars (negative delay amount).</summary>
public int Predict { get; }
/// <summary>Bandpass tolerance as fraction of period.</summary>
public double Bandwidth { get; }
/// <summary>The Order of the Voss predictor (3 * Predict).</summary>
public int Order => _order;
/// <summary>Last computed Bandpass Filter value (Filt output).</summary>
public double LastFilt => _s.LastFilt;
public bool IsNew => _isNew;
public override bool IsHot => _s.Count > 5;
public Voss(int period = 20, int predict = 3, double bandwidth = 0.25)
{
if (period < 2)
{
throw new ArgumentOutOfRangeException(nameof(period), "Period must be >= 2");
}
if (predict < 1)
{
throw new ArgumentOutOfRangeException(nameof(predict), "Predict must be >= 1");
}
if (bandwidth <= 0 || bandwidth >= 1)
{
throw new ArgumentOutOfRangeException(nameof(bandwidth), "Bandwidth must be in (0, 1)");
}
Period = period;
Predict = predict;
Bandwidth = bandwidth;
_order = 3 * predict;
Name = $"VOSS({period},{predict},{bandwidth:F2})";
WarmupPeriod = period;
// Precompute BPF coefficients
// F1 = cos(2π / Period)
// G1 = cos(Bandwidth * 2π / Period)
// S1 = 1/G1 - sqrt(1/G1² - 1)
double twoPiOverPeriod = 2.0 * Math.PI / period;
_f1 = Math.Cos(twoPiOverPeriod);
double g1 = Math.Cos(bandwidth * twoPiOverPeriod);
_s1 = 1.0 / g1 - Math.Sqrt(1.0 / (g1 * g1) - 1.0);
_vossRing = new double[_order + 1];
_s.LastValid = double.NaN;
}
public Voss(ITValuePublisher source, int period = 20, int predict = 3, double bandwidth = 0.25)
: this(period, predict, bandwidth)
{
_publisher = source;
_handler = Handle;
source.Pub += _handler;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void Handle(object? sender, in TValueEventArgs args)
{
Update(args.Value, args.IsNew);
}
public override TSeries Update(TSeries source)
{
if (source.Count == 0)
{
return [];
}
double[] values = source.Values.ToArray();
double[] results = new double[values.Length];
Batch(values, results, Period, Predict, Bandwidth);
TSeries output = [];
for (int i = 0; i < values.Length; i++)
{
output.Add(source[i].Time, results[i]);
}
// Resync internal state by replaying
Reset();
for (int i = 0; i < source.Count; i++)
{
Update(source[i]);
}
return output;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override TValue Update(TValue input, bool isNew = true)
{
_isNew = isNew;
if (isNew)
{
_ps = _s;
_p_vossRing ??= new double[_vossRing.Length];
Array.Copy(_vossRing, _p_vossRing, _vossRing.Length);
_p_vossIdx = _vossIdx;
}
else
{
_s = _ps;
if (_p_vossRing != null)
{
Array.Copy(_p_vossRing, _vossRing, _vossRing.Length);
}
_vossIdx = _p_vossIdx;
}
var s = _s;
// Handle bad data — last-valid substitution
double val = input.Value;
if (!double.IsFinite(val))
{
val = double.IsFinite(s.LastValid) ? s.LastValid : 0.0;
}
else
{
s.LastValid = val;
}
// Stage 1: Two-pole Bandpass Filter
// Filt = 0.5*(1-S1)*(src - src[2]) + F1*(1+S1)*Filt[1] - S1*Filt[2]
double diff = val - s.Src2;
double filt = Math.FusedMultiplyAdd(
0.5 * (1.0 - _s1), diff,
Math.FusedMultiplyAdd(_f1 * (1.0 + _s1), s.Filt1, -_s1 * s.Filt2));
if (s.Count <= 5)
{
filt = 0.0;
}
// Stage 2: Voss Predictor
// SumC = sum of ((count+1)/Order) * Voss[Order - count] for count=0..Order-1
double sumC = 0.0;
int ringLen = _vossRing.Length;
for (int count = 0; count < _order; count++)
{
int idx = _order - count; // lookback distance
int ringPos = (_vossIdx - idx + ringLen * 2) % ringLen;
sumC += (double)(count + 1) / _order * _vossRing[ringPos];
}
double vossVal = (double)(3 + _order) / 2.0 * filt - sumC;
// State shifts for next bar
if (isNew)
{
// Shift delay chain: src[2] = previous src[1], src[1] = current val
s.Src2 = s.Src1;
s.Src1 = val;
// Advance Filt history
s.Filt2 = s.Filt1;
s.Filt1 = filt;
// Write to current position FIRST, then advance for next bar
// skipcq: CS-R1140 - write-then-advance keeps ring index aligned with bar index
_vossRing[_vossIdx] = vossVal;
_vossIdx = (_vossIdx + 1) % ringLen;
s.Count++;
}
else
{
// Bar correction: overwrite current bar's position
// _vossIdx was restored to pre-advance state, so it points to current bar
_vossRing[_vossIdx] = vossVal;
}
s.LastFilt = filt;
_s = s;
Last = new TValue(input.Time, vossVal);
PubEvent(Last, isNew);
return Last;
}
public static TSeries Batch(TSeries source, int period = 20, int predict = 3, double bandwidth = 0.25)
{
var indicator = new Voss(period, predict, bandwidth);
return indicator.Update(source);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Batch(ReadOnlySpan<double> source, Span<double> output,
int period = 20, int predict = 3, double bandwidth = 0.25)
{
if (source.Length != output.Length)
{
throw new ArgumentException("Source and output spans must be of the same length.", nameof(output));
}
// Precompute BPF coefficients
double twoPiOverPeriod = 2.0 * Math.PI / period;
double f1 = Math.Cos(twoPiOverPeriod);
double g1 = Math.Cos(bandwidth * twoPiOverPeriod);
double s1 = 1.0 / g1 - Math.Sqrt(1.0 / (g1 * g1) - 1.0);
int order = 3 * predict;
double[] vossHistory = new double[source.Length];
double filt1 = 0, filt2 = 0;
double lastValid = 0;
if (source.Length > 0)
{
lastValid = source[0];
if (!double.IsFinite(lastValid))
{
lastValid = 0;
}
}
for (int i = 0; i < source.Length; i++)
{
double val = source[i];
if (!double.IsFinite(val))
{
val = lastValid;
}
else
{
lastValid = val;
}
// BPF: (src - src[2])
double src2 = i >= 2 ? source[i - 2] : val;
if (!double.IsFinite(src2))
{
src2 = lastValid;
}
double diff = val - src2;
double filt = Math.FusedMultiplyAdd(
0.5 * (1.0 - s1), diff,
Math.FusedMultiplyAdd(f1 * (1.0 + s1), filt1, -s1 * filt2));
if (i <= 5)
{
filt = 0.0;
}
// Voss predictor
double sumC = 0.0;
for (int count = 0; count < order; count++)
{
int idx = order - count;
int histIdx = i - idx;
if (histIdx >= 0)
{
sumC += (double)(count + 1) / order * vossHistory[histIdx];
}
}
double vossVal = (double)(3 + order) / 2.0 * filt - sumC;
vossHistory[i] = vossVal;
output[i] = vossVal;
filt2 = filt1;
filt1 = filt;
}
}
public override void Reset()
{
_s = default;
_s.LastValid = double.NaN;
_ps = default;
Array.Clear(_vossRing);
_vossIdx = 0;
_p_vossRing = null;
_p_vossIdx = 0;
Last = default;
}
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
{
foreach (double val in source)
{
Update(new TValue(DateTime.UtcNow, val), isNew: true);
}
}
public static (TSeries Results, Voss Indicator) Calculate(TSeries source,
int period = 20, int predict = 3, double bandwidth = 0.25)
{
var indicator = new Voss(period, predict, bandwidth);
TSeries results = indicator.Update(source);
return (results, indicator);
}
protected override void Dispose(bool disposing)
{
if (disposing && _publisher != null && _handler != null)
{
_publisher.Pub -= _handler;
_publisher = null;
_handler = null;
}
base.Dispose(disposing);
}
}
+146
View File
@@ -0,0 +1,146 @@
# VOSS: Ehlers Voss Predictive Filter
> "The best filter is one that tells you what is about to happen, not what already did." — paraphrasing Ehlers
## Introduction
The Voss Predictive Filter is a two-stage signal processing pipeline that extracts a dominant cycle from noisy price data and then predicts its future trajectory using negative group delay. Stage 1 is a two-pole bandpass filter (BPF) that isolates cycles near a specified period. Stage 2 is the Voss predictor, which applies a weighted feedback summation over past output values to shift the filter response forward in time. The result is a leading oscillator that anticipates bandpass zero crossings by a configurable number of bars. Crossings between the Filt (bandpass) and Voss (predictor) lines generate early trade signals with reduced lag.
## Historical Context
John Ehlers introduced the Voss Predictive Filter in his August 2019 TASC article "A Peek Into the Future." The algorithm builds on theoretical work by Henning U. Voss, who demonstrated that universal negative delay filters can be constructed using weighted feedback over a finite history of output samples. Ehlers adapted this concept to financial time series by pairing it with his standard two-pole bandpass filter for cycle extraction. The predictor stage computes `Order = 3 * Predict` weighted lookback terms, where each weight increases linearly from `1/Order` to `1.0`. This linear ramp produces a negative group delay proportional to the `Predict` parameter, effectively looking `Predict` bars into the future of the bandpass signal.
Unlike zero-lag moving averages that reduce delay by overshooting, the Voss predictor achieves genuine anticipation by exploiting the mathematical structure of narrowband signals. When the input contains a dominant cycle near the tuned period, the predictor's weighted sum reconstructs future values with high fidelity. For broadband or aperiodic inputs, prediction accuracy degrades gracefully; the output reverts toward the bandpass filter itself.
## Architecture and Physics
### 1. Two-Pole Bandpass Filter (Stage 1)
The BPF isolates cycles near the specified `Period` using a two-pole recursive structure:
$$F_1 = \cos\!\left(\frac{2\pi}{\text{Period}}\right)$$
$$G_1 = \cos\!\left(\frac{\text{Bandwidth} \cdot 2\pi}{\text{Period}}\right)$$
$$S_1 = \frac{1}{G_1} - \sqrt{\frac{1}{G_1^2} - 1}$$
$$\text{Filt}[n] = \frac{1 - S_1}{2}\bigl(\text{src}[n] - \text{src}[n{-}2]\bigr) + F_1(1 + S_1)\,\text{Filt}[n{-}1] - S_1\,\text{Filt}[n{-}2]$$
The differencing term `(src[n] - src[n-2])` removes DC content. The feedback coefficients `F1` and `S1` create a resonant peak at the target frequency. The `Bandwidth` parameter controls the Q factor (selectivity) of the resonance.
For the first 6 bars (`n <= 5`), Filt is clamped to zero to prevent startup transients from propagating into the predictor.
### 2. Voss Predictor (Stage 2)
The predictor computes a weighted sum of its own past values:
$$\text{Order} = 3 \times \text{Predict}$$
$$\text{SumC} = \sum_{k=0}^{\text{Order}-1} \frac{k+1}{\text{Order}} \cdot \text{Voss}[n - (\text{Order} - k)]$$
$$\text{Voss}[n] = \frac{3 + \text{Order}}{2} \cdot \text{Filt}[n] - \text{SumC}$$
The gain factor `(3 + Order) / 2` scales the current bandpass value, while `SumC` subtracts a weighted average of past predictor values. The linear weight ramp `(k+1)/Order` gives more influence to recent values, creating the negative group delay effect.
### 3. Ring Buffer Implementation
The predictor requires `Order` past Voss values. The implementation uses a ring buffer of size `Order + 1` with modular indexing, making the per-bar cost O(Order) with zero heap allocation in streaming mode.
## Mathematical Foundation
### Transfer Function Analysis
The BPF stage has a z-domain transfer function with conjugate poles at angular frequency $\omega_0 = 2\pi/\text{Period}$:
$$H_{\text{BPF}}(z) = \frac{(1-S_1)/2 \cdot (1 - z^{-2})}{1 - F_1(1+S_1)z^{-1} + S_1 z^{-2}}$$
The Voss predictor stage is an IIR filter with `Order` feedback taps, each weighted linearly. Its transfer function creates constructive interference at the tuned frequency, producing a net negative group delay of approximately `Predict` bars near $\omega_0$.
### Parameter Mapping
| Parameter | Default | Range | Effect |
|-----------|---------|-------|--------|
| Period | 20 | >= 2 | Center frequency of the bandpass |
| Predict | 3 | >= 1 | Bars of anticipation (negative delay) |
| Bandwidth | 0.25 | (0, 1) | Selectivity; lower = narrower passband |
## Performance Profile
| Metric | Value |
|--------|-------|
| Time Complexity | O(Order) per bar streaming; O(N * Order) batch |
| Space | Ring buffer of Order+1 doubles + 6-field state struct |
| Allocations per Update | 0 (streaming) |
| SIMD | Not applicable (recursive filter) |
| FMA | Used in BPF stage for coefficient multiplication |
### Quality Metrics (1-10)
| Metric | Score | Notes |
|--------|-------|-------|
| Lag Reduction | 9 | Genuine negative group delay at tuned frequency |
| Noise Rejection | 7 | BPF passband limits noise but Voss amplifies in-band noise |
| Stability | 8 | Linear weights prevent runaway feedback |
| Parameter Sensitivity | 6 | Requires approximate knowledge of dominant cycle period |
| Computational Cost | 7 | O(Order) per bar; Order = 9 at defaults |
## Validation
Since the Voss Predictive Filter is a proprietary Ehlers indicator with no reference implementations in standard TA libraries, validation relies on self-consistency checks:
| Test | Method | Criterion |
|------|--------|-----------|
| Bandpass behavior | Synthetic sinusoids at various periods | In-band signal passes; out-of-band attenuated |
| Predictor lead | Zero-crossing analysis | Voss crosses zero before Filt for in-band signals |
| DC rejection | Constant input | Output converges to zero |
| Mode consistency | Span vs streaming vs batch vs eventing | All four modes match within 1e-9 |
| Determinism | Same input twice | Bitwise identical output |
| Stability | 10,000 bar GBM dataset | All outputs finite |
| NaN safety | Periodic NaN injection | All outputs finite; last-valid substitution |
## Common Pitfalls
1. **Wrong period estimate.** The predictor only works well when the `Period` parameter approximates the actual dominant cycle in the data. Mistuning by more than 30% degrades prediction accuracy significantly.
2. **Over-prediction.** Setting `Predict` too high (e.g., > 5) increases `Order` to 15+, which amplifies noise in the weighted feedback loop. Practical values are 2-5.
3. **Narrow bandwidth on noisy data.** Setting `Bandwidth` below 0.1 creates a very selective filter that rings excessively and responds slowly to cycle changes.
4. **Interpreting Voss as a price level.** Both `Filt` and `Voss` oscillate around zero. They are not price predictions; they are cycle-phase predictions. Use crossings, not levels.
5. **Ignoring warmup.** The BPF needs approximately `Period` bars to stabilize. The first 6 bars are explicitly clamped to zero. Signals during the warmup phase are unreliable.
6. **Using on trending data without detrending.** The BPF removes DC, but strong trends can create aliasing artifacts. Consider pre-processing with a highpass filter for strongly trending instruments.
7. **Bar correction overhead.** The ring buffer of size `Order + 1` is copied on each `isNew` state snapshot. For large `Predict` values, this copy cost increases linearly.
## Usage
```csharp
// Streaming
var voss = new Voss(period: 20, predict: 3, bandwidth: 0.25);
foreach (var bar in data)
{
var result = voss.Update(bar);
double vossValue = result.Value; // Predictor output
double filtValue = voss.LastFilt; // Bandpass output
// Signal: Voss crosses above Filt → bullish
// Signal: Voss crosses below Filt → bearish
}
// Batch (span)
double[] input = prices.ToArray();
double[] output = new double[input.Length];
Voss.Batch(input, output, period: 20, predict: 3, bandwidth: 0.25);
// Event-driven chaining
var source = new TSeries();
var voss = new Voss(source, period: 20, predict: 3, bandwidth: 0.25);
source.Add(new TValue(DateTime.UtcNow, 100.0));
```
## References
1. Ehlers, J. F. "A Peek Into the Future." *Technical Analysis of Stocks and Commodities*, August 2019.
2. Voss, H. U. "Anticipating chaotic synchronization." *Physical Review E*, 61(5), 2000.
3. Ehlers, J. F. *Cycle Analytics for Traders*. Wiley, 2013.
+68
View File
@@ -0,0 +1,68 @@
// The MIT License (MIT)
// © mihakralj
//@version=6
indicator("Voss Predictive Filter (VOSS)", "VOSS", overlay=false)
//@function Ehlers Voss Predictive Filter — negative group delay bandpass predictor
//@param source Series to filter
//@param period Primary cycle period (bars)
//@param predict Prediction bars (negative delay amount)
//@param bandwidth Bandpass tolerance (fraction of period)
//@returns [filt, voss] — bandpass output and predictive filter output
//@optimized Two-pole BPF + weighted feedback predictor, O(Order) per bar
voss(series float src, simple int period, simple int predict, simple float bandwidth) =>
// --- Filter constants (computed once, recomputed if params change) ---
var int _order = 0
var float _f1 = 0.0
var float _s1 = 0.0
var int _prev_period = 0
var int _prev_predict = 0
var float _prev_bw = 0.0
if _prev_period != period or _prev_predict != predict or _prev_bw != bandwidth
_order := 3 * predict
_f1 := math.cos(2.0 * math.pi / float(period))
float g1 = math.cos(bandwidth * 2.0 * math.pi / float(period))
_s1 := 1.0 / g1 - math.sqrt(1.0 / (g1 * g1) - 1.0)
_prev_period := period
_prev_predict := predict
_prev_bw := bandwidth
// --- Stage 1: Two-pole Bandpass Filter ---
// Filt = 0.5*(1-S1)*(src - src[2]) + F1*(1+S1)*Filt[1] - S1*Filt[2]
var float filt = na
float ssrc = nz(src, src[1])
float src2 = nz(src[2], ssrc)
float filt1 = nz(filt[1], 0.0)
float filt2 = nz(filt[2], 0.0)
filt := 0.5 * (1.0 - _s1) * (ssrc - src2) + _f1 * (1.0 + _s1) * filt1 - _s1 * filt2
if bar_index <= 5
filt := 0.0
// --- Stage 2: Voss Predictor ---
// SumC = sum of ((count+1)/Order) * Voss[Order - count] for count = 0 to Order-1
// Voss = ((3 + Order) / 2) * Filt - SumC
var float voss_val = na
float sumC = 0.0
for count = 0 to _order - 1
int idx = _order - count
sumC += (float(count + 1) / float(_order)) * nz(voss_val[idx], 0.0)
voss_val := (float(3 + _order) / 2.0) * filt - sumC
[filt, voss_val]
// ---------- Main loop ----------
// Inputs
i_period = input.int(20, "Period", minval=2, tooltip="Primary cycle period in bars")
i_predict = input.int(3, "Predict", minval=1, tooltip="Prediction bars (negative delay)")
i_bandwidth = input.float(0.25, "Bandwidth", minval=0.01, maxval=0.99, step=0.05,
tooltip="Bandpass tolerance as fraction of period")
i_source = input.source(close, "Source")
// Calculation
[filt, voss_out] = voss(i_source, i_period, i_predict, i_bandwidth)
// Plot
plot(filt, "Bandpass", color=color.red, linewidth=1)
plot(voss_out, "Voss", color=color.new(color.blue, 0), linewidth=2)
hline(0, "Zero", color=color.gray, linestyle=hline.style_dotted)