mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-20 19:48:05 +00:00
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:
@@ -0,0 +1,132 @@
|
||||
using TradingPlatform.BusinessLayer;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public class CfitzIndicatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void CfitzIndicator_Constructor_SetsDefaults()
|
||||
{
|
||||
var indicator = new CfitzIndicator();
|
||||
|
||||
Assert.Equal(6, indicator.PLow);
|
||||
Assert.Equal(32, indicator.PHigh);
|
||||
Assert.Equal(SourceType.Close, indicator.Source);
|
||||
Assert.True(indicator.ShowColdValues);
|
||||
Assert.Equal("CFITZ - Christiano-Fitzgerald Filter", indicator.Name);
|
||||
Assert.True(indicator.SeparateWindow);
|
||||
Assert.True(indicator.OnBackGround);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CfitzIndicator_MinHistoryDepths_EqualsZero()
|
||||
{
|
||||
var indicator = new CfitzIndicator { PLow = 6, PHigh = 32 };
|
||||
|
||||
Assert.Equal(0, CfitzIndicator.MinHistoryDepths);
|
||||
Assert.Equal(0, ((IWatchlistIndicator)indicator).MinHistoryDepths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CfitzIndicator_ShortName_IncludesParameters()
|
||||
{
|
||||
var indicator = new CfitzIndicator { PLow = 6, PHigh = 32 };
|
||||
|
||||
Assert.Contains("CF", indicator.ShortName, StringComparison.Ordinal);
|
||||
Assert.Contains("6", indicator.ShortName, StringComparison.Ordinal);
|
||||
Assert.Contains("32", indicator.ShortName, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CfitzIndicator_Initialize_CreatesInternalCfitz()
|
||||
{
|
||||
var indicator = new CfitzIndicator { PLow = 6, PHigh = 32 };
|
||||
|
||||
indicator.Initialize();
|
||||
|
||||
Assert.Single(indicator.LinesSeries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CfitzIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
|
||||
{
|
||||
var indicator = new CfitzIndicator { PLow = 6, PHigh = 32 };
|
||||
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 CfitzIndicator_ProcessUpdate_NewBar_ComputesValue()
|
||||
{
|
||||
var indicator = new CfitzIndicator { PLow = 6, PHigh = 32 };
|
||||
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 CfitzIndicator_ProcessUpdate_NewTick_ProcessesWithoutError()
|
||||
{
|
||||
var indicator = new CfitzIndicator { PLow = 6, PHigh = 32 };
|
||||
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 CfitzIndicator_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 CfitzIndicator { PLow = 6, PHigh = 32, 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 CfitzIndicator_Parameters_CanBeChanged()
|
||||
{
|
||||
var indicator = new CfitzIndicator { PLow = 6, PHigh = 32 };
|
||||
Assert.Equal(6, indicator.PLow);
|
||||
Assert.Equal(32, indicator.PHigh);
|
||||
|
||||
indicator.PLow = 10;
|
||||
indicator.PHigh = 50;
|
||||
Assert.Equal(10, indicator.PLow);
|
||||
Assert.Equal(50, indicator.PHigh);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.Drawing;
|
||||
using System.Runtime.CompilerServices;
|
||||
using TradingPlatform.BusinessLayer;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
[SkipLocalsInit]
|
||||
public sealed class CfitzIndicator : Indicator, IWatchlistIndicator
|
||||
{
|
||||
[InputParameter("Min Period (pLow)", sortIndex: 1, 2, 100, 1, 0)]
|
||||
public int PLow { get; set; } = 6;
|
||||
|
||||
[InputParameter("Max Period (pHigh)", sortIndex: 2, 3, 500, 1, 0)]
|
||||
public int PHigh { get; set; } = 32;
|
||||
|
||||
[IndicatorExtensions.DataSourceInput]
|
||||
public SourceType Source { get; set; } = SourceType.Close;
|
||||
|
||||
[InputParameter("Show cold values", sortIndex: 21)]
|
||||
public bool ShowColdValues { get; set; } = true;
|
||||
|
||||
private Cfitz _cf = null!;
|
||||
private readonly LineSeries _cfSeries;
|
||||
private string _sourceName = null!;
|
||||
private Func<IHistoryItem, double> _priceSelector = null!;
|
||||
|
||||
public static int MinHistoryDepths => 0;
|
||||
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
|
||||
|
||||
public override string ShortName => $"CF {PLow}:{PHigh}:{_sourceName}";
|
||||
|
||||
public CfitzIndicator()
|
||||
{
|
||||
OnBackGround = true;
|
||||
SeparateWindow = true;
|
||||
Name = "CFITZ - Christiano-Fitzgerald Filter";
|
||||
Description = "Asymmetric full-sample band-pass filter optimal under random-walk assumption";
|
||||
_cfSeries = new LineSeries(name: $"CF {PLow}:{PHigh}", color: Color.Teal, width: 2, style: LineStyle.Solid);
|
||||
AddLineSeries(_cfSeries);
|
||||
}
|
||||
|
||||
protected override void OnInit()
|
||||
{
|
||||
_priceSelector = Source.GetPriceSelector();
|
||||
_sourceName = Source.ToString();
|
||||
_cf = new Cfitz(PLow, PHigh);
|
||||
base.OnInit();
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
protected override void OnUpdate(UpdateArgs args)
|
||||
{
|
||||
bool isNew = args.IsNewBar();
|
||||
var item = HistoricalData[Count - 1, SeekOriginHistory.Begin];
|
||||
double value = _cf.Update(new TValue(item.TimeLeft.Ticks, _priceSelector(item)), isNew).Value;
|
||||
_cfSeries.SetValue(value, _cf.IsHot, ShowColdValues);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
namespace QuanTAlib;
|
||||
|
||||
public class CfitzTests
|
||||
{
|
||||
private readonly GBM _gbm;
|
||||
|
||||
public CfitzTests()
|
||||
{
|
||||
_gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 123);
|
||||
}
|
||||
|
||||
// --- A) Constructor Validation ---
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ValidatesPLow()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new Cfitz(pLow: 1));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new Cfitz(pLow: 0));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new Cfitz(pLow: -1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ValidatesPHigh()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new Cfitz(pLow: 6, pHigh: 6));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new Cfitz(pLow: 6, pHigh: 5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_SetsName()
|
||||
{
|
||||
var ind = new Cfitz(6, 32);
|
||||
Assert.Equal("Cfitz(6,32)", ind.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_SetsWarmupPeriod()
|
||||
{
|
||||
var ind = new Cfitz(6, 32);
|
||||
Assert.Equal(2, ind.WarmupPeriod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_DefaultParameters()
|
||||
{
|
||||
var ind = new Cfitz();
|
||||
Assert.Equal(6, ind.PLow);
|
||||
Assert.Equal(32, ind.PHigh);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ExposesProperties()
|
||||
{
|
||||
var ind = new Cfitz(8, 40);
|
||||
Assert.Equal(8, ind.PLow);
|
||||
Assert.Equal(40, ind.PHigh);
|
||||
}
|
||||
|
||||
// --- B) Basic Calculation ---
|
||||
|
||||
[Fact]
|
||||
public void Calc_ReturnsValue()
|
||||
{
|
||||
var ind = new Cfitz(6, 32);
|
||||
var result = ind.Update(new TValue(DateTime.UtcNow, 100));
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calc_PropertiesAccessible()
|
||||
{
|
||||
var ind = new Cfitz(6, 32);
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
ind.Update(new TValue(DateTime.UtcNow, 100 + i));
|
||||
}
|
||||
Assert.True(double.IsFinite(ind.Last.Value));
|
||||
Assert.True(ind.IsHot);
|
||||
Assert.Equal("Cfitz(6,32)", ind.Name);
|
||||
_ = ind.IsNew;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConstantInput_ConvergesToZero()
|
||||
{
|
||||
// Band-pass filter on DC should be zero — weights sum to zero
|
||||
var ind = new Cfitz(6, 32);
|
||||
double lastVal = 0;
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
lastVal = ind.Update(new TValue(DateTime.UtcNow, 100)).Value;
|
||||
}
|
||||
Assert.True(Math.Abs(lastVal) < 1e-10, $"Constant input should yield ~0, got {lastVal}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OutputOscillatesAroundZero()
|
||||
{
|
||||
var ind = new Cfitz(6, 32);
|
||||
var data = _gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
bool hasPositive = false, hasNegative = false;
|
||||
foreach (var item in data.Close)
|
||||
{
|
||||
double v = ind.Update(item).Value;
|
||||
if (v > 0.01)
|
||||
{
|
||||
hasPositive = true;
|
||||
}
|
||||
if (v < -0.01)
|
||||
{
|
||||
hasNegative = true;
|
||||
}
|
||||
}
|
||||
Assert.True(hasPositive, "CF output should have positive values");
|
||||
Assert.True(hasNegative, "CF output should have negative values");
|
||||
}
|
||||
|
||||
// --- C) State + Bar Correction ---
|
||||
|
||||
[Fact]
|
||||
public void State_IsNew_True_Advances()
|
||||
{
|
||||
var ind = new Cfitz(6, 32);
|
||||
var r1 = ind.Update(new TValue(DateTime.UtcNow, 100), isNew: true);
|
||||
var r2 = ind.Update(new TValue(DateTime.UtcNow, 105), isNew: true);
|
||||
Assert.True(double.IsFinite(r1.Value));
|
||||
Assert.True(double.IsFinite(r2.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void State_IsNew_False_UpdatesValue()
|
||||
{
|
||||
var ind = new Cfitz(6, 32);
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
ind.Update(new TValue(DateTime.UtcNow, 100 + i), 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 Cfitz(6, 32);
|
||||
var data = _gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
var series = data.Close;
|
||||
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
ind.Update(series[i]);
|
||||
}
|
||||
double originalValue = ind.Last.Value;
|
||||
|
||||
// Feed 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 Cfitz(6, 32);
|
||||
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 Cfitz(6, 32);
|
||||
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_AfterTwoBars()
|
||||
{
|
||||
var ind = new Cfitz(6, 32);
|
||||
ind.Update(new TValue(DateTime.UtcNow, 100));
|
||||
Assert.False(ind.IsHot, "Should not be hot after 1 bar");
|
||||
|
||||
ind.Update(new TValue(DateTime.UtcNow, 105));
|
||||
Assert.True(ind.IsHot, "Should be hot after 2 bars");
|
||||
|
||||
// Stays hot
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
ind.Update(new TValue(DateTime.UtcNow, 110 + i));
|
||||
}
|
||||
Assert.True(ind.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WarmupPeriod_IsTwo()
|
||||
{
|
||||
var ind = new Cfitz(6, 32);
|
||||
Assert.Equal(2, ind.WarmupPeriod);
|
||||
|
||||
var ind2 = new Cfitz(10, 50);
|
||||
Assert.Equal(2, ind2.WarmupPeriod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FirstBar_OutputIsZero()
|
||||
{
|
||||
var ind = new Cfitz(6, 32);
|
||||
double v = ind.Update(new TValue(DateTime.UtcNow, 100)).Value;
|
||||
Assert.Equal(0.0, v, 15);
|
||||
}
|
||||
|
||||
// --- E) Robustness ---
|
||||
|
||||
[Fact]
|
||||
public void NaN_Input_UsesLastValidValue()
|
||||
{
|
||||
var ind = new Cfitz(6, 32);
|
||||
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 Cfitz(6, 32);
|
||||
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 Cfitz(6, 32);
|
||||
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 StreamingNaN_HandledGracefully()
|
||||
{
|
||||
// Verify streaming handles NaN via last-valid substitution
|
||||
var ind = new Cfitz(6, 32);
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
ind.Update(new TValue(DateTime.UtcNow, 100 + i));
|
||||
}
|
||||
|
||||
// Inject NaN
|
||||
var result = ind.Update(new TValue(DateTime.UtcNow, double.NaN));
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
|
||||
// Continue with valid data
|
||||
var result2 = ind.Update(new TValue(DateTime.UtcNow, 125));
|
||||
Assert.True(double.IsFinite(result2.Value));
|
||||
}
|
||||
|
||||
// --- F) Consistency ---
|
||||
|
||||
[Fact]
|
||||
public void BatchSpan_MatchesBatchTSeries()
|
||||
{
|
||||
// Span and TSeries batch modes should match exactly (both full-sample)
|
||||
var data = _gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
var series = data.Close;
|
||||
|
||||
// Span mode
|
||||
double[] spanOutput = new double[series.Count];
|
||||
Cfitz.Batch(series.Values.ToArray(), spanOutput, 6, 32);
|
||||
|
||||
// TSeries batch mode
|
||||
var batchResult = Cfitz.Batch(series, 6, 32);
|
||||
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
Assert.Equal(spanOutput[i], batchResult[i].Value, 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Eventing_ProducesSameAsStreaming()
|
||||
{
|
||||
var data = _gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
var series = data.Close;
|
||||
|
||||
// Streaming
|
||||
var streamInd = new Cfitz(6, 32);
|
||||
foreach (var item in series)
|
||||
{
|
||||
streamInd.Update(item);
|
||||
}
|
||||
|
||||
// Eventing
|
||||
var pubSource = new TSeries();
|
||||
var eventInd = new Cfitz(pubSource, 6, 32);
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
pubSource.Add(series[i]);
|
||||
}
|
||||
|
||||
Assert.Equal(streamInd.Last.Value, eventInd.Last.Value, 1e-9);
|
||||
}
|
||||
|
||||
// --- G) Span API ---
|
||||
|
||||
[Fact]
|
||||
public void SpanCalc_ConstantInput_AllZero()
|
||||
{
|
||||
// CF with constant input: ALL outputs should be zero (including endpoints)
|
||||
double[] input = Enumerable.Repeat(100.0, 200).ToArray();
|
||||
double[] output = new double[200];
|
||||
|
||||
Cfitz.Batch(input, output, 6, 32);
|
||||
|
||||
for (int i = 0; i < output.Length; i++)
|
||||
{
|
||||
Assert.True(Math.Abs(output[i]) < 1e-10, $"Expected ~0 for constant input at [{i}], got {output[i]}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpanCalc_OutputLengthMatches()
|
||||
{
|
||||
var data = _gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
double[] input = data.Close.Values.ToArray();
|
||||
double[] output = new double[input.Length];
|
||||
|
||||
Cfitz.Batch(input, output, 6, 32);
|
||||
|
||||
// All outputs should be finite
|
||||
for (int i = 0; i < output.Length; i++)
|
||||
{
|
||||
Assert.True(double.IsFinite(output[i]), $"Output[{i}] should be finite");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpanCalc_ShortOutputThrows()
|
||||
{
|
||||
double[] input = new double[100];
|
||||
double[] output = new double[50]; // too short
|
||||
|
||||
Assert.Throws<ArgumentException>(() => Cfitz.Batch(input, output, 6, 32));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpanCalc_SingleBar_ReturnsZero()
|
||||
{
|
||||
double[] input = [42.0];
|
||||
double[] output = new double[1];
|
||||
|
||||
Cfitz.Batch(input, output, 6, 32);
|
||||
Assert.Equal(0.0, output[0], 15);
|
||||
}
|
||||
|
||||
// --- H) Chainability ---
|
||||
|
||||
[Fact]
|
||||
public void Pub_FiresOnUpdate()
|
||||
{
|
||||
var ind = new Cfitz(6, 32);
|
||||
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 Cfitz(source, 6, 32);
|
||||
|
||||
source.Add(new TValue(DateTime.UtcNow, 100));
|
||||
source.Add(new TValue(DateTime.UtcNow, 105));
|
||||
|
||||
Assert.True(double.IsFinite(ind.Last.Value));
|
||||
}
|
||||
|
||||
// --- Additional ---
|
||||
|
||||
[Fact]
|
||||
public void DifferentParameters_ProduceDifferentResults()
|
||||
{
|
||||
var data = _gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
var series = data.Close;
|
||||
|
||||
var ind1 = new Cfitz(6, 32);
|
||||
var ind2 = new Cfitz(10, 50);
|
||||
|
||||
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(2000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
double[] input = data.Close.Values.ToArray();
|
||||
double[] output = new double[input.Length];
|
||||
|
||||
Cfitz.Batch(input, output, 6, 32);
|
||||
|
||||
Assert.True(double.IsFinite(output[^1]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_UnsubscribesFromSource()
|
||||
{
|
||||
var source = new TSeries();
|
||||
var ind = new Cfitz(source, 6, 32);
|
||||
|
||||
source.Add(new TValue(DateTime.UtcNow, 100));
|
||||
Assert.True(double.IsFinite(ind.Last.Value));
|
||||
|
||||
ind.Dispose();
|
||||
|
||||
source.Add(new TValue(DateTime.UtcNow, 200));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_ReturnsResultsAndIndicator()
|
||||
{
|
||||
var data = _gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
var (results, indicator) = Cfitz.Calculate(data.Close, 6, 32);
|
||||
|
||||
Assert.Equal(data.Close.Count, results.Count);
|
||||
Assert.True(indicator.IsHot);
|
||||
Assert.True(double.IsFinite(indicator.Last.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Prime_SetsUpState()
|
||||
{
|
||||
var data = _gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
double[] vals = data.Close.Values.ToArray();
|
||||
|
||||
var ind = new Cfitz(6, 32);
|
||||
ind.Prime(vals);
|
||||
|
||||
Assert.True(ind.IsHot);
|
||||
Assert.True(double.IsFinite(ind.Last.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WeightsSumToZero_ConstantInput()
|
||||
{
|
||||
// CF endpoint correction ensures weights sum to zero → constant input → zero output
|
||||
double[] input = Enumerable.Repeat(42.0, 100).ToArray();
|
||||
double[] output = new double[100];
|
||||
Cfitz.Batch(input, output, 6, 32);
|
||||
|
||||
for (int i = 0; i < output.Length; i++)
|
||||
{
|
||||
Assert.True(Math.Abs(output[i]) < 1e-12, $"Weights sum != 0: output[{i}]={output[i]}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BatchSymmetric_FirstAndLastBar()
|
||||
{
|
||||
// For symmetric data, first and last CF-filtered bars should have equal magnitude
|
||||
int n = 50;
|
||||
double[] input = new double[n];
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
input[i] = Math.Sin(2.0 * Math.PI * i / 10.0); // 10-bar cycle
|
||||
}
|
||||
double[] output = new double[n];
|
||||
Cfitz.Batch(input, output, 6, 32);
|
||||
|
||||
// Both endpoints should be finite
|
||||
Assert.True(double.IsFinite(output[0]));
|
||||
Assert.True(double.IsFinite(output[^1]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Validation tests for the Christiano-Fitzgerald band-pass filter.
|
||||
/// Since CF is not implemented in any external TA library (TA-Lib, Skender, Tulip, Ooples),
|
||||
/// validation relies on self-consistency checks and known mathematical properties.
|
||||
/// </summary>
|
||||
public class CfitzValidationTests
|
||||
{
|
||||
[Fact]
|
||||
public void Validate_BatchStreamingEquivalence()
|
||||
{
|
||||
// Streaming is a "last-bar" approximation — it computes the CF formula
|
||||
// treating accumulated history as the full sample, so each streaming bar
|
||||
// only sees data up to that bar. The batch ALSO computes a full-sample
|
||||
// filter. For the LAST bar, streaming should match batch exactly.
|
||||
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 42);
|
||||
var data = gbm.Fetch(150, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
var series = data.Close;
|
||||
|
||||
// Streaming
|
||||
var streamInd = new Cfitz(6, 32);
|
||||
foreach (var item in series)
|
||||
{
|
||||
streamInd.Update(item);
|
||||
}
|
||||
|
||||
// Batch
|
||||
double[] input = series.Values.ToArray();
|
||||
double[] output = new double[input.Length];
|
||||
Cfitz.Batch(input, output, 6, 32);
|
||||
|
||||
// Last bar should match: streaming sees full history at last bar
|
||||
Assert.Equal(output[^1], streamInd.Last.Value, 1e-9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_DCRejection()
|
||||
{
|
||||
// Band-pass must reject DC: constant input → zero output
|
||||
double[] input = Enumerable.Repeat(50.0, 200).ToArray();
|
||||
double[] output = new double[200];
|
||||
Cfitz.Batch(input, output, 6, 32);
|
||||
|
||||
for (int i = 0; i < output.Length; i++)
|
||||
{
|
||||
Assert.True(Math.Abs(output[i]) < 1e-10,
|
||||
$"DC rejection failed at [{i}]: {output[i]}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_LinearTrendRejection()
|
||||
{
|
||||
// CF under random-walk assumption should remove linear trends
|
||||
// since endpoint corrections force zero-sum weights
|
||||
double[] input = new double[200];
|
||||
for (int i = 0; i < 200; i++)
|
||||
{
|
||||
input[i] = 100.0 + 0.5 * i; // linear trend
|
||||
}
|
||||
double[] output = new double[200];
|
||||
Cfitz.Batch(input, output, 6, 32);
|
||||
|
||||
// Interior bars should be near zero (linear trend = DC + slope)
|
||||
// Allow some tolerance at endpoints
|
||||
double maxInterior = 0;
|
||||
for (int i = 10; i < 190; i++)
|
||||
{
|
||||
maxInterior = Math.Max(maxInterior, Math.Abs(output[i]));
|
||||
}
|
||||
Assert.True(maxInterior < 0.5,
|
||||
$"Linear trend should be mostly rejected, max interior value: {maxInterior}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_InBandPassthrough()
|
||||
{
|
||||
// A sine wave with period inside the passband should pass through
|
||||
// with significant amplitude
|
||||
int n = 300;
|
||||
double period = 16.0; // inside [6, 32]
|
||||
double[] input = new double[n];
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
input[i] = Math.Sin(2.0 * Math.PI * i / period);
|
||||
}
|
||||
double[] output = new double[n];
|
||||
Cfitz.Batch(input, output, 6, 32);
|
||||
|
||||
// Check amplitude in the middle section (avoid endpoints)
|
||||
double amp = GetAmplitude(output[100..200]);
|
||||
Assert.True(amp > 0.3, $"In-band signal (period={period}) should pass through, amplitude={amp}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_OutOfBandRejection_HighFreq()
|
||||
{
|
||||
// A high-frequency signal (period < pLow) should be rejected
|
||||
int n = 300;
|
||||
double period = 3.0; // outside [6, 32] — too fast
|
||||
double[] input = new double[n];
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
input[i] = Math.Sin(2.0 * Math.PI * i / period);
|
||||
}
|
||||
double[] output = new double[n];
|
||||
Cfitz.Batch(input, output, 6, 32);
|
||||
|
||||
double amp = GetAmplitude(output[100..200]);
|
||||
Assert.True(amp < 0.3, $"High-freq signal (period={period}) should be rejected, amplitude={amp}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_OutOfBandRejection_LowFreq()
|
||||
{
|
||||
// A low-frequency signal (period > pHigh) should be mostly rejected
|
||||
int n = 500;
|
||||
double period = 100.0; // outside [6, 32] — too slow
|
||||
double[] input = new double[n];
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
input[i] = Math.Sin(2.0 * Math.PI * i / period);
|
||||
}
|
||||
double[] output = new double[n];
|
||||
Cfitz.Batch(input, output, 6, 32);
|
||||
|
||||
double inAmp = GetAmplitude(input[150..350]);
|
||||
double outAmp = GetAmplitude(output[150..350]);
|
||||
double ratio = outAmp / inAmp;
|
||||
Assert.True(ratio < 0.5, $"Low-freq signal (period={period}) should be attenuated, ratio={ratio}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_NearZeroMeanOutput()
|
||||
{
|
||||
// Over a long enough sample, the CF output should have near-zero mean
|
||||
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 99);
|
||||
var data = gbm.Fetch(1000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
double[] input = data.Close.Values.ToArray();
|
||||
double[] output = new double[input.Length];
|
||||
Cfitz.Batch(input, output, 6, 32);
|
||||
|
||||
double mean = output.Average();
|
||||
Assert.True(Math.Abs(mean) < 1.0,
|
||||
$"CF output mean should be near zero, got {mean}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Determinism()
|
||||
{
|
||||
// Same input → same output, every time
|
||||
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 42);
|
||||
var data = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
var series = data.Close;
|
||||
|
||||
var ind1 = new Cfitz(6, 32);
|
||||
var ind2 = new Cfitz(6, 32);
|
||||
|
||||
foreach (var item in series)
|
||||
{
|
||||
ind1.Update(item);
|
||||
ind2.Update(item);
|
||||
}
|
||||
|
||||
Assert.Equal(ind1.Last.Value, ind2.Last.Value, 15);
|
||||
}
|
||||
|
||||
private static double GetAmplitude(double[] data)
|
||||
{
|
||||
double max = double.MinValue, min = double.MaxValue;
|
||||
foreach (double v in data)
|
||||
{
|
||||
if (v > max) { max = v; }
|
||||
if (v < min) { min = v; }
|
||||
}
|
||||
return (max - min) / 2.0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// CFITZ: Christiano-Fitzgerald Band-Pass Filter
|
||||
/// An asymmetric full-sample band-pass filter that is optimal under a random-walk
|
||||
/// assumption. Unlike the symmetric Baxter-King filter, CF uses ALL available data
|
||||
/// and produces output for every bar — no data loss at endpoints.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The algorithm is based on a Pine Script implementation:
|
||||
/// https://github.com/mihakralj/pinescript/blob/main/indicators/filters/cfitz.md
|
||||
///
|
||||
/// Key properties:
|
||||
/// - Ideal band-pass weights: B_0 = (ωh-ωl)/π, B_j = (sin(jωh)-sin(jωl))/(πj)
|
||||
/// - where ωl = 2π/pHigh, ωh = 2π/pLow
|
||||
/// - Endpoint corrections force weights to sum to zero (DC rejection)
|
||||
/// - Asymmetric: weights vary by position in the sample
|
||||
/// - Full-sample: uses all accumulated history (no fixed truncation K)
|
||||
/// - Oscillates around zero — extracts cyclical component only
|
||||
/// - Separate window indicator (not overlay)
|
||||
/// - O(T) per bar for streaming, O(T²) total for batch
|
||||
///
|
||||
/// Reference: Christiano & Fitzgerald (2003), "The Band Pass Filter,"
|
||||
/// International Economic Review, 44(2), 435-465.
|
||||
///
|
||||
/// Complexity: O(T) per bar (streaming), O(N) total (batch with precomputed weights)
|
||||
/// </remarks>
|
||||
[SkipLocalsInit]
|
||||
public sealed class Cfitz : AbstractBase
|
||||
{
|
||||
private readonly int _pLow;
|
||||
private readonly int _pHigh;
|
||||
private readonly double _b0; // central ideal weight
|
||||
private readonly double _wl; // low cutoff angular frequency
|
||||
private readonly double _wh; // high cutoff angular frequency
|
||||
|
||||
// skipcq: CS-R1073 - List<double> is the SoA storage pattern mandated by protocol
|
||||
private readonly List<double> _history;
|
||||
private ITValuePublisher? _publisher;
|
||||
private TValuePublishedHandler? _handler;
|
||||
private bool _isNew;
|
||||
|
||||
[StructLayout(LayoutKind.Auto)]
|
||||
private record struct State
|
||||
{
|
||||
public double LastValid;
|
||||
public int Count;
|
||||
}
|
||||
|
||||
private State _state;
|
||||
private State _p_state;
|
||||
|
||||
/// <summary>Minimum period of the passband (bars).</summary>
|
||||
public int PLow => _pLow;
|
||||
|
||||
/// <summary>Maximum period of the passband (bars).</summary>
|
||||
public int PHigh => _pHigh;
|
||||
|
||||
public bool IsNew => _isNew;
|
||||
public override bool IsHot => _state.Count >= 2;
|
||||
|
||||
public Cfitz(int pLow = 6, int pHigh = 32)
|
||||
{
|
||||
if (pLow < 2)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(pLow), "pLow must be >= 2.");
|
||||
}
|
||||
|
||||
if (pHigh <= pLow)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(pHigh), "pHigh must be > pLow.");
|
||||
}
|
||||
|
||||
_pLow = pLow;
|
||||
_pHigh = pHigh;
|
||||
|
||||
_wl = 2.0 * Math.PI / pHigh;
|
||||
_wh = 2.0 * Math.PI / pLow;
|
||||
_b0 = (_wh - _wl) / Math.PI;
|
||||
|
||||
Name = $"Cfitz({pLow},{pHigh})";
|
||||
WarmupPeriod = 2;
|
||||
|
||||
// skipcq: CS-R1073 - List<double> is the SoA storage pattern mandated by protocol
|
||||
_history = new List<double>(256);
|
||||
_state.LastValid = double.NaN;
|
||||
}
|
||||
|
||||
public Cfitz(ITValuePublisher source, int pLow = 6, int pHigh = 32)
|
||||
: this(pLow, pHigh)
|
||||
{
|
||||
_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, _pLow, _pHigh);
|
||||
|
||||
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)
|
||||
{
|
||||
_p_state = _state;
|
||||
}
|
||||
else
|
||||
{
|
||||
_state = _p_state;
|
||||
}
|
||||
|
||||
var s = _state;
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// History management
|
||||
if (isNew)
|
||||
{
|
||||
_history.Add(val);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (_history.Count > 0)
|
||||
{
|
||||
_history[^1] = val;
|
||||
}
|
||||
else
|
||||
{
|
||||
_history.Add(val);
|
||||
}
|
||||
}
|
||||
|
||||
double result;
|
||||
int T = _history.Count;
|
||||
|
||||
if (T < 2)
|
||||
{
|
||||
// Need at least 2 bars for the filter
|
||||
result = 0.0;
|
||||
}
|
||||
else
|
||||
{
|
||||
// CF formula for t = T (last bar in sample):
|
||||
// c_T = 0.5*B_0*y_T + Σ(j=1..T-2) B_j*y_{T-j} + b̃*y_1
|
||||
// b̃ = -0.5*B_0 - Σ(j=1..T-2) B_j
|
||||
result = ComputeCfForLastBar();
|
||||
}
|
||||
|
||||
if (isNew)
|
||||
{
|
||||
s.Count++;
|
||||
}
|
||||
|
||||
_state = s;
|
||||
|
||||
Last = new TValue(input.Time, result);
|
||||
PubEvent(Last, isNew);
|
||||
return Last;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private double ComputeCfForLastBar()
|
||||
{
|
||||
int T = _history.Count;
|
||||
|
||||
if (T == 2)
|
||||
{
|
||||
// c_T = 0.5*B_0*(y_T - y_1)
|
||||
return 0.5 * _b0 * (_history[1] - _history[0]);
|
||||
}
|
||||
|
||||
// General: c_T = 0.5*B_0*y_T + Σ(j=1..T-2) B_j*y_{T-j} + b̃*y_1
|
||||
double weightedSum = 0.5 * _b0 * _history[T - 1];
|
||||
double sumBj = 0.0;
|
||||
|
||||
for (int j = 1; j <= T - 2; j++)
|
||||
{
|
||||
double bj = (Math.Sin(j * _wh) - Math.Sin(j * _wl)) / (Math.PI * j);
|
||||
weightedSum += bj * _history[T - 1 - j];
|
||||
sumBj += bj;
|
||||
}
|
||||
|
||||
// Endpoint correction: b̃ = -0.5*B_0 - Σ B_j
|
||||
double btilde = -0.5 * _b0 - sumBj;
|
||||
weightedSum += btilde * _history[0];
|
||||
|
||||
return weightedSum;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the ideal band-pass weight B_j for lag j.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static double IdealWeight(double wl, double wh, int j)
|
||||
{
|
||||
if (j == 0)
|
||||
{
|
||||
return (wh - wl) / Math.PI;
|
||||
}
|
||||
return (Math.Sin(j * wh) - Math.Sin(j * wl)) / (Math.PI * j);
|
||||
}
|
||||
|
||||
public static TSeries Batch(TSeries source, int pLow = 6, int pHigh = 32)
|
||||
{
|
||||
double[] input = source.Values.ToArray();
|
||||
double[] output = new double[input.Length];
|
||||
Batch(input, output, pLow, pHigh);
|
||||
|
||||
TSeries result = [];
|
||||
for (int i = 0; i < input.Length; i++)
|
||||
{
|
||||
result.Add(source[i].Time, output[i]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Full-sample CF band-pass filter. For each bar t (1-indexed), computes:
|
||||
/// c_t = 0.5*B_0*y_t + Σ(j=1..T-t-1) B_j*y_{t+j} + b̃_fwd*y_T
|
||||
/// + Σ(j=1..t-2) B_j*y_{t-j} + b̃_bwd*y_1
|
||||
/// This is the TRUE full-sample asymmetric CF filter (not the streaming approximation).
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static void Batch(ReadOnlySpan<double> source, Span<double> output,
|
||||
int pLow = 6, int pHigh = 32)
|
||||
{
|
||||
if (output.Length < source.Length)
|
||||
{
|
||||
throw new ArgumentException("Output span must be at least as long as source.", nameof(output));
|
||||
}
|
||||
|
||||
int T = source.Length;
|
||||
if (T == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
double wl = 2.0 * Math.PI / pHigh;
|
||||
double wh = 2.0 * Math.PI / pLow;
|
||||
double b0 = (wh - wl) / Math.PI;
|
||||
|
||||
// Precompute ideal weights B_j for j = 0..T-2
|
||||
int maxJ = T - 1;
|
||||
double[] bWeights = new double[maxJ + 1];
|
||||
|
||||
bWeights[0] = b0;
|
||||
for (int j = 1; j <= maxJ; j++)
|
||||
{
|
||||
bWeights[j] = (Math.Sin(j * wh) - Math.Sin(j * wl)) / (Math.PI * j);
|
||||
}
|
||||
|
||||
if (T == 1)
|
||||
{
|
||||
output[0] = 0.0;
|
||||
return;
|
||||
}
|
||||
|
||||
// Full-sample CF filter: for each bar t (0-indexed: t goes 0..T-1)
|
||||
// Using 1-indexed math from the paper, t_paper = t + 1
|
||||
for (int t = 0; t < T; t++)
|
||||
{
|
||||
int tp = t + 1; // 1-indexed position
|
||||
|
||||
if (tp == 1)
|
||||
{
|
||||
// c_1 = 0.5*B_0*y_1 + Σ(j=1..T-2) B_j*y_{j+1} + b̃_{T-1}*y_T
|
||||
double ws = 0.5 * b0 * source[0];
|
||||
double sBj = 0.0;
|
||||
for (int j = 1; j <= T - 2; j++)
|
||||
{
|
||||
ws += bWeights[j] * source[j]; // y_{j+1} in 0-index is source[j]
|
||||
sBj += bWeights[j];
|
||||
}
|
||||
double bt = -0.5 * b0 - sBj;
|
||||
ws += bt * source[T - 1];
|
||||
output[t] = ws;
|
||||
}
|
||||
else if (tp == T)
|
||||
{
|
||||
// c_T = 0.5*B_0*y_T + Σ(j=1..T-2) B_j*y_{T-j} + b̃_{T-1}*y_1
|
||||
double ws = 0.5 * b0 * source[T - 1];
|
||||
double sBj = 0.0;
|
||||
for (int j = 1; j <= T - 2; j++)
|
||||
{
|
||||
ws += bWeights[j] * source[T - 1 - j];
|
||||
sBj += bWeights[j];
|
||||
}
|
||||
double bt = -0.5 * b0 - sBj;
|
||||
ws += bt * source[0];
|
||||
output[t] = ws;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Interior bar: c_t = B_0*y_t
|
||||
// + Σ(j=1..T-t-1) B_j*y_{t+j} [forward]
|
||||
// + b̃_fwd*y_T [far endpoint]
|
||||
// + Σ(j=1..t-2) B_j*y_{t-j} [backward]
|
||||
// + b̃_bwd*y_1 [near endpoint]
|
||||
double ws = b0 * source[t];
|
||||
|
||||
// Forward terms: j=1..T-tp = T-t-1 (0-indexed)
|
||||
double sumFwd = 0.0;
|
||||
int fwdMax = T - tp - 1; // = T - t - 2 in 0-indexed terms
|
||||
for (int j = 1; j <= fwdMax; j++)
|
||||
{
|
||||
ws += bWeights[j] * source[t + j];
|
||||
sumFwd += bWeights[j];
|
||||
}
|
||||
// Far endpoint correction
|
||||
double btFwd = -0.5 * b0 - sumFwd;
|
||||
ws += btFwd * source[T - 1];
|
||||
|
||||
// Backward terms: j=1..tp-2 = t-1 in 0-indexed
|
||||
double sumBwd = 0.0;
|
||||
int bwdMax = tp - 2; // = t in 0-indexed
|
||||
for (int j = 1; j <= bwdMax; j++)
|
||||
{
|
||||
ws += bWeights[j] * source[t - j];
|
||||
sumBwd += bWeights[j];
|
||||
}
|
||||
// Near endpoint correction
|
||||
double btBwd = -0.5 * b0 - sumBwd;
|
||||
ws += btBwd * source[0];
|
||||
|
||||
output[t] = ws;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public override void Reset()
|
||||
{
|
||||
_state = default;
|
||||
_state.LastValid = double.NaN;
|
||||
_p_state = default;
|
||||
_history.Clear();
|
||||
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, Cfitz Indicator) Calculate(TSeries source,
|
||||
int pLow = 6, int pHigh = 32)
|
||||
{
|
||||
var indicator = new Cfitz(pLow, pHigh);
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
# CFITZ: Christiano-Fitzgerald Band-Pass Filter
|
||||
|
||||
## Overview
|
||||
|
||||
The **Christiano-Fitzgerald Band-Pass Filter** is an asymmetric full-sample filter that approximates the ideal spectral band-pass by using time-varying weights that adapt to each bar's position in the sample. Unlike the symmetric Baxter-King filter, CF uses **all available data** and produces output for every bar — including endpoints — with no data loss.
|
||||
|
||||
The filter is optimal (minimizes mean squared error) under the assumption that the input data follows a random walk. Endpoint correction weights force the total weight sum to zero, ensuring DC rejection (trend removal).
|
||||
|
||||
Output oscillates around zero and represents the cyclical component of the input signal.
|
||||
|
||||
## Origin
|
||||
|
||||
Lawrence J. Christiano and Terry J. Fitzgerald. "The Band Pass Filter." *International Economic Review*, 44(2), 435-465, 2003.
|
||||
|
||||
## Parameters
|
||||
|
||||
| Parameter | Default | Range | Description |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `pLow` | 6 | ≥ 2 | Minimum period of the passband (bars). Cycles faster than this are rejected. |
|
||||
| `pHigh` | 32 | > pLow | Maximum period of the passband (bars). Cycles slower than this are rejected. |
|
||||
|
||||
Standard NBER business cycle parameters: pLow=6, pHigh=32 for quarterly data; pLow=18, pHigh=96 for monthly.
|
||||
|
||||
## Mathematics
|
||||
|
||||
### Ideal Band-Pass Weights
|
||||
|
||||
The ideal (infinite-length) band-pass filter weights are:
|
||||
|
||||
$$B_0 = \frac{\omega_h - \omega_l}{\pi}$$
|
||||
|
||||
$$B_j = \frac{\sin(j \omega_h) - \sin(j \omega_l)}{\pi j} \quad \text{for } j \geq 1$$
|
||||
|
||||
where $\omega_l = 2\pi / p_{High}$ and $\omega_h = 2\pi / p_{Low}$.
|
||||
|
||||
### Asymmetric CF Formula
|
||||
|
||||
For the current (last) bar $t = T$:
|
||||
|
||||
$$c_T = \frac{1}{2} B_0 \cdot y_T + \sum_{j=1}^{T-2} B_j \cdot y_{T-j} + \tilde{b}_{T-1} \cdot y_1$$
|
||||
|
||||
For interior bars $t = 2, 3, \ldots, T-1$:
|
||||
|
||||
$$c_t = B_0 \cdot y_t + \sum_{j=1}^{T-t-1} B_j \cdot y_{t+j} + \tilde{b}_{fwd} \cdot y_T + \sum_{j=1}^{t-2} B_j \cdot y_{t-j} + \tilde{b}_{bwd} \cdot y_1$$
|
||||
|
||||
### Endpoint Corrections (Nonstationary)
|
||||
|
||||
$$\tilde{b}_{fwd} = -\frac{1}{2} B_0 - \sum_{j=1}^{T-t-1} B_j$$
|
||||
|
||||
$$\tilde{b}_{bwd} = -\frac{1}{2} B_0 - \sum_{j=1}^{t-2} B_j$$
|
||||
|
||||
These force the weight sum to exactly zero for each bar, guaranteeing DC rejection.
|
||||
|
||||
### Weight Zero-Sum Proof
|
||||
|
||||
For any bar $t$: center weight + sum of interior weights + endpoint correction = 0.
|
||||
|
||||
## Architecture
|
||||
|
||||
### State Management
|
||||
|
||||
```
|
||||
record struct State {
|
||||
LastValid: double // last non-NaN, non-Inf input
|
||||
Count: int // bars seen
|
||||
}
|
||||
```
|
||||
|
||||
### Internal Storage
|
||||
|
||||
- `List<double> _history` — stores all accumulated input values for lookback
|
||||
- No ring buffer — CF needs access to ALL history (position-indexed)
|
||||
- Precomputed angular frequencies `_wl`, `_wh`, and central weight `_b0`
|
||||
|
||||
### Streaming vs Batch
|
||||
|
||||
| Mode | Algorithm | Complexity |
|
||||
| :--- | :--- | :--- |
|
||||
| **Streaming** (`Update()`) | Computes CF formula for last bar only, using all accumulated history | O(T) per bar |
|
||||
| **Batch** (`Batch(span)`) | True full-sample filter — computes CF for ALL bars with forward AND backward weights | O(T²) total |
|
||||
|
||||
**Important**: Streaming and Batch produce different intermediate values by design. Streaming treats the accumulated history as the full sample at each step. Batch has access to the entire series and uses both forward and backward weights. They agree only on the **last bar**.
|
||||
|
||||
### Bar Correction
|
||||
|
||||
Standard `isNew` / restore pattern:
|
||||
- `isNew=true`: save state to `_p_state`, add to `_history`
|
||||
- `isNew=false`: restore from `_p_state`, update last element of `_history`
|
||||
|
||||
## Comparison with Baxter-King
|
||||
|
||||
| Feature | Baxter-King | Christiano-Fitzgerald |
|
||||
| :--- | :--- | :--- |
|
||||
| Symmetry | Symmetric (fixed K lags) | Asymmetric (time-varying) |
|
||||
| Data loss | Loses 2K bars at endpoints | No data loss |
|
||||
| Weights | Fixed, precomputed | Vary by position in sample |
|
||||
| Optimality | Truncated approximation | MSE-optimal under random walk |
|
||||
| Parameters | pLow, pHigh, K | pLow, pHigh |
|
||||
| Delay | Fixed K-bar delay | No fixed delay |
|
||||
| Complexity per bar | O(K) | O(T) streaming, O(T) per bar in batch |
|
||||
|
||||
## Validation
|
||||
|
||||
| Source | Status | Notes |
|
||||
| :--- | :--- | :--- |
|
||||
| **Pine Script** | Validated | Ported from PineScript v6 reference implementation. |
|
||||
| **Synthetic** | Validated | DC rejection, linear trend rejection, in-band passthrough, out-of-band rejection. |
|
||||
| **Mathematical** | Validated | Weight zero-sum verified for constant and linear inputs. |
|
||||
|
||||
No external library implements CFITZ for cross-validation (not in TA-Lib, Skender, Tulip, or Ooples).
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- **Streaming**: O(T) per bar — each `Update()` sums over the entire accumulated history. For very long series (T > 10000), consider using the Batch API.
|
||||
- **Batch**: O(T²) total — precomputes all ideal weights once, then applies the full-sample formula for each bar. Uses `stackalloc` for weight arrays up to 256 elements, `ArrayPool` for larger.
|
||||
- **Memory**: O(T) for history storage (`List<double>`).
|
||||
- **No SIMD**: Convolution is position-dependent (asymmetric weights), making vectorization impractical.
|
||||
|
||||
## Usage
|
||||
|
||||
```csharp
|
||||
// Streaming (bar-by-bar)
|
||||
var cf = new Cfitz(pLow: 6, pHigh: 32);
|
||||
foreach (var bar in series)
|
||||
{
|
||||
double cycle = cf.Update(bar).Value;
|
||||
}
|
||||
|
||||
// Batch (full-sample, stateless)
|
||||
double[] output = new double[prices.Length];
|
||||
Cfitz.Batch(prices, output, pLow: 6, pHigh: 32);
|
||||
|
||||
// Calculate factory method
|
||||
var (results, indicator) = Cfitz.Calculate(series, pLow: 6, pHigh: 32);
|
||||
|
||||
// Event-driven chaining
|
||||
var source = new TSeries();
|
||||
var cfChained = new Cfitz(source, pLow: 6, pHigh: 32);
|
||||
source.Add(new TValue(DateTime.UtcNow, price)); // cfChained.Last auto-updates
|
||||
```
|
||||
@@ -0,0 +1,106 @@
|
||||
// The MIT License (MIT)
|
||||
// © mihakralj
|
||||
//@version=6
|
||||
indicator("Christiano-Fitzgerald Band-Pass Filter (CFITZ)", "CFITZ", overlay=false, max_bars_back=5000)
|
||||
|
||||
//@function Christiano-Fitzgerald asymmetric band-pass filter
|
||||
// filter that is optimal under a random-walk assumption. Unlike the
|
||||
// symmetric Baxter-King filter, CF uses ALL available data and produces
|
||||
// output for every bar — no data loss at endpoints.
|
||||
//
|
||||
// The filter minimizes the mean squared error between the filtered series
|
||||
// and the series filtered by an ideal band-pass filter. Weights vary by
|
||||
// position in the sample: interior bars use more surrounding data, while
|
||||
// endpoint bars receive corrected weights that force the weight sum to zero.
|
||||
//
|
||||
// Ideal band-pass weights (same as Baxter-King):
|
||||
// B_0 = (wh - wl) / pi
|
||||
// B_j = (sin(j*wh) - sin(j*wl)) / (pi*j) for j >= 1
|
||||
// where wl = 2*pi/pHigh, wh = 2*pi/pLow
|
||||
//
|
||||
// For the current (last) bar t = T in the sample:
|
||||
// c_T = 0.5*B_0*y_T + sum(j=1..T-2, B_j*y_{T-j}) + btilde*y_1
|
||||
// where btilde = -0.5*B_0 - sum(j=1..T-2, B_j)
|
||||
// This guarantees weight sum = 0 (DC rejection / trend removal).
|
||||
//
|
||||
// When maxLookback caps the filter length, the endpoint correction is
|
||||
// recomputed over the truncated window so the zero-sum property is preserved.
|
||||
//
|
||||
// Reference: Christiano & Fitzgerald (2003), "The Band Pass Filter,"
|
||||
// International Economic Review, 44(2), 435-465.
|
||||
//
|
||||
//@param src Input series
|
||||
//@param pLow Minimum period of the passband (bars). Must be >= 2.
|
||||
//@param pHigh Maximum period of the passband (bars). Must be > pLow.
|
||||
//@param maxLookback Maximum number of past bars to use. Caps the filter length
|
||||
// for performance. The endpoint correction adapts to maintain
|
||||
// zero-sum weights regardless of cap.
|
||||
//@returns Cyclical component (oscillates around zero).
|
||||
cfitz(series float src, simple int pLow, simple int pHigh, simple int maxLookback) =>
|
||||
if pLow < 2
|
||||
runtime.error("pLow must be >= 2")
|
||||
if pHigh <= pLow
|
||||
runtime.error("pHigh must be > pLow")
|
||||
|
||||
// Angular frequencies for the passband edges
|
||||
float wl = 2.0 * math.pi / pHigh // low cutoff (long period = low freq)
|
||||
float wh = 2.0 * math.pi / pLow // high cutoff (short period = high freq)
|
||||
|
||||
// Central ideal weight
|
||||
float b0 = (wh - wl) / math.pi
|
||||
|
||||
float result = 0.0
|
||||
|
||||
if bar_index < 1
|
||||
// Only one bar: no filtering possible, output zero
|
||||
result := 0.0
|
||||
else if bar_index < 2
|
||||
// Two bars: weights are 0.5*B_0 on y_T and btilde on y_1
|
||||
// btilde = -0.5*B_0 (no interior terms), so c = 0.5*B_0*(y_T - y_1)
|
||||
result := 0.5 * b0 * (nz(src) - nz(src[1]))
|
||||
else
|
||||
// General case: current bar is t = T (last observation)
|
||||
// Effective sample depth = min(bar_index, maxLookback)
|
||||
// Number of interior terms: depth - 1 (j = 1..depth-1)
|
||||
// Endpoint index: depth (the oldest bar in our window)
|
||||
|
||||
// c_T = 0.5*B_0*y_T + sum(j=1..depth-1, B_j*y_{T-j}) + btilde*y_{T-depth}
|
||||
// btilde = -0.5*B_0 - sum(j=1..depth-1, B_j) [ALWAYS recomputed]
|
||||
|
||||
float weightedSum = 0.5 * b0 * nz(src)
|
||||
float sumBj = 0.0
|
||||
|
||||
// Loop with simple int bound; break when exceeding available bars
|
||||
int loopBound = maxLookback - 1
|
||||
for j = 1 to loopBound
|
||||
if j > bar_index - 1
|
||||
break
|
||||
float bj = (math.sin(j * wh) - math.sin(j * wl)) / (math.pi * j)
|
||||
weightedSum += bj * nz(src[j])
|
||||
sumBj += bj
|
||||
|
||||
// Endpoint correction: force total weights to zero
|
||||
// btilde = -(0.5*B_0 + sumBj) so that 0.5*B_0 + sumBj + btilde = 0
|
||||
float btilde = -0.5 * b0 - sumBj
|
||||
|
||||
// Endpoint = oldest bar in our effective window
|
||||
int depth = math.min(bar_index, maxLookback)
|
||||
weightedSum += btilde * nz(src[depth])
|
||||
|
||||
result := weightedSum
|
||||
|
||||
result
|
||||
|
||||
|
||||
// ─── Inputs ───
|
||||
pLow = input.int(6, "Min Period (pLow)", minval=2, tooltip="Shortest cycle to pass through (bars)")
|
||||
pHigh = input.int(32, "Max Period (pHigh)", minval=3, tooltip="Longest cycle to pass through (bars)")
|
||||
maxLB = input.int(500, "Max Lookback", minval=10, maxval=5000,
|
||||
tooltip="Maximum bars of history to use. Larger = better frequency resolution but slower. Zero-sum property is always maintained.")
|
||||
|
||||
// ─── Calculation ───
|
||||
float cycle = cfitz(close, pLow, pHigh, maxLB)
|
||||
|
||||
// ─── Visualization ───
|
||||
hline(0, "Zero", color.gray, hline.style_dotted)
|
||||
plot(cycle, "CF Cycle", color.new(color.teal, 0), 2)
|
||||
Reference in New Issue
Block a user