adding missing validations

This commit is contained in:
Miha Kralj
2026-02-26 09:59:44 -08:00
parent 467a8c1cef
commit 9ab37c1200
231 changed files with 60015 additions and 302 deletions
+178
View File
@@ -0,0 +1,178 @@
using Xunit;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib.Tests;
public class FftIndicatorTests
{
[Fact]
public void FftIndicator_Constructor_SetsDefaults()
{
var indicator = new FftIndicator();
Assert.Equal(SourceType.Close, indicator.Source);
Assert.Equal(64, indicator.WindowSize);
Assert.Equal(4, indicator.MinPeriod);
Assert.Equal(32, indicator.MaxPeriod);
Assert.True(indicator.ShowColdValues);
Assert.Equal("FFT - Fast Fourier Transform Dominant Cycle", indicator.Name);
Assert.True(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void FftIndicator_MinHistoryDepths_EqualsWindowSize()
{
var indicator = new FftIndicator { WindowSize = 64 };
Assert.Equal(64, indicator.MinHistoryDepths);
indicator.WindowSize = 32;
Assert.Equal(32, indicator.MinHistoryDepths);
indicator.WindowSize = 128;
Assert.Equal(128, indicator.MinHistoryDepths);
}
[Fact]
public void FftIndicator_ShortName_IsCorrect()
{
var indicator = new FftIndicator { WindowSize = 32, MinPeriod = 4, MaxPeriod = 16 };
Assert.Equal("FFT(32,4,16)", indicator.ShortName);
}
[Fact]
public void FftIndicator_ShortName_DefaultParams()
{
var indicator = new FftIndicator();
Assert.Equal("FFT(64,4,32)", indicator.ShortName);
}
[Fact]
public void FftIndicator_Initialize_CreatesThreeLineSeries()
{
var indicator = new FftIndicator();
indicator.Initialize();
Assert.Equal(3, indicator.LinesSeries.Count);
Assert.Equal("Dominant Period", indicator.LinesSeries[0].Name);
Assert.Equal("Max Period", indicator.LinesSeries[1].Name);
Assert.Equal("Min Period", indicator.LinesSeries[2].Name);
}
[Fact]
public void FftIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new FftIndicator { WindowSize = 32, MinPeriod = 4, MaxPeriod = 16 };
indicator.Initialize();
var now = DateTime.UtcNow;
int windowSize = indicator.MinHistoryDepths;
for (int i = 0; i < windowSize; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 0, 105 + i, 95 - i, 100 + i);
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
}
double val = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(val), "Output must be finite after warmup");
Assert.True(val >= 4.0 && val <= 16.0,
$"Detected period {val:F2} must be in [4,16]");
}
[Fact]
public void FftIndicator_ProcessUpdate_NewBar_AddsNewValue()
{
var indicator = new FftIndicator { WindowSize = 32, MinPeriod = 4, MaxPeriod = 16 };
indicator.Initialize();
var now = DateTime.UtcNow;
int windowSize = indicator.MinHistoryDepths;
for (int i = 0; i < windowSize; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 0, 105, 95, 100 + i);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
indicator.HistoricalData.AddBar(now.AddMinutes(windowSize), 0, 106, 96, 103);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
Assert.Equal(windowSize + 1, indicator.LinesSeries[0].Count);
}
[Fact]
public void FftIndicator_ProcessUpdate_NewTick_ProcessesWithoutError()
{
var indicator = new FftIndicator { WindowSize = 32, MinPeriod = 4, MaxPeriod = 16 };
indicator.Initialize();
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 0, 105, 95, 100);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick));
Assert.Equal(2, indicator.LinesSeries[0].Count);
}
[Fact]
public void FftIndicator_ReferenceLines_WithinBounds()
{
var indicator = new FftIndicator { WindowSize = 32, MinPeriod = 4, MaxPeriod = 16 };
indicator.Initialize();
var now = DateTime.UtcNow;
int windowSize = indicator.MinHistoryDepths;
for (int i = 0; i < windowSize + 5; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 0, 105, 95, 100 + i);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
// Verify max period reference line
for (int i = 0; i < indicator.LinesSeries[1].Count; i++)
{
double maxPeriodVal = indicator.LinesSeries[1].GetValue(i);
Assert.Equal(16.0, maxPeriodVal, 1e-10);
}
// Verify min period reference line
for (int i = 0; i < indicator.LinesSeries[2].Count; i++)
{
double minPeriodVal = indicator.LinesSeries[2].GetValue(i);
Assert.Equal(4.0, minPeriodVal, 1e-10);
}
}
[Fact]
public void FftIndicator_DifferentSourceType_Works()
{
var indicator = new FftIndicator { WindowSize = 32, MinPeriod = 4, MaxPeriod = 16, Source = SourceType.High };
indicator.Initialize();
var now = DateTime.UtcNow;
int windowSize = indicator.MinHistoryDepths;
for (int i = 0; i < windowSize; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 0, 110 + i, 90, 100);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
double val = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(val), "Output using High source must be finite");
}
[Fact]
public void FftIndicator_MaxPeriodClamped_ToHalfWindow()
{
// MaxPeriod=40 with WindowSize=32 → should be clamped to 16 in OnInit
var indicator = new FftIndicator { WindowSize = 32, MinPeriod = 4, MaxPeriod = 40 };
indicator.Initialize(); // Should not throw
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 0, 105, 95, 100);
// Should process without exception
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
Assert.Equal(1, indicator.LinesSeries[0].Count);
}
}
+75
View File
@@ -0,0 +1,75 @@
using System.Drawing;
using TradingPlatform.BusinessLayer;
using static QuanTAlib.IndicatorExtensions;
namespace QuanTAlib;
/// <summary>
/// FFT (Fast Fourier Transform Dominant Cycle Detector) Quantower indicator.
/// Estimates the dominant cycle period in bars using Hanning-windowed DFT.
/// Output is the detected period in bars — displays in a separate window.
/// </summary>
public class FftIndicator : Indicator, IWatchlistIndicator
{
[DataSourceInput]
public SourceType Source { get; set; } = SourceType.Close;
[InputParameter("Window Size", sortIndex: 0, minimum: 32, maximum: 128)]
public int WindowSize { get; set; } = 64;
[InputParameter("Min Period", sortIndex: 1, minimum: 2, maximum: 32)]
public int MinPeriod { get; set; } = 4;
[InputParameter("Max Period", sortIndex: 2, minimum: 4, maximum: 64)]
public int MaxPeriod { get; set; } = 32;
[InputParameter("Show Cold Values", sortIndex: 100)]
public bool ShowColdValues { get; set; } = true;
private Fft? _fft;
private Func<IHistoryItem, double>? _selector;
public int MinHistoryDepths => WindowSize;
public override string ShortName => $"FFT({WindowSize},{MinPeriod},{MaxPeriod})";
public FftIndicator()
{
Name = "FFT - Fast Fourier Transform Dominant Cycle";
Description = "Estimates dominant cycle period in bars using Hanning-windowed DFT";
SeparateWindow = true;
OnBackGround = true;
}
protected override void OnInit()
{
int clampedMax = Math.Min(MaxPeriod, WindowSize / 2);
_fft = new Fft(WindowSize, MinPeriod, clampedMax);
_selector = Source.GetPriceSelector();
AddLineSeries(new LineSeries("Dominant Period", Color.Yellow, 2, LineStyle.Solid));
AddLineSeries(new LineSeries("Max Period", Color.Gray, 1, LineStyle.Dash));
AddLineSeries(new LineSeries("Min Period", Color.Gray, 1, LineStyle.Dash));
}
protected override void OnUpdate(UpdateArgs args)
{
if (_fft == null || _selector == null)
{
return;
}
var item = HistoricalData[0, SeekOriginHistory.End];
double value = _selector(item);
bool isNew = args.IsNewBar();
TValue input = new(item.TimeLeft, value);
_fft.Update(input, isNew);
bool isHot = _fft.IsHot;
int clampedMax = Math.Min(MaxPeriod, WindowSize / 2);
LinesSeries[0].SetValue(_fft.Last.Value, isHot, ShowColdValues);
LinesSeries[1].SetValue(clampedMax, isHot, ShowColdValues);
LinesSeries[2].SetValue(MinPeriod, isHot, ShowColdValues);
}
}
+681
View File
@@ -0,0 +1,681 @@
using Xunit;
namespace QuanTAlib.Tests;
public class FftTests
{
private const double Tolerance = 1e-10;
// ─── A) Constructor validation ────────────────────────────────────────────
[Fact]
public void Constructor_DefaultParameters_SetsProperties()
{
var indicator = new Fft();
Assert.Equal("Fft(64,4,32)", indicator.Name);
Assert.False(indicator.IsHot);
}
[Fact]
public void Constructor_CustomParameters_SetsName()
{
var indicator = new Fft(windowSize: 32, minPeriod: 2, maxPeriod: 16);
Assert.Equal("Fft(32,2,16)", indicator.Name);
}
[Fact]
public void Constructor_InvalidWindowSize_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() => new Fft(windowSize: 48));
Assert.Equal("windowSize", ex.ParamName);
}
[Fact]
public void Constructor_WindowSize16_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() => new Fft(windowSize: 16));
Assert.Equal("windowSize", ex.ParamName);
}
[Fact]
public void Constructor_MinPeriodOne_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() => new Fft(minPeriod: 1));
Assert.Equal("minPeriod", ex.ParamName);
}
[Fact]
public void Constructor_MinPeriodZero_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() => new Fft(minPeriod: 0));
Assert.Equal("minPeriod", ex.ParamName);
}
[Fact]
public void Constructor_MaxPeriodExceedsHalfWindow_ThrowsArgumentException()
{
// windowSize=64, half=32, maxPeriod=33 → invalid
var ex = Assert.Throws<ArgumentException>(() => new Fft(windowSize: 64, maxPeriod: 33));
Assert.Equal("maxPeriod", ex.ParamName);
}
[Fact]
public void Constructor_WarmupPeriod_IsWindowSize()
{
var ind64 = new Fft(windowSize: 64);
Assert.Equal(64, ind64.WarmupPeriod);
// maxPeriod must be <= windowSize/2; explicit maxPeriod required for windowSize=32
var ind32 = new Fft(windowSize: 32, maxPeriod: 16);
Assert.Equal(32, ind32.WarmupPeriod);
var ind128 = new Fft(windowSize: 128, maxPeriod: 64);
Assert.Equal(128, ind128.WarmupPeriod);
}
[Fact]
public void Constructor_ValidWindowSizes_DoNotThrow()
{
var ind32 = new Fft(windowSize: 32, maxPeriod: 16);
var ind64 = new Fft(windowSize: 64);
var ind128 = new Fft(windowSize: 128, maxPeriod: 64);
Assert.Equal(32, ind32.WarmupPeriod);
Assert.Equal(64, ind64.WarmupPeriod);
Assert.Equal(128, ind128.WarmupPeriod);
}
// ─── B) Basic calculation ─────────────────────────────────────────────────
[Fact]
public void Update_ReturnsValidTValue()
{
var indicator = new Fft(windowSize: 32, maxPeriod: 16);
var time = DateTime.UtcNow;
var input = new TValue(time, 100.0);
var result = indicator.Update(input);
Assert.Equal(input.Time, result.Time);
Assert.True(double.IsFinite(result.Value));
}
[Fact]
public void Update_OutputWithinClampRange()
{
var indicator = new Fft(windowSize: 32, minPeriod: 4, maxPeriod: 16);
var time = DateTime.UtcNow;
int windowSize = indicator.WarmupPeriod;
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 80001);
var bars = gbm.Fetch(windowSize + 20, time.Ticks, TimeSpan.FromMinutes(1));
for (int i = 0; i < bars.Close.Count; i++)
{
indicator.Update(bars.Close[i]);
if (indicator.IsHot)
{
double v = indicator.Last.Value;
Assert.True(v >= 4.0 && v <= 16.0,
$"Output {v} must be within [minPeriod={4}, maxPeriod={16}]");
}
}
}
[Fact]
public void Last_IsAccessible_AfterUpdate()
{
var indicator = new Fft();
var time = DateTime.UtcNow;
indicator.Update(new TValue(time, 50.0));
Assert.NotEqual(default, indicator.Last);
}
[Fact]
public void Name_Accessible()
{
var indicator = new Fft(windowSize: 64, minPeriod: 4, maxPeriod: 32);
Assert.NotNull(indicator.Name);
Assert.Contains("Fft", indicator.Name, StringComparison.Ordinal);
}
// ─── C) State + bar correction ────────────────────────────────────────────
[Fact]
public void Update_IsNewTrue_AdvancesState()
{
var indicator = new Fft(windowSize: 32, maxPeriod: 16);
var time = DateTime.UtcNow;
int windowSize = indicator.WarmupPeriod;
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 80002);
var bars = gbm.Fetch(windowSize + 5, time.Ticks, TimeSpan.FromMinutes(1));
for (int i = 0; i < windowSize; i++)
{
indicator.Update(bars.Close[i]);
}
double before = indicator.Last.Value;
indicator.Update(new TValue(time.AddMinutes(windowSize), 9999.0), true);
double after = indicator.Last.Value;
Assert.True(double.IsFinite(after));
_ = before; // consumed
}
[Fact]
public void Update_IsNewFalse_RollsBackState()
{
// Verify that isNew=false rolls back to pre-bar state so the next isNew=true
// advances from the same checkpoint, not from the corrected bar.
var time = DateTime.UtcNow;
int windowSize = 32;
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 80003);
var bars = gbm.Fetch(windowSize + 4, time.Ticks, TimeSpan.FromMinutes(1));
// Reference: straight run through all bars
var refInd = new Fft(windowSize: windowSize, maxPeriod: 16);
for (int i = 0; i < bars.Close.Count - 2; i++)
{
refInd.Update(bars.Close[i]);
}
double refValue = refInd.Last.Value;
// Corrected run: same bars but bar N-2 is corrected before committing
var corrInd = new Fft(windowSize: windowSize, maxPeriod: 16);
for (int i = 0; i < bars.Close.Count - 3; i++)
{
corrInd.Update(bars.Close[i]);
}
// Feed penultimate bar as new, then correct it
corrInd.Update(new TValue(bars.Close[bars.Close.Count - 3].Time, 9999.0), true);
corrInd.Update(bars.Close[bars.Close.Count - 3], false);
// Now feed last-but-one bar: should match reference path from same checkpoint
corrInd.Update(bars.Close[bars.Close.Count - 2]);
Assert.Equal(refValue, corrInd.Last.Value, Tolerance);
}
[Fact]
public void Update_IterativeCorrection_RestoresState()
{
var time = DateTime.UtcNow;
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 80004);
int count = 50;
var bars = gbm.Fetch(count, time.Ticks, TimeSpan.FromMinutes(1));
var straight = new Fft(windowSize: 32, maxPeriod: 16);
for (int i = 0; i < bars.Close.Count; i++)
{
straight.Update(bars.Close[i]);
}
double finalStraight = straight.Last.Value;
var corrected = new Fft(windowSize: 32, maxPeriod: 16);
for (int i = 0; i < bars.Close.Count; i++)
{
corrected.Update(new TValue(bars.Close[i].Time, 999.0), true);
corrected.Update(bars.Close[i], false);
}
Assert.Equal(finalStraight, corrected.Last.Value, Tolerance);
}
[Fact]
public void Reset_ClearsState()
{
var indicator = new Fft(windowSize: 32, maxPeriod: 16);
var time = DateTime.UtcNow;
int windowSize = indicator.WarmupPeriod;
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 80005);
var bars = gbm.Fetch(windowSize, time.Ticks, TimeSpan.FromMinutes(1));
for (int i = 0; i < bars.Close.Count; i++)
{
indicator.Update(bars.Close[i]);
}
Assert.True(indicator.IsHot);
indicator.Reset();
Assert.False(indicator.IsHot);
Assert.Equal(default, indicator.Last);
}
// ─── D) Warmup / convergence ──────────────────────────────────────────────
[Fact]
public void IsHot_FlipsAtWindowSize()
{
var indicator = new Fft(windowSize: 32, maxPeriod: 16);
var time = DateTime.UtcNow;
int windowSize = indicator.WarmupPeriod;
for (int i = 0; i < windowSize - 1; i++)
{
indicator.Update(new TValue(time.AddMinutes(i), 100.0 + i));
Assert.False(indicator.IsHot, $"Should not be hot at bar {i + 1}");
}
indicator.Update(new TValue(time.AddMinutes(windowSize - 1), 100.0 + windowSize));
Assert.True(indicator.IsHot, "Should be hot after windowSize bars");
}
[Fact]
public void WarmupPeriod_EqualToWindowSize()
{
Assert.Equal(32, new Fft(windowSize: 32, maxPeriod: 16).WarmupPeriod);
Assert.Equal(64, new Fft(windowSize: 64).WarmupPeriod);
Assert.Equal(128, new Fft(windowSize: 128, maxPeriod: 64).WarmupPeriod);
}
// ─── E) Robustness ────────────────────────────────────────────────────────
[Fact]
public void Update_NaN_UsesLastValidValue()
{
var indicator = new Fft(windowSize: 32, maxPeriod: 16);
var time = DateTime.UtcNow;
int windowSize = indicator.WarmupPeriod;
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 80006);
var bars = gbm.Fetch(windowSize, time.Ticks, TimeSpan.FromMinutes(1));
for (int i = 0; i < windowSize; i++)
{
indicator.Update(bars.Close[i]);
}
double before = indicator.Last.Value;
indicator.Update(new TValue(time.AddMinutes(windowSize), double.NaN));
Assert.Equal(before, indicator.Last.Value, Tolerance);
}
[Fact]
public void Update_PositiveInfinity_UsesLastValidValue()
{
var indicator = new Fft(windowSize: 32, maxPeriod: 16);
var time = DateTime.UtcNow;
int windowSize = indicator.WarmupPeriod;
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 80007);
var bars = gbm.Fetch(windowSize, time.Ticks, TimeSpan.FromMinutes(1));
for (int i = 0; i < windowSize; i++)
{
indicator.Update(bars.Close[i]);
}
double before = indicator.Last.Value;
indicator.Update(new TValue(time.AddMinutes(windowSize), double.PositiveInfinity));
Assert.Equal(before, indicator.Last.Value, Tolerance);
}
[Fact]
public void Update_NegativeInfinity_UsesLastValidValue()
{
var indicator = new Fft(windowSize: 32, maxPeriod: 16);
var time = DateTime.UtcNow;
int windowSize = indicator.WarmupPeriod;
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 80008);
var bars = gbm.Fetch(windowSize, time.Ticks, TimeSpan.FromMinutes(1));
for (int i = 0; i < windowSize; i++)
{
indicator.Update(bars.Close[i]);
}
double before = indicator.Last.Value;
indicator.Update(new TValue(time.AddMinutes(windowSize), double.NegativeInfinity));
Assert.Equal(before, indicator.Last.Value, Tolerance);
}
[Fact]
public void Update_BatchNaN_AlwaysFinite()
{
var indicator = new Fft(windowSize: 32, maxPeriod: 16);
var time = DateTime.UtcNow;
double[] prices = { 100.0, double.NaN, 102.0, double.NaN, 98.0, 105.0, 103.0, 99.0, 101.0, 104.0, 97.0, 106.0, 108.0 };
for (int i = 0; i < prices.Length; i++)
{
var result = indicator.Update(new TValue(time.AddMinutes(i), prices[i]));
Assert.True(double.IsFinite(result.Value), $"Output must be finite at {i}, got {result.Value}");
}
}
// ─── F) Consistency: batch == streaming == span == eventing ──────────────
[Fact]
public void AllModes_ConsistencyCheck()
{
int windowSize = 32;
int count = 80;
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 80009);
var bars = gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var source = bars.Close;
// Streaming
var streaming = new Fft(windowSize, maxPeriod: 16);
for (int i = 0; i < source.Count; i++)
{
streaming.Update(source[i]);
}
// Batch (TSeries)
var batch = Fft.Batch(source, windowSize, maxPeriod: 16);
// Span
var rawValues = new double[source.Count];
for (int i = 0; i < source.Count; i++)
{
rawValues[i] = source[i].Value;
}
var spanOutput = new double[source.Count];
Fft.Batch(rawValues, spanOutput, windowSize, maxPeriod: 16);
// Eventing
var eventResults = new List<double>();
var eventSource = new TSeries();
var eventIndicator = new Fft(eventSource, windowSize, maxPeriod: 16);
eventIndicator.Pub += (object? s, in TValueEventArgs e) => eventResults.Add(e.Value.Value);
for (int i = 0; i < source.Count; i++)
{
eventSource.Add(source[i], true);
}
double streamingLast = streaming.Last.Value;
double batchLast = batch[source.Count - 1].Value;
double spanLast = spanOutput[source.Count - 1];
double eventLast = eventResults[^1];
Assert.Equal(streamingLast, batchLast, Tolerance);
Assert.Equal(streamingLast, spanLast, Tolerance);
Assert.Equal(streamingLast, eventLast, Tolerance);
}
[Fact]
public void Streaming_VsBatch_AllValues_Match()
{
int count = 80;
int windowSize = 32;
var gbm = new GBM(startPrice: 50, mu: 0.0, sigma: 0.3, seed: 80010);
var bars = gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var source = bars.Close;
var streaming = new Fft(windowSize, maxPeriod: 16);
var streamingVals = new double[count];
for (int i = 0; i < count; i++)
{
streaming.Update(source[i]);
streamingVals[i] = streaming.Last.Value;
}
var batch = Fft.Batch(source, windowSize, maxPeriod: 16);
for (int i = 0; i < count; i++)
{
Assert.Equal(streamingVals[i], batch[i].Value, Tolerance);
}
}
// ─── G) Span API tests ────────────────────────────────────────────────────
[Fact]
public void Batch_Span_EmptySource_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() =>
Fft.Batch([], Array.Empty<double>()));
Assert.Equal("src", ex.ParamName);
}
[Fact]
public void Batch_Span_OutputTooShort_ThrowsArgumentException()
{
double[] src = [1.0, 2.0, 3.0];
double[] dst = new double[2];
var ex = Assert.Throws<ArgumentException>(() =>
Fft.Batch(src, dst));
Assert.Equal("output", ex.ParamName);
}
[Fact]
public void Batch_Span_InvalidWindowSize_ThrowsArgumentException()
{
double[] src = [1.0, 2.0, 3.0];
double[] dst = new double[3];
var ex = Assert.Throws<ArgumentException>(() =>
Fft.Batch(src, dst, windowSize: 48));
Assert.Equal("windowSize", ex.ParamName);
}
[Fact]
public void Batch_Span_InvalidMinPeriod_ThrowsArgumentException()
{
double[] src = [1.0, 2.0, 3.0];
double[] dst = new double[3];
var ex = Assert.Throws<ArgumentException>(() =>
Fft.Batch(src, dst, minPeriod: 0));
Assert.Equal("minPeriod", ex.ParamName);
}
[Fact]
public void Batch_Span_InvalidMaxPeriod_ThrowsArgumentException()
{
double[] src = [1.0, 2.0, 3.0];
double[] dst = new double[3];
var ex = Assert.Throws<ArgumentException>(() =>
Fft.Batch(src, dst, windowSize: 32, maxPeriod: 33));
Assert.Equal("maxPeriod", ex.ParamName);
}
[Fact]
public void Batch_Span_OutputWithinClampRange()
{
int count = 100;
int windowSize = 32;
int minP = 4;
int maxP = 16;
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 80011);
var bars = gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
double[] src = new double[count];
for (int i = 0; i < count; i++)
{
src[i] = bars.Close[i].Value;
}
double[] dst = new double[count];
Fft.Batch(src, dst, windowSize, minP, maxP);
for (int i = windowSize; i < count; i++)
{
Assert.True(dst[i] >= minP && dst[i] <= maxP,
$"Output {dst[i]} out of range [{minP},{maxP}] at index {i}");
}
}
[Fact]
public void Batch_Span_HandlesNaN()
{
int windowSize = 32;
double[] src = new double[windowSize + 5];
for (int i = 0; i < src.Length; i++)
{
src[i] = 100.0 + i;
}
src[3] = double.NaN;
double[] dst = new double[src.Length];
Fft.Batch(src, dst, windowSize, maxPeriod: 16);
foreach (double v in dst)
{
Assert.True(double.IsFinite(v), $"Span output should always be finite, got {v}");
}
}
[Fact]
public void Batch_Span_NoStackOverflow_LargeWindow()
{
// windowSize=128: uses ArrayPool (> 64 StackallocThreshold)
int count = 300;
double[] src = new double[count];
for (int i = 0; i < count; i++)
{
src[i] = 100.0 + Math.Sin(i * 0.2) * 10.0;
}
double[] dst = new double[count];
Fft.Batch(src, dst, windowSize: 128, maxPeriod: 64);
foreach (double v in dst)
{
Assert.True(double.IsFinite(v));
}
}
[Fact]
public void Batch_Span_MatchesStreaming()
{
int count = 60;
int windowSize = 32;
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.25, seed: 80012);
var bars = gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
double[] src = new double[count];
for (int i = 0; i < count; i++)
{
src[i] = bars.Close[i].Value;
}
double[] spanOut = new double[count];
Fft.Batch(src, spanOut, windowSize, maxPeriod: 16);
var streaming = new Fft(windowSize, maxPeriod: 16);
for (int i = 0; i < count; i++)
{
streaming.Update(bars.Close[i]);
Assert.Equal(streaming.Last.Value, spanOut[i], Tolerance);
}
}
// ─── H) Chainability ──────────────────────────────────────────────────────
[Fact]
public void Pub_EventFires()
{
var indicator = new Fft(windowSize: 32, maxPeriod: 16);
int count = 0;
indicator.Pub += (object? sender, in TValueEventArgs args) => count++;
var time = DateTime.UtcNow;
for (int i = 0; i < 5; i++)
{
indicator.Update(new TValue(time.AddMinutes(i), 100.0 + i));
}
Assert.Equal(5, count);
}
[Fact]
public void Chaining_Constructor_Works()
{
int windowSize = 32;
var source = new TSeries();
var indicator = new Fft(source, windowSize, maxPeriod: 16);
var time = DateTime.UtcNow;
for (int i = 0; i < windowSize; i++)
{
source.Add(new TValue(time.AddMinutes(i), 100.0 + Math.Sin(i * 0.5) * 5.0), true);
}
Assert.True(indicator.IsHot);
Assert.True(double.IsFinite(indicator.Last.Value));
}
[Fact]
public void Pub_EventValue_MatchesLast()
{
var indicator = new Fft(windowSize: 32, maxPeriod: 16);
TValue? lastEvent = null;
indicator.Pub += (object? s, in TValueEventArgs e) => lastEvent = e.Value;
var time = DateTime.UtcNow;
int windowSize = indicator.WarmupPeriod;
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 80013);
var bars = gbm.Fetch(windowSize + 2, time.Ticks, TimeSpan.FromMinutes(1));
for (int i = 0; i < bars.Close.Count; i++)
{
indicator.Update(bars.Close[i]);
}
Assert.NotNull(lastEvent);
Assert.Equal(indicator.Last.Value, lastEvent.Value.Value, Tolerance);
}
// ─── Additional: static Calculate method ─────────────────────────────────
[Fact]
public void Calculate_StaticMethod_ReturnsTuple()
{
int count = 80;
int windowSize = 32;
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 80014);
var bars = gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var (results, instance) = Fft.Calculate(bars.Close, windowSize, maxPeriod: 16);
Assert.Equal(count, results.Count);
Assert.Equal(results[^1].Value, instance.Last.Value, Tolerance);
}
// ─── FFT-specific: sinusoidal period detection ────────────────────────────
[Fact]
public void Fft_SinusoidalInput_DetectsApproximatePeriod()
{
// Pure sinusoid at period 16 bars; N=64, minP=4, maxP=32
// DFT bin k=4 corresponds to period 64/4=16 → should detect near 16
int period = 16;
int windowSize = 64;
var indicator = new Fft(windowSize, minPeriod: 4, maxPeriod: 32);
var time = DateTime.UtcNow;
// Feed 3x the window size to ensure convergence
for (int i = 0; i < windowSize * 3; i++)
{
double signal = 50.0 + 10.0 * Math.Sin(2.0 * Math.PI * i / period);
indicator.Update(new TValue(time.AddMinutes(i), signal), true);
}
Assert.True(indicator.IsHot);
double detected = indicator.Last.Value;
// Allow ±3 bars tolerance as specified
Assert.True(Math.Abs(detected - period) <= 3.0,
$"Detected period {detected:F2} should be within 3 bars of {period}");
}
[Fact]
public void Fft_OutputAlwaysClamped()
{
var indicator = new Fft(windowSize: 32, minPeriod: 4, maxPeriod: 16);
var time = DateTime.UtcNow;
var gbm = new GBM(startPrice: 100, mu: 0.0, sigma: 0.5, seed: 80015);
var bars = gbm.Fetch(200, time.Ticks, TimeSpan.FromMinutes(1));
for (int i = 0; i < bars.Close.Count; i++)
{
indicator.Update(bars.Close[i]);
if (indicator.IsHot)
{
double v = indicator.Last.Value;
Assert.True(v >= 4.0, $"Output {v} below minPeriod=4");
Assert.True(v <= 16.0, $"Output {v} above maxPeriod=16");
}
}
}
}
+236
View File
@@ -0,0 +1,236 @@
using Xunit;
namespace QuanTAlib.Tests;
/// <summary>
/// FFT validation tests — verifies known spectral responses against analytical results.
/// No external library implements this exact Ehlers-style windowed-DFT dominant cycle
/// detector, so validation uses self-consistency and analytical known-answer tests.
/// </summary>
public class FftValidationTests
{
private const double Tolerance = 1e-10;
private const double LooseTolerance = 3.0; // ±3 bars for period detection
// ─── Self-consistency: batch vs streaming ─────────────────────────────────
[Fact]
public void Fft_BatchVsStreaming_AllValuesMatch()
{
int windowSize = 32;
int count = 120;
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 81001);
var bars = gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var source = bars.Close;
var streaming = new Fft(windowSize, maxPeriod: 16);
var streamVals = new double[count];
for (int i = 0; i < count; i++)
{
streaming.Update(source[i]);
streamVals[i] = streaming.Last.Value;
}
var batch = Fft.Batch(source, windowSize, maxPeriod: 16);
for (int i = 0; i < count; i++)
{
Assert.Equal(streamVals[i], batch[i].Value, Tolerance);
}
}
// ─── Pure sine: dominant period detection ─────────────────────────────────
[Fact]
public void Fft_PureSine_Period16_Detected_N64()
{
// Sine at period 16, N=64, minP=4, maxP=32
// Bin k=4 → period 64/4=16; should detect ≈ 16 ± 3
int targetPeriod = 16;
int windowSize = 64;
var indicator = new Fft(windowSize, minPeriod: 4, maxPeriod: 32);
var time = DateTime.UtcNow;
for (int i = 0; i < windowSize * 3; i++)
{
double signal = 50.0 + 10.0 * Math.Sin(2.0 * Math.PI * i / targetPeriod);
indicator.Update(new TValue(time.AddMinutes(i), signal), true);
}
Assert.True(indicator.IsHot);
double detected = indicator.Last.Value;
Assert.True(Math.Abs(detected - targetPeriod) <= LooseTolerance,
$"Detected period {detected:F2} should be within {LooseTolerance} bars of {targetPeriod}");
}
[Fact]
public void Fft_PureSine_Period8_Detected_N32()
{
// Sine at period 8, N=32, minP=4, maxP=16
int targetPeriod = 8;
int windowSize = 32;
var indicator = new Fft(windowSize, minPeriod: 4, maxPeriod: 16);
var time = DateTime.UtcNow;
for (int i = 0; i < windowSize * 4; i++)
{
double signal = 50.0 + 10.0 * Math.Sin(2.0 * Math.PI * i / targetPeriod);
indicator.Update(new TValue(time.AddMinutes(i), signal), true);
}
Assert.True(indicator.IsHot);
double detected = indicator.Last.Value;
Assert.True(Math.Abs(detected - targetPeriod) <= LooseTolerance,
$"Detected period {detected:F2} should be within {LooseTolerance} bars of {targetPeriod}");
}
// ─── Constant input → clamped to maxPeriod ───────────────────────────────
[Fact]
public void Fft_ConstantInput_ClampedToMaxPeriod()
{
// Constant input has no spectral peak → should output maxPeriod (clamped)
int windowSize = 32;
int maxP = 16;
var indicator = new Fft(windowSize, minPeriod: 4, maxPeriod: maxP);
var time = DateTime.UtcNow;
for (int i = 0; i < windowSize + 20; i++)
{
indicator.Update(new TValue(time.AddMinutes(i), 100.0));
}
Assert.True(indicator.IsHot);
double detected = indicator.Last.Value;
// Constant input → all bins equal zero → peak at minBin → period = N/minBin = maxPeriod
Assert.InRange(detected, 4.0, (double)maxP);
}
// ─── Determinism ─────────────────────────────────────────────────────────
[Fact]
public void Fft_SameInput_SameOutput_Deterministic()
{
int windowSize = 32;
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 81002);
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var ind1 = new Fft(windowSize, maxPeriod: 16);
var ind2 = new Fft(windowSize, maxPeriod: 16);
for (int i = 0; i < bars.Close.Count; i++)
{
ind1.Update(bars.Close[i]);
ind2.Update(bars.Close[i]);
}
Assert.Equal(ind1.Last.Value, ind2.Last.Value, Tolerance);
}
// ─── Two independent instances → same result ─────────────────────────────
[Fact]
public void Fft_TwoInstances_SameParameters_Consistent()
{
int windowSize = 32;
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 81003);
int count = 60;
var bars = gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var indA = new Fft(windowSize, minPeriod: 4, maxPeriod: 16);
var indB = new Fft(windowSize, minPeriod: 4, maxPeriod: 16);
for (int i = 0; i < count; i++)
{
indA.Update(bars.Close[i]);
indB.Update(bars.Close[i]);
if (indA.IsHot)
{
Assert.Equal(indA.Last.Value, indB.Last.Value, Tolerance);
}
}
}
// ─── Span API self-consistency ────────────────────────────────────────────
[Fact]
public void Fft_SpanBatch_MatchesStreamingAllBars()
{
int windowSize = 32;
int count = 80;
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 81004);
var bars = gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
double[] src = new double[count];
for (int i = 0; i < count; i++)
{
src[i] = bars.Close[i].Value;
}
double[] spanOut = new double[count];
Fft.Batch(src, spanOut, windowSize, maxPeriod: 16);
var streaming = new Fft(windowSize, maxPeriod: 16);
for (int i = 0; i < count; i++)
{
streaming.Update(bars.Close[i]);
Assert.Equal(streaming.Last.Value, spanOut[i], Tolerance);
}
}
// ─── Output clamp guarantee ───────────────────────────────────────────────
[Fact]
public void Fft_OutputNeverExceedsClampBounds_LargeDataset()
{
int windowSize = 64;
int minP = 4;
int maxP = 32;
int count = 500;
var gbm = new GBM(startPrice: 100, mu: 0.0, sigma: 0.5, seed: 81005);
var bars = gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var indicator = new Fft(windowSize, minP, maxP);
for (int i = 0; i < count; i++)
{
indicator.Update(bars.Close[i]);
if (indicator.IsHot)
{
double v = indicator.Last.Value;
Assert.True(v >= minP && v <= maxP,
$"Bar {i}: output {v:F2} outside [{minP},{maxP}]");
}
}
}
// ─── Batch span NaN safety ────────────────────────────────────────────────
[Fact]
public void Fft_SpanBatch_WithNaN_AllOutputsFinite()
{
int windowSize = 32;
int count = 80;
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 81006);
var bars = gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
double[] src = new double[count];
for (int i = 0; i < count; i++)
{
src[i] = bars.Close[i].Value;
}
// Inject NaNs at various positions
src[5] = double.NaN;
src[20] = double.NaN;
src[45] = double.NaN;
double[] dst = new double[count];
Fft.Batch(src, dst, windowSize, maxPeriod: 16);
for (int i = 0; i < count; i++)
{
Assert.True(double.IsFinite(dst[i]),
$"Output at {i} must be finite, got {dst[i]}");
}
}
}
+383
View File
@@ -0,0 +1,383 @@
// FFT: Fast Fourier Transform — Dominant Cycle Detector
// Estimates the dominant cycle period in bars using a DFT on a windowed price buffer.
// Algorithm: Ehlers, J.F. "Cycle Analytics for Traders." Wiley, 2013.
// Hanning-windowed DFT across bins [minBin..maxBin], with parabolic interpolation
// for sub-bin period estimation. Output: dominant cycle period in bars (clamped).
using System.Buffers;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// FFT: Fast Fourier Transform Dominant Cycle Detector
/// Computes the dominant cycle period using a Hanning-windowed DFT
/// over a rolling price buffer, with parabolic interpolation refinement.
/// </summary>
/// <remarks>
/// Key properties:
/// - Output: dominant cycle period in bars, clamped to [minPeriod, maxPeriod]
/// - windowSize must be 32, 64, or 128
/// - WarmupPeriod = windowSize bars
/// - No allocation in Update (RingBuffer + precomputed Hanning weights)
/// - Parabolic interpolation on peak bin for sub-bin accuracy
/// </remarks>
[SkipLocalsInit]
public sealed class Fft : AbstractBase
{
private readonly int _windowSize;
private readonly int _minPeriod;
private readonly int _maxPeriod;
private readonly int _minBin;
private readonly int _maxBin;
private readonly double _twoPiOverN;
private readonly double[] _hanning;
private readonly RingBuffer _buffer;
[StructLayout(LayoutKind.Auto)]
private record struct State(double LastValid);
private State _state, _p_state;
public override bool IsHot => _buffer.Count >= _windowSize;
/// <summary>
/// Initializes a new Fft indicator.
/// </summary>
/// <param name="windowSize">DFT window size in bars. Must be 32, 64, or 128. Default 64.</param>
/// <param name="minPeriod">Minimum detectable cycle period. Must be >= 2. Default 4.</param>
/// <param name="maxPeriod">Maximum detectable cycle period. Must be &lt;= windowSize/2. Default 32.</param>
public Fft(int windowSize = 64, int minPeriod = 4, int maxPeriod = 32)
{
if (windowSize != 32 && windowSize != 64 && windowSize != 128)
{
throw new ArgumentException("windowSize must be 32, 64, or 128", nameof(windowSize));
}
if (minPeriod < 2)
{
throw new ArgumentException("minPeriod must be >= 2", nameof(minPeriod));
}
if (maxPeriod > windowSize / 2)
{
throw new ArgumentException($"maxPeriod must be <= windowSize/2 ({windowSize / 2})", nameof(maxPeriod));
}
_windowSize = windowSize;
_minPeriod = minPeriod;
_maxPeriod = maxPeriod;
_twoPiOverN = 2.0 * Math.PI / windowSize;
// bin k corresponds to period N/k; k=minBin → period=N/minBin=maxPeriod, k=maxBin → period=N/maxBin=minPeriod
_minBin = Math.Max(1, windowSize / maxPeriod);
_maxBin = Math.Min(windowSize / 2, windowSize / minPeriod);
// Precompute Hanning window: w[n] = 0.5 - 0.5*cos(2π*n/N), n=0..N-1
_hanning = new double[windowSize];
for (int n = 0; n < windowSize; n++)
{
_hanning[n] = 0.5 - 0.5 * Math.Cos(_twoPiOverN * n);
}
_buffer = new RingBuffer(windowSize);
Name = $"Fft({windowSize},{minPeriod},{maxPeriod})";
WarmupPeriod = windowSize;
_state = new State((minPeriod + maxPeriod) * 0.5);
_p_state = _state;
}
/// <summary>
/// Initializes a new Fft indicator with source for event-based chaining.
/// </summary>
/// <param name="source">Source indicator for chaining</param>
/// <param name="windowSize">DFT window size. Must be 32, 64, or 128. Default 64.</param>
/// <param name="minPeriod">Minimum detectable period. Must be >= 2. Default 4.</param>
/// <param name="maxPeriod">Maximum detectable period. Must be &lt;= windowSize/2. Default 32.</param>
public Fft(ITValuePublisher source, int windowSize = 64, int minPeriod = 4, int maxPeriod = 32)
: this(windowSize, minPeriod, maxPeriod)
{
source.Pub += HandleUpdate;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void HandleUpdate(object? sender, in TValueEventArgs e) => Update(e.Value, e.IsNew);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private double ComputeDominantPeriod()
{
var span = _buffer.GetSpan();
int n = _windowSize;
double maxMag = 0.0;
int peakBin = _minBin;
double magBefore = 0.0;
double magAtPeak = 0.0;
double magAfter = 0.0;
for (int k = _minBin; k <= _maxBin; k++)
{
double omegaK = _twoPiOverN * k;
double re = 0.0;
double im = 0.0;
for (int idx = 0; idx < n; idx++)
{
// span[0]=oldest, span[n-1]=newest
// n=0 in DFT = current (newest): map DFT-n to span index (n-1-dftN)
// span[n-1-dftN]: dftN=0 → span[n-1] (newest), dftN=n-1 → span[0] (oldest)
double val = span[n - 1 - idx];
double xw = val * _hanning[idx];
double angle = omegaK * idx;
double cosA = Math.Cos(angle);
double sinA = Math.Sin(angle);
re = Math.FusedMultiplyAdd(xw, cosA, re);
im = Math.FusedMultiplyAdd(xw, -sinA, im);
}
double mag = Math.FusedMultiplyAdd(re, re, im * im);
if (mag > maxMag)
{
magBefore = magAtPeak;
magAfter = 0.0;
maxMag = mag;
magAtPeak = mag;
peakBin = k;
}
else if (peakBin > 0 && magAfter == 0.0)
{
magAfter = mag;
}
}
// Parabolic interpolation for sub-bin refinement
double denom = magBefore + 2.0 * maxMag + magAfter;
double shift = (denom > 0.0) ? (magBefore - magAfter) / denom : 0.0;
double dominantPeriod = (double)_windowSize / (peakBin + shift);
// Clamp to [minPeriod, maxPeriod]
return Math.Clamp(dominantPeriod, _minPeriod, _maxPeriod);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override TValue Update(TValue input, bool isNew = true)
{
if (isNew)
{
_p_state = _state;
}
else
{
_state = _p_state;
}
double value = input.Value;
double result;
if (double.IsFinite(value))
{
_buffer.Add(value, isNew);
if (IsHot)
{
result = ComputeDominantPeriod();
_state = new State(result);
}
else
{
result = _state.LastValid;
}
}
else
{
result = _state.LastValid;
}
Last = new TValue(input.Time, result);
PubEvent(Last, isNew);
return Last;
}
public override TSeries Update(TSeries source)
{
var result = new TSeries(source.Count);
ReadOnlySpan<double> values = source.Values;
ReadOnlySpan<long> times = source.Times;
for (int i = 0; i < source.Count; i++)
{
var tv = Update(new TValue(new DateTime(times[i], DateTimeKind.Utc), values[i]), true);
result.Add(tv, true);
}
return result;
}
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
{
TimeSpan interval = step ?? TimeSpan.FromSeconds(1);
DateTime time = DateTime.UtcNow - (interval * source.Length);
for (int i = 0; i < source.Length; i++)
{
Update(new TValue(time, source[i]), true);
time += interval;
}
}
public static TSeries Batch(TSeries source, int windowSize = 64, int minPeriod = 4, int maxPeriod = 32)
{
var indicator = new Fft(windowSize, minPeriod, maxPeriod);
return indicator.Update(source);
}
/// <summary>
/// Computes dominant cycle period over a span of values using a sliding Hanning-windowed DFT.
/// Uses stackalloc for Hanning weights when windowSize &lt;= 64, otherwise ArrayPool.
/// </summary>
public static void Batch(
ReadOnlySpan<double> src, Span<double> output,
int windowSize = 64, int minPeriod = 4, int maxPeriod = 32)
{
if (src.Length == 0)
{
throw new ArgumentException("Source cannot be empty", nameof(src));
}
if (output.Length < src.Length)
{
throw new ArgumentException("Output length must be >= source length", nameof(output));
}
if (windowSize != 32 && windowSize != 64 && windowSize != 128)
{
throw new ArgumentException("windowSize must be 32, 64, or 128", nameof(windowSize));
}
if (minPeriod < 2)
{
throw new ArgumentException("minPeriod must be >= 2", nameof(minPeriod));
}
if (maxPeriod > windowSize / 2)
{
throw new ArgumentException($"maxPeriod must be <= windowSize/2", nameof(maxPeriod));
}
double twoPiOverN = 2.0 * Math.PI / windowSize;
int minBin = Math.Max(1, windowSize / maxPeriod);
int maxBin = Math.Min(windowSize / 2, windowSize / minPeriod);
double defaultPeriod = (minPeriod + maxPeriod) * 0.5;
double lastValid = defaultPeriod;
const int StackallocThreshold = 64;
double[]? rentedW = null;
scoped Span<double> hanning;
if (windowSize <= StackallocThreshold)
{
hanning = stackalloc double[windowSize];
}
else
{
rentedW = ArrayPool<double>.Shared.Rent(windowSize);
hanning = rentedW.AsSpan(0, windowSize);
}
try
{
for (int n = 0; n < windowSize; n++)
{
hanning[n] = 0.5 - 0.5 * Math.Cos(twoPiOverN * n);
}
for (int i = 0; i < src.Length; i++)
{
double val = src[i];
if (!double.IsFinite(val))
{
output[i] = lastValid;
continue;
}
if (i < windowSize - 1)
{
output[i] = lastValid;
continue;
}
double maxMag = 0.0;
int peakBin = minBin;
double magBefore = 0.0;
double magAtPeak = 0.0;
double magAfter = 0.0;
for (int k = minBin; k <= maxBin; k++)
{
double omegaK = twoPiOverN * k;
double re = 0.0;
double im = 0.0;
for (int dftN = 0; dftN < windowSize; dftN++)
{
// dftN=0 → newest (src[i]), dftN=windowSize-1 → oldest (src[start])
double v = src[i - dftN];
if (!double.IsFinite(v))
{
v = lastValid;
}
double xw = v * hanning[dftN];
double angle = omegaK * dftN;
re = Math.FusedMultiplyAdd(xw, Math.Cos(angle), re);
im = Math.FusedMultiplyAdd(xw, -Math.Sin(angle), im);
}
double mag = Math.FusedMultiplyAdd(re, re, im * im);
if (mag > maxMag)
{
magBefore = magAtPeak;
magAfter = 0.0;
maxMag = mag;
magAtPeak = mag;
peakBin = k;
}
else if (peakBin > 0 && magAfter == 0.0)
{
magAfter = mag;
}
}
double denom = magBefore + 2.0 * maxMag + magAfter;
double shift = (denom > 0.0) ? (magBefore - magAfter) / denom : 0.0;
double dominant = (double)windowSize / (peakBin + shift);
double clamped = Math.Clamp(dominant, minPeriod, maxPeriod);
lastValid = clamped;
output[i] = clamped;
}
}
finally
{
if (rentedW != null)
{
ArrayPool<double>.Shared.Return(rentedW);
}
}
}
public static (TSeries Results, Fft Indicator) Calculate(
TSeries source, int windowSize = 64, int minPeriod = 4, int maxPeriod = 32)
{
var indicator = new Fft(windowSize, minPeriod, maxPeriod);
TSeries results = indicator.Update(source);
return (results, indicator);
}
public override void Reset()
{
_buffer.Clear();
_state = new State((_minPeriod + _maxPeriod) * 0.5);
_p_state = _state;
Last = default;
}
}
+158 -56
View File
@@ -1,75 +1,177 @@
// The MIT License (MIT)
// © mihakralj
//@version=6
indicator("Fast Fourier Transform (FFT)", "FFT", overlay=false, precision=2)
indicator("FFT Dominant Cycle (Radix-2 FFT)", "FFT-DC", overlay=false, precision=2)
//@function Computes dominant cycle period via DFT with Hanning window
//@param source Series to analyze
//@param windowSize DFT window size (power of 2: 32, 64, 128)
//@param minPeriod Minimum detectable cycle period (>= 2)
//@param maxPeriod Maximum detectable cycle period (<= windowSize/2)
//@returns dominant cycle period in bars
//@optimized O(N * N/2) per bar; N=64 → ~2048 multiply-adds
fft(series float source, simple int windowSize, simple int minPeriod, simple int maxPeriod) =>
if windowSize != 32 and windowSize != 64 and windowSize != 128
runtime.error("Window size must be 32, 64, or 128")
if minPeriod < 2
runtime.error("Min period must be >= 2")
if maxPeriod > windowSize / 2
runtime.error("Max period must be <= windowSize / 2")
// -----------------------
// Helpers
// -----------------------
int N = windowSize
int ilog2(int n) =>
// n must be power of 2
int p = 0
int x = n
while x > 1
x := x / 2
p += 1
p
int bitReverse(int x, int bits) =>
int r = 0
for i = 0 to bits - 1
r := (r << 1) | (x & 1)
x := x >> 1
r
// In-place iterative radix-2 FFT on arrays re/im of length N
void fft_inplace(float[] re, float[] im, int N) =>
int bits = ilog2(N)
// Bit-reversal permutation
for i = 0 to N - 1
int j = bitReverse(i, bits)
if j > i
float tre = array.get(re, i)
float tim = array.get(im, i)
array.set(re, i, array.get(re, j))
array.set(im, i, array.get(im, j))
array.set(re, j, tre)
array.set(im, j, tim)
// CooleyTukey butterflies
int len = 2
while len <= N
int half = len / 2
float angStep = -2.0 * math.pi / len
for start = 0 to N - 1 by len
for k = 0 to half - 1
float ang = angStep * k
float wr = math.cos(ang)
float wi = math.sin(ang)
int i0 = start + k
int i1 = i0 + half
float ur = array.get(re, i0)
float ui = array.get(im, i0)
float vr = array.get(re, i1)
float vi = array.get(im, i1)
// t = w * v
float tr = vr * wr - vi * wi
float ti = vr * wi + vi * wr
array.set(re, i0, ur + tr)
array.set(im, i0, ui + ti)
array.set(re, i1, ur - tr)
array.set(im, i1, ui - ti)
len *= 2
// Dominant cycle period via FFT magnitude peak + parabolic interpolation
float dominantPeriod_fft(series float src, int N, int minPeriod, int maxPeriod, float[] win) =>
int halfN = N / 2
float twoPiOverN = 2.0 * math.pi / N
float maxMag = 0.0
int peakBin = 0
float peakMagA = 0.0
float peakMagB = 0.0
int minBin = math.max(1, N / maxPeriod)
int maxBin = math.min(halfN, N / minPeriod)
// Build windowed input (oldest..newest), imag=0
float[] re = array.new_float(N, 0.0)
float[] im = array.new_float(N, 0.0)
for n = 0 to N - 1
// src[n] in Pine: n bars ago; so n = N-1 is oldest in the window
// We want time order oldest..newest in FFT input:
// oldest = src[N-1], newest = src[0]
float x = nz(src[N - 1 - n])
float w = array.get(win, n)
array.set(re, n, x * w)
array.set(im, n, 0.0)
// FFT
fft_inplace(re, im, N)
// Find peak magnitude in requested bin range
float bestMag = na
int bestK = minBin
// We also need neighbor mags for interpolation later.
// We'll compute mags on-demand because range is small.
for k = minBin to maxBin
float re = 0.0
float im = 0.0
float omega_k = twoPiOverN * k
for n = 0 to N - 1
float val = nz(source[n])
float w = 0.5 - 0.5 * math.cos(twoPiOverN * n)
float xw = val * w
float angle = omega_k * n
re += xw * math.cos(angle)
im -= xw * math.sin(angle)
float mag = re * re + im * im
if mag > maxMag
if peakBin > 0
peakMagA := maxMag
maxMag := mag
peakBin := k
else if peakBin > 0 and peakMagB == 0.0
peakMagB := mag
float rr = array.get(re, k)
float ii = array.get(im, k)
float mag = rr * rr + ii * ii
if na(bestMag) or mag > bestMag
bestMag := mag
bestK := k
float dominantPeriod = float(N)
if peakBin > 0
float denom = peakMagA + 2.0 * maxMag + peakMagB
float shift = denom > 0.0 ? (peakMagA - peakMagB) / denom : 0.0
dominantPeriod := N / (peakBin + shift)
// Neighbor magnitudes for interpolation (handle edges)
float a = 0.0
float b = bestMag
float c = 0.0
math.max(float(minPeriod), math.min(float(maxPeriod), dominantPeriod))
if bestK > minBin
float rrA = array.get(re, bestK - 1)
float iiA = array.get(im, bestK - 1)
a := rrA * rrA + iiA * iiA
else
a := b
// ---------- Main loop ----------
if bestK < maxBin
float rrC = array.get(re, bestK + 1)
float iiC = array.get(im, bestK + 1)
c := rrC * rrC + iiC * iiC
else
c := b
// Correct parabolic interpolation:
// shift = 0.5*(a - c)/(a - 2b + c)
float denom = (a - 2.0 * b + c)
float shift = math.abs(denom) > 0.0 ? 0.5 * (a - c) / denom : 0.0
float period = N / (bestK + shift)
math.max(float(minPeriod), math.min(float(maxPeriod), period))
// -----------------------
// Inputs
i_source = input.source(close, "Source")
i_window = input.int(64, "Window Size", options=[32, 64, 128], tooltip="DFT window; larger = finer frequency resolution")
i_minP = input.int(4, "Min Period", minval=2, maxval=64, tooltip="Shortest cycle to detect (bars)")
i_maxP = input.int(32, "Max Period", minval=4, maxval=64, tooltip="Longest cycle to detect (bars)")
// -----------------------
// Calculation
float period = fft(i_source, i_window, i_minP, i_maxP)
i_source = input.source(close, "Source")
i_window = input.int(64, "Window Size", options=[32, 64, 128])
i_minP = input.int(4, "Min Period", minval=2, maxval=64)
i_maxP = input.int(32, "Max Period", minval=4, maxval=64)
// Validate
if i_window != 32 and i_window != 64 and i_window != 128
runtime.error("Window size must be 32, 64, or 128")
if i_minP < 2
runtime.error("Min period must be >= 2")
if i_maxP > i_window / 2
runtime.error("Max period must be <= windowSize/2")
// -----------------------
// Precompute Hanning window once per N
// -----------------------
var int prevN = na
var float[] win = array.new_float(0)
if na(prevN) or prevN != i_window
prevN := i_window
win := array.new_float(i_window, 0.0)
float twoPiOverN = 2.0 * math.pi / i_window
for n = 0 to i_window - 1
array.set(win, n, 0.5 - 0.5 * math.cos(twoPiOverN * n))
// -----------------------
// Main
// -----------------------
float period = na
if bar_index >= i_window
period := dominantPeriod_fft(i_source, i_window, i_minP, i_maxP, win)
// Plot
plot(period, "Dominant Period", color=color.yellow, linewidth=2)
hline(8, "Fast Cycle", color=color.green, linestyle=hline.style_dashed)
hline(20, "Slow Cycle", color=color.red, linestyle=hline.style_dashed)
hline(8, "Fast Cycle", color=color.green, linestyle=hline.style_dashed)
hline(20, "Slow Cycle", color=color.red, linestyle=hline.style_dashed)