mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-20 19:48:05 +00:00
adding missing validations
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <= 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 <= 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 <= 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
@@ -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)
|
||||
|
||||
// Cooley–Tukey 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)
|
||||
Reference in New Issue
Block a user