From b1302a476113f91d04970c6f32f7f19a363d59e5 Mon Sep 17 00:00:00 2001 From: Miha Kralj Date: Wed, 18 Mar 2026 19:58:53 -0700 Subject: [PATCH] feat: add FSI (Ehlers Fourier Series Indicator, TASC June 2019) --- _sidebar.md | 1 + docs/indicators.md | 1 + docs/pinescript.md | 1 + lib/_index.md | 3 +- lib/cycles/_index.md | 1 + lib/cycles/fsi/Fsi.Quantower.cs | 61 +++ lib/cycles/fsi/Fsi.cs | 420 ++++++++++++++++++ lib/cycles/fsi/Fsi.md | 76 ++++ lib/cycles/fsi/fsi.pine | 51 +++ lib/cycles/fsi/tests/Fsi.Quantower.Tests.cs | 123 ++++++ lib/cycles/fsi/tests/Fsi.Tests.cs | 428 +++++++++++++++++++ lib/cycles/fsi/tests/Fsi.Validation.Tests.cs | 158 +++++++ python/quantalib/_bridge.py | 1 + python/quantalib/cycles.py | 10 + python/src/Exports.cs | 10 + 15 files changed, 1344 insertions(+), 1 deletion(-) create mode 100644 lib/cycles/fsi/Fsi.Quantower.cs create mode 100644 lib/cycles/fsi/Fsi.cs create mode 100644 lib/cycles/fsi/Fsi.md create mode 100644 lib/cycles/fsi/fsi.pine create mode 100644 lib/cycles/fsi/tests/Fsi.Quantower.Tests.cs create mode 100644 lib/cycles/fsi/tests/Fsi.Tests.cs create mode 100644 lib/cycles/fsi/tests/Fsi.Validation.Tests.cs diff --git a/_sidebar.md b/_sidebar.md index 7fc1d028..2788c9e0 100644 --- a/_sidebar.md +++ b/_sidebar.md @@ -323,6 +323,7 @@ * [CCYC - Ehlers Cyber Cycle](/lib/cycles/ccyc/Ccyc.md) * [CG - Ehlers Center of Gravity](/lib/cycles/cg/Cg.md) * [DSP - Ehlers Detrended Synthetic Price](/lib/cycles/dsp/Dsp.md) + * [FSI - Ehlers Fourier Series Indicator](/lib/cycles/fsi/Fsi.md) * [ACP - Ehlers Autocorrelation Periodogram](/lib/cycles/acp/Acp.md) * [EBSW - Ehlers Even Better Sinewave](/lib/cycles/ebsw/Ebsw.md) * [HOMOD - Ehlers Homodyne Discriminator](/lib/cycles/homod/Homod.md) diff --git a/docs/indicators.md b/docs/indicators.md index 336f5ef1..9bd7dbb6 100644 --- a/docs/indicators.md +++ b/docs/indicators.md @@ -441,6 +441,7 @@ Periodic pattern detection and dominant frequency extraction. Markets exhibit cy | [**CCYC**](../lib/cycles/ccyc/Ccyc.md) | Ehlers Cyber Cycle | 4-tap FIR + 2-pole high-pass IIR cycle extraction | | [**CG**](../lib/cycles/cg/Cg.md) | Ehlers Center of Gravity | Ehlers cycle measurement | | [**DSP**](../lib/cycles/dsp/Dsp.md) | Ehlers Detrended Synthetic Price | Cycle-isolated price component | +| [**FSI**](../lib/cycles/fsi/Fsi.md) | Ehlers Fourier Series Indicator | 3-harmonic bandpass + amplitude-weighted reconstruction | | [**ACP**](../lib/cycles/acp/Acp.md) | Ehlers Autocorrelation Periodogram | Ehlers dominant cycle detection | | [**EBSW**](../lib/cycles/ebsw/Ebsw.md) | Ehlers Even Better Sinewave | Ehlers improved cycle indicator | | [**HOMOD**](../lib/cycles/homod/Homod.md) | Ehlers Homodyne Discriminator | Dominant cycle period tracking | diff --git a/docs/pinescript.md b/docs/pinescript.md index f65efd23..db716de5 100644 --- a/docs/pinescript.md +++ b/docs/pinescript.md @@ -429,6 +429,7 @@ Markets oscillate. These indicators try to measure the oscillation itself — th | CCYC | Ehlers Cyber Cycle | [ccyc.pine](../lib/cycles/ccyc/ccyc.pine) | | CG | Ehlers Center of Gravity | [cg.pine](../lib/cycles/cg/cg.pine) | | DSP | Ehlers Detrended Synthetic Price | [dsp.pine](../lib/cycles/dsp/dsp.pine) | +| FSI | Ehlers Fourier Series Indicator | [fsi.pine](../lib/cycles/fsi/fsi.pine) | | ACP | Ehlers Autocorrelation Periodogram | [acp.pine](../lib/cycles/acp/acp.pine) | | EBSW | Ehlers Even Better Sinewave | [ebsw.pine](../lib/cycles/ebsw/ebsw.pine) | | HOMOD | Ehlers Homodyne Discriminator | [homod.pine](../lib/cycles/homod/homod.pine) | diff --git a/lib/_index.md b/lib/_index.md index 1d2bafbc..ec3feb61 100644 --- a/lib/_index.md +++ b/lib/_index.md @@ -129,8 +129,9 @@ | [FI](oscillators/fi/Fi.md) | Force Index | Oscillators | | [FISHER](oscillators/fisher/Fisher.md) | Ehlers Fisher Transform | Oscillators | | [FISHER04](oscillators/fisher04/Fisher04.md) | Ehlers Fisher Transform (2004) | Oscillators | -| [FRACTALS](reversals/fractals/Fractals.md) | Williams Fractals | Reversals | | [FRAMA](trends_IIR/frama/Frama.md) | Ehlers Fractal Adaptive MA | Trends (IIR) | +| [FSI](cycles/fsi/Fsi.md) | Ehlers Fourier Series Indicator | Cycles | +| [FRACTALS](reversals/fractals/Fractals.md) | Williams Fractals | Reversals | | [FWMA](trends_FIR/fwma/Fwma.md) | Fibonacci Weighted MA | Trends (FIR) | | [GAMMADIST](numerics/gammadist/Gammadist.md) | Gamma Distribution | Numerics | | [GATOR](oscillators/gator/Gator.md) | Williams Gator Oscillator | Oscillators | diff --git a/lib/cycles/_index.md b/lib/cycles/_index.md index a0ff5ab8..366dbaa0 100644 --- a/lib/cycles/_index.md +++ b/lib/cycles/_index.md @@ -12,6 +12,7 @@ Cycle analysis identifies repeating patterns in price data. John Ehlers pioneere | [CCYC](ccyc/Ccyc.md) | Ehlers Cyber Cycle | Ehlers. 4-tap FIR + 2-pole high-pass IIR. Isolates dominant cycle component. | | [CG](cg/Cg.md) | Ehlers Center of Gravity | Ehlers. Weighted sum position. Minimal lag cycle indicator. | | [DSP](dsp/Dsp.md) | Ehlers Detrended Synthetic Price | Removes trend to reveal underlying cycles. | +| [FSI](fsi/Fsi.md) | Ehlers Fourier Series Indicator | Ehlers. 3-harmonic bandpass + amplitude-weighted reconstruction. Cycle timing.| | [EBSW](ebsw/Ebsw.md) | Ehlers Even Better Sinewave | Ehlers. Improved sinewave extraction. Reduces false signals. | | [HOMOD](homod/Homod.md) | Ehlers Homodyne Discriminator | Dominant cycle detection via homodyne technique. | | [HT_DCPERIOD](ht_dcperiod/Htdcperiod.md) | Ehlers Hilbert Transform Dominant Cycle Period | Ehlers Hilbert Transform. Measures current cycle length. | diff --git a/lib/cycles/fsi/Fsi.Quantower.cs b/lib/cycles/fsi/Fsi.Quantower.cs new file mode 100644 index 00000000..1453a765 --- /dev/null +++ b/lib/cycles/fsi/Fsi.Quantower.cs @@ -0,0 +1,61 @@ +using System.Drawing; +using System.Runtime.CompilerServices; +using TradingPlatform.BusinessLayer; + +namespace QuanTAlib; + +[SkipLocalsInit] +public sealed class FsiIndicator : Indicator, IWatchlistIndicator +{ + [InputParameter("Fundamental Cycle Period", sortIndex: 1, minimum: 6, maximum: 500, increment: 1, decimalPlaces: 0)] + public int Period { get; set; } = 20; + + [InputParameter("Bandwidth", sortIndex: 2, minimum: 0.001, maximum: 1.0, increment: 0.01, decimalPlaces: 3)] + public double Bandwidth { get; set; } = 0.1; + + [IndicatorExtensions.DataSourceInput(sortIndex: 3)] + public SourceType Source { get; set; } = SourceType.Close; + + [InputParameter("Show cold values", sortIndex: 21)] + public bool ShowColdValues { get; set; } = true; + + private Fsi _fsi = null!; + private readonly LineSeries _fsiLine; + + public static int MinHistoryDepths => 0; + int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths; + + public override string ShortName => $"FSI ({Period},{Bandwidth:F2})"; + public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/cycles/fsi/Fsi.Quantower.cs"; + + public FsiIndicator() + { + OnBackGround = true; + SeparateWindow = true; + Name = "FSI - Ehlers Fourier Series Indicator"; + Description = "Fourier series bandpass decomposition reconstructing a waveshape from the first three harmonics of price cycles."; + + _fsiLine = new LineSeries("FSI", Color.Yellow, 2, LineStyle.Solid); + AddLineSeries(_fsiLine); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + protected override void OnInit() + { + _fsi = new Fsi(Period, Bandwidth); + base.OnInit(); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + protected override void OnUpdate(UpdateArgs args) + { + var priceSelector = Source.GetPriceSelector(); + var item = HistoricalData[0, SeekOriginHistory.End]; + double price = priceSelector(item); + + TValue input = new(item.TimeLeft, price); + TValue result = _fsi.Update(input, args.IsNewBar()); + + _fsiLine.SetValue(result.Value, _fsi.IsHot, ShowColdValues); + } +} diff --git a/lib/cycles/fsi/Fsi.cs b/lib/cycles/fsi/Fsi.cs new file mode 100644 index 00000000..ca474126 --- /dev/null +++ b/lib/cycles/fsi/Fsi.cs @@ -0,0 +1,420 @@ +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace QuanTAlib; + +/// +/// FSI: Ehlers Fourier Series Indicator +/// +/// +/// +/// Decomposes price cycles into three harmonic bandpass components and reconstructs +/// a waveshape using amplitude-weighted Fourier synthesis. The output represents the +/// dominant cycle content of price, useful for identifying turning points. +/// +/// +/// Algorithm (TASC June 2019, John F. Ehlers "Fourier Series Model of the Market"): +/// +/// Apply Ehlers bandpass filters at the fundamental period and its 2nd/3rd harmonics +/// Compute quadrature components Q = (period/2π) · d(BP)/dt for each harmonic +/// Estimate power P_k = Sum(BP_k² + Q_k², period) for each harmonic +/// Reconstruct: FSI = BP1 + sqrt(P2/P1)·BP2 + sqrt(P3/P1)·BP3 +/// +/// +/// +/// Complexity: O(1) per bar — three IIR bandpass evaluations + rolling power sums. +/// +/// +[SkipLocalsInit] +public sealed class Fsi : AbstractBase +{ + // ── Bandpass coefficients per harmonic ── + // Fundamental (period = x) + private readonly double _a0_1, _a1_1, _a2_1; + // 2nd harmonic (period = x/2) + private readonly double _a0_2, _a1_2, _a2_2; + // 3rd harmonic (period = x/3) + private readonly double _a0_3, _a1_3, _a2_3; + + // Quadrature scale factor: period / (2π) + private readonly double _qScale; + + // Rolling power sum buffers + private readonly RingBuffer _p1Buf; + private readonly RingBuffer _p2Buf; + private readonly RingBuffer _p3Buf; + + private const int DefaultPeriod = 20; + private const double DefaultBandwidth = 0.1; + private const int MinPeriod = 6; + private const double MinBandwidth = 0.001; + + [StructLayout(LayoutKind.Auto)] + private record struct State( + double Bp1, double Bp1_1, // fundamental bandpass + double Bp2, double Bp2_1, // 2nd harmonic bandpass + double Bp3, double Bp3_1, // 3rd harmonic bandpass + double Src1, double Src2, // previous close values + double LastValid, + int Count); + + private State _state; + private State _p_state; + + /// Fundamental cycle period. + public int Period { get; } + + /// Bandpass filter bandwidth. + public double Bandwidth { get; } + + /// + /// Creates an FSI indicator. + /// + /// Fundamental cycle length (default 20, minimum 6). + /// Bandpass filter bandwidth (default 0.1). + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Fsi(int period = DefaultPeriod, double bandwidth = DefaultBandwidth) + { + if (period < MinPeriod) + { + throw new ArgumentOutOfRangeException(nameof(period), + $"Period must be at least {MinPeriod}."); + } + if (bandwidth < MinBandwidth) + { + throw new ArgumentOutOfRangeException(nameof(bandwidth), + $"Bandwidth must be at least {MinBandwidth}."); + } + + Period = period; + Bandwidth = bandwidth; + + // Precompute coefficients for three harmonics + ComputeBpCoefficients(period, bandwidth, out _a0_1, out _a1_1, out _a2_1); + ComputeBpCoefficients(period / 2.0, bandwidth, out _a0_2, out _a1_2, out _a2_2); + ComputeBpCoefficients(period / 3.0, bandwidth, out _a0_3, out _a1_3, out _a2_3); + + _qScale = period / (2.0 * Math.PI); + + _p1Buf = new RingBuffer(period); + _p2Buf = new RingBuffer(period); + _p3Buf = new RingBuffer(period); + + Name = $"FSI({period},{bandwidth:F2})"; + WarmupPeriod = period; + _state = new State { LastValid = double.NaN }; + _p_state = _state; + } + + /// + /// Creates an FSI indicator subscribed to a publisher source. + /// + public Fsi(ITValuePublisher source, int period = DefaultPeriod, double bandwidth = DefaultBandwidth) + : this(period, bandwidth) + { + source.Pub += (object? _, in TValueEventArgs args) => Update(args.Value, args.IsNew); + } + + /// + /// Computes Ehlers bandpass filter coefficients for a given period. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void ComputeBpCoefficients(double period, double bandwidth, + out double a0, out double a1, out double a2) + { + double twoPiOverP = 2.0 * Math.PI / period; + double L = Math.Cos(twoPiOverP); + double G = Math.Cos(bandwidth * twoPiOverP); + double S = (1.0 / G) - Math.Sqrt((1.0 / (G * G)) - 1.0); + + a0 = 0.5 * (1.0 - S); + a1 = L * (1.0 + S); + a2 = -S; + } + + public override bool IsHot => _state.Count >= WarmupPeriod; + + /// Primes the indicator with historical data. + public override void Prime(ReadOnlySpan source, TimeSpan? step = null) + { + foreach (double v in source) + { + Update(new TValue(DateTime.MinValue, v), isNew: true); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public override TValue Update(TValue input, bool isNew = true) + { + if (isNew) + { + _p_state = _state; + _p1Buf.Snapshot(); + _p2Buf.Snapshot(); + _p3Buf.Snapshot(); + } + else + { + _state = _p_state; + _p1Buf.Restore(); + _p2Buf.Restore(); + _p3Buf.Restore(); + } + + double src = input.Value; + + // Sanitize NaN/Inf + if (!double.IsFinite(src)) + { + src = double.IsFinite(_state.LastValid) ? _state.LastValid : 0.0; + } + else + { + _state.LastValid = src; + } + + ref State s = ref _state; + double result; + + if (s.Count < 2) + { + // Bootstrap: not enough bars for 2nd-order difference + if (s.Count == 0) + { + s.Src1 = src; + s.Src2 = src; + } + else + { + s.Src2 = s.Src1; + s.Src1 = src; + } + s.Count++; + result = 0.0; + } + else + { + double diff = src - s.Src2; // Close[i] - Close[i-2] + + // ── Bandpass filters ── + double bp1 = Math.FusedMultiplyAdd(_a0_1, diff, + Math.FusedMultiplyAdd(_a1_1, s.Bp1, _a2_1 * s.Bp1_1)); + double bp2 = Math.FusedMultiplyAdd(_a0_2, diff, + Math.FusedMultiplyAdd(_a1_2, s.Bp2, _a2_2 * s.Bp2_1)); + double bp3 = Math.FusedMultiplyAdd(_a0_3, diff, + Math.FusedMultiplyAdd(_a1_3, s.Bp3, _a2_3 * s.Bp3_1)); + + // ── Quadrature components (differentiation-based 90° phase shift) ── + double q1 = _qScale * (bp1 - s.Bp1); + double q2 = _qScale * (bp2 - s.Bp2); + double q3 = _qScale * (bp3 - s.Bp3); + + // ── Power estimation (rolling sum over period bars) ── + double pw1 = (bp1 * bp1) + (q1 * q1); + double pw2 = (bp2 * bp2) + (q2 * q2); + double pw3 = (bp3 * bp3) + (q3 * q3); + + _p1Buf.Add(pw1); + _p2Buf.Add(pw2); + _p3Buf.Add(pw3); + + double p1Sum = _p1Buf.Sum; + double p2Sum = _p2Buf.Sum; + double p3Sum = _p3Buf.Sum; + + // ── Amplitude-weighted reconstruction ── + if (p1Sum > 1e-20) + { + double w2 = Math.Sqrt(p2Sum / p1Sum); + double w3 = Math.Sqrt(p3Sum / p1Sum); + result = bp1 + (w2 * bp2) + (w3 * bp3); + } + else + { + result = bp1; + } + + // Update bandpass state + s.Bp1_1 = s.Bp1; s.Bp1 = bp1; + s.Bp2_1 = s.Bp2; s.Bp2 = bp2; + s.Bp3_1 = s.Bp3; s.Bp3 = bp3; + + // Update source history + s.Src2 = s.Src1; + s.Src1 = src; + s.Count++; + } + + Last = new TValue(input.Time, result); + PubEvent(Last, isNew); + return Last; + } + + /// Updates with a full TSeries and returns results. + [MethodImpl(MethodImplOptions.AggressiveOptimization)] + public override TSeries Update(TSeries source) + { + if (source.Count == 0) + { + return []; + } + + var resultValues = new double[source.Count]; + Batch(source.Values, resultValues, Period, Bandwidth); + + var result = new TSeries(); + var times = source.Times; + for (int i = 0; i < source.Count; i++) + { + result.Add(new TValue(times[i], resultValues[i])); + } + + // Sync internal state by replaying + int len = source.Count; + if (len >= 2) + { + var replay = new Fsi(Period, Bandwidth); + for (int i = 0; i < len; i++) + { + replay.Update(new TValue(times[i], source.Values[i])); + } + _state = replay._state; + _p1Buf.CopyFrom(replay._p1Buf); + _p2Buf.CopyFrom(replay._p2Buf); + _p3Buf.CopyFrom(replay._p3Buf); + } + _p_state = _state; + return result; + } + + /// Static batch on TSeries. + public static TSeries Batch(TSeries source, int period = DefaultPeriod, double bandwidth = DefaultBandwidth) + { + var indicator = new Fsi(period, bandwidth); + return indicator.Update(source); + } + + /// + /// Static batch calculation on spans. Zero allocation on the hot path. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + public static void Batch(ReadOnlySpan source, Span output, + int period = DefaultPeriod, double bandwidth = DefaultBandwidth) + { + if (source.Length != output.Length) + { + throw new ArgumentException("Source and output spans must be of equal length.", nameof(output)); + } + if (source.Length == 0) + { + return; + } + if (period < MinPeriod) + { + throw new ArgumentOutOfRangeException(nameof(period), + $"Period must be at least {MinPeriod}."); + } + if (bandwidth < MinBandwidth) + { + throw new ArgumentOutOfRangeException(nameof(bandwidth), + $"Bandwidth must be at least {MinBandwidth}."); + } + + // Precompute coefficients + ComputeBpCoefficients(period, bandwidth, out double a0_1, out double a1_1, out double a2_1); + ComputeBpCoefficients(period / 2.0, bandwidth, out double a0_2, out double a1_2, out double a2_2); + ComputeBpCoefficients(period / 3.0, bandwidth, out double a0_3, out double a1_3, out double a2_3); + double qScale = period / (2.0 * Math.PI); + + // Bandpass state + double bp1 = 0, bp1_1 = 0; + double bp2 = 0, bp2_1 = 0; + double bp3 = 0, bp3_1 = 0; + + // Power ring buffers + Span pw1Buf = stackalloc double[period]; + pw1Buf.Clear(); + Span pw2Buf = stackalloc double[period]; + pw2Buf.Clear(); + Span pw3Buf = stackalloc double[period]; + pw3Buf.Clear(); + + double p1Sum = 0, p2Sum = 0, p3Sum = 0; + int pwIdx = 0; + + // First two bars: no 2nd-order difference available + output[0] = 0.0; + if (source.Length < 2) + { + return; + } + output[1] = 0.0; + + for (int i = 2; i < source.Length; i++) + { + double diff = source[i] - source[i - 2]; + + // Bandpass filters + double newBp1 = Math.FusedMultiplyAdd(a0_1, diff, + Math.FusedMultiplyAdd(a1_1, bp1, a2_1 * bp1_1)); + double newBp2 = Math.FusedMultiplyAdd(a0_2, diff, + Math.FusedMultiplyAdd(a1_2, bp2, a2_2 * bp2_1)); + double newBp3 = Math.FusedMultiplyAdd(a0_3, diff, + Math.FusedMultiplyAdd(a1_3, bp3, a2_3 * bp3_1)); + + // Quadrature + double q1 = qScale * (newBp1 - bp1); + double q2 = qScale * (newBp2 - bp2); + double q3 = qScale * (newBp3 - bp3); + + // Power + double pw1 = (newBp1 * newBp1) + (q1 * q1); + double pw2 = (newBp2 * newBp2) + (q2 * q2); + double pw3 = (newBp3 * newBp3) + (q3 * q3); + + // Rolling sum update + p1Sum = p1Sum - pw1Buf[pwIdx] + pw1; + p2Sum = p2Sum - pw2Buf[pwIdx] + pw2; + p3Sum = p3Sum - pw3Buf[pwIdx] + pw3; + pw1Buf[pwIdx] = pw1; + pw2Buf[pwIdx] = pw2; + pw3Buf[pwIdx] = pw3; + pwIdx = (pwIdx + 1) % period; + + // Amplitude-weighted reconstruction + if (p1Sum > 1e-20) + { + double w2 = Math.Sqrt(p2Sum / p1Sum); + double w3 = Math.Sqrt(p3Sum / p1Sum); + output[i] = newBp1 + (w2 * newBp2) + (w3 * newBp3); + } + else + { + output[i] = newBp1; + } + + // Advance state + bp1_1 = bp1; bp1 = newBp1; + bp2_1 = bp2; bp2 = newBp2; + bp3_1 = bp3; bp3 = newBp3; + } + } + + /// Calculate factory returning results and indicator. + public static (TSeries Results, Fsi Indicator) Calculate(TSeries source, + int period = DefaultPeriod, double bandwidth = DefaultBandwidth) + { + var indicator = new Fsi(period, bandwidth); + TSeries results = indicator.Update(source); + return (results, indicator); + } + + public override void Reset() + { + _state = new State { LastValid = double.NaN }; + _p_state = _state; + _p1Buf.Clear(); + _p2Buf.Clear(); + _p3Buf.Clear(); + } +} diff --git a/lib/cycles/fsi/Fsi.md b/lib/cycles/fsi/Fsi.md new file mode 100644 index 00000000..32117bbd --- /dev/null +++ b/lib/cycles/fsi/Fsi.md @@ -0,0 +1,76 @@ +# FSI: Ehlers Fourier Series Indicator + +## Overview + +The Fourier Series Indicator (FSI) decomposes price into its dominant cyclic components using three bandpass filters tuned to the fundamental cycle and its 2nd and 3rd harmonics. The outputs are amplitude-weighted and summed to reconstruct a waveshape that reveals the underlying cycle structure of price. + +## Origin + +John F. Ehlers, "Fourier Series Model of the Market," *Technical Analysis of Stocks & Commodities* (TASC), June 2019. + +## Algorithm + +### 1. Ehlers Bandpass Coefficients + +For a given period $P$ and bandwidth $\delta$: + +$$L = \cos\!\left(\frac{2\pi}{P}\right), \quad G = \cos\!\left(\frac{\delta \cdot 2\pi}{P}\right), \quad S = \frac{1}{G} - \sqrt{\frac{1}{G^2} - 1}$$ + +### 2. Three Harmonic Bandpass Filters + +| Harmonic | Period | Variables | +|:-------------|:-------|:----------| +| Fundamental | $x$ | BP1, Q1, P1 | +| 2nd harmonic | $x/2$ | BP2, Q2, P2 | +| 3rd harmonic | $x/3$ | BP3, Q3, P3 | + +Each bandpass filter is a 2-pole IIR: + +$$BP_k[i] = \tfrac{1}{2}(1 - S_k)\bigl(C[i] - C[i{-}2]\bigr) + L_k(1 + S_k)\,BP_k[i{-}1] - S_k\,BP_k[i{-}2]$$ + +### 3. Quadrature (90° Phase Shift) + +$$Q_k[i] = \frac{x}{2\pi}\bigl(BP_k[i] - BP_k[i{-}1]\bigr)$$ + +### 4. Power Estimation + +$$P_k = \sum_{j=0}^{x-1}\bigl(BP_k[i{-}j]^2 + Q_k[i{-}j]^2\bigr)$$ + +### 5. Amplitude-Weighted Reconstruction + +$$\text{FSI} = BP_1 + \sqrt{\frac{P_2}{P_1}} \cdot BP_2 + \sqrt{\frac{P_3}{P_1}} \cdot BP_3$$ + +### 6. Complexity + +- **Streaming:** O(1) per bar — three IIR evaluations + O(1) rolling power sums +- **Batch (span):** O(N) total, zero allocation via `stackalloc` + +## Parameters + +| Parameter | Default | Min | Description | +|:------------|:--------|:------|:-----------------------------------------| +| `period` | 20 | 6 | Fundamental cycle length in bars | +| `bandwidth` | 0.1 | 0.001 | Bandpass filter bandwidth (passband width)| + +## Output Interpretation + +- **Zero crossings:** Potential cycle turning points +- **Peaks/troughs:** Local cycle extremes +- **Amplitude:** Reflects the strength of the dominant cycle +- **The output is zero-centered** — positive values indicate upward cycle phase, negative values indicate downward + +## Operation Count (Streaming Mode) + +| Operation | Count | +|:--------------------|:------| +| Multiplications | 12 | +| Additions | 12 | +| Square roots | 2 | +| Comparisons | 1 | +| **Total per bar** | ~27 | + +## Resources + +- John F. Ehlers, "Fourier Series Model of the Market," TASC June 2019 +- [MetaStock formula reference](https://forum.metastock.com/posts/m185900findunread-June-2019--Fourier-Series-Model-of-the-Market) +- [thinkorswim FourierSeriesIndicator](https://toslc.thinkorswim.com/center/reference/Tech-Indicators/studies-library/E-F/FourierSeriesIndicator) diff --git a/lib/cycles/fsi/fsi.pine b/lib/cycles/fsi/fsi.pine new file mode 100644 index 00000000..0db647bf --- /dev/null +++ b/lib/cycles/fsi/fsi.pine @@ -0,0 +1,51 @@ +// This Pine Script™ code is subject to the terms of the Mozilla Public License 2.0 +// https://mozilla.org/MPL/2.0/ +// © QuanTAlib + +//@version=6 +indicator("FSI - Ehlers Fourier Series Indicator", shorttitle="FSI", overlay=false) + +// Inputs +x = input.int(20, "Fundamental Cycle Period", minval=6) +bw = input.float(0.1, "Bandwidth", minval=0.001, step=0.01) + +// Fundamental bandpass coefficients +L1 = math.cos(2 * math.pi / x) +G1 = math.cos(bw * 2 * math.pi / x) +S1 = 1 / G1 - math.sqrt(1 / (G1 * G1) - 1) + +// 2nd harmonic coefficients (period = x/2) +L2 = math.cos(2 * math.pi / (x / 2)) +G2 = math.cos(bw * 2 * math.pi / (x / 2)) +S2 = 1 / G2 - math.sqrt(1 / (G2 * G2) - 1) + +// 3rd harmonic coefficients (period = x/3) +L3 = math.cos(2 * math.pi / (x / 3)) +G3 = math.cos(bw * 2 * math.pi / (x / 3)) +S3 = 1 / G3 - math.sqrt(1 / (G3 * G3) - 1) + +// Bandpass filters +float BP1 = 0.0 +BP1 := 0.5 * (1 - S1) * (close - close[2]) + L1 * (1 + S1) * nz(BP1[1]) - S1 * nz(BP1[2]) + +float BP2 = 0.0 +BP2 := 0.5 * (1 - S2) * (close - close[2]) + L2 * (1 + S2) * nz(BP2[1]) - S2 * nz(BP2[2]) + +float BP3 = 0.0 +BP3 := 0.5 * (1 - S3) * (close - close[2]) + L3 * (1 + S3) * nz(BP3[1]) - S3 * nz(BP3[2]) + +// Quadrature components (differentiation-based 90° phase shift) +Q1 = (x / (2 * math.pi)) * (BP1 - nz(BP1[1])) +Q2 = (x / (2 * math.pi)) * (BP2 - nz(BP2[1])) +Q3 = (x / (2 * math.pi)) * (BP3 - nz(BP3[1])) + +// Power estimation (rolling sum over x bars) +P1 = math.sum(BP1 * BP1 + Q1 * Q1, x) +P2 = math.sum(BP2 * BP2 + Q2 * Q2, x) +P3 = math.sum(BP3 * BP3 + Q3 * Q3, x) + +// Amplitude-weighted reconstruction +fsi = P1 > 0 ? BP1 + math.sqrt(P2 / P1) * BP2 + math.sqrt(P3 / P1) * BP3 : BP1 + +plot(fsi, "FSI", color.yellow, 2) +hline(0, "Zero", color.gray, hline.style_dotted) diff --git a/lib/cycles/fsi/tests/Fsi.Quantower.Tests.cs b/lib/cycles/fsi/tests/Fsi.Quantower.Tests.cs new file mode 100644 index 00000000..c7f91b28 --- /dev/null +++ b/lib/cycles/fsi/tests/Fsi.Quantower.Tests.cs @@ -0,0 +1,123 @@ +using TradingPlatform.BusinessLayer; + +namespace QuanTAlib.Tests; + +public class FsiIndicatorTests +{ + [Fact] + public void FsiIndicator_BasicProperties() + { + var indicator = new FsiIndicator(); + Assert.Equal(20, indicator.Period); + Assert.Equal(0.1, indicator.Bandwidth, 10); + Assert.True(indicator.SeparateWindow); + } + + [Fact] + public void FsiIndicator_Name_ContainsEhlers() + { + var indicator = new FsiIndicator(); + Assert.Contains("Ehlers", indicator.Name, StringComparison.Ordinal); + } + + [Fact] + public void FsiIndicator_Name_ContainsFSI() + { + var indicator = new FsiIndicator(); + Assert.Contains("FSI", indicator.Name, StringComparison.Ordinal); + } + + [Fact] + public void FsiIndicator_HasLineSeries() + { + var indicator = new FsiIndicator(); + Assert.Single(indicator.LinesSeries); + } + + [Fact] + public void FsiIndicator_SeparateWindow() + { + var indicator = new FsiIndicator(); + Assert.True(indicator.SeparateWindow); + } + + [Fact] + public void FsiIndicator_Initialize_CreatesInternalIndicator() + { + var indicator = new FsiIndicator { Period = 30, Bandwidth = 0.2 }; + indicator.Initialize(); + indicator.HistoricalData.AddBar(DateTime.UtcNow, 100, 105, 95, 102, 1000); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0))); + } + + [Fact] + public void FsiIndicator_MultipleUpdates_ProducesFiniteValues() + { + var indicator = new FsiIndicator { Period = 20, Bandwidth = 0.1 }; + indicator.Initialize(); + + for (int i = 0; i < 50; i++) + { + double price = 100.0 + Math.Sin(2.0 * Math.PI * i / 20.0) * 5.0; + indicator.HistoricalData.AddBar( + DateTime.UtcNow.AddMinutes(i), price, price + 1, price - 1, price, 1000); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + } + + double lastValue = indicator.LinesSeries[0].GetValue(0); + Assert.True(double.IsFinite(lastValue)); + } + + [Fact] + public void FsiIndicator_BarCorrection_ProducesConsistentValues() + { + var indicator = new FsiIndicator { Period = 20, Bandwidth = 0.1 }; + indicator.Initialize(); + + for (int i = 0; i < 30; i++) + { + double price = 100.0 + i * 0.5; + indicator.HistoricalData.AddBar( + DateTime.UtcNow.AddMinutes(i), price, price + 1, price - 1, price, 1000); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + } + + // New bar + indicator.HistoricalData.AddBar( + DateTime.UtcNow.AddMinutes(30), 120, 121, 119, 120, 1000); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar)); + double valAfterNew = indicator.LinesSeries[0].GetValue(0); + + // Correction (same bar, different price) + indicator.HistoricalData.AddBar( + DateTime.UtcNow.AddMinutes(30), 130, 131, 129, 130, 1000); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar)); + double valAfterCorrection = indicator.LinesSeries[0].GetValue(0); + + Assert.True(double.IsFinite(valAfterNew)); + Assert.True(double.IsFinite(valAfterCorrection)); + } + + [Fact] + public void FsiIndicator_ShortName_IncludesPeriod() + { + var indicator = new FsiIndicator { Period = 30, Bandwidth = 0.2 }; + Assert.Contains("30", indicator.ShortName, StringComparison.Ordinal); + } + + [Fact] + public void FsiIndicator_SourceCodeLink_IsValid() + { + var indicator = new FsiIndicator(); + Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal); + Assert.Contains("Fsi.Quantower.cs", indicator.SourceCodeLink, StringComparison.Ordinal); + } + + [Fact] + public void FsiIndicator_DefaultSource_IsClose() + { + var indicator = new FsiIndicator(); + Assert.Equal(SourceType.Close, indicator.Source); + } +} diff --git a/lib/cycles/fsi/tests/Fsi.Tests.cs b/lib/cycles/fsi/tests/Fsi.Tests.cs new file mode 100644 index 00000000..9e34c6ca --- /dev/null +++ b/lib/cycles/fsi/tests/Fsi.Tests.cs @@ -0,0 +1,428 @@ +namespace QuanTAlib; + +public class FsiTests +{ + private static readonly Random _rng = new(42); + + private static TSeries MakeSeries(int count = 500) + { + var series = new TSeries(); + double price = 100.0; + for (int i = 0; i < count; i++) + { + price += (_rng.NextDouble() - 0.5) * 2.0; + series.Add(new TValue(DateTime.UtcNow.AddMinutes(i), price)); + } + return series; + } + + // ════════════════════════════════════════════════════════ + // A — Constructor + // ════════════════════════════════════════════════════════ + + [Fact] + public void Constructor_DefaultParameters() + { + var fsi = new Fsi(); + Assert.Equal(20, fsi.Period); + Assert.Equal(0.1, fsi.Bandwidth, 10); + } + + [Fact] + public void Constructor_CustomParameters() + { + var fsi = new Fsi(period: 40, bandwidth: 0.2); + Assert.Equal(40, fsi.Period); + Assert.Equal(0.2, fsi.Bandwidth, 10); + } + + [Fact] + public void Constructor_PeriodTooSmall_Throws() + { + Assert.Throws(() => new Fsi(period: 5)); + } + + [Fact] + public void Constructor_BandwidthTooSmall_Throws() + { + Assert.Throws(() => new Fsi(period: 20, bandwidth: 0.0001)); + } + + // ════════════════════════════════════════════════════════ + // B — Basic Calculation + // ════════════════════════════════════════════════════════ + + [Fact] + public void FirstBar_OutputIsZero() + { + var fsi = new Fsi(20, 0.1); + var result = fsi.Update(new TValue(DateTime.UtcNow, 100.0)); + Assert.Equal(0.0, result.Value); + } + + [Fact] + public void SecondBar_OutputIsZero() + { + var fsi = new Fsi(20, 0.1); + fsi.Update(new TValue(DateTime.UtcNow, 100.0)); + var result = fsi.Update(new TValue(DateTime.UtcNow.AddMinutes(1), 101.0)); + Assert.Equal(0.0, result.Value); + } + + [Fact] + public void ThirdBar_OutputIsFinite() + { + var fsi = new Fsi(20, 0.1); + fsi.Update(new TValue(DateTime.UtcNow, 100.0)); + fsi.Update(new TValue(DateTime.UtcNow.AddMinutes(1), 101.0)); + var result = fsi.Update(new TValue(DateTime.UtcNow.AddMinutes(2), 102.0)); + Assert.True(double.IsFinite(result.Value)); + } + + // ════════════════════════════════════════════════════════ + // C — State / Bar Correction + // ════════════════════════════════════════════════════════ + + [Fact] + public void IsNew_True_AdvancesState() + { + var fsi = new Fsi(20, 0.1); + var series = MakeSeries(100); + foreach (var bar in series) + { + fsi.Update(bar); + } + double val1 = fsi.Update(new TValue(DateTime.UtcNow, 105.0), isNew: true).Value; + double val2 = fsi.Update(new TValue(DateTime.UtcNow.AddMinutes(1), 110.0), isNew: true).Value; + Assert.NotEqual(val1, val2); + } + + [Fact] + public void IsNew_False_CorrectionReproducible() + { + var fsi = new Fsi(20, 0.1); + var series = MakeSeries(100); + foreach (var bar in series) + { + fsi.Update(bar); + } + + double v1 = fsi.Update(new TValue(DateTime.UtcNow, 105.0), isNew: true).Value; + _ = fsi.Update(new TValue(DateTime.UtcNow, 108.0), isNew: false).Value; + double v3 = fsi.Update(new TValue(DateTime.UtcNow, 105.0), isNew: false).Value; + Assert.Equal(v1, v3, 10); + } + + [Fact] + public void Reset_ClearsState() + { + var fsi = new Fsi(20, 0.1); + var series = MakeSeries(100); + foreach (var bar in series) + { + fsi.Update(bar); + } + fsi.Reset(); + Assert.False(fsi.IsHot); + Assert.Equal(0.0, fsi.Update(new TValue(DateTime.UtcNow, 100.0)).Value); + } + + // ════════════════════════════════════════════════════════ + // D — Warmup + // ════════════════════════════════════════════════════════ + + [Fact] + public void IsHot_FalseBeforePeriodBars() + { + var fsi = new Fsi(20, 0.1); + Assert.False(fsi.IsHot); + fsi.Update(new TValue(DateTime.UtcNow, 100.0)); + Assert.False(fsi.IsHot); + } + + [Fact] + public void IsHot_TrueAfterPeriodBars() + { + var fsi = new Fsi(20, 0.1); + for (int i = 0; i < 20; i++) + { + fsi.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100.0 + i)); + } + Assert.True(fsi.IsHot); + } + + [Fact] + public void WarmupPeriod_MatchesPeriod() + { + var fsi = new Fsi(30, 0.2); + Assert.Equal(30, fsi.WarmupPeriod); + } + + // ════════════════════════════════════════════════════════ + // E — Robustness + // ════════════════════════════════════════════════════════ + + [Fact] + public void LargeSeries_NoOverflow() + { + var fsi = new Fsi(20, 0.1); + var series = MakeSeries(5000); + foreach (var bar in series) + { + fsi.Update(bar); + } + Assert.True(double.IsFinite(fsi.Last.Value)); + } + + [Fact] + public void VolatileInput_RemainsFinite() + { + var fsi = new Fsi(20, 0.1); + var rng = new Random(123); + for (int i = 0; i < 1000; i++) + { + double price = 100 + (rng.NextDouble() - 0.5) * 50; + fsi.Update(new TValue(DateTime.UtcNow.AddMinutes(i), price)); + } + Assert.True(double.IsFinite(fsi.Last.Value)); + } + + [Fact] + public void NaN_Input_Handled() + { + var fsi = new Fsi(20, 0.1); + for (int i = 0; i < 30; i++) + { + fsi.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100.0 + i)); + } + var result = fsi.Update(new TValue(DateTime.UtcNow.AddMinutes(30), double.NaN)); + Assert.True(double.IsFinite(result.Value)); + } + + // ════════════════════════════════════════════════════════ + // F — Consistency (4-API mode) + // ════════════════════════════════════════════════════════ + + [Fact] + public void AllModes_ProduceSameResults() + { + var series = MakeSeries(300); + int p = 20; + double bw = 0.1; + + // Mode 1: Streaming + var streaming = new Fsi(p, bw); + foreach (var bar in series) + { + streaming.Update(bar); + } + + // Mode 2: Batch TSeries + var batchResult = Fsi.Batch(series, p, bw); + + // Mode 3: Span + var output = new double[series.Count]; + Fsi.Batch(series.Values, output, p, bw); + + // Mode 4: Calculate + var (calcResult, _) = Fsi.Calculate(series, p, bw); + + // Compare last values + double streamVal = streaming.Last.Value; + double batchVal = batchResult[^1].Value; + double spanVal = output[^1]; + double calcVal = calcResult[^1].Value; + + Assert.Equal(streamVal, batchVal, 10); + Assert.Equal(streamVal, spanVal, 10); + Assert.Equal(streamVal, calcVal, 10); + } + + // ════════════════════════════════════════════════════════ + // G — Span API + // ════════════════════════════════════════════════════════ + + [Fact] + public void SpanBatch_MatchesStreaming() + { + var series = MakeSeries(200); + int p = 20; + double bw = 0.1; + + var streaming = new Fsi(p, bw); + var streamResults = new double[series.Count]; + for (int i = 0; i < series.Count; i++) + { + streamResults[i] = streaming.Update(series[i]).Value; + } + + var spanResults = new double[series.Count]; + Fsi.Batch(series.Values, spanResults, p, bw); + + for (int i = 0; i < series.Count; i++) + { + Assert.Equal(streamResults[i], spanResults[i], 10); + } + } + + [Fact] + public void SpanBatch_EmptyInput_NoThrow() + { + var exception = Record.Exception(() => Fsi.Batch(ReadOnlySpan.Empty, Span.Empty, 20, 0.1)); + Assert.Null(exception); + } + + [Fact] + public void SpanBatch_MismatchedLengths_Throws() + { + var src = new double[10]; + var dst = new double[5]; + Assert.Throws(() => Fsi.Batch(src, dst, 20, 0.1)); + } + + // ════════════════════════════════════════════════════════ + // H — Chainability + // ════════════════════════════════════════════════════════ + + [Fact] + public void PubSub_ChainWorks() + { + var source = new TSeries(); + var fsi = new Fsi(source, period: 20, bandwidth: 0.1); + for (int i = 0; i < 100; i++) + { + source.Add(new TValue(DateTime.UtcNow.AddMinutes(i), 100.0 + i * 0.1)); + } + Assert.True(double.IsFinite(fsi.Last.Value)); + } + + // ════════════════════════════════════════════════════════ + // FSI-Specific Behavioral Tests + // ════════════════════════════════════════════════════════ + + [Fact] + public void ConstantInput_OutputIsZero() + { + var fsi = new Fsi(20, 0.1); + for (int i = 0; i < 300; i++) + { + fsi.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100.0)); + } + // Constant price → zero 2nd-order difference → BP = 0 → FSI = 0 + Assert.Equal(0.0, fsi.Last.Value, 10); + } + + [Fact] + public void SineWave_AtFundamental_ProducesOutput() + { + // Sine wave at period=20 (the fundamental) should produce significant output + var fsi = new Fsi(20, 0.3); + double lastAbsMax = 0; + for (int i = 0; i < 200; i++) + { + double price = 100.0 + 10.0 * Math.Sin(2.0 * Math.PI * i / 20.0); + fsi.Update(new TValue(DateTime.UtcNow.AddMinutes(i), price)); + if (i > 100) + { + lastAbsMax = Math.Max(lastAbsMax, Math.Abs(fsi.Last.Value)); + } + } + Assert.True(lastAbsMax > 0.1, + $"Expected significant output for fundamental sine, got max={lastAbsMax}"); + } + + [Fact] + public void SineWave_With2ndHarmonic_IncludesBoth() + { + // Composite sine with fundamental + 2nd harmonic + var fsi = new Fsi(20, 0.3); + double lastAbsMax = 0; + for (int i = 0; i < 300; i++) + { + double price = 100.0 + 5.0 * Math.Sin(2.0 * Math.PI * i / 20.0) + + 3.0 * Math.Sin(2.0 * Math.PI * i / 10.0); + fsi.Update(new TValue(DateTime.UtcNow.AddMinutes(i), price)); + if (i > 150) + { + lastAbsMax = Math.Max(lastAbsMax, Math.Abs(fsi.Last.Value)); + } + } + Assert.True(lastAbsMax > 0.1, + $"Expected output for composite sine, got max={lastAbsMax}"); + } + + [Fact] + public void DifferentPeriods_ProduceDifferentResults() + { + var series = MakeSeries(300); + var fsi1 = new Fsi(20, 0.1); + var fsi2 = new Fsi(40, 0.1); + foreach (var bar in series) + { + fsi1.Update(bar); + fsi2.Update(bar); + } + Assert.NotEqual(fsi1.Last.Value, fsi2.Last.Value); + } + + [Fact] + public void DifferentBandwidths_ProduceDifferentResults() + { + var series = MakeSeries(300); + var fsi1 = new Fsi(20, 0.1); + var fsi2 = new Fsi(20, 0.5); + foreach (var bar in series) + { + fsi1.Update(bar); + fsi2.Update(bar); + } + Assert.NotEqual(fsi1.Last.Value, fsi2.Last.Value); + } + + [Fact] + public void Name_IncludesPeriodAndBandwidth() + { + var fsi = new Fsi(30, 0.25); + Assert.Contains("30", fsi.Name, StringComparison.Ordinal); + Assert.Contains("0.25", fsi.Name, StringComparison.Ordinal); + } + + [Fact] + public void Calculate_ReturnsIndicatorAndResults() + { + var series = MakeSeries(200); + var (results, indicator) = Fsi.Calculate(series, 20, 0.1); + Assert.Equal(series.Count, results.Count); + Assert.True(indicator.IsHot); + } + + [Fact] + public void Prime_SetsState() + { + var fsi = new Fsi(20, 0.1); + var values = new double[100]; + for (int i = 0; i < 100; i++) + { + values[i] = 100.0 + i * 0.1; + } + fsi.Prime(values); + Assert.True(fsi.IsHot); + } + + [Theory] + [InlineData(6)] + [InlineData(10)] + [InlineData(20)] + [InlineData(50)] + [InlineData(100)] + public void VariousPeriods_AllFinite(int period) + { + var fsi = new Fsi(period, 0.1); + var series = MakeSeries(500); + foreach (var bar in series) + { + fsi.Update(bar); + } + Assert.True(double.IsFinite(fsi.Last.Value)); + } +} diff --git a/lib/cycles/fsi/tests/Fsi.Validation.Tests.cs b/lib/cycles/fsi/tests/Fsi.Validation.Tests.cs new file mode 100644 index 00000000..5b0fb799 --- /dev/null +++ b/lib/cycles/fsi/tests/Fsi.Validation.Tests.cs @@ -0,0 +1,158 @@ +namespace QuanTAlib.Tests; + +public class FsiValidationTests +{ + private static readonly Random _rng = new(42); + + private static TSeries MakeSeries(int count = 500) + { + var series = new TSeries(); + double price = 100.0; + for (int i = 0; i < count; i++) + { + price += (_rng.NextDouble() - 0.5) * 2.0; + series.Add(new TValue(DateTime.UtcNow.AddMinutes(i), price)); + } + return series; + } + + [Fact] + public void BatchStreaming_Match() + { + var series = MakeSeries(300); + int period = 20; + double bw = 0.1; + + // Streaming + var streaming = new Fsi(period, bw); + var streamResults = new double[series.Count]; + for (int i = 0; i < series.Count; i++) + { + streamResults[i] = streaming.Update(series[i]).Value; + } + + // Batch + var batchResult = Fsi.Batch(series, period, bw); + + for (int i = 0; i < series.Count; i++) + { + Assert.Equal(streamResults[i], batchResult[i].Value, 10); + } + } + + [Fact] + public void SpanStreaming_Match() + { + var series = MakeSeries(300); + int period = 20; + double bw = 0.1; + + // Streaming + var streaming = new Fsi(period, bw); + var streamResults = new double[series.Count]; + for (int i = 0; i < series.Count; i++) + { + streamResults[i] = streaming.Update(series[i]).Value; + } + + // Span batch + var spanResults = new double[series.Count]; + Fsi.Batch(series.Values, spanResults, period, bw); + + for (int i = 0; i < series.Count; i++) + { + Assert.Equal(streamResults[i], spanResults[i], 10); + } + } + + [Fact] + public void DifferentPeriods_ProduceDifferentOutputs() + { + var series = MakeSeries(300); + + var result1 = new double[series.Count]; + var result2 = new double[series.Count]; + Fsi.Batch(series.Values, result1, 20, 0.1); + Fsi.Batch(series.Values, result2, 40, 0.1); + + bool allEqual = true; + for (int i = 50; i < series.Count; i++) + { + if (Math.Abs(result1[i] - result2[i]) > 1e-12) + { + allEqual = false; + break; + } + } + Assert.False(allEqual, "Different periods should produce different outputs"); + } + + [Fact] + public void ConstantInput_ProducesZero() + { + int count = 200; + var src = new double[count]; + var dst = new double[count]; + Array.Fill(src, 100.0); + + Fsi.Batch(src, dst, 20, 0.1); + + // After warmup, constant input → all-zero bandpass → output = 0 + for (int i = 20; i < count; i++) + { + Assert.Equal(0.0, dst[i], 10); + } + } + + [Fact] + public void Calculate_ReturnsHotIndicator() + { + var series = MakeSeries(200); + var (results, indicator) = Fsi.Calculate(series, 20, 0.1); + Assert.Equal(series.Count, results.Count); + Assert.True(indicator.IsHot); + } + + [Fact] + public void BarCorrection_Consistency() + { + var series = MakeSeries(100); + var fsi = new Fsi(20, 0.1); + + foreach (var bar in series) + { + fsi.Update(bar); + } + + // New bar + double v1 = fsi.Update(new TValue(DateTime.UtcNow, 105.0), isNew: true).Value; + + // Corrections + _ = fsi.Update(new TValue(DateTime.UtcNow, 108.0), isNew: false); + _ = fsi.Update(new TValue(DateTime.UtcNow, 112.0), isNew: false); + double v4 = fsi.Update(new TValue(DateTime.UtcNow, 105.0), isNew: false).Value; + + Assert.Equal(v1, v4, 10); + } + + [Fact] + public void SubsetStability() + { + // Running on a longer series should not change earlier values + var series = MakeSeries(300); + int period = 20; + double bw = 0.1; + + var result200 = new double[200]; + Fsi.Batch(series.Values[..200], result200, period, bw); + + var result300 = new double[300]; + Fsi.Batch(series.Values, result300, period, bw); + + // First 200 bars of both runs must match exactly + for (int i = 0; i < 200; i++) + { + Assert.Equal(result200[i], result300[i], 15); + } + } +} diff --git a/python/quantalib/_bridge.py b/python/quantalib/_bridge.py index 7fe6e430..9eb6f942 100644 --- a/python/quantalib/_bridge.py +++ b/python/quantalib/_bridge.py @@ -576,6 +576,7 @@ HAS_EBSW = _bind("qtl_ebsw", [_dp, _ci, _dp, _ci, _ci]) HAS_ACP = _bind("qtl_acp", [_dp, _ci, _dp, _ci, _ci, _ci, _ci]) HAS_LPF = _bind("qtl_lpf", [_dp, _ci, _dp, _ci, _ci, _ci]) HAS_AMFM = _bind("qtl_amfm", [_dp, _dp, _ci, _dp, _dp, _ci]) +HAS_FSI = _bind("qtl_fsi", [_dp, _ci, _dp, _ci, _cd]) # ── Numerics (Exports.cs — manual) ── HAS_CHANGE = _bind("qtl_change", [_dp, _ci, _dp, _ci]) diff --git a/python/quantalib/cycles.py b/python/quantalib/cycles.py index 51ccf136..bc45baff 100644 --- a/python/quantalib/cycles.py +++ b/python/quantalib/cycles.py @@ -23,6 +23,7 @@ __all__ = [ "ebsw", "acp", "amfm", + "fsi", ] @@ -172,3 +173,12 @@ def amfm(open: object, close: object, period: int = 30, n = len(o); fm = _out(n); am = _out(n) _check(_lib.qtl_amfm(_ptr(o), _ptr(c), n, _ptr(fm), _ptr(am), period)) return _wrap_multi({f"FM_{period}": fm, f"AM_{period}": am}, idx, "cycles", offset) + + +def fsi(close: object, period: int = 20, bandwidth: float = 0.1, + offset: int = 0, **kwargs) -> object: + """Ehlers Fourier Series Indicator.""" + period = int(kwargs.get("length", period)); offset = int(offset) + src, idx = _arr(close); n = len(src); dst = _out(n) + _check(_lib.qtl_fsi(_ptr(src), n, _ptr(dst), period, float(bandwidth))) + return _wrap(dst, idx, f"FSI_{period}", "cycles", offset) diff --git a/python/src/Exports.cs b/python/src/Exports.cs index 4f3ba2e5..3d4d0f5e 100644 --- a/python/src/Exports.cs +++ b/python/src/Exports.cs @@ -1556,6 +1556,16 @@ public static unsafe partial class Exports catch { return StatusCodes.QTL_ERR_INTERNAL; } } + // Fsi: Pattern A (src → dst, int period, double bandwidth) + [UnmanagedCallersOnly(EntryPoint = "qtl_fsi")] + public static int QtlFsi(double* src, int n, double* dst, int period, double bandwidth) + { + int v = Chk1(src, dst, n); if (v != 0) return v; + v = ChkPeriod(period); if (v != 0) return v; + try { Fsi.Batch(Src(src, n), Dst(dst, n), period, bandwidth); return StatusCodes.QTL_OK; } + catch { return StatusCodes.QTL_ERR_INTERNAL; } + } + // ═══════════════════════════════════════════════════════════════════════ // §8.14 Numerics / transforms // ═══════════════════════════════════════════════════════════════════════