mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-25 13:58:04 +00:00
filters update
This commit is contained in:
+16
-18
@@ -6,21 +6,19 @@ Cycle analysis identifies repeating patterns in price data. John Ehlers pioneere
|
||||
|
||||
## Indicators
|
||||
|
||||
| Indicator | Full Name | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| [CCOR](ccor/Ccor.md) | Ehlers Correlation Cycle | Ehlers. Dual Pearson correlation (cos + -sin). Phasor angle + market state. |
|
||||
| [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. |
|
||||
| [EACP](eacp/Eacp.md) | Ehlers Autocorrelation Periodogram | Ehlers. Spectral analysis via autocorrelation. Detects dominant period. |
|
||||
| [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/Ht_dcperiod.md) | Ehlers Hilbert Transform Dominant Cycle Period | Ehlers Hilbert Transform. Measures current cycle length. |
|
||||
| [HT_DCPHASE](ht_dcphase/Ht_dcphase.md) | Ehlers Hilbert Transform Dominant Cycle Phase | Ehlers Hilbert Transform. Measures current position in cycle. |
|
||||
| [HT_PHASOR](ht_phasor/HtPhasor.md) | Ehlers Hilbert Transform Phasor Components | Ehlers. In-phase and quadrature components. |
|
||||
| [HT_SINE](ht_sine/HtSine.md) | Ehlers Hilbert Transform SineWave | Ehlers Hilbert Transform. Sine and lead sine for cycle timing. |
|
||||
| [LUNAR](lunar/Lunar.md) | Lunar Phase | 29.5-day lunar cycle. Studied for market correlations. |
|
||||
| [SINE](sine/Sine.md) | Ehlers Sine Wave | Ehlers. Basic sinewave indicator for cycle mode. |
|
||||
| [SOLAR](solar/Solar.md) | Solar Activity Cycle | ~11-year sunspot cycle. Long-term research indicator. |
|
||||
| [SSFDSP](ssfdsp/Ssfdsp.md) | Ehlers SSF Detrended Synthetic Price | Super Smoother Filter based DSP. Cleaner cycle extraction. |
|
||||
| [STC](stc/Stc.md) | Schaff Trend Cycle | MACD + double Stochastic smoothing. Fast cycle oscillator (0-100). |
|
||||
| Indicator | Full Name | Description |
|
||||
| :--------------------------------------- | :----------------------------------------------------- | :--------------------------------------------------------------------------- |
|
||||
| [CCOR](ccor/Ccor.md) | Ehlers Correlation Cycle | Ehlers. Dual Pearson correlation (cos + -sin). Phasor angle + market state. |
|
||||
| [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. |
|
||||
| [EACP](eacp/Eacp.md) | Ehlers Autocorrelation Periodogram | Ehlers. Spectral analysis via autocorrelation. Detects dominant period. |
|
||||
| [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. |
|
||||
| [HT_DCPHASE](ht_dcphase/Htdcphase.md) | Ehlers Hilbert Transform Dominant Cycle Phase | Ehlers Hilbert Transform. Measures current position in cycle. |
|
||||
| [HT_PHASOR](ht_phasor/HtPhasor.md) | Ehlers Hilbert Transform Phasor Components | Ehlers. In-phase and quadrature components. |
|
||||
| [HT_SINE](ht_sine/HtSine.md) | Ehlers Hilbert Transform SineWave (also known as SINE) | Ehlers Hilbert Transform. Sine and lead sine for cycle timing. |
|
||||
| [LUNAR](lunar/Lunar.md) | Lunar Phase | 29.5-day lunar cycle. Studied for market correlations. |
|
||||
| [SOLAR](solar/Solar.md) | Solar Activity Cycle | ~11-year sunspot cycle. Long-term research indicator. |
|
||||
| [SSFDSP](ssfdsp/Ssfdsp.md) | Ehlers SSF Detrended Synthetic Price | Super Smoother Filter based DSP. Cleaner cycle extraction. |
|
||||
|
||||
@@ -4,9 +4,9 @@ using System.Runtime.InteropServices;
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// HT_SINE: Hilbert Transform - SineWave indicator that uses the Hilbert Transform
|
||||
/// to compute the sine of the dominant cycle phase. Returns both Sine and LeadSine
|
||||
/// (45° phase lead) for cycle timing.
|
||||
/// HT_SINE: Hilbert Transform - SineWave (also known as SINE) indicator that uses
|
||||
/// the Hilbert Transform to compute the sine of the dominant cycle phase. Returns
|
||||
/// both Sine and LeadSine (45° phase lead) for cycle timing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The Hilbert Transform SineWave indicator identifies the dominant market cycle
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# HT_SINE: Ehlers Hilbert Transform SineWave
|
||||
# HT_SINE: Ehlers Hilbert Transform SineWave (also known as SINE)
|
||||
|
||||
HT_SINE extracts the dominant market cycle phase and outputs both Sine and LeadSine (45° phase advance) for cycle timing. The crossover of these two waves identifies turning points in ranging markets up to one-eighth of a cycle early. Compatible with TA-Lib's `HT_SINE` function, the indicator builds on the full Hilbert Transform cascade (phasor extraction, homodyne period estimation, DFT phase accumulation) to produce dual bounded $[-1, +1]$ oscillators that track cycle position rather than price amplitude.
|
||||
|
||||
|
||||
@@ -1,385 +0,0 @@
|
||||
using TradingPlatform.BusinessLayer;
|
||||
|
||||
namespace QuanTAlib.Quantower.Tests;
|
||||
|
||||
public class SineIndicatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void SineIndicator_Constructor_SetsDefaults()
|
||||
{
|
||||
var indicator = new SineIndicator();
|
||||
|
||||
Assert.Equal(40, indicator.HpPeriod);
|
||||
Assert.Equal(10, indicator.SsfPeriod);
|
||||
Assert.Equal(SourceType.Close, indicator.Source);
|
||||
Assert.True(indicator.ShowColdValues);
|
||||
Assert.Equal("SINE - Ehlers Sine Wave", indicator.Name);
|
||||
Assert.True(indicator.SeparateWindow);
|
||||
Assert.True(indicator.OnBackGround);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SineIndicator_MinHistoryDepths_EqualsZero()
|
||||
{
|
||||
var indicator = new SineIndicator();
|
||||
|
||||
Assert.Equal(0, SineIndicator.MinHistoryDepths);
|
||||
Assert.Equal(0, ((IWatchlistIndicator)indicator).MinHistoryDepths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SineIndicator_ShortName_IncludesParameters()
|
||||
{
|
||||
var indicator = new SineIndicator { HpPeriod = 20, SsfPeriod = 5 };
|
||||
|
||||
Assert.True(indicator.ShortName.Contains("SINE", StringComparison.Ordinal));
|
||||
Assert.True(indicator.ShortName.Contains("20", StringComparison.Ordinal));
|
||||
Assert.True(indicator.ShortName.Contains("5", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SineIndicator_Initialize_CreatesInternalSine()
|
||||
{
|
||||
var indicator = new SineIndicator { HpPeriod = 40, SsfPeriod = 10 };
|
||||
|
||||
// Initialize should not throw
|
||||
indicator.Initialize();
|
||||
|
||||
// After init, line series should exist (SINE + Zero + Upper + Lower lines)
|
||||
Assert.Equal(4, indicator.LinesSeries.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SineIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
|
||||
{
|
||||
var indicator = new SineIndicator { HpPeriod = 20, SsfPeriod = 5 };
|
||||
indicator.Initialize();
|
||||
|
||||
// Add historical data
|
||||
var now = DateTime.UtcNow;
|
||||
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
|
||||
|
||||
// Process update
|
||||
var args = new UpdateArgs(UpdateReason.HistoricalBar);
|
||||
indicator.ProcessUpdate(args);
|
||||
|
||||
// Line series should have a value
|
||||
Assert.Equal(1, indicator.LinesSeries[0].Count);
|
||||
Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SineIndicator_ProcessUpdate_NewBar_ComputesValue()
|
||||
{
|
||||
var indicator = new SineIndicator { HpPeriod = 20, SsfPeriod = 5 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(1), 102, 108, 100, 106);
|
||||
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
|
||||
|
||||
Assert.Equal(2, indicator.LinesSeries[0].Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SineIndicator_ProcessUpdate_NewTick_ProcessesWithoutError()
|
||||
{
|
||||
var indicator = new SineIndicator { HpPeriod = 20, SsfPeriod = 5 };
|
||||
indicator.Initialize();
|
||||
|
||||
// Should not throw an exception
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick));
|
||||
|
||||
// Assert that the indicator still exists (method completed without exception)
|
||||
Assert.NotNull(indicator);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SineIndicator_MultipleUpdates_ProducesCorrectSequence()
|
||||
{
|
||||
var indicator = new SineIndicator { HpPeriod = 20, SsfPeriod = 5 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
double[] closes = { 100, 102, 105, 103, 107, 110, 108, 112, 115, 113 };
|
||||
|
||||
foreach (var close in closes)
|
||||
{
|
||||
indicator.HistoricalData.AddBar(now, close, close + 2, close - 2, close);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
now = now.AddMinutes(1);
|
||||
}
|
||||
|
||||
// All values should be finite
|
||||
for (int i = 0; i < closes.Length; i++)
|
||||
{
|
||||
Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(closes.Length - 1 - i)));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SineIndicator_DifferentSourceTypes_Work()
|
||||
{
|
||||
var sources = new[] { SourceType.Open, SourceType.High, SourceType.Low, SourceType.Close, SourceType.HL2, SourceType.HLC3 };
|
||||
|
||||
foreach (var source in sources)
|
||||
{
|
||||
var indicator = new SineIndicator { HpPeriod = 20, SsfPeriod = 5, Source = source };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
indicator.HistoricalData.AddBar(now, 100, 110, 90, 105);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
|
||||
Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0)),
|
||||
$"Source {source} should produce finite value");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SineIndicator_HpPeriod_CanBeChanged()
|
||||
{
|
||||
var indicator = new SineIndicator { HpPeriod = 40 };
|
||||
|
||||
Assert.Equal(40, indicator.HpPeriod);
|
||||
|
||||
indicator.HpPeriod = 20;
|
||||
Assert.Equal(20, indicator.HpPeriod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SineIndicator_SsfPeriod_CanBeChanged()
|
||||
{
|
||||
var indicator = new SineIndicator { SsfPeriod = 10 };
|
||||
|
||||
Assert.Equal(10, indicator.SsfPeriod);
|
||||
|
||||
indicator.SsfPeriod = 5;
|
||||
Assert.Equal(5, indicator.SsfPeriod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SineIndicator_Source_CanBeChanged()
|
||||
{
|
||||
var indicator = new SineIndicator { Source = SourceType.Close };
|
||||
|
||||
Assert.Equal(SourceType.Close, indicator.Source);
|
||||
|
||||
indicator.Source = SourceType.Open;
|
||||
Assert.Equal(SourceType.Open, indicator.Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SineIndicator_ShowColdValues_CanBeChanged()
|
||||
{
|
||||
var indicator = new SineIndicator { ShowColdValues = true };
|
||||
|
||||
Assert.True(indicator.ShowColdValues);
|
||||
|
||||
indicator.ShowColdValues = false;
|
||||
Assert.False(indicator.ShowColdValues);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SineIndicator_ShortName_UpdatesWhenParametersChange()
|
||||
{
|
||||
var indicator = new SineIndicator { HpPeriod = 40, SsfPeriod = 10 };
|
||||
string initialName = indicator.ShortName;
|
||||
|
||||
Assert.True(initialName.Contains("40", StringComparison.Ordinal));
|
||||
Assert.True(initialName.Contains("10", StringComparison.Ordinal));
|
||||
|
||||
indicator.HpPeriod = 20;
|
||||
indicator.SsfPeriod = 5;
|
||||
string updatedName = indicator.ShortName;
|
||||
|
||||
Assert.True(updatedName.Contains("20", StringComparison.Ordinal));
|
||||
Assert.True(updatedName.Contains("5", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SineIndicator_ProcessUpdate_IgnoresNonBarUpdates()
|
||||
{
|
||||
var indicator = new SineIndicator { HpPeriod = 20, SsfPeriod = 5 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
|
||||
|
||||
// Process historical bar first
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
|
||||
// Process other update reasons - should not throw
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick));
|
||||
|
||||
// Assert that the indicator still exists (method completed without exception)
|
||||
Assert.NotNull(indicator);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SineIndicator_LineSeries_HasCorrectProperties()
|
||||
{
|
||||
var indicator = new SineIndicator { HpPeriod = 40, SsfPeriod = 10 };
|
||||
indicator.Initialize();
|
||||
|
||||
var lineSeries = indicator.LinesSeries[0];
|
||||
|
||||
Assert.Equal("SINE", lineSeries.Name);
|
||||
Assert.Equal(2, lineSeries.Width);
|
||||
Assert.Equal(LineStyle.Solid, lineSeries.Style);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SineIndicator_ZeroLine_HasCorrectProperties()
|
||||
{
|
||||
var indicator = new SineIndicator { HpPeriod = 40, SsfPeriod = 10 };
|
||||
indicator.Initialize();
|
||||
|
||||
var zeroLine = indicator.LinesSeries[1];
|
||||
|
||||
Assert.Equal("Zero", zeroLine.Name);
|
||||
Assert.Equal(1, zeroLine.Width);
|
||||
Assert.Equal(LineStyle.Dash, zeroLine.Style);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SineIndicator_BoundaryLines_HasCorrectProperties()
|
||||
{
|
||||
var indicator = new SineIndicator { HpPeriod = 40, SsfPeriod = 10 };
|
||||
indicator.Initialize();
|
||||
|
||||
var upperLine = indicator.LinesSeries[2];
|
||||
var lowerLine = indicator.LinesSeries[3];
|
||||
|
||||
Assert.Equal("+1", upperLine.Name);
|
||||
Assert.Equal("-1", lowerLine.Name);
|
||||
Assert.Equal(LineStyle.Dot, upperLine.Style);
|
||||
Assert.Equal(LineStyle.Dot, lowerLine.Style);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SineIndicator_DifferentParameters_Work()
|
||||
{
|
||||
var paramSets = new[] { (10, 3), (20, 5), (40, 10), (80, 20) };
|
||||
|
||||
foreach (var (hpPeriod, ssfPeriod) in paramSets)
|
||||
{
|
||||
var indicator = new SineIndicator { HpPeriod = hpPeriod, SsfPeriod = ssfPeriod };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
// Add enough bars to fill the buffer
|
||||
for (int i = 0; i < hpPeriod + 10; i++)
|
||||
{
|
||||
double close = 100 + (i % 10);
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), close, close + 2, close - 2, close);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
}
|
||||
|
||||
// Last value should be finite
|
||||
double sineValue = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.True(double.IsFinite(sineValue), $"HP {hpPeriod}, SSF {ssfPeriod} should produce finite value");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SineIndicator_ConstantPrice_ProducesBoundedOutput()
|
||||
{
|
||||
var indicator = new SineIndicator { HpPeriod = 20, SsfPeriod = 5 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
// Add constant price bars
|
||||
for (int i = 0; i < 500; i++)
|
||||
{
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100, 100, 100, 100);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
}
|
||||
|
||||
// Output is normalized to [-1, +1]
|
||||
double sineValue = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.True(sineValue >= -1.0 && sineValue <= 1.0,
|
||||
$"SINE value {sineValue} should be in [-1, +1]");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SineIndicator_OutputBounded_BetweenNegativeOneAndOne()
|
||||
{
|
||||
var indicator = new SineIndicator { HpPeriod = 20, SsfPeriod = 5 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
// Add varying price bars
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
double price = 100 + 20 * Math.Sin(i * 0.2);
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), price, price + 2, price - 2, price);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
|
||||
double sineValue = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.True(sineValue >= -1.0 && sineValue <= 1.0,
|
||||
$"SINE value {sineValue} should be in [-1, +1]");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SineIndicator_OscillatesAroundZero_ForSineWave()
|
||||
{
|
||||
var indicator = new SineIndicator { HpPeriod = 40, SsfPeriod = 10 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var values = new List<double>();
|
||||
|
||||
// Generate sine wave price pattern
|
||||
for (int i = 0; i < 200; i++)
|
||||
{
|
||||
double price = 100.0 + 10.0 * Math.Sin(i * 0.1);
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), price, price + 1, price - 1, price);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
values.Add(indicator.LinesSeries[0].GetValue(0));
|
||||
}
|
||||
|
||||
// Should have both positive and negative values
|
||||
int positiveCount = values.Count(v => v > 0);
|
||||
int negativeCount = values.Count(v => v < 0);
|
||||
|
||||
Assert.True(positiveCount > 0, "Should have positive SINE values");
|
||||
Assert.True(negativeCount > 0, "Should have negative SINE values");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SineIndicator_ZeroCrossings_IndicateCyclePhase()
|
||||
{
|
||||
var indicator = new SineIndicator { HpPeriod = 20, SsfPeriod = 5 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var values = new List<double>();
|
||||
|
||||
// Generate sine wave price pattern
|
||||
for (int i = 0; i < 200; i++)
|
||||
{
|
||||
double price = 100.0 + 10.0 * Math.Sin(i * 0.15);
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), price, price + 1, price - 1, price);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
values.Add(indicator.LinesSeries[0].GetValue(0));
|
||||
}
|
||||
|
||||
// Count zero crossings
|
||||
int crossings = 0;
|
||||
for (int i = 1; i < values.Count; i++)
|
||||
{
|
||||
if (values[i - 1] * values[i] < 0)
|
||||
{
|
||||
crossings++;
|
||||
}
|
||||
}
|
||||
|
||||
// Should have multiple zero crossings for oscillating price
|
||||
Assert.True(crossings >= 3, $"Should have multiple zero crossings, got {crossings}");
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
using System.Drawing;
|
||||
using System.Runtime.CompilerServices;
|
||||
using TradingPlatform.BusinessLayer;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
[SkipLocalsInit]
|
||||
public sealed class SineIndicator : Indicator, IWatchlistIndicator
|
||||
{
|
||||
[InputParameter("HP Period", sortIndex: 1, 1, 2000, 1, 0)]
|
||||
public int HpPeriod { get; set; } = 40;
|
||||
|
||||
[InputParameter("SSF Period", sortIndex: 2, 1, 500, 1, 0)]
|
||||
public int SsfPeriod { get; set; } = 10;
|
||||
|
||||
[IndicatorExtensions.DataSourceInput]
|
||||
public SourceType Source { get; set; } = SourceType.Close;
|
||||
|
||||
[InputParameter("Show cold values", sortIndex: 21)]
|
||||
public bool ShowColdValues { get; set; } = true;
|
||||
|
||||
private Sine _sine = null!;
|
||||
private readonly LineSeries _series;
|
||||
private readonly LineSeries _zeroLine;
|
||||
private readonly LineSeries _upperLine;
|
||||
private readonly LineSeries _lowerLine;
|
||||
private Func<IHistoryItem, double> _priceSelector = null!;
|
||||
|
||||
public static int MinHistoryDepths => 0;
|
||||
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
|
||||
|
||||
public override string ShortName => $"SINE ({HpPeriod},{SsfPeriod})";
|
||||
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/cycles/sine/Sine.Quantower.cs";
|
||||
|
||||
public SineIndicator()
|
||||
{
|
||||
OnBackGround = true;
|
||||
SeparateWindow = true;
|
||||
Name = "SINE - Ehlers Sine Wave";
|
||||
Description = "Ehlers' Sine Wave indicator extracts the dominant cycle from price data using High-Pass filter, Super-Smoother, and Hilbert Transform";
|
||||
|
||||
_series = new LineSeries(name: "SINE", color: IndicatorExtensions.Oscillators, width: 2, style: LineStyle.Solid);
|
||||
_zeroLine = new LineSeries(name: "Zero", color: Color.Gray, width: 1, style: LineStyle.Dash);
|
||||
_upperLine = new LineSeries(name: "+1", color: Color.DarkGray, width: 1, style: LineStyle.Dot);
|
||||
_lowerLine = new LineSeries(name: "-1", color: Color.DarkGray, width: 1, style: LineStyle.Dot);
|
||||
AddLineSeries(_series);
|
||||
AddLineSeries(_zeroLine);
|
||||
AddLineSeries(_upperLine);
|
||||
AddLineSeries(_lowerLine);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
protected override void OnInit()
|
||||
{
|
||||
_sine = new Sine(HpPeriod, SsfPeriod);
|
||||
_priceSelector = Source.GetPriceSelector();
|
||||
base.OnInit();
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
protected override void OnUpdate(UpdateArgs args)
|
||||
{
|
||||
if (args.Reason != UpdateReason.NewBar && args.Reason != UpdateReason.HistoricalBar)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var item = this.HistoricalData[this.Count - 1, SeekOriginHistory.Begin];
|
||||
double value = _priceSelector(item);
|
||||
var time = this.HistoricalData.Time();
|
||||
|
||||
var input = new TValue(time, value);
|
||||
TValue result = _sine.Update(input, args.IsNewBar());
|
||||
|
||||
_series.SetValue(result.Value, _sine.IsHot, ShowColdValues);
|
||||
_zeroLine.SetValue(0.0);
|
||||
_upperLine.SetValue(1.0);
|
||||
_lowerLine.SetValue(-1.0);
|
||||
}
|
||||
}
|
||||
@@ -1,352 +0,0 @@
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
using Xunit;
|
||||
|
||||
public class SineTests
|
||||
{
|
||||
private const double Tolerance = 1e-9;
|
||||
private readonly GBM _gbm;
|
||||
|
||||
public SineTests()
|
||||
{
|
||||
_gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.2, seed: 42);
|
||||
}
|
||||
|
||||
private TBarSeries GenerateBars(int count)
|
||||
{
|
||||
return _gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromDays(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sine_ConstructorDefaults()
|
||||
{
|
||||
var sine = new Sine();
|
||||
Assert.Equal("SINE", sine.Name);
|
||||
Assert.Equal(40, sine.HpPeriod);
|
||||
Assert.Equal(10, sine.SsfPeriod);
|
||||
Assert.Equal(48, sine.WarmupPeriod); // max(40, 10) + 8
|
||||
Assert.False(sine.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sine_ConstructorCustomParameters()
|
||||
{
|
||||
var sine = new Sine(hpPeriod: 20, ssfPeriod: 5);
|
||||
Assert.Equal(20, sine.HpPeriod);
|
||||
Assert.Equal(5, sine.SsfPeriod);
|
||||
Assert.Equal(28, sine.WarmupPeriod); // max(20, 5) + 8
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sine_ConstructorValidation_ThrowsOnInvalidHpPeriod()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new Sine(hpPeriod: 0));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new Sine(hpPeriod: -1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sine_ConstructorValidation_ThrowsOnInvalidSsfPeriod()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new Sine(ssfPeriod: 0));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new Sine(ssfPeriod: -1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sine_Update_ReturnsValidRange()
|
||||
{
|
||||
var sine = new Sine();
|
||||
var bars = GenerateBars(200);
|
||||
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
var result = sine.Update(new TValue(bar.Time, bar.Close));
|
||||
Assert.True(result.Value >= -1.0 && result.Value <= 1.0,
|
||||
$"Sine value {result.Value} out of range [-1, 1]");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sine_IsHot_AfterWarmup()
|
||||
{
|
||||
var sine = new Sine(hpPeriod: 20, ssfPeriod: 5);
|
||||
var bars = GenerateBars(50);
|
||||
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
sine.Update(new TValue(bars[i].Time, bars[i].Close));
|
||||
|
||||
if (i + 1 < sine.WarmupPeriod)
|
||||
{
|
||||
Assert.False(sine.IsHot, $"Should not be hot at index {i}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.True(sine.IsHot, $"Should be hot at index {i}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sine_IsNew_AdvancesState()
|
||||
{
|
||||
var sine = new Sine();
|
||||
var input = new TValue(DateTime.UtcNow, 100.0);
|
||||
|
||||
var result1 = sine.Update(input, isNew: true);
|
||||
var result2 = sine.Update(new TValue(DateTime.UtcNow.AddDays(1), 101.0), isNew: true);
|
||||
|
||||
// With isNew=true, each call should advance state
|
||||
// Values might be the same early on, but state should advance
|
||||
Assert.NotEqual(result1.Time, result2.Time);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sine_IsNew_False_UpdatesCurrentBar()
|
||||
{
|
||||
var sine = new Sine();
|
||||
var bars = GenerateBars(60);
|
||||
|
||||
// Process first 50 bars normally
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
sine.Update(new TValue(bars[i].Time, bars[i].Close), isNew: true);
|
||||
}
|
||||
|
||||
// Get result at bar 50
|
||||
var newBarResult = sine.Update(new TValue(bars[50].Time, bars[50].Close), isNew: true);
|
||||
|
||||
// Reset and replay to bar 49, then update bar 50 with different value
|
||||
var sine2 = new Sine();
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
sine2.Update(new TValue(bars[i].Time, bars[i].Close), isNew: true);
|
||||
}
|
||||
|
||||
// First update bar 50
|
||||
sine2.Update(new TValue(bars[50].Time, bars[50].Close), isNew: true);
|
||||
|
||||
// Update same bar with different value (bar correction)
|
||||
var correctedResult = sine2.Update(new TValue(bars[50].Time, bars[50].Close * 1.1), isNew: false);
|
||||
|
||||
// Results should differ due to different input
|
||||
Assert.NotEqual(newBarResult.Value, correctedResult.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sine_Reset_ClearsState()
|
||||
{
|
||||
var sine = new Sine();
|
||||
var bars = GenerateBars(100);
|
||||
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
sine.Update(new TValue(bar.Time, bar.Close));
|
||||
}
|
||||
|
||||
Assert.True(sine.IsHot);
|
||||
|
||||
sine.Reset();
|
||||
|
||||
Assert.False(sine.IsHot);
|
||||
Assert.Equal(0, sine.Last.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sine_TSeries_Update()
|
||||
{
|
||||
var bars = GenerateBars(100);
|
||||
var series = new TSeries(100);
|
||||
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
series.Add(new TValue(bar.Time, bar.Close));
|
||||
}
|
||||
|
||||
var sine = new Sine();
|
||||
var result = sine.Update(series);
|
||||
|
||||
Assert.Equal(100, result.Count);
|
||||
|
||||
// Verify all values are in range
|
||||
foreach (var val in result)
|
||||
{
|
||||
Assert.True(val.Value >= -1.0 && val.Value <= 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sine_StaticCalculate_TSeries()
|
||||
{
|
||||
var bars = GenerateBars(100);
|
||||
var series = new TSeries(100);
|
||||
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
series.Add(new TValue(bar.Time, bar.Close));
|
||||
}
|
||||
|
||||
var result = Sine.Batch(series);
|
||||
|
||||
Assert.Equal(100, result.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sine_StaticCalculate_WithCustomParams()
|
||||
{
|
||||
var bars = GenerateBars(100);
|
||||
var series = new TSeries(100);
|
||||
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
series.Add(new TValue(bar.Time, bar.Close));
|
||||
}
|
||||
|
||||
var result = Sine.Batch(series, hpPeriod: 20, ssfPeriod: 5);
|
||||
|
||||
Assert.Equal(100, result.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sine_Chaining_Works()
|
||||
{
|
||||
var source = new Sma(10);
|
||||
var sine = new Sine(source);
|
||||
|
||||
bool eventFired = false;
|
||||
sine.Pub += (object? sender, in TValueEventArgs args) => eventFired = true;
|
||||
|
||||
var input = new TValue(DateTime.UtcNow, 100.0);
|
||||
source.Update(input);
|
||||
|
||||
Assert.True(eventFired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sine_EmptyTSeries_ReturnsEmpty()
|
||||
{
|
||||
var sine = new Sine();
|
||||
var empty = new TSeries();
|
||||
var result = sine.Update(empty);
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sine_Streaming_MatchesBatch()
|
||||
{
|
||||
var bars = GenerateBars(200);
|
||||
var series = new TSeries(200);
|
||||
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
series.Add(new TValue(bar.Time, bar.Close));
|
||||
}
|
||||
|
||||
// Streaming calculation
|
||||
var streamingSine = new Sine();
|
||||
var streamingResults = new List<double>();
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
var result = streamingSine.Update(new TValue(bar.Time, bar.Close));
|
||||
streamingResults.Add(result.Value);
|
||||
}
|
||||
|
||||
// Batch calculation
|
||||
var batchResult = Sine.Batch(series);
|
||||
|
||||
// Compare last 100 values (after warmup)
|
||||
for (int i = 100; i < 200; i++)
|
||||
{
|
||||
Assert.Equal(streamingResults[i], batchResult[i].Value, Tolerance);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sine_NaN_HandledGracefully()
|
||||
{
|
||||
var sine = new Sine();
|
||||
var bars = GenerateBars(60);
|
||||
|
||||
// Process some bars
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
sine.Update(new TValue(bars[i].Time, bars[i].Close));
|
||||
}
|
||||
|
||||
// Feed NaN - should substitute with last valid value
|
||||
var nanResult = sine.Update(new TValue(DateTime.UtcNow, double.NaN));
|
||||
|
||||
// Result should NOT be NaN (last-valid substitution) and in valid range
|
||||
Assert.False(double.IsNaN(nanResult.Value), "NaN should not propagate");
|
||||
Assert.True(nanResult.Value >= -1.0 && nanResult.Value <= 1.0,
|
||||
$"Value {nanResult.Value} should be in [-1, 1]");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sine_Prime_InitializesState()
|
||||
{
|
||||
var bars = GenerateBars(100);
|
||||
var primeData = bars.Select(b => b.Close).ToArray();
|
||||
|
||||
var sine = new Sine();
|
||||
sine.Prime(primeData);
|
||||
|
||||
Assert.True(sine.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sine_WithCyclingData_ProducesOscillation()
|
||||
{
|
||||
var sine = new Sine(hpPeriod: 20, ssfPeriod: 5);
|
||||
|
||||
// Generate sinusoidal price data
|
||||
var results = new List<double>();
|
||||
var baseTime = DateTime.UtcNow;
|
||||
|
||||
for (int i = 0; i < 200; i++)
|
||||
{
|
||||
// Create a price with embedded 30-bar cycle
|
||||
double price = 100.0 + 10.0 * Math.Sin(2.0 * Math.PI * i / 30.0);
|
||||
var result = sine.Update(new TValue(baseTime.AddDays(i), price));
|
||||
results.Add(result.Value);
|
||||
}
|
||||
|
||||
// After warmup, check that we have both positive and negative values
|
||||
var afterWarmup = results.Skip(30).ToList();
|
||||
Assert.True(afterWarmup.Any(v => v > 0.5), "Should have positive cycle values");
|
||||
Assert.True(afterWarmup.Any(v => v < -0.5), "Should have negative cycle values");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sine_ConstantInput_ProducesValidOutput()
|
||||
{
|
||||
var sine = new Sine();
|
||||
var baseTime = DateTime.UtcNow;
|
||||
|
||||
// Feed constant values
|
||||
for (int i = 0; i < 200; i++)
|
||||
{
|
||||
var result = sine.Update(new TValue(baseTime.AddDays(i), 100.0));
|
||||
// Output should always be in valid range regardless of input
|
||||
Assert.True(result.Value >= -1.0 && result.Value <= 1.0,
|
||||
$"Value {result.Value} out of range at index {i}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sine_TrendingInput_ProducesValidOutput()
|
||||
{
|
||||
var sine = new Sine(hpPeriod: 40, ssfPeriod: 10);
|
||||
var baseTime = DateTime.UtcNow;
|
||||
|
||||
// Feed trending data (very low frequency)
|
||||
for (int i = 0; i < 200; i++)
|
||||
{
|
||||
double price = 100.0 + i * 0.1; // Slow uptrend
|
||||
var result = sine.Update(new TValue(baseTime.AddDays(i), price));
|
||||
// Output should always be in valid range regardless of input
|
||||
Assert.True(result.Value >= -1.0 && result.Value <= 1.0,
|
||||
$"Value {result.Value} out of range at index {i}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
using Xunit;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Validation tests for Ehlers Sine Wave indicator.
|
||||
/// Sine is Ehlers' proprietary cycle indicator not commonly implemented in trading libraries
|
||||
/// (TA-Lib, Skender, Tulip), so validation is done against mathematical properties
|
||||
/// and known theoretical results based on the original algorithm.
|
||||
/// </summary>
|
||||
public class SineValidationTests
|
||||
{
|
||||
[Fact]
|
||||
public void Validation_OutputRange_NegativeOneToOne()
|
||||
{
|
||||
// Sine wave output should be in [-1, 1]
|
||||
var sine = new Sine();
|
||||
|
||||
var gbm = new GBM(seed: 42);
|
||||
var bars = gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
sine.Update(new TValue(bar.Time, bar.Close));
|
||||
if (sine.IsHot)
|
||||
{
|
||||
double val = sine.Last.Value;
|
||||
Assert.True(val >= -1.0 && val <= 1.0,
|
||||
$"Sine value {val} is outside expected range [-1, 1]");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validation_ConstantSeries_Bounded()
|
||||
{
|
||||
// For a constant price series, there is no real cycle — output should remain bounded
|
||||
var sine = new Sine();
|
||||
|
||||
for (int i = 0; i < 200; i++)
|
||||
{
|
||||
sine.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0));
|
||||
}
|
||||
|
||||
// Constant series may not produce exactly zero due to filter initialization artifacts
|
||||
// but output should remain within the bounded range [-1, 1]
|
||||
Assert.True(sine.Last.Value >= -1.0 && sine.Last.Value <= 1.0,
|
||||
$"Constant series should produce bounded sine output, got {sine.Last.Value}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validation_SinusoidInput_DetectsCycle()
|
||||
{
|
||||
// Feed a known sinusoidal signal and verify output oscillates
|
||||
var sine = new Sine(hpPeriod: 40, ssfPeriod: 10);
|
||||
|
||||
var values = new List<double>();
|
||||
for (int i = 0; i < 300; i++)
|
||||
{
|
||||
double price = 100.0 + 5.0 * Math.Sin(2.0 * Math.PI * i / 20.0);
|
||||
sine.Update(new TValue(DateTime.UtcNow.AddSeconds(i), price));
|
||||
if (sine.IsHot)
|
||||
{
|
||||
values.Add(sine.Last.Value);
|
||||
}
|
||||
}
|
||||
|
||||
// The output should oscillate: check that it crosses zero at least once
|
||||
bool hasCrossedZero = false;
|
||||
for (int i = 1; i < values.Count; i++)
|
||||
{
|
||||
if ((values[i - 1] >= 0 && values[i] < 0) || (values[i - 1] < 0 && values[i] >= 0))
|
||||
{
|
||||
hasCrossedZero = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(hasCrossedZero, "Sine should oscillate (cross zero) on sinusoidal input");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validation_FiniteOutputs()
|
||||
{
|
||||
var sine = new Sine();
|
||||
|
||||
var gbm = new GBM(seed: 99);
|
||||
var bars = gbm.Fetch(300, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
sine.Update(new TValue(bar.Time, bar.Close));
|
||||
Assert.True(double.IsFinite(sine.Last.Value),
|
||||
$"Sine produced non-finite value: {sine.Last.Value}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validation_DifferentPeriods_ProduceDifferentResults()
|
||||
{
|
||||
var sine1 = new Sine(hpPeriod: 20, ssfPeriod: 5);
|
||||
var sine2 = new Sine(hpPeriod: 80, ssfPeriod: 20);
|
||||
|
||||
var gbm = new GBM(seed: 42);
|
||||
var bars = gbm.Fetch(300, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
bool foundDifference = false;
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
sine1.Update(new TValue(bar.Time, bar.Close));
|
||||
sine2.Update(new TValue(bar.Time, bar.Close));
|
||||
if (sine1.IsHot && sine2.IsHot &&
|
||||
Math.Abs(sine1.Last.Value - sine2.Last.Value) > 1e-6)
|
||||
{
|
||||
foundDifference = true;
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(foundDifference, "Different HP/SSF periods should produce different results");
|
||||
}
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
// Ehlers Sine Wave (SINE) - Cycle extraction using Hilbert Transform
|
||||
// Uses High-Pass filter + Super-Smoother + Hilbert Transform to extract sine wave
|
||||
// Based on John Ehlers' "Cybernetic Analysis for Stocks and Futures"
|
||||
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// Ehlers Sine Wave indicator extracts the dominant cycle from price data.
|
||||
/// Uses a High-Pass filter for detrending, Super-Smoother for noise reduction,
|
||||
/// and Hilbert Transform FIR for quadrature component extraction.
|
||||
/// Output ranges from -1.0 to +1.0 representing the normalized sine wave.
|
||||
/// </summary>
|
||||
[SkipLocalsInit]
|
||||
public sealed class Sine : AbstractBase
|
||||
{
|
||||
private readonly int _hpPeriod;
|
||||
private readonly int _ssfPeriod;
|
||||
private readonly RingBuffer _srcBuffer;
|
||||
private readonly RingBuffer _hpBuffer;
|
||||
private readonly RingBuffer _filtBuffer;
|
||||
|
||||
// High-Pass filter coefficient
|
||||
private readonly double _alphaHP;
|
||||
|
||||
// Super-Smoother coefficients
|
||||
private readonly double _c1, _c2, _c3;
|
||||
|
||||
// Hilbert FIR coefficients
|
||||
private const double H1 = 0.0962;
|
||||
private const double H2 = 0.5769;
|
||||
|
||||
// State tracking
|
||||
private int _count;
|
||||
|
||||
public int HpPeriod => _hpPeriod;
|
||||
public int SsfPeriod => _ssfPeriod;
|
||||
public override bool IsHot => _count >= WarmupPeriod;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new Ehlers Sine Wave indicator.
|
||||
/// </summary>
|
||||
/// <param name="hpPeriod">High-Pass filter period for detrending (default: 40)</param>
|
||||
/// <param name="ssfPeriod">Super-Smoother filter period for smoothing (default: 10)</param>
|
||||
public Sine(int hpPeriod = 40, int ssfPeriod = 10)
|
||||
{
|
||||
if (hpPeriod < 1)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(hpPeriod), "High-Pass period must be >= 1");
|
||||
}
|
||||
if (ssfPeriod < 1)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(ssfPeriod), "Super-Smoother period must be >= 1");
|
||||
}
|
||||
|
||||
_hpPeriod = hpPeriod;
|
||||
_ssfPeriod = ssfPeriod;
|
||||
Name = "SINE";
|
||||
WarmupPeriod = Math.Max(hpPeriod, ssfPeriod) + 8; // +8 for Hilbert lookback
|
||||
|
||||
// High-Pass filter coefficient
|
||||
double angHP = 2.0 * Math.PI / hpPeriod;
|
||||
_alphaHP = (1.0 - Math.Sin(angHP)) / Math.Cos(angHP);
|
||||
|
||||
// Super-Smoother coefficients (2-pole Butterworth)
|
||||
double angSSF = Math.Sqrt(2.0) * Math.PI / ssfPeriod;
|
||||
double aSSF = Math.Exp(-angSSF);
|
||||
double bSSF = 2.0 * aSSF * Math.Cos(angSSF);
|
||||
_c2 = bSSF;
|
||||
_c3 = -aSSF * aSSF;
|
||||
_c1 = 1.0 - _c2 - _c3;
|
||||
|
||||
// Buffers for historical values
|
||||
_srcBuffer = new RingBuffer(2); // src[0], src[1]
|
||||
_hpBuffer = new RingBuffer(2); // hp[0], hp[1]
|
||||
_filtBuffer = new RingBuffer(8); // filt[0..7] for Hilbert
|
||||
|
||||
_count = 0;
|
||||
Last = new TValue(DateTime.UtcNow, 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a chained Sine indicator.
|
||||
/// </summary>
|
||||
public Sine(ITValuePublisher source, int hpPeriod = 40, int ssfPeriod = 10) : this(hpPeriod, ssfPeriod)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
source.Pub += HandleInput;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private void HandleInput(object? sender, in TValueEventArgs e)
|
||||
{
|
||||
Update(e.Value, e.IsNew);
|
||||
}
|
||||
|
||||
// Last valid value for NaN substitution
|
||||
private double _lastValidValue;
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public override TValue Update(TValue input, bool isNew = true)
|
||||
{
|
||||
double src = input.Value;
|
||||
|
||||
// Handle NaN/Infinity: substitute with last valid value
|
||||
if (!double.IsFinite(src))
|
||||
{
|
||||
src = _lastValidValue;
|
||||
}
|
||||
else
|
||||
{
|
||||
_lastValidValue = src;
|
||||
}
|
||||
|
||||
if (isNew)
|
||||
{
|
||||
_srcBuffer.Add(src);
|
||||
_count++;
|
||||
}
|
||||
else
|
||||
{
|
||||
_srcBuffer.UpdateNewest(src);
|
||||
}
|
||||
|
||||
// High-Pass filter: hp = 0.5 * (1 + α) * (src - src[1]) + α * hp[1]
|
||||
double src1 = _srcBuffer.Count > 1 ? _srcBuffer[0] : 0;
|
||||
double hp1 = _hpBuffer.Count > 0 ? _hpBuffer[^1] : 0;
|
||||
double hp = Math.FusedMultiplyAdd(0.5 * (1.0 + _alphaHP), src - src1, _alphaHP * hp1);
|
||||
|
||||
if (isNew)
|
||||
{
|
||||
_hpBuffer.Add(hp);
|
||||
}
|
||||
else
|
||||
{
|
||||
_hpBuffer.UpdateNewest(hp);
|
||||
}
|
||||
|
||||
// Super-Smoother: filt = c1 * (hp + hp[1]) / 2 + c2 * filt[1] + c3 * filt[2]
|
||||
double hp1b = _hpBuffer.Count > 1 ? _hpBuffer[0] : hp;
|
||||
double filt1 = _filtBuffer.Count > 0 ? _filtBuffer[^1] : 0;
|
||||
double filt2 = _filtBuffer.Count > 1 ? _filtBuffer[^2] : 0;
|
||||
double filt = Math.FusedMultiplyAdd(_c1, (hp + hp1b) / 2.0,
|
||||
Math.FusedMultiplyAdd(_c2, filt1, _c3 * filt2));
|
||||
|
||||
if (isNew)
|
||||
{
|
||||
_filtBuffer.Add(filt);
|
||||
}
|
||||
else
|
||||
{
|
||||
_filtBuffer.UpdateNewest(filt);
|
||||
}
|
||||
|
||||
// Hilbert Transform for quadrature component Q
|
||||
// Q = 0.0962 * filt[3] + 0.5769 * filt[1] - 0.5769 * filt[5] - 0.0962 * filt[7]
|
||||
// Using ^N for from-end indexing: ^1 = newest, ^2 = second newest, etc.
|
||||
double filt1q = _filtBuffer.Count > 1 ? _filtBuffer[^2] : 0;
|
||||
double filt3 = _filtBuffer.Count > 3 ? _filtBuffer[^4] : 0;
|
||||
double filt5 = _filtBuffer.Count > 5 ? _filtBuffer[^6] : 0;
|
||||
double filt7 = _filtBuffer.Count > 7 ? _filtBuffer[^8] : 0;
|
||||
|
||||
double Q = Math.FusedMultiplyAdd(H1, filt3,
|
||||
Math.FusedMultiplyAdd(H2, filt1q,
|
||||
Math.FusedMultiplyAdd(-H2, filt5, -H1 * filt7)));
|
||||
|
||||
// In-phase component I = filt (current smoothed value)
|
||||
double I = filt;
|
||||
|
||||
// Power and normalization
|
||||
double pwr = (I * I) + (Q * Q);
|
||||
double sineWave = pwr < double.Epsilon ? 0.0 : I / Math.Sqrt(pwr);
|
||||
|
||||
// Clamp to [-1, 1]
|
||||
sineWave = Math.Clamp(sineWave, -1.0, 1.0);
|
||||
|
||||
Last = new TValue(input.Time, sineWave);
|
||||
PubEvent(Last, isNew);
|
||||
return Last;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates Sine for an entire TSeries.
|
||||
/// </summary>
|
||||
public override TSeries Update(TSeries source)
|
||||
{
|
||||
if (source.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
int len = source.Count;
|
||||
var t = new List<long>(len);
|
||||
var v = new List<double>(len);
|
||||
CollectionsMarshal.SetCount(t, len);
|
||||
CollectionsMarshal.SetCount(v, len);
|
||||
|
||||
var tSpan = CollectionsMarshal.AsSpan(t);
|
||||
var vSpan = CollectionsMarshal.AsSpan(v);
|
||||
|
||||
// Reset and process each value
|
||||
Reset();
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
var result = Update(source[i], true);
|
||||
tSpan[i] = source.Times[i];
|
||||
vSpan[i] = result.Value;
|
||||
}
|
||||
|
||||
return new TSeries(t, v);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new Sine indicator and calculates for the source series.
|
||||
/// </summary>
|
||||
public static TSeries Batch(TSeries source, int hpPeriod = 40, int ssfPeriod = 10)
|
||||
{
|
||||
var sine = new Sine(hpPeriod, ssfPeriod);
|
||||
return sine.Update(source);
|
||||
}
|
||||
|
||||
public static (TSeries Results, Sine Indicator) Calculate(TSeries source, int hpPeriod = 40, int ssfPeriod = 10)
|
||||
{
|
||||
var indicator = new Sine(hpPeriod, ssfPeriod);
|
||||
TSeries results = indicator.Update(source);
|
||||
return (results, indicator);
|
||||
}
|
||||
|
||||
public override void Reset()
|
||||
{
|
||||
_srcBuffer.Clear();
|
||||
_hpBuffer.Clear();
|
||||
_filtBuffer.Clear();
|
||||
_count = 0;
|
||||
Last = new TValue(DateTime.UtcNow, 0);
|
||||
}
|
||||
|
||||
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
|
||||
{
|
||||
TimeSpan interval = step ?? TimeSpan.FromDays(1);
|
||||
DateTime baseTime = DateTime.UtcNow;
|
||||
|
||||
for (int i = 0; i < source.Length; i++)
|
||||
{
|
||||
Update(new TValue(baseTime + (interval * i), source[i]), true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
# SINE: Ehlers Sine Wave
|
||||
|
||||
SINE extracts the dominant cycle from price data using cascaded signal processing: a high-pass filter removes the trend, a Super-Smoother filter removes noise, and a Hilbert Transform FIR decomposes the filtered signal into In-Phase and Quadrature components for power-normalized sine wave output. The result oscillates between $-1$ and $+1$, representing the normalized position within the current cycle. Unlike HT_SINE which derives phase from the full TA-Lib Hilbert cascade, this Ehlers implementation uses explicit detrending and bandpass stages for cleaner cycle isolation.
|
||||
|
||||
## Historical Context
|
||||
|
||||
John Ehlers introduced the Sine Wave indicator in *Cybernetic Analysis for Stocks and Futures* (2004) as a refined approach to cycle extraction. The design philosophy separates three signal processing concerns into distinct filter stages: (1) trend removal via high-pass filtering sets the long-wavelength cutoff, (2) aliasing prevention via Super-Smoother sets the short-wavelength cutoff, and (3) cycle extraction via Hilbert Transform generates the quadrature decomposition. This staged approach produces cleaner output than attempting all three simultaneously (as in the HT_SINE). The Sine Wave output at extremes ($\pm 1$) indicates the cyclical component is stretched and likely to revert, while zero crossings indicate phase transitions. The indicator is particularly valuable for mean-reversion strategies in ranging markets.
|
||||
|
||||
## Architecture & Physics
|
||||
|
||||
### 1. High-Pass Filter (Detrending)
|
||||
|
||||
A single-pole high-pass filter removes low-frequency trends below the cutoff:
|
||||
|
||||
$$\alpha_{HP} = \frac{1 - \sin(2\pi / P_{HP})}{\cos(2\pi / P_{HP})}$$
|
||||
|
||||
$$HP_t = \frac{1 + \alpha_{HP}}{2}(P_t - P_{t-1}) + \alpha_{HP} \cdot HP_{t-1}$$
|
||||
|
||||
### 2. Super-Smoother Filter (Noise Removal)
|
||||
|
||||
A 2-pole Butterworth low-pass removes high-frequency noise:
|
||||
|
||||
$$a = e^{-\sqrt{2}\pi / P_{SSF}}$$
|
||||
|
||||
$$b = 2a \cos(\sqrt{2}\pi / P_{SSF})$$
|
||||
|
||||
$$c_1 = 1 - b + a^2, \quad c_2 = b, \quad c_3 = -a^2$$
|
||||
|
||||
$$Filt_t = \frac{c_1}{2}(HP_t + HP_{t-1}) + c_2 \cdot Filt_{t-1} + c_3 \cdot Filt_{t-2}$$
|
||||
|
||||
### 3. Hilbert Transform FIR
|
||||
|
||||
Discrete Hilbert approximation extracts quadrature component:
|
||||
|
||||
$$Q_t = 0.0962 \cdot Filt_{t-3} + 0.5769 \cdot Filt_{t-1} - 0.5769 \cdot Filt_{t-5} - 0.0962 \cdot Filt_{t-7}$$
|
||||
|
||||
$$I_t = Filt_t$$
|
||||
|
||||
### 4. Power Normalization
|
||||
|
||||
$$Power_t = I_t^2 + Q_t^2$$
|
||||
|
||||
$$Sine_t = \frac{I_t}{\sqrt{Power_t}}$$
|
||||
|
||||
When $Power \approx 0$, output is zero.
|
||||
|
||||
### 5. Complexity
|
||||
|
||||
$O(1)$ per bar. Fixed filter stages with ring buffers of 2 (source) + 2 (HP) + 8 (filtered) = 12 elements. Warmup: $\max(P_{HP}, P_{SSF}) + 8$ bars.
|
||||
|
||||
## Mathematical Foundation
|
||||
|
||||
### Parameters
|
||||
|
||||
| Parameter | Description | Default | Constraint |
|
||||
|-----------|-------------|---------|------------|
|
||||
| `hpPeriod` | High-pass filter cutoff period | 40 | $\geq 1$ |
|
||||
| `ssfPeriod` | Super-smoother filter period | 10 | $\geq 1$ |
|
||||
|
||||
### Tuning Relationship
|
||||
|
||||
Typically $P_{SSF} \approx P_{HP} / 4$ to $P_{HP} / 2$. The high-pass defines the trend/cycle boundary; the super-smoother defines the noise/cycle boundary. Together they create a bandpass that isolates the frequency range of interest.
|
||||
|
||||
### Pseudo-code
|
||||
|
||||
```
|
||||
function SINE(source, hpPeriod, ssfPeriod):
|
||||
// Precompute HP coefficient
|
||||
α_hp ← (1 - sin(2π/hpPeriod)) / cos(2π/hpPeriod)
|
||||
|
||||
// Precompute SSF coefficients
|
||||
a ← exp(-√2·π / ssfPeriod)
|
||||
b ← 2·a·cos(√2·π / ssfPeriod)
|
||||
c₁ ← (1 - b + a²) / 2
|
||||
|
||||
hp_prev ← 0; p_prev ← 0
|
||||
filt_1 ← 0; filt_2 ← 0
|
||||
filtBuf ← CircularBuffer(8)
|
||||
|
||||
for each price in source:
|
||||
// High-pass
|
||||
hp ← 0.5·(1 + α_hp)·(price - p_prev) + α_hp·hp_prev
|
||||
|
||||
// Super-smoother
|
||||
filt ← c₁·(hp + hp_prev) + b·filt_1 - a²·filt_2
|
||||
|
||||
// Hilbert FIR quadrature
|
||||
filtBuf.Add(filt)
|
||||
Q ← 0.0962·filtBuf[3] + 0.5769·filtBuf[1]
|
||||
- 0.5769·filtBuf[5] - 0.0962·filtBuf[7]
|
||||
I ← filt
|
||||
|
||||
// Power normalization
|
||||
power ← I² + Q²
|
||||
sine ← (power > 0) ? I / √power : 0
|
||||
|
||||
// Shift state
|
||||
hp_prev ← hp; p_prev ← price
|
||||
filt_2 ← filt_1; filt_1 ← filt
|
||||
|
||||
emit sine
|
||||
```
|
||||
|
||||
### SINE vs HT_SINE
|
||||
|
||||
| Aspect | SINE | HT_SINE |
|
||||
|--------|------|---------|
|
||||
| Detrending | Explicit high-pass filter | Implicit in Hilbert cascade |
|
||||
| Noise removal | Explicit Super-Smoother | 4-bar WMA only |
|
||||
| Period tuning | User-configurable (hpPeriod, ssfPeriod) | Fixed (TA-Lib spec) |
|
||||
| Output | Single (Sine only) | Dual (Sine + LeadSine) |
|
||||
| Phase source | I/Q power normalization | DFT phase accumulation |
|
||||
|
||||
### Output Interpretation
|
||||
|
||||
| Condition | Meaning |
|
||||
|-----------|---------|
|
||||
| $Sine \approx +1$ | Cycle peak (potential short / mean-reversion) |
|
||||
| $Sine \approx -1$ | Cycle trough (potential long / mean-reversion) |
|
||||
| Zero crossing up | Bullish phase transition |
|
||||
| Zero crossing down | Bearish phase transition |
|
||||
| Erratic output | Strong trend overwhelming cycle extraction |
|
||||
|
||||
## Performance Profile
|
||||
|
||||
### Operation Count (Streaming Mode)
|
||||
|
||||
| Operation | Count per bar | Notes |
|
||||
|-----------|--------------|-------|
|
||||
| High-pass filter | ~4 | 1 SUB + 1 MUL + 1 FMA |
|
||||
| Super-Smoother (2-pole IIR) | ~5 | 1 ADD + 2 FMA + 1 MUL |
|
||||
| Hilbert FIR (quadrature) | ~7 | 4-tap FIR: 4 MUL + 3 ADD |
|
||||
| I² + Q² (power) | ~3 | 2 MUL + 1 ADD |
|
||||
| SQRT + normalization | ~4 | 1 SQRT + 1 DIV + 1 branch |
|
||||
| Buffer management | ~3 | 1 circular buffer write + index update |
|
||||
| State shift | ~4 | 4 register moves |
|
||||
| **Total** | **~30** | **O(1) fixed; single SQRT is only transcendental** |
|
||||
|
||||
### Batch Mode (SIMD Analysis)
|
||||
|
||||
| Aspect | Assessment |
|
||||
|--------|------------|
|
||||
| SIMD vectorizable | No: HP and SSF are recursive IIR with sequential state dependencies |
|
||||
| Bottleneck | `Math.Sqrt` in power normalization (~15 cycles); rest is pure arithmetic |
|
||||
| Parallelism | None: each bar's HP/SSF output depends on previous bar |
|
||||
| Memory | O(1): 8-element ring buffer + 4 scalar state variables (~96 bytes) |
|
||||
| Throughput | Very fast; slightly faster than EBSW (no 3-bar averaging, no clamp) |
|
||||
|
||||
## Resources
|
||||
|
||||
- **Ehlers, J.F.** *Cybernetic Analysis for Stocks and Futures*. Wiley, 2004.
|
||||
- **Ehlers, J.F.** *Cycle Analytics for Traders*. Wiley, 2013.
|
||||
@@ -1,47 +0,0 @@
|
||||
// The MIT License (MIT)
|
||||
// © mihakralj
|
||||
//@version=6
|
||||
indicator("Ehlers Sine Wave (SINE)", "SINE", overlay=false)
|
||||
|
||||
//@function Calculates Ehlers’ original Sine Wave using a two‑pole High‑Pass, a Super‑Smoother,
|
||||
// and a Hilbert‑transform FIR pair (In‑phase I / Quadrature Q).
|
||||
//@param src Series to calculate the Sine Wave from
|
||||
//@param hpLength High‑Pass filter length (detrending period)
|
||||
//@param ssfLength Super‑Smoother filter length (cycle smoothing period)
|
||||
//@returns single normalized sine‑wave value in [‑1 … +1]
|
||||
sine(series float src, simple int hpLength, simple int ssfLength) =>
|
||||
if hpLength <= 0 or ssfLength <= 0
|
||||
runtime.error("Periods must be > 0")
|
||||
float pi = 2 * math.asin(1)
|
||||
float angHP = 2 * pi / hpLength
|
||||
float aHP = (1 - math.sin(angHP)) / math.cos(angHP)
|
||||
var float hp = 0.0
|
||||
hp := 0.5 * (1 + aHP) * (src - nz(src[1])) + aHP * nz(hp[1])
|
||||
float angSSF = math.sqrt(2) * pi / ssfLength
|
||||
float aSSF = math.exp(-angSSF)
|
||||
float bSSF = 2 * aSSF * math.cos(angSSF)
|
||||
float c2 = bSSF
|
||||
float c3 = -aSSF * aSSF
|
||||
float c1 = 1 - c2 - c3
|
||||
var float filt = 0.0
|
||||
filt := c1 * (hp + nz(hp[1])) / 2 + c2 * nz(filt[1]) + c3 * nz(filt[2])
|
||||
float Q = 0.0962 * nz(filt[3]) + 0.5769 * nz(filt[1])
|
||||
- 0.5769 * nz(filt[5]) - 0.0962 * nz(filt[7])
|
||||
float I = filt
|
||||
float pwr = I*I + Q*Q
|
||||
float sineWave = pwr == 0 ? 0 : I / math.sqrt(pwr)
|
||||
math.min(1, math.max(-1, sineWave))
|
||||
|
||||
// ---------- Main loop ----------
|
||||
|
||||
// Inputs
|
||||
i_source = input.source(close, "Source")
|
||||
i_hpLength = input.int(40, "High‑Pass Filter Length", minval=1)
|
||||
i_ssfLength = input.int(10, "Super‑Smoother Filter Length", minval=1)
|
||||
|
||||
// Calculation
|
||||
sine_wave = sine(i_source, i_hpLength, i_ssfLength)
|
||||
|
||||
// Plot
|
||||
plot(sine_wave, "SINE", color=color.yellow, linewidth=2)
|
||||
hline(0, "Zero Line", color.gray, linestyle=hline.style_dashed)
|
||||
@@ -1,210 +0,0 @@
|
||||
using TradingPlatform.BusinessLayer;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public class StcIndicatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void StcIndicator_Constructor_SetsDefaults()
|
||||
{
|
||||
var indicator = new StcIndicator();
|
||||
|
||||
Assert.Equal(12, indicator.CycleLength);
|
||||
Assert.Equal(26, indicator.FastLength);
|
||||
Assert.Equal(50, indicator.SlowLength);
|
||||
Assert.Equal(StcSmoothing.Sigmoid, indicator.Smoothing);
|
||||
Assert.Equal(SourceType.Close, indicator.Source);
|
||||
Assert.True(indicator.ShowColdValues);
|
||||
Assert.Equal("STC - Schaff Trend Cycle", indicator.Name);
|
||||
Assert.True(indicator.SeparateWindow);
|
||||
Assert.True(indicator.OnBackGround);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StcIndicator_MinHistoryDepths_EqualsZero()
|
||||
{
|
||||
var indicator = new StcIndicator();
|
||||
|
||||
Assert.Equal(0, StcIndicator.MinHistoryDepths);
|
||||
Assert.Equal(0, ((IWatchlistIndicator)indicator).MinHistoryDepths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StcIndicator_ShortName_IncludesParameters()
|
||||
{
|
||||
var indicator = new StcIndicator
|
||||
{
|
||||
CycleLength = 10,
|
||||
FastLength = 23,
|
||||
SlowLength = 50,
|
||||
Smoothing = StcSmoothing.Ema,
|
||||
};
|
||||
|
||||
// Format is "STC {CycleLength}:{FastLength}:{SlowLength}:{Smoothing}:{Source}"
|
||||
// e.g. "STC 10:23:50:Ema:Close"
|
||||
string shortName = indicator.ShortName;
|
||||
|
||||
Assert.Contains("STC", shortName, StringComparison.Ordinal);
|
||||
Assert.Contains("10", shortName, StringComparison.Ordinal);
|
||||
Assert.Contains("23", shortName, StringComparison.Ordinal);
|
||||
Assert.Contains("50", shortName, StringComparison.Ordinal);
|
||||
Assert.Contains("Ema", shortName, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StcIndicator_Initialize_CreatesInternalStc()
|
||||
{
|
||||
var indicator = new StcIndicator();
|
||||
|
||||
// Initialize should not throw
|
||||
indicator.Initialize();
|
||||
|
||||
// After init, line series should exist
|
||||
Assert.Single(indicator.LinesSeries);
|
||||
Assert.Equal("STC", indicator.LinesSeries[0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StcIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
|
||||
{
|
||||
var indicator = new StcIndicator { CycleLength = 5, FastLength = 10, SlowLength = 20 };
|
||||
indicator.Initialize();
|
||||
|
||||
// Add historical data
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// We must feed bars one by one to simulate history for stateful indicators
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100, 105, 95, 100);
|
||||
var args = new UpdateArgs(UpdateReason.HistoricalBar);
|
||||
indicator.ProcessUpdate(args);
|
||||
}
|
||||
|
||||
// Line series should have values
|
||||
Assert.Equal(50, indicator.LinesSeries[0].Count);
|
||||
Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0))); // GetValue(0) is the most recent
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StcIndicator_ProcessUpdate_NewBar_ComputesValue()
|
||||
{
|
||||
var indicator = new StcIndicator { CycleLength = 5, FastLength = 10, SlowLength = 20 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Feed enough history to warm up
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100, 105, 95, 100);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
}
|
||||
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(50), 102, 108, 100, 106);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
|
||||
|
||||
Assert.True(indicator.LinesSeries[0].Count > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StcIndicator_ProcessUpdate_NewTick_ProcessesWithoutError()
|
||||
{
|
||||
var indicator = new StcIndicator { CycleLength = 5, FastLength = 10, SlowLength = 20 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Feed warmup bars
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100, 105, 95, 100);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
}
|
||||
|
||||
double firstValue = indicator.LinesSeries[0].GetValue(0);
|
||||
|
||||
// Update with NewTick (same bar, new price potentially, but reusing last bar in this mock)
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick));
|
||||
double secondValue = indicator.LinesSeries[0].GetValue(0);
|
||||
|
||||
Assert.True(double.IsFinite(firstValue));
|
||||
Assert.True(double.IsFinite(secondValue));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StcIndicator_MultipleUpdates_ProducesCorrectSequence()
|
||||
{
|
||||
var indicator = new StcIndicator { CycleLength = 10, FastLength = 12, SlowLength = 26 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
// Generate enough price action to clear warmup (SlowLength + 2*CycleLength = 26 + 20 = 46)
|
||||
// We'll generate 100 bars to be safe
|
||||
double[] closes = new double[100];
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
closes[i] = 100 + Math.Sin(i * 0.1) * 10;
|
||||
}
|
||||
|
||||
foreach (var close in closes)
|
||||
{
|
||||
indicator.HistoricalData.AddBar(now, close, close + 2, close - 2, close);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
now = now.AddMinutes(1);
|
||||
}
|
||||
|
||||
// The last value should be finite (we are well past 46)
|
||||
double lastVal = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.True(double.IsFinite(lastVal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StcIndicator_DifferentSourceTypes_Work()
|
||||
{
|
||||
var sources = new[] { SourceType.Open, SourceType.High, SourceType.Low, SourceType.Close, SourceType.HL2, SourceType.HLC3 };
|
||||
|
||||
foreach (var source in sources)
|
||||
{
|
||||
var indicator = new StcIndicator
|
||||
{
|
||||
CycleLength = 10,
|
||||
FastLength = 23,
|
||||
SlowLength = 50,
|
||||
Source = source,
|
||||
};
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Feed enough bars to produce a value
|
||||
// Warmup = 50 + 20 = 70 approx
|
||||
for (int i = 0; i < 80; i++)
|
||||
{
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100, 110, 90, 105);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
}
|
||||
|
||||
Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0)),
|
||||
$"Source {source} should produce finite value");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StcIndicator_Parameters_CanBeChanged()
|
||||
{
|
||||
var indicator = new StcIndicator();
|
||||
|
||||
indicator.CycleLength = 20;
|
||||
Assert.Equal(20, indicator.CycleLength);
|
||||
|
||||
indicator.FastLength = 12;
|
||||
Assert.Equal(12, indicator.FastLength);
|
||||
|
||||
indicator.SlowLength = 26;
|
||||
Assert.Equal(26, indicator.SlowLength);
|
||||
|
||||
indicator.Smoothing = StcSmoothing.Digital;
|
||||
Assert.Equal(StcSmoothing.Digital, indicator.Smoothing);
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
using System.Drawing;
|
||||
using System.Runtime.CompilerServices;
|
||||
using TradingPlatform.BusinessLayer;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
[SkipLocalsInit]
|
||||
public sealed class StcIndicator : Indicator, IWatchlistIndicator
|
||||
{
|
||||
[InputParameter("Cycle Length", sortIndex: 1, 2, 2000, 1, 0)]
|
||||
public int CycleLength { get; set; } = 12;
|
||||
|
||||
[InputParameter("Fast Length", sortIndex: 2, 2, 2000, 1, 0)]
|
||||
public int FastLength { get; set; } = 26;
|
||||
|
||||
[InputParameter("Slow Length", sortIndex: 3, 2, 2000, 1, 0)]
|
||||
public int SlowLength { get; set; } = 50;
|
||||
|
||||
[InputParameter("Smoothing", sortIndex: 4, variants: new object[] {
|
||||
"None", StcSmoothing.None,
|
||||
"EMA", StcSmoothing.Ema,
|
||||
"Sigmoid", StcSmoothing.Sigmoid,
|
||||
"Digital", StcSmoothing.Digital,
|
||||
})]
|
||||
public StcSmoothing Smoothing { get; set; } = StcSmoothing.Sigmoid;
|
||||
|
||||
[IndicatorExtensions.DataSourceInput]
|
||||
public SourceType Source { get; set; } = SourceType.Close;
|
||||
|
||||
[InputParameter("Show cold values", sortIndex: 21)]
|
||||
public bool ShowColdValues { get; set; } = true;
|
||||
|
||||
private Stc _stc = null!;
|
||||
private readonly LineSeries _series;
|
||||
private string _sourceName = null!;
|
||||
private Func<IHistoryItem, double> _priceSelector = null!;
|
||||
|
||||
public static int MinHistoryDepths => 0;
|
||||
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
|
||||
|
||||
public override string ShortName => $"STC {CycleLength}:{FastLength}:{SlowLength}:{Smoothing}:{_sourceName}";
|
||||
|
||||
public StcIndicator()
|
||||
{
|
||||
OnBackGround = true;
|
||||
SeparateWindow = true;
|
||||
Name = "STC - Schaff Trend Cycle";
|
||||
Description = "Schaff Trend Cycle Oscillator";
|
||||
_series = new LineSeries(name: "STC", color: IndicatorExtensions.Oscillators, width: 2, style: LineStyle.Solid);
|
||||
AddLineSeries(_series);
|
||||
}
|
||||
|
||||
protected override void OnInit()
|
||||
{
|
||||
_priceSelector = Source.GetPriceSelector();
|
||||
_sourceName = Source.ToString();
|
||||
_stc = new Stc(kPeriod: CycleLength, dPeriod: CycleLength, fastLength: FastLength, slowLength: SlowLength, smoothing: Smoothing);
|
||||
base.OnInit();
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
protected override void OnUpdate(UpdateArgs args)
|
||||
{
|
||||
bool isNew = args.IsNewBar();
|
||||
var item = HistoricalData[Count - 1, SeekOriginHistory.Begin];
|
||||
double value = _stc.Update(new TValue(item.TimeLeft.Ticks, _priceSelector(item)), isNew).Value;
|
||||
_series.SetValue(value, _stc.IsHot, ShowColdValues);
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
using System;
|
||||
using Xunit;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
public class StcTests
|
||||
{
|
||||
private const int CycleLength = 12;
|
||||
private const int FastLength = 26;
|
||||
private const int SlowLength = 50;
|
||||
|
||||
private static Stc CreateDefaultStc() => new(kPeriod: CycleLength, dPeriod: CycleLength, fastLength: FastLength, slowLength: SlowLength, smoothing: StcSmoothing.Sigmoid);
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ValidatesInput()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new Stc(kPeriod: 1));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new Stc(dPeriod: 0));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new Stc(fastLength: 1));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new Stc(slowLength: 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calc_ReturnsValue()
|
||||
{
|
||||
var stc = CreateDefaultStc();
|
||||
var result = stc.Update(new TValue(DateTime.UtcNow, 100));
|
||||
// Expect NaN during warmup
|
||||
Assert.True(double.IsNaN(result.Value) || double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Properties_Accessible()
|
||||
{
|
||||
var stc = CreateDefaultStc();
|
||||
Assert.Equal(0, stc.Last.Value); // Initial value before updates
|
||||
Assert.False(stc.IsHot);
|
||||
Assert.Contains("Stc", stc.Name, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsHot_BecomesTrueWhenBufferFull()
|
||||
{
|
||||
var stc = CreateDefaultStc();
|
||||
int warmup = stc.WarmupPeriod;
|
||||
|
||||
for (int i = 0; i < warmup - 1; i++)
|
||||
{
|
||||
stc.Update(new TValue(DateTime.UtcNow, 100));
|
||||
Assert.False(stc.IsHot);
|
||||
}
|
||||
|
||||
stc.Update(new TValue(DateTime.UtcNow, 100));
|
||||
Assert.True(stc.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BatchCalc_MatchesIterativeCalc()
|
||||
{
|
||||
var iterativeStc = CreateDefaultStc();
|
||||
var batchStc = CreateDefaultStc();
|
||||
var series = new TSeries();
|
||||
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 123);
|
||||
|
||||
for (int i = 0; i < 200; i++)
|
||||
{
|
||||
var bar = gbm.Next();
|
||||
series.Add(bar.Time, bar.Close);
|
||||
iterativeStc.Update(new TValue(bar.Time, bar.Close));
|
||||
}
|
||||
|
||||
var batchResult = batchStc.Update(series);
|
||||
|
||||
Assert.Equal(iterativeStc.Last.Value, batchResult.Last.Value, 1e-9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpanBatch_MatchesTSeriesBatch()
|
||||
{
|
||||
// Use default parameters for static calculation
|
||||
var series = new TSeries();
|
||||
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 123);
|
||||
double[] input = new double[200];
|
||||
double[] output = new double[200];
|
||||
|
||||
for (int i = 0; i < 200; i++)
|
||||
{
|
||||
var bar = gbm.Next();
|
||||
series.Add(bar.Time, bar.Close);
|
||||
input[i] = bar.Close;
|
||||
}
|
||||
|
||||
var batchStc = CreateDefaultStc();
|
||||
var tseriesResult = batchStc.Update(series);
|
||||
|
||||
Stc.Batch(input.AsSpan(), output.AsSpan(), kPeriod: CycleLength, dPeriod: CycleLength, fastLength: FastLength, slowLength: SlowLength, smoothing: StcSmoothing.Sigmoid);
|
||||
|
||||
// Compare last value
|
||||
Assert.Equal(tseriesResult.Last.Value, output[^1], 1e-9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NaN_Input_HandledSafely()
|
||||
{
|
||||
var stc = CreateDefaultStc();
|
||||
stc.Update(new TValue(DateTime.UtcNow, 100));
|
||||
var result = stc.Update(new TValue(DateTime.UtcNow, double.NaN));
|
||||
// Should be NaN during warmup
|
||||
Assert.True(double.IsNaN(result.Value) || double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SmoothingOptions_ProduceDifferentResults()
|
||||
{
|
||||
var stcSigmoid = new Stc(kPeriod: 10, dPeriod: 10, fastLength: 20, slowLength: 40, smoothing: StcSmoothing.Sigmoid);
|
||||
var stcEma = new Stc(kPeriod: 10, dPeriod: 10, fastLength: 20, slowLength: 40, smoothing: StcSmoothing.Ema);
|
||||
var stcDigital = new Stc(kPeriod: 10, dPeriod: 10, fastLength: 20, slowLength: 40, smoothing: StcSmoothing.Digital);
|
||||
|
||||
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 123);
|
||||
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
double val = gbm.Next().Close;
|
||||
stcSigmoid.Update(new TValue(DateTime.UtcNow, val));
|
||||
stcEma.Update(new TValue(DateTime.UtcNow, val));
|
||||
stcDigital.Update(new TValue(DateTime.UtcNow, val));
|
||||
}
|
||||
|
||||
Assert.NotEqual(stcSigmoid.Last.Value, stcEma.Last.Value);
|
||||
Assert.NotEqual(stcSigmoid.Last.Value, stcDigital.Last.Value);
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Skender.Stock.Indicators;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public sealed class StcValidationTests : IDisposable
|
||||
{
|
||||
private readonly ValidationTestData _testData;
|
||||
private readonly ITestOutputHelper _output;
|
||||
|
||||
public StcValidationTests(ITestOutputHelper output)
|
||||
{
|
||||
_output = output;
|
||||
_testData = new ValidationTestData();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_testData.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Skender_Stc_Deviation()
|
||||
{
|
||||
// Skender's STC implementation uses a "Single Smoothed" approach (Stoch of MACD).
|
||||
// QuanTAlib implements the standard "Double Smoothed" approach (Stoch of Stoch of MACD),
|
||||
// as originally defined by Schaff.
|
||||
//
|
||||
// Example mismatch at index 333:
|
||||
// QuanTAlib (Double Smoothed) = 50.0
|
||||
// Skender (Single Smoothed) = 97.05
|
||||
//
|
||||
// This test documents this known deviation rather than failing on it.
|
||||
|
||||
const int cycle = 10;
|
||||
int fast = 23;
|
||||
int slow = 50;
|
||||
|
||||
var sResult = _testData.SkenderQuotes.GetStc(cycle, fast, slow).ToList();
|
||||
var qStc = new Stc(kPeriod: cycle, dPeriod: 3, fastLength: fast, slowLength: slow, smoothing: StcSmoothing.Ema);
|
||||
var qResult = qStc.Update(_testData.Data);
|
||||
|
||||
// Skender recommends S+C+250 warmup. 50+10+250 = 310.
|
||||
int skip = 310;
|
||||
double sumSq = 0;
|
||||
int count = 0;
|
||||
|
||||
for (int i = skip; i < qResult.Count; i++)
|
||||
{
|
||||
double sVal = sResult[i].Stc ?? double.NaN;
|
||||
double qVal = qResult[i].Value;
|
||||
|
||||
if (!double.IsNaN(sVal) && !double.IsNaN(qVal))
|
||||
{
|
||||
sumSq += (sVal - qVal) * (sVal - qVal);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
double rmse = Math.Sqrt(sumSq / count);
|
||||
_output.WriteLine($"Known Methodology Deviation - RMSE: {rmse:F4}");
|
||||
|
||||
// Assert that we are essentially different (RMSE > 5.0 implies significant deviation)
|
||||
// If they accidentally matched (e.g. if we broke our logic to match Skender), this should fail.
|
||||
Assert.True(rmse > 5.0, "QuanTAlib STC matches Skender STC, which suggests regression to Single Smoothed logic.");
|
||||
|
||||
// Assert values are valid
|
||||
for (int i = skip; i < qResult.Count; i++)
|
||||
{
|
||||
Assert.True(double.IsFinite(qResult[i].Value));
|
||||
Assert.InRange(qResult[i].Value, 0, 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,675 +0,0 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// Defines the smoothing method applied to the final STC output.
|
||||
/// </summary>
|
||||
public enum StcSmoothing { None = 0, Ema = 1, Sigmoid = 2, Digital = 3 }
|
||||
|
||||
/// <summary>
|
||||
/// STC: Schaff Trend Cycle - A cycle oscillator that combines MACD and Stochastic to detect market trends with improved speed and accuracy.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The Schaff Trend Cycle (STC), developed by Doug Schaff, is an oscillator that moves between 0 and 100.
|
||||
/// It identifies market trends and cycles by applying a Stochastic calculation to the MACD line,
|
||||
/// and then smoothing the result. This results in an indicator that is faster than MACD and smoother than Stochastic.
|
||||
///
|
||||
/// Algorithm:
|
||||
/// 1. Calculate MACD = Exponential Moving Average (Fast) - Exponential Moving Average (Slow).
|
||||
/// 2. Calculate %K (Stoch K) of the MACD over a specified period.
|
||||
/// 3. Smooth %K with a fast average to get %D (Stoch D).
|
||||
/// 4. Re-calculate %K of the %D value (Stoch of Stoch).
|
||||
/// 5. Smooth the result again to produce the final STC value.
|
||||
///
|
||||
/// Properties:
|
||||
/// - Ranges from 0 to 100.
|
||||
/// - High values (>75) indicate overbought conditions.
|
||||
/// - Low values (<25) indicate oversold conditions.
|
||||
/// - Signals are generated when the indicator crosses these thresholds.
|
||||
/// - Minimizes false signals found in traditional MACD or Stochastic indicators.
|
||||
///
|
||||
/// Key Insight:
|
||||
/// By performing a double stochastic calculation on the MACD (Stochastic of the Stochastic of MACD),
|
||||
/// STC emphasizes the cyclic nature of trends while reducing noise.
|
||||
/// </remarks>
|
||||
[SkipLocalsInit]
|
||||
public sealed class Stc : AbstractBase
|
||||
{
|
||||
private readonly StcSmoothing _smoothing;
|
||||
|
||||
private readonly double _fastAlpha;
|
||||
private readonly double _slowAlpha;
|
||||
private readonly double _dAlpha;
|
||||
|
||||
private readonly RingBuffer _macdBuf;
|
||||
private readonly RingBuffer _stoch1Buf;
|
||||
private readonly ITValuePublisher? _publisher;
|
||||
private readonly TValuePublishedHandler? _handler;
|
||||
private bool _isNew;
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private record struct State
|
||||
{
|
||||
public double FastEma;
|
||||
public double SlowEma;
|
||||
public double Stoch1Ema;
|
||||
public double Stoch2Ema;
|
||||
public double PrevStc;
|
||||
public double LastFiniteInput;
|
||||
public bool HasFiniteInput;
|
||||
|
||||
public double MacdMin;
|
||||
public double MacdMax;
|
||||
public double Stoch1Min;
|
||||
public double Stoch1Max;
|
||||
}
|
||||
|
||||
private State _s, _ps;
|
||||
private int _samples;
|
||||
|
||||
public Stc(
|
||||
int kPeriod = 10,
|
||||
int dPeriod = 3,
|
||||
int fastLength = 23,
|
||||
int slowLength = 50,
|
||||
StcSmoothing smoothing = StcSmoothing.Ema)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(kPeriod, 2);
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(dPeriod, 1);
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(fastLength, 2);
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(slowLength, 2);
|
||||
|
||||
_smoothing = smoothing;
|
||||
|
||||
_fastAlpha = 2.0 / (fastLength + 1.0);
|
||||
_slowAlpha = 2.0 / (slowLength + 1.0);
|
||||
_dAlpha = 2.0 / (dPeriod + 1.0);
|
||||
|
||||
int bufSize = kPeriod;
|
||||
_macdBuf = new RingBuffer(bufSize);
|
||||
_stoch1Buf = new RingBuffer(bufSize);
|
||||
|
||||
Name = $"Stc(k={kPeriod},d={dPeriod},fast={fastLength},slow={slowLength},{smoothing})";
|
||||
WarmupPeriod = slowLength + bufSize;
|
||||
|
||||
Reset();
|
||||
}
|
||||
|
||||
public Stc(ITValuePublisher source, int kPeriod = 10, int dPeriod = 3, int fastLength = 23, int slowLength = 50, StcSmoothing smoothing = StcSmoothing.Ema)
|
||||
: this(kPeriod, dPeriod, fastLength, slowLength, smoothing)
|
||||
{
|
||||
_publisher = source;
|
||||
_handler = Handle;
|
||||
source.Pub += _handler;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private void Handle(object? sender, in TValueEventArgs args)
|
||||
{
|
||||
Update(args.Value, args.IsNew);
|
||||
}
|
||||
|
||||
public bool IsNew => _isNew;
|
||||
public override bool IsHot => _samples >= WarmupPeriod;
|
||||
|
||||
public override void Reset()
|
||||
{
|
||||
_s = new State
|
||||
{
|
||||
FastEma = double.NaN,
|
||||
SlowEma = double.NaN,
|
||||
Stoch1Ema = double.NaN,
|
||||
Stoch2Ema = double.NaN,
|
||||
PrevStc = double.NaN,
|
||||
LastFiniteInput = double.NaN,
|
||||
HasFiniteInput = false,
|
||||
MacdMin = double.PositiveInfinity,
|
||||
MacdMax = double.NegativeInfinity,
|
||||
Stoch1Min = double.PositiveInfinity,
|
||||
Stoch1Max = double.NegativeInfinity,
|
||||
};
|
||||
_ps = _s;
|
||||
_samples = 0;
|
||||
_macdBuf.Clear();
|
||||
_stoch1Buf.Clear();
|
||||
Last = default;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static double Clamp100(double x)
|
||||
{
|
||||
if (double.IsNaN(x))
|
||||
{
|
||||
return x;
|
||||
}
|
||||
|
||||
return Math.Clamp(x, 0, 100);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies final smoothing to stoch2Raw based on smoothing mode.
|
||||
/// Shared between Update() and Calculate() to eliminate duplication.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static double ApplySmoothing(double stoch2Raw, StcSmoothing smoothing, double dAlpha, ref double stoch2Ema, ref double prevStc)
|
||||
{
|
||||
double stc;
|
||||
switch (smoothing)
|
||||
{
|
||||
case StcSmoothing.Ema:
|
||||
stoch2Ema = double.IsNaN(stoch2Ema)
|
||||
? stoch2Raw
|
||||
: Math.FusedMultiplyAdd(dAlpha, stoch2Raw - stoch2Ema, stoch2Ema);
|
||||
stc = Clamp100(stoch2Ema);
|
||||
break;
|
||||
|
||||
case StcSmoothing.Sigmoid:
|
||||
stc = 100.0 / (1.0 + Math.Exp(-0.1 * (stoch2Raw - 50.0)));
|
||||
break;
|
||||
|
||||
case StcSmoothing.Digital:
|
||||
if (stoch2Raw > 75)
|
||||
{
|
||||
stc = 100;
|
||||
}
|
||||
else if (stoch2Raw < 25)
|
||||
{
|
||||
stc = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
stc = double.IsNaN(prevStc) ? stoch2Raw : prevStc;
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
default: // Includes StcSmoothing.None
|
||||
stc = stoch2Raw;
|
||||
break;
|
||||
}
|
||||
prevStc = stc;
|
||||
return stc;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates min/max tracking for a sliding window.
|
||||
/// Returns true if a full rescan is needed (removed value was at boundary).
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static bool UpdateMinMaxCore(double added, double removed, bool hasRemoved, ref double min, ref double max)
|
||||
{
|
||||
if (double.IsNaN(added))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
bool expandMin = added < min;
|
||||
bool expandMax = added > max;
|
||||
|
||||
if (!hasRemoved)
|
||||
{
|
||||
if (expandMin)
|
||||
{
|
||||
min = added;
|
||||
}
|
||||
|
||||
if (expandMax)
|
||||
{
|
||||
max = added;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use relative tolerance for floating-point comparison
|
||||
double tolerance = Math.Max(Math.Abs(min), Math.Abs(max)) * 1e-12;
|
||||
if (tolerance < 1e-15)
|
||||
{
|
||||
tolerance = 1e-15; // minimum absolute tolerance
|
||||
}
|
||||
|
||||
bool removedMin = Math.Abs(removed - min) <= tolerance;
|
||||
bool removedMax = Math.Abs(removed - max) <= tolerance;
|
||||
|
||||
if (expandMin)
|
||||
{
|
||||
min = added;
|
||||
}
|
||||
|
||||
if (expandMax)
|
||||
{
|
||||
max = added;
|
||||
}
|
||||
|
||||
return (removedMin && !expandMin) || (removedMax && !expandMax);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rescans a span to find new min/max values.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static void RescanMinMax(ReadOnlySpan<double> span, ref double min, ref double max)
|
||||
{
|
||||
min = double.PositiveInfinity;
|
||||
max = double.NegativeInfinity;
|
||||
foreach (double v in span)
|
||||
{
|
||||
if (double.IsNaN(v))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (v < min)
|
||||
{
|
||||
min = v;
|
||||
}
|
||||
|
||||
if (v > max)
|
||||
{
|
||||
max = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static void UpdateMinMax(double added, double removed, bool hasRemoved, RingBuffer buf, ref double min, ref double max)
|
||||
{
|
||||
if (UpdateMinMaxCore(added, removed, hasRemoved, ref min, ref max))
|
||||
{
|
||||
var span = buf.IsFull ? buf.InternalBuffer : buf.GetSpan();
|
||||
RescanMinMax(span, ref min, ref max);
|
||||
}
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static void UpdateMinMax(double added, double removed, bool hasRemoved, ReadOnlySpan<double> buf, ref double min, ref double max)
|
||||
{
|
||||
if (UpdateMinMaxCore(added, removed, hasRemoved, ref min, ref max))
|
||||
{
|
||||
RescanMinMax(buf, ref min, ref max);
|
||||
}
|
||||
}
|
||||
|
||||
// skipcq: CS-R1140 - Cyclomatic complexity justified: STC algorithm requires
|
||||
// sequential MACD→Stoch1→Stoch2→Smoothing pipeline with min/max tracking per stage.
|
||||
// Splitting would fragment the tightly-coupled state machine and harm readability.
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public override TValue Update(TValue input, bool isNew = true)
|
||||
{
|
||||
_isNew = isNew;
|
||||
if (isNew)
|
||||
{
|
||||
_ps = _s;
|
||||
}
|
||||
else
|
||||
{
|
||||
_s = _ps;
|
||||
}
|
||||
|
||||
var s = _s;
|
||||
|
||||
double x = input.Value;
|
||||
|
||||
if (!double.IsFinite(x))
|
||||
{
|
||||
if (!s.HasFiniteInput)
|
||||
{
|
||||
Last = new TValue(input.Time, double.NaN);
|
||||
PubEvent(Last, isNew);
|
||||
return Last;
|
||||
}
|
||||
x = s.LastFiniteInput;
|
||||
}
|
||||
else
|
||||
{
|
||||
s.LastFiniteInput = x;
|
||||
s.HasFiniteInput = true;
|
||||
}
|
||||
|
||||
// 1) MACD
|
||||
s.FastEma = double.IsNaN(s.FastEma) ? x : Math.FusedMultiplyAdd(_fastAlpha, x - s.FastEma, s.FastEma);
|
||||
|
||||
s.SlowEma = double.IsNaN(s.SlowEma) ? x : Math.FusedMultiplyAdd(_slowAlpha, x - s.SlowEma, s.SlowEma);
|
||||
|
||||
double macd = s.FastEma - s.SlowEma;
|
||||
|
||||
double removedMacd = 0;
|
||||
bool hasRemovedMacd;
|
||||
if (isNew)
|
||||
{
|
||||
hasRemovedMacd = _macdBuf.IsFull;
|
||||
removedMacd = _macdBuf.Add(macd);
|
||||
}
|
||||
else
|
||||
{
|
||||
removedMacd = _macdBuf.Newest;
|
||||
hasRemovedMacd = _macdBuf.Count > 0;
|
||||
_macdBuf.UpdateNewest(macd);
|
||||
}
|
||||
UpdateMinMax(macd, removedMacd, hasRemovedMacd, _macdBuf, ref s.MacdMin, ref s.MacdMax);
|
||||
|
||||
// 2) Stoch1 of MACD
|
||||
double stoch1Raw;
|
||||
if (_macdBuf.IsFull)
|
||||
{
|
||||
double span = s.MacdMax - s.MacdMin;
|
||||
if (span > double.Epsilon)
|
||||
{
|
||||
stoch1Raw = 100.0 * (macd - s.MacdMin) / span;
|
||||
}
|
||||
else
|
||||
{
|
||||
stoch1Raw = double.IsNaN(s.Stoch1Ema) ? 50.0 : s.Stoch1Ema;
|
||||
}
|
||||
|
||||
stoch1Raw = Clamp100(stoch1Raw);
|
||||
}
|
||||
else
|
||||
{
|
||||
stoch1Raw = 50.0;
|
||||
}
|
||||
|
||||
// Smooth Stoch1
|
||||
if (!double.IsNaN(stoch1Raw))
|
||||
{
|
||||
s.Stoch1Ema = double.IsNaN(s.Stoch1Ema)
|
||||
? stoch1Raw
|
||||
: Math.FusedMultiplyAdd(_dAlpha, stoch1Raw - s.Stoch1Ema, s.Stoch1Ema);
|
||||
}
|
||||
|
||||
double stoch1 = double.NaN;
|
||||
if (!double.IsNaN(s.Stoch1Ema))
|
||||
{
|
||||
stoch1 = Clamp100(s.Stoch1Ema);
|
||||
|
||||
double removedStoch1 = 0;
|
||||
bool hasRemovedStoch1;
|
||||
if (isNew)
|
||||
{
|
||||
hasRemovedStoch1 = _stoch1Buf.IsFull;
|
||||
removedStoch1 = _stoch1Buf.Add(stoch1);
|
||||
}
|
||||
else
|
||||
{
|
||||
removedStoch1 = _stoch1Buf.Newest;
|
||||
hasRemovedStoch1 = _stoch1Buf.Count > 0;
|
||||
_stoch1Buf.UpdateNewest(stoch1);
|
||||
}
|
||||
UpdateMinMax(stoch1, removedStoch1, hasRemovedStoch1, _stoch1Buf, ref s.Stoch1Min, ref s.Stoch1Max);
|
||||
}
|
||||
|
||||
// 3) Stoch2 of Stoch1
|
||||
double stoch2Raw;
|
||||
if (_stoch1Buf.IsFull)
|
||||
{
|
||||
double span = s.Stoch1Max - s.Stoch1Min;
|
||||
if (span > double.Epsilon)
|
||||
{
|
||||
stoch2Raw = 100.0 * (stoch1 - s.Stoch1Min) / span;
|
||||
}
|
||||
else
|
||||
{
|
||||
stoch2Raw = double.IsNaN(s.Stoch2Ema) ? stoch1 : s.Stoch2Ema;
|
||||
}
|
||||
|
||||
stoch2Raw = Clamp100(stoch2Raw);
|
||||
}
|
||||
else
|
||||
{
|
||||
stoch2Raw = stoch1;
|
||||
}
|
||||
|
||||
// 4) Final Smooth
|
||||
double stc = double.NaN;
|
||||
if (!double.IsNaN(stoch2Raw))
|
||||
{
|
||||
stc = ApplySmoothing(stoch2Raw, _smoothing, _dAlpha, ref s.Stoch2Ema, ref s.PrevStc);
|
||||
}
|
||||
|
||||
if (isNew)
|
||||
{
|
||||
_samples++;
|
||||
}
|
||||
|
||||
_s = s;
|
||||
Last = new TValue(input.Time, stc);
|
||||
PubEvent(Last, isNew);
|
||||
return Last;
|
||||
}
|
||||
|
||||
public override TSeries Update(TSeries source)
|
||||
{
|
||||
var result = new TSeries();
|
||||
foreach (var item in source)
|
||||
{
|
||||
result.Add(Update(item, isNew: true));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
|
||||
{
|
||||
foreach (double v in source)
|
||||
{
|
||||
Update(new TValue(DateTime.MinValue, v), isNew: true);
|
||||
}
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && _publisher != null && _handler != null)
|
||||
{
|
||||
_publisher.Pub -= _handler;
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Static convenience method that creates a new Stc instance and processes the entire series.
|
||||
/// </summary>
|
||||
public static TSeries Batch(TSeries source, int kPeriod = 10, int dPeriod = 3, int fastLength = 23, int slowLength = 50, StcSmoothing smoothing = StcSmoothing.Ema)
|
||||
{
|
||||
var indicator = new Stc(kPeriod, dPeriod, fastLength, slowLength, smoothing);
|
||||
return indicator.Update(source);
|
||||
}
|
||||
|
||||
// skipcq: CS-R1140 - Cyclomatic complexity justified: span-based Calculate must
|
||||
// replicate the full STC state machine inline for zero-allocation performance.
|
||||
// The sequential MACD→Stoch1→Stoch2→Smoothing pipeline cannot be decomposed
|
||||
// without introducing heap allocations or sacrificing inlining opportunities.
|
||||
public static void Batch(ReadOnlySpan<double> source, Span<double> output,
|
||||
int kPeriod = 10, int dPeriod = 3, int fastLength = 23, int slowLength = 50, StcSmoothing smoothing = StcSmoothing.Ema)
|
||||
{
|
||||
if (source.Length != output.Length)
|
||||
{
|
||||
throw new ArgumentException("Source and output spans must be of equal length.", nameof(output));
|
||||
}
|
||||
|
||||
double fastAlpha = 2.0 / (fastLength + 1.0);
|
||||
double slowAlpha = 2.0 / (slowLength + 1.0);
|
||||
double dAlpha = 2.0 / (dPeriod + 1.0);
|
||||
|
||||
double fastEma = double.NaN;
|
||||
double slowEma = double.NaN;
|
||||
double stoch1Ema = double.NaN;
|
||||
double stoch2Ema = double.NaN;
|
||||
double prevStc = double.NaN;
|
||||
double lastFiniteInput = double.NaN;
|
||||
bool hasFiniteInput = false;
|
||||
|
||||
const int StackallocThreshold = 256;
|
||||
double[]? rentedMacd = null;
|
||||
double[]? rentedStoch1 = null;
|
||||
|
||||
scoped Span<double> macdBuf;
|
||||
scoped Span<double> stoch1Buf;
|
||||
|
||||
if (kPeriod <= StackallocThreshold)
|
||||
{
|
||||
macdBuf = stackalloc double[kPeriod];
|
||||
stoch1Buf = stackalloc double[kPeriod];
|
||||
}
|
||||
else
|
||||
{
|
||||
rentedMacd = ArrayPool<double>.Shared.Rent(kPeriod);
|
||||
macdBuf = rentedMacd.AsSpan(0, kPeriod);
|
||||
rentedStoch1 = ArrayPool<double>.Shared.Rent(kPeriod);
|
||||
stoch1Buf = rentedStoch1.AsSpan(0, kPeriod);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
int macdIdx = 0;
|
||||
int stoch1Idx = 0;
|
||||
int macdCount = 0;
|
||||
int stoch1Count = 0;
|
||||
|
||||
double macdMin = double.PositiveInfinity;
|
||||
double macdMax = double.NegativeInfinity;
|
||||
double stoch1Min = double.PositiveInfinity;
|
||||
double stoch1Max = double.NegativeInfinity;
|
||||
|
||||
for (int i = 0; i < source.Length; i++)
|
||||
{
|
||||
double x = source[i];
|
||||
|
||||
if (!double.IsFinite(x))
|
||||
{
|
||||
if (!hasFiniteInput)
|
||||
{
|
||||
output[i] = double.NaN;
|
||||
continue;
|
||||
}
|
||||
x = lastFiniteInput;
|
||||
}
|
||||
else
|
||||
{
|
||||
lastFiniteInput = x;
|
||||
hasFiniteInput = true;
|
||||
}
|
||||
|
||||
// 1) MACD
|
||||
fastEma = double.IsNaN(fastEma) ? x : Math.FusedMultiplyAdd(fastAlpha, x - fastEma, fastEma);
|
||||
slowEma = double.IsNaN(slowEma) ? x : Math.FusedMultiplyAdd(slowAlpha, x - slowEma, slowEma);
|
||||
|
||||
double macd = fastEma - slowEma;
|
||||
|
||||
// Buffer MACD
|
||||
bool macdHasRemoved = macdCount == kPeriod;
|
||||
double macdRemoved = macdBuf[macdIdx];
|
||||
macdBuf[macdIdx] = macd;
|
||||
macdIdx = (macdIdx + 1) % kPeriod;
|
||||
if (!macdHasRemoved)
|
||||
{
|
||||
macdCount++;
|
||||
}
|
||||
|
||||
ReadOnlySpan<double> macdValidSpan = macdBuf.Slice(0, macdCount);
|
||||
UpdateMinMax(macd, macdRemoved, macdHasRemoved, macdValidSpan, ref macdMin, ref macdMax);
|
||||
|
||||
// 2) Stoch1
|
||||
double stoch1Raw;
|
||||
if (macdCount == kPeriod)
|
||||
{
|
||||
double span = macdMax - macdMin;
|
||||
if (span > double.Epsilon)
|
||||
{
|
||||
stoch1Raw = 100.0 * (macd - macdMin) / span;
|
||||
}
|
||||
else
|
||||
{
|
||||
stoch1Raw = double.IsNaN(stoch1Ema) ? 50.0 : stoch1Ema;
|
||||
}
|
||||
|
||||
stoch1Raw = Clamp100(stoch1Raw);
|
||||
}
|
||||
else
|
||||
{
|
||||
stoch1Raw = 50.0;
|
||||
}
|
||||
|
||||
// Smooth Stoch1
|
||||
if (!double.IsNaN(stoch1Raw))
|
||||
{
|
||||
stoch1Ema = double.IsNaN(stoch1Ema)
|
||||
? stoch1Raw
|
||||
: Math.FusedMultiplyAdd(dAlpha, stoch1Raw - stoch1Ema, stoch1Ema);
|
||||
}
|
||||
|
||||
double stoch1 = double.NaN;
|
||||
if (!double.IsNaN(stoch1Ema))
|
||||
{
|
||||
stoch1 = Clamp100(stoch1Ema);
|
||||
|
||||
// Buffer Stoch1
|
||||
bool stochHasRemoved = stoch1Count == kPeriod;
|
||||
double stochRemoved = stoch1Buf[stoch1Idx];
|
||||
stoch1Buf[stoch1Idx] = stoch1;
|
||||
stoch1Idx = (stoch1Idx + 1) % kPeriod;
|
||||
if (!stochHasRemoved)
|
||||
{
|
||||
stoch1Count++;
|
||||
}
|
||||
|
||||
ReadOnlySpan<double> stochValidSpan = stoch1Buf.Slice(0, stoch1Count);
|
||||
UpdateMinMax(stoch1, stochRemoved, stochHasRemoved, stochValidSpan, ref stoch1Min, ref stoch1Max);
|
||||
}
|
||||
|
||||
// 3) Stoch2
|
||||
double stoch2Raw;
|
||||
if (stoch1Count == kPeriod)
|
||||
{
|
||||
double span = stoch1Max - stoch1Min;
|
||||
if (span > double.Epsilon)
|
||||
{
|
||||
stoch2Raw = 100.0 * (stoch1 - stoch1Min) / span;
|
||||
}
|
||||
else
|
||||
{
|
||||
stoch2Raw = double.IsNaN(stoch2Ema) ? stoch1 : stoch2Ema;
|
||||
}
|
||||
|
||||
stoch2Raw = Clamp100(stoch2Raw);
|
||||
}
|
||||
else
|
||||
{
|
||||
stoch2Raw = stoch1;
|
||||
}
|
||||
|
||||
// 4) Final Smooth
|
||||
double stc = double.NaN;
|
||||
if (!double.IsNaN(stoch2Raw))
|
||||
{
|
||||
stc = ApplySmoothing(stoch2Raw, smoothing, dAlpha, ref stoch2Ema, ref prevStc);
|
||||
}
|
||||
|
||||
output[i] = stc;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (rentedMacd != null)
|
||||
{
|
||||
ArrayPool<double>.Shared.Return(rentedMacd);
|
||||
}
|
||||
|
||||
if (rentedStoch1 != null)
|
||||
{
|
||||
ArrayPool<double>.Shared.Return(rentedStoch1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static (TSeries Results, Stc Indicator) Calculate(TSeries source, int kPeriod = 10, int dPeriod = 3, int fastLength = 23, int slowLength = 50, StcSmoothing smoothing = StcSmoothing.Ema)
|
||||
{
|
||||
var indicator = new Stc(kPeriod, dPeriod, fastLength, slowLength, smoothing);
|
||||
TSeries results = indicator.Update(source);
|
||||
return (results, indicator);
|
||||
}
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
# STC: Schaff Trend Cycle
|
||||
|
||||
The Schaff Trend Cycle is a cyclometric oscillator that applies double-Stochastic normalization to MACD, extracting the cyclical phase hidden within the trend itself. The recursive normalization produces a bounded 0–100 output that reaches extremes earlier than raw MACD while suppressing Stochastic jitter. Developed for currency markets, STC's tendency to flatline at 0 or 100 during strong trends signals continuation rather than reversal — a feature that distinguishes it from conventional momentum oscillators. Output converges toward a square wave in steady-state trending conditions.
|
||||
|
||||
## Historical Context
|
||||
|
||||
Doug Schaff developed STC in the 1990s while trading currency markets. His diagnosis: MACD identified trends correctly but with unacceptable lag — by signal time, much of the move had elapsed. The Stochastic oscillator was fast but noisy, generating false signals in trending markets. Schaff's synthesis recognized that trends themselves move in cycles. Rather than choosing between lagging trend detection and noisy cycle extraction, he piped MACD through the Stochastic twice. The first pass normalizes MACD within its recent range, collapsing the unbounded trend signal into a 0–100 band. The second pass normalizes the smoothed first pass, further compressing the cycle information and creating a self-normalizing oscillator. The double normalization acts as a nonlinear filter that amplifies transitions and suppresses noise during sustained moves. STC found particular traction in forex trading where the 24-hour market rewarded speed advantages over MACD. The flatline behavior at extremes — initially dismissed as a limitation — became recognized as a defining feature: sustained 0 or 100 readings indicate trend continuation with high confidence, equivalent to a digital "trend on" signal.
|
||||
|
||||
## Architecture & Physics
|
||||
|
||||
### 1. MACD Construction
|
||||
|
||||
Fast and slow EMAs generate the raw trend signal:
|
||||
|
||||
$$\alpha_f = \frac{2}{\text{fastLength} + 1}, \quad \alpha_s = \frac{2}{\text{slowLength} + 1}$$
|
||||
|
||||
$$\text{EMA}_{f,t} = \alpha_f \cdot P_t + (1 - \alpha_f) \cdot \text{EMA}_{f,t-1}$$
|
||||
|
||||
$$\text{EMA}_{s,t} = \alpha_s \cdot P_t + (1 - \alpha_s) \cdot \text{EMA}_{s,t-1}$$
|
||||
|
||||
$$\text{MACD}_t = \text{EMA}_{f,t} - \text{EMA}_{s,t}$$
|
||||
|
||||
### 2. First Stochastic (%K₁)
|
||||
|
||||
Normalize MACD within its recent $k$-bar range:
|
||||
|
||||
$$\%K_1 = 100 \times \frac{\text{MACD}_t - \min(\text{MACD}_{t-k+1:t})}{\max(\text{MACD}_{t-k+1:t}) - \min(\text{MACD}_{t-k+1:t})}$$
|
||||
|
||||
When $\max = \min$ (flat MACD), $\%K_1$ holds its previous value. This collapses the unbounded MACD into [0, 100].
|
||||
|
||||
### 3. First Smoothing (%D₁)
|
||||
|
||||
EMA smooth the first Stochastic to reduce whipsaw:
|
||||
|
||||
$$\alpha_d = \frac{2}{d\text{Period} + 1}$$
|
||||
|
||||
$$\%D_{1,t} = \alpha_d \cdot \%K_{1,t} + (1 - \alpha_d) \cdot \%D_{1,t-1}$$
|
||||
|
||||
### 4. Second Stochastic (%K₂)
|
||||
|
||||
Apply Stochastic normalization again to %D₁, using the same $k$-bar window:
|
||||
|
||||
$$\%K_2 = 100 \times \frac{\%D_{1,t} - \min(\%D_{1,t-k+1:t})}{\max(\%D_{1,t-k+1:t}) - \min(\%D_{1,t-k+1:t})}$$
|
||||
|
||||
This second pass further compresses the signal, amplifying transitions between trend phases.
|
||||
|
||||
### 5. Final Smoothing
|
||||
|
||||
Apply selected smoothing method to %K₂:
|
||||
|
||||
$$\text{STC}_t = \text{Smooth}(\%K_{2,t})$$
|
||||
|
||||
Smoothing options:
|
||||
|
||||
- **None:** Raw %K₂ output
|
||||
- **EMA:** Standard EMA smoothing with $\alpha_d$
|
||||
- **Sigmoid:** $S(x) = \frac{100}{1 + e^{-0.1(x - 50)}}$ — S-curve compression
|
||||
- **Digital:** Threshold at 50 → output snaps to 0 or 100 (square wave)
|
||||
|
||||
### 6. Complexity
|
||||
|
||||
- **Time:** $O(k)$ per bar for min/max scanning over both Stochastic windows
|
||||
- **Space:** $O(k)$ — two ring buffers of size kPeriod (MACD values and %D₁ values)
|
||||
- **Warmup:** slowLength + kPeriod bars before output stabilizes
|
||||
|
||||
## Mathematical Foundation
|
||||
|
||||
### Parameters
|
||||
|
||||
| Symbol | Parameter | Default | Constraint |
|
||||
|--------|-----------|---------|------------|
|
||||
| $k$ | kPeriod | 10 | $k \geq 2$ |
|
||||
| $d$ | dPeriod | 3 | $d \geq 1$ |
|
||||
| $f$ | fastLength | 23 | $f \geq 1$ |
|
||||
| $s$ | slowLength | 50 | $s > f$ |
|
||||
| — | smoothing | EMA | None / EMA / Sigmoid / Digital |
|
||||
|
||||
### Pseudo-code
|
||||
|
||||
```
|
||||
Initialize:
|
||||
ema_fast = ema_slow = first price
|
||||
α_f = 2 / (fastLength + 1)
|
||||
α_s = 2 / (slowLength + 1)
|
||||
α_d = 2 / (dPeriod + 1)
|
||||
macd_buf = RingBuffer(kPeriod)
|
||||
d1_buf = RingBuffer(kPeriod)
|
||||
%D₁ = 0
|
||||
bar_count = 0
|
||||
|
||||
On each bar (price, isNew):
|
||||
if !isNew: restore previous state
|
||||
|
||||
// Step 1: MACD
|
||||
ema_fast = FMA(ema_fast, 1 - α_f, α_f × price)
|
||||
ema_slow = FMA(ema_slow, 1 - α_s, α_s × price)
|
||||
macd = ema_fast - ema_slow
|
||||
|
||||
// Step 2: First Stochastic
|
||||
macd_buf.Add(macd)
|
||||
macd_max = Max(macd_buf)
|
||||
macd_min = Min(macd_buf)
|
||||
range1 = macd_max - macd_min
|
||||
%K₁ = range1 > 0 ? 100 × (macd - macd_min) / range1 : prev_%K₁
|
||||
|
||||
// Step 3: First Smoothing
|
||||
%D₁ = FMA(%D₁, 1 - α_d, α_d × %K₁)
|
||||
|
||||
// Step 4: Second Stochastic
|
||||
d1_buf.Add(%D₁)
|
||||
d1_max = Max(d1_buf)
|
||||
d1_min = Min(d1_buf)
|
||||
range2 = d1_max - d1_min
|
||||
%K₂ = range2 > 0 ? 100 × (%D₁ - d1_min) / range2 : prev_%K₂
|
||||
|
||||
// Step 5: Final Smoothing
|
||||
switch smoothing:
|
||||
None: STC = %K₂
|
||||
EMA: STC = FMA(prev_STC, 1 - α_d, α_d × %K₂)
|
||||
Sigmoid: STC = 100 / (1 + exp(-0.1 × (%K₂ - 50)))
|
||||
Digital: STC = %K₂ ≥ 50 ? 100 : 0
|
||||
|
||||
output = Clamp(STC, 0, 100)
|
||||
```
|
||||
|
||||
### Signal Characteristics
|
||||
|
||||
| Condition | Output Behavior |
|
||||
|-----------|----------------|
|
||||
| Strong uptrend | Flatlines at 100 (square wave high) |
|
||||
| Strong downtrend | Flatlines at 0 (square wave low) |
|
||||
| Trend transition | Rapid swing between extremes |
|
||||
| Ranging market | Oscillates mid-range (25–75) |
|
||||
| Above 75 | Overbought zone |
|
||||
| Below 25 | Oversold zone |
|
||||
|
||||
### Cycle Length Heuristic
|
||||
|
||||
Setting $k \approx f/2$ targets the half-cycle of the MACD's dominant frequency, aligning the Stochastic window with the trend's internal oscillation period.
|
||||
|
||||
### SIMD Applicability
|
||||
|
||||
The recursive EMA dependencies and sequential min/max ring buffer updates prevent SIMD vectorization of the streaming path. The `Calculate(Span)` path can parallelize independent MACD computations but must serialize the double-Stochastic pipeline.
|
||||
|
||||
## Performance Profile
|
||||
|
||||
### Operation Count (Streaming Mode)
|
||||
|
||||
| Operation | Count per bar | Notes |
|
||||
|-----------|--------------|-------|
|
||||
| Fast EMA | ~3 | 1 FMA + 1 MUL |
|
||||
| Slow EMA | ~3 | 1 FMA + 1 MUL |
|
||||
| MACD subtraction | ~1 | 1 SUB |
|
||||
| Ring buffer add (MACD) | ~1 | 1 write + index update |
|
||||
| Min/Max scan (MACD buf) | ~2k | Linear scan of k elements × 2 (min + max) |
|
||||
| First Stochastic (%K₁) | ~4 | 1 SUB + 1 DIV + 1 MUL + 1 branch |
|
||||
| First EMA smoothing (%D₁) | ~3 | 1 FMA + 1 MUL |
|
||||
| Ring buffer add (%D₁) | ~1 | 1 write + index update |
|
||||
| Min/Max scan (%D₁ buf) | ~2k | Linear scan of k elements × 2 |
|
||||
| Second Stochastic (%K₂) | ~4 | 1 SUB + 1 DIV + 1 MUL + 1 branch |
|
||||
| Final smoothing (EMA) | ~3 | 1 FMA + 1 MUL |
|
||||
| Clamp | ~2 | 2 comparisons |
|
||||
| **Total (k=10 default)** | **~65** | **O(k) dominated by dual min/max scans** |
|
||||
| **Total (k=50 worst)** | **~225** | **Linear growth with kPeriod** |
|
||||
|
||||
### Batch Mode (SIMD Analysis)
|
||||
|
||||
| Aspect | Assessment |
|
||||
|--------|------------|
|
||||
| SIMD vectorizable | No: recursive EMAs + sequential ring buffer min/max prevent vectorization |
|
||||
| Bottleneck | Dual min/max scans over ring buffers (2×k comparisons per bar) |
|
||||
| Parallelism | MACD EMA computation is independent of Stochastic pipeline but still sequential IIR |
|
||||
| Memory | O(k): two ring buffers of kPeriod doubles + 6 scalar EMA states (~200 bytes at k=10) |
|
||||
| Throughput | Moderate; faster than HT family (no transcendentals) but slower than pure IIR (min/max scans) |
|
||||
|
||||
## Resources
|
||||
|
||||
- Schaff, D. — "Schaff Trend Cycle" (currency trading methodology, 1990s)
|
||||
- PineScript reference: `stc.pine` in indicator directory
|
||||
- Ehlers, J.F. — *Cybernetic Analysis for Stocks and Futures* (cycle extraction theory)
|
||||
@@ -1,76 +0,0 @@
|
||||
// The MIT License (MIT)
|
||||
// © mihakralj
|
||||
//@version=6
|
||||
indicator("Schaff Trend Cycle (STC)", "STC", overlay=false)
|
||||
|
||||
ema(series float source,simple int period=0,simple float alpha=0)=>
|
||||
if alpha<=0 and period<=0
|
||||
runtime.error("Alpha or period must be provided")
|
||||
float a=alpha>0?alpha:2.0/(math.max(period,1)+1)
|
||||
var float raw_ema=na
|
||||
var float ema=na
|
||||
var float e=1.0
|
||||
var bool warmup=true
|
||||
if not na(source)
|
||||
if na(raw_ema)
|
||||
raw_ema:=0
|
||||
ema:=source
|
||||
else
|
||||
raw_ema:=a*(source-raw_ema)+raw_ema
|
||||
if warmup
|
||||
e*=(1-a)
|
||||
float c=1.0/(1.0-e)
|
||||
ema:=c*raw_ema
|
||||
if e<=1e-10
|
||||
warmup:=false
|
||||
else
|
||||
ema:=raw_ema
|
||||
ema
|
||||
|
||||
//@function Calculates the Schaff Trend Cycle (STC) indicator
|
||||
//@param source Input price series
|
||||
//@param cycleLength Main cycle length parameter for lookback periods
|
||||
//@param fastLength Period for fast EMA calculation
|
||||
//@param slowLength Period for slow EMA calculation
|
||||
//@param smoothingType Type of smoothing (0:none, 1:ema, 2:sigmoid, 3:digital)
|
||||
//@returns Smoothed STC value
|
||||
stc(series float source, simple int cycleLength, simple int fastLength, simple int slowLength, simple int smoothingType = 1) =>
|
||||
float fast_ema = ema(source, fastLength)
|
||||
float slow_ema = ema(source, slowLength)
|
||||
float macdLine = fast_ema - slow_ema
|
||||
|
||||
h1 = ta.highest(macdLine, cycleLength)
|
||||
l1 = ta.lowest(macdLine, cycleLength)
|
||||
float stoch1_raw = (h1 - l1) > 0 ? 100 * (macdLine - l1) / (h1 - l1) : 0
|
||||
float stoch1 = ema(stoch1_raw, 3)
|
||||
h2 = ta.highest(stoch1, cycleLength)
|
||||
l2 = ta.lowest(stoch1, cycleLength)
|
||||
float stoch2_raw = (h2 - l2) > 0 ? 100 * (stoch1 - l2) / (h2 - l2) : 0
|
||||
|
||||
// Second-stage IIR smoothing: PFF = PFF[1] + 0.5 * (Frac2 - PFF[1])
|
||||
var float stoch2 = na
|
||||
stoch2 := na(stoch2[1]) ? stoch2_raw : stoch2[1] + 0.5 * (stoch2_raw - stoch2[1])
|
||||
|
||||
float stcValue = stoch2
|
||||
if smoothingType == 1
|
||||
stcValue := ema(stoch2, 3)
|
||||
else if smoothingType == 2
|
||||
stcValue := 100 / (1 + math.exp(-0.1 * (stcValue - 50)))
|
||||
else if smoothingType == 3
|
||||
stcValue := stcValue > 75 ? 100 : stcValue < 25 ? 0 : stcValue[1]
|
||||
stcValue
|
||||
|
||||
// ---------- Main loop ----------
|
||||
|
||||
// Inputs
|
||||
i_source = input.source(close, title="Source")
|
||||
i_cycleLength = input.int(10, title="Cycle Length", minval=2)
|
||||
i_fastLength = input.int(23, title="Fast Length", minval=2)
|
||||
i_slowLength = input.int(50, title="Slow Length", minval=2)
|
||||
i_smoothingType = input.int(1, title="Smoothing", minval=0, maxval=3, tooltip="0: none, 1:ema, 2:sigmoid, 3:digital")
|
||||
|
||||
// Calculation
|
||||
stcValue = stc(i_source, i_cycleLength, i_fastLength, i_slowLength, i_smoothingType)
|
||||
|
||||
// Plot
|
||||
plot(stcValue, "STC", color=color.yellow, linewidth=2)
|
||||
Reference in New Issue
Block a user