docs: remove C# Implementation Considerations sections, clean up temp scripts, reorganize test files

- Remove 'C# Implementation Considerations' sections from 34 indicator .md files
- Delete 29 temp PowerShell scripts (_fix_mojibake.ps1, _hex_scan.ps1, etc.)
- Move test files into tests/ subdirectories for consistent project structure
- Add trader-focused bullet points to indicator documentation
This commit is contained in:
Miha Kralj
2026-03-12 12:34:16 -07:00
parent 8937b0c0fa
commit 060649192f
1149 changed files with 1780 additions and 3316 deletions
@@ -0,0 +1,272 @@
using TradingPlatform.BusinessLayer;
namespace QuanTAlib.Quantower.Tests;
public class SsfdspIndicatorTests
{
[Fact]
public void SsfdspIndicator_Constructor_SetsDefaults()
{
var indicator = new SsfdspIndicator();
Assert.Equal(20, indicator.Period);
Assert.Equal(SourceType.Close, indicator.Source);
Assert.True(indicator.ShowColdValues);
Assert.Equal("SSFDSP - Ehlers SSF Detrended Synthetic Price", indicator.Name);
Assert.True(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void SsfdspIndicator_MinHistoryDepths_EqualsZero()
{
var indicator = new SsfdspIndicator();
Assert.Equal(0, SsfdspIndicator.MinHistoryDepths);
Assert.Equal(0, ((IWatchlistIndicator)indicator).MinHistoryDepths);
}
[Fact]
public void SsfdspIndicator_ShortName_IncludesPeriod()
{
var indicator = new SsfdspIndicator { Period = 30 };
Assert.True(indicator.ShortName.Contains("SSFDSP", StringComparison.Ordinal));
Assert.True(indicator.ShortName.Contains("30", StringComparison.Ordinal));
}
[Fact]
public void SsfdspIndicator_Initialize_CreatesInternalIndicator()
{
var indicator = new SsfdspIndicator { Period = 20 };
// Initialize should not throw
indicator.Initialize();
// After init, line series should exist (SSFDSP + Zero lines)
Assert.Equal(2, indicator.LinesSeries.Count);
}
[Fact]
public void SsfdspIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new SsfdspIndicator { Period = 20 };
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 SsfdspIndicator_ProcessUpdate_NewBar_ComputesValue()
{
var indicator = new SsfdspIndicator { Period = 20 };
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 SsfdspIndicator_ProcessUpdate_NewTick_ProcessesWithoutError()
{
var indicator = new SsfdspIndicator { Period = 20 };
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 SsfdspIndicator_MultipleUpdates_ProducesCorrectSequence()
{
var indicator = new SsfdspIndicator { Period = 20 };
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 SsfdspIndicator_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 SsfdspIndicator { Period = 20, 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 SsfdspIndicator_Period_CanBeChanged()
{
var indicator = new SsfdspIndicator { Period = 20 };
Assert.Equal(20, indicator.Period);
indicator.Period = 40;
Assert.Equal(40, indicator.Period);
}
[Fact]
public void SsfdspIndicator_Source_CanBeChanged()
{
var indicator = new SsfdspIndicator { Source = SourceType.Close };
Assert.Equal(SourceType.Close, indicator.Source);
indicator.Source = SourceType.Open;
Assert.Equal(SourceType.Open, indicator.Source);
}
[Fact]
public void SsfdspIndicator_ShowColdValues_CanBeChanged()
{
var indicator = new SsfdspIndicator { ShowColdValues = true };
Assert.True(indicator.ShowColdValues);
indicator.ShowColdValues = false;
Assert.False(indicator.ShowColdValues);
}
[Fact]
public void SsfdspIndicator_ShortName_UpdatesWhenParametersChange()
{
var indicator = new SsfdspIndicator { Period = 20 };
string initialName = indicator.ShortName;
Assert.True(initialName.Contains("20", StringComparison.Ordinal));
indicator.Period = 40;
string updatedName = indicator.ShortName;
Assert.True(updatedName.Contains("40", StringComparison.Ordinal));
}
[Fact]
public void SsfdspIndicator_LineSeries_HasCorrectProperties()
{
var indicator = new SsfdspIndicator { Period = 20 };
indicator.Initialize();
var lineSeries = indicator.LinesSeries[0];
Assert.Equal("SSFDSP", lineSeries.Name);
Assert.Equal(2, lineSeries.Width);
Assert.Equal(LineStyle.Solid, lineSeries.Style);
}
[Fact]
public void SsfdspIndicator_ZeroLine_HasCorrectProperties()
{
var indicator = new SsfdspIndicator { Period = 20 };
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 SsfdspIndicator_DifferentPeriods_Work()
{
var periods = new[] { 8, 20, 40, 100 };
foreach (var period in periods)
{
var indicator = new SsfdspIndicator { Period = period };
indicator.Initialize();
var now = DateTime.UtcNow;
// Add enough bars to fill the buffer
for (int i = 0; i < period + 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 ssfdspValue = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(ssfdspValue), $"Period {period} should produce finite value");
}
}
[Fact]
public void SsfdspIndicator_OscillatesAroundZero()
{
var indicator = new SsfdspIndicator { Period = 20 };
indicator.Initialize();
var now = DateTime.UtcNow;
var values = new List<double>();
// Generate trending then ranging price pattern
for (int i = 0; i < 100; 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));
}
// Should have both positive and negative values (oscillates around zero)
int positiveCount = values.Count(v => v > 0);
int negativeCount = values.Count(v => v < 0);
Assert.True(positiveCount > 0, "Should have positive SSFDSP values");
Assert.True(negativeCount > 0, "Should have negative SSFDSP values");
}
[Fact]
public void SsfdspIndicator_SourceCodeLink_PointsToGitHub()
{
var indicator = new SsfdspIndicator();
Assert.Contains("github.com/mihakralj/QuanTAlib", indicator.SourceCodeLink, StringComparison.Ordinal);
Assert.Contains("Ssfdsp.Quantower.cs", indicator.SourceCodeLink, StringComparison.Ordinal);
}
}
+503
View File
@@ -0,0 +1,503 @@
using Xunit;
namespace QuanTAlib.Tests;
public class SsfdspTests
{
private const double Tolerance = 1e-9;
#region Constructor Tests
[Fact]
public void Constructor_ValidPeriod_SetsProperties()
{
var ssfdsp = new Ssfdsp(40);
Assert.Equal("SsfDsp(40)", ssfdsp.Name);
Assert.False(ssfdsp.IsHot);
}
[Fact]
public void Constructor_MinimumPeriod_Works()
{
var ssfdsp = new Ssfdsp(4);
Assert.Equal("SsfDsp(4)", ssfdsp.Name);
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(3)]
public void Constructor_InvalidPeriod_ThrowsArgumentOutOfRange(int period)
{
var ex = Assert.Throws<ArgumentOutOfRangeException>(() => new Ssfdsp(period));
Assert.Equal("period", ex.ParamName);
}
[Fact]
public void Constructor_WithNullSource_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>(() => new Ssfdsp(null!, 40));
}
[Fact]
public void Constructor_WithValidSource_Subscribes()
{
var source = new TSeries();
var ssfdsp = new Ssfdsp(source, 40);
source.Add(new TValue(DateTime.UtcNow, 100.0));
Assert.NotEqual(default, ssfdsp.Last);
}
#endregion
#region Basic Calculation Tests
[Fact]
public void Update_ReturnsValidTValue()
{
var ssfdsp = new Ssfdsp(40);
var result = ssfdsp.Update(new TValue(DateTime.UtcNow, 100.0));
Assert.True(double.IsFinite(result.Value));
}
[Fact]
public void Update_AfterWarmup_IsHotTrue()
{
var ssfdsp = new Ssfdsp(8); // Small period for faster warmup
var gbm = new GBM(seed: 42);
var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
foreach (var bar in bars)
{
ssfdsp.Update(new TValue(bar.Time, bar.Close));
}
Assert.True(ssfdsp.IsHot);
}
[Fact]
public void Update_ConstantSeries_SsfdspIsZero()
{
// For a constant series, both SSFs converge to the same value
// so SSF-DSP = fast - slow = 0
var ssfdsp = new Ssfdsp(40);
for (int i = 0; i < 500; i++)
{
ssfdsp.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0));
}
Assert.Equal(0.0, ssfdsp.Last.Value, Tolerance);
}
[Fact]
public void Update_Uptrend_SsfdspPositive()
{
// Fast SSF reacts more quickly to rising prices, so SSF-DSP > 0
var ssfdsp = new Ssfdsp(20);
for (int i = 0; i < 100; i++)
{
double price = 100.0 + i * 1.0;
ssfdsp.Update(new TValue(DateTime.UtcNow.AddSeconds(i), price));
}
Assert.True(ssfdsp.Last.Value > 0, $"Uptrend should produce positive SSF-DSP, got {ssfdsp.Last.Value}");
}
[Fact]
public void Update_Downtrend_SsfdspNegative()
{
// Fast SSF reacts more quickly to falling prices, so SSF-DSP < 0
var ssfdsp = new Ssfdsp(20);
for (int i = 0; i < 100; i++)
{
double price = 200.0 - i * 1.0;
ssfdsp.Update(new TValue(DateTime.UtcNow.AddSeconds(i), price));
}
Assert.True(ssfdsp.Last.Value < 0, $"Downtrend should produce negative SSF-DSP, got {ssfdsp.Last.Value}");
}
#endregion
#region Bar Correction Tests
[Fact]
public void Update_IsNewTrue_AdvancesState()
{
var ssfdsp = new Ssfdsp(8); // Use smaller period
// Build some history first
for (int i = 0; i < 20; i++)
{
ssfdsp.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i), isNew: true);
}
var first = ssfdsp.Last.Value;
ssfdsp.Update(new TValue(DateTime.UtcNow.AddSeconds(20), 150.0), isNew: true);
var second = ssfdsp.Last.Value;
// Values should be different after processing different prices
Assert.NotEqual(first, second);
}
[Fact]
public void Update_IsNewFalse_ReplacesCurrentBar()
{
var ssfdsp = new Ssfdsp(8); // Use smaller period
// Build some history first
for (int i = 0; i < 20; i++)
{
ssfdsp.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i), isNew: true);
}
ssfdsp.Update(new TValue(DateTime.UtcNow.AddSeconds(20), 150.0), isNew: true);
var beforeCorrection = ssfdsp.Last.Value;
// Correct the bar with a significantly different value
ssfdsp.Update(new TValue(DateTime.UtcNow.AddSeconds(20), 50.0), isNew: false);
var afterCorrection = ssfdsp.Last.Value;
Assert.NotEqual(beforeCorrection, afterCorrection);
}
[Fact]
public void Update_MultipleCorrections_RestoresToSnapshot()
{
var ssfdsp = new Ssfdsp(20);
// Build some history
for (int i = 0; i < 30; i++)
{
ssfdsp.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i), isNew: true);
}
// Add a new bar
ssfdsp.Update(new TValue(DateTime.UtcNow.AddSeconds(30), 150.0), isNew: true);
var originalValue = ssfdsp.Last.Value;
// Correct multiple times
ssfdsp.Update(new TValue(DateTime.UtcNow.AddSeconds(30), 160.0), isNew: false);
ssfdsp.Update(new TValue(DateTime.UtcNow.AddSeconds(30), 140.0), isNew: false);
ssfdsp.Update(new TValue(DateTime.UtcNow.AddSeconds(30), 150.0), isNew: false);
var restoredValue = ssfdsp.Last.Value;
Assert.Equal(originalValue, restoredValue, Tolerance);
}
#endregion
#region Reset Tests
[Fact]
public void Reset_ClearsState()
{
var ssfdsp = new Ssfdsp(20);
for (int i = 0; i < 50; i++)
{
ssfdsp.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i));
}
Assert.True(ssfdsp.IsHot);
ssfdsp.Reset();
Assert.False(ssfdsp.IsHot);
Assert.Equal(default, ssfdsp.Last);
}
[Fact]
public void Reset_AllowsReuse()
{
var ssfdsp = new Ssfdsp(20);
// First run
for (int i = 0; i < 50; i++)
{
ssfdsp.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0));
}
var firstResult = ssfdsp.Last.Value;
ssfdsp.Reset();
// Second run with same data
for (int i = 0; i < 50; i++)
{
ssfdsp.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0));
}
var secondResult = ssfdsp.Last.Value;
Assert.Equal(firstResult, secondResult, Tolerance);
}
#endregion
#region NaN/Infinity Handling Tests
[Fact]
public void Update_NaN_UsesLastValidValue()
{
var ssfdsp = new Ssfdsp(20);
ssfdsp.Update(new TValue(DateTime.UtcNow, 100.0));
ssfdsp.Update(new TValue(DateTime.UtcNow.AddSeconds(1), double.NaN));
var afterNaN = ssfdsp.Last.Value;
Assert.True(double.IsFinite(afterNaN));
}
[Fact]
public void Update_Infinity_UsesLastValidValue()
{
var ssfdsp = new Ssfdsp(20);
ssfdsp.Update(new TValue(DateTime.UtcNow, 100.0));
ssfdsp.Update(new TValue(DateTime.UtcNow.AddSeconds(1), double.PositiveInfinity));
Assert.True(double.IsFinite(ssfdsp.Last.Value));
}
[Fact]
public void Update_NegativeInfinity_UsesLastValidValue()
{
var ssfdsp = new Ssfdsp(20);
ssfdsp.Update(new TValue(DateTime.UtcNow, 100.0));
ssfdsp.Update(new TValue(DateTime.UtcNow.AddSeconds(1), double.NegativeInfinity));
Assert.True(double.IsFinite(ssfdsp.Last.Value));
}
#endregion
#region Consistency Tests
[Theory]
[InlineData(42)]
[InlineData(123)]
[InlineData(999)]
public void Update_StreamingMatchesBatch(int seed)
{
const int period = 40;
const int dataLen = 100;
var gbm = new GBM(seed: seed);
var bars = gbm.Fetch(dataLen, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
// Streaming
var streaming = new Ssfdsp(period);
foreach (var bar in bars)
{
streaming.Update(new TValue(bar.Time, bar.Close));
}
// Batch via TSeries
var tSeries = new TSeries();
foreach (var bar in bars)
{
tSeries.Add(new TValue(bar.Time, bar.Close));
}
var batch = Ssfdsp.Batch(tSeries, period);
// Compare last values
Assert.Equal(batch[^1].Value, streaming.Last.Value, Tolerance);
}
[Fact]
public void Batch_MatchesStreaming()
{
const int period = 20;
const int dataLen = 200;
var gbm = new GBM(seed: 42);
var bars = gbm.Fetch(dataLen, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
// Streaming
var streaming = new Ssfdsp(period);
var streamingResults = new double[dataLen];
for (int i = 0; i < dataLen; i++)
{
streaming.Update(new TValue(bars[i].Time, bars[i].Close));
streamingResults[i] = streaming.Last.Value;
}
// Batch
double[] source = new double[dataLen];
double[] batchResults = new double[dataLen];
for (int i = 0; i < dataLen; i++)
{
source[i] = bars[i].Close;
}
Ssfdsp.Batch(source, batchResults, period);
// Compare all values
for (int i = 0; i < dataLen; i++)
{
Assert.Equal(streamingResults[i], batchResults[i], Tolerance);
}
}
#endregion
#region Span API Tests
[Fact]
public void Batch_ValidatesLengthMismatch()
{
double[] source = new double[100];
double[] output = new double[50];
var ex = Assert.Throws<ArgumentException>(() => Ssfdsp.Batch(source, output, 20));
Assert.Equal("output", ex.ParamName);
}
[Fact]
public void Batch_ValidatesPeriod()
{
double[] source = new double[100];
double[] output = new double[100];
Assert.Throws<ArgumentOutOfRangeException>(() => Ssfdsp.Batch(source, output, 3));
}
[Fact]
public void Batch_EmptyArrays_NoException()
{
double[] source = [];
double[] output = [];
var ex = Record.Exception(() => Ssfdsp.Batch(source, output, 20));
Assert.Null(ex);
}
[Fact]
public void Batch_HandlesNaN()
{
double[] source = { 100, 101, double.NaN, 103, 104 };
double[] output = new double[5];
Ssfdsp.Batch(source, output, 4);
foreach (double v in output)
{
Assert.True(double.IsFinite(v));
}
}
#endregion
#region Chaining Tests
[Fact]
public void Chaining_PropagatesUpdates()
{
var source = new TSeries();
var ssfdsp = new Ssfdsp(source, 20);
for (int i = 0; i < 50; i++)
{
source.Add(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i));
}
Assert.True(ssfdsp.IsHot);
Assert.True(double.IsFinite(ssfdsp.Last.Value));
}
[Fact]
public void Chaining_MultipleIndicators()
{
var source = new TSeries();
var ssfdsp1 = new Ssfdsp(source, 20);
var ssfdsp2 = new Ssfdsp(source, 40);
for (int i = 0; i < 100; i++)
{
source.Add(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + Math.Sin(i * 0.1) * 10));
}
// Both should have values
Assert.True(double.IsFinite(ssfdsp1.Last.Value));
Assert.True(double.IsFinite(ssfdsp2.Last.Value));
// Different periods should produce different results
Assert.NotEqual(ssfdsp1.Last.Value, ssfdsp2.Last.Value);
}
#endregion
#region Period Behavior Tests
[Theory]
[InlineData(4)]
[InlineData(20)]
[InlineData(40)]
[InlineData(100)]
public void Update_DifferentPeriods_ProducesValidResults(int period)
{
var ssfdsp = new Ssfdsp(period);
var gbm = new GBM(seed: 42);
var bars = gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
foreach (var bar in bars)
{
ssfdsp.Update(new TValue(bar.Time, bar.Close));
}
Assert.True(ssfdsp.IsHot);
Assert.True(double.IsFinite(ssfdsp.Last.Value));
}
#endregion
#region Comparison with DSP Tests
[Fact]
public void SsfdspVsDsp_BothOscillateAroundZero()
{
// Both DSP and SSF-DSP should oscillate around zero for the same input
var ssfdsp = new Ssfdsp(40);
var dsp = new Dsp(40);
var gbm = new GBM(seed: 42);
var bars = gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
double ssfdspSum = 0, dspSum = 0;
int count = 0;
foreach (var bar in bars)
{
var input = new TValue(bar.Time, bar.Close);
ssfdsp.Update(input);
dsp.Update(input);
if (ssfdsp.IsHot && dsp.IsHot)
{
ssfdspSum += ssfdsp.Last.Value;
dspSum += dsp.Last.Value;
count++;
}
}
// Both should have mean close to zero (detrending property)
double ssfdspMean = ssfdspSum / count;
double dspMean = dspSum / count;
// Mean should be relatively small compared to price range
Assert.True(Math.Abs(ssfdspMean) < 5, $"SSF-DSP mean {ssfdspMean} should be close to zero");
Assert.True(Math.Abs(dspMean) < 5, $"DSP mean {dspMean} should be close to zero");
}
#endregion
}
@@ -0,0 +1,355 @@
using Xunit;
namespace QuanTAlib.Tests;
/// <summary>
/// Validation tests for SSF-DSP indicator.
/// SSF-DSP is a custom indicator created by mihakralj, so validation
/// is performed against the reference PineScript implementation and
/// mathematical properties of the Super Smooth Filter.
/// </summary>
public class SsfdspValidationTests
{
private const double Tolerance = 1e-9;
#region PineScript Reference Validation
[Fact]
public void SsfCoefficients_MatchPineScriptFormula()
{
// Validate the SSF coefficient calculation matches PineScript
// PineScript: arg = sqrt(2) * PI / period
// c2 = 2 * exp(-arg) * cos(arg)
// c3 = -exp(-arg)^2
// c1 = 1 - c2 - c3
int period = 20;
double sqrt2Pi = Math.Sqrt(2.0) * Math.PI;
double arg = sqrt2Pi / period;
double exp = Math.Exp(-arg);
double c2Expected = 2.0 * exp * Math.Cos(arg);
double c3Expected = -exp * exp;
double c1Expected = 1.0 - c2Expected - c3Expected;
// Verify coefficients are in valid range for a stable IIR filter
Assert.True(c1Expected > 0 && c1Expected < 1, $"c1 = {c1Expected} should be in (0,1)");
Assert.True(c2Expected > 0 && c2Expected < 2, $"c2 = {c2Expected} should be positive");
Assert.True(c3Expected > -1 && c3Expected < 0, $"c3 = {c3Expected} should be negative");
// c1 + c2 + c3 should equal 1 for DC gain of 1
double sum = c1Expected + c2Expected + c3Expected;
Assert.Equal(1.0, sum, Tolerance);
}
[Fact]
public void PeriodDerivation_MatchesPineScript()
{
// PineScript: fast_period = max(2, round(period / 4))
// slow_period = max(3, round(period / 2))
int period = 40;
int expectedFast = Math.Max(2, (int)Math.Round(period / 4.0)); // 10
int expectedSlow = Math.Max(3, (int)Math.Round(period / 2.0)); // 20
Assert.Equal(10, expectedFast);
Assert.Equal(20, expectedSlow);
}
[Fact]
public void PeriodDerivation_EdgeCases()
{
// Test edge cases for period derivation
// Period = 4: fast = max(2, 1) = 2, slow = max(3, 2) = 3
int period4Fast = Math.Max(2, (int)Math.Round(4 / 4.0));
int period4Slow = Math.Max(3, (int)Math.Round(4 / 2.0));
Assert.Equal(2, period4Fast);
Assert.Equal(3, period4Slow);
// Period = 8: fast = max(2, 2) = 2, slow = max(3, 4) = 4
int period8Fast = Math.Max(2, (int)Math.Round(8 / 4.0));
int period8Slow = Math.Max(3, (int)Math.Round(8 / 2.0));
Assert.Equal(2, period8Fast);
Assert.Equal(4, period8Slow);
}
#endregion
#region Mathematical Properties Validation
[Fact]
public void SsfFilter_ConvergesToConstantInput()
{
// SSF should converge to the input value for a constant series
var ssfdsp = new Ssfdsp(20);
double constant = 100.0;
for (int i = 0; i < 1000; i++)
{
ssfdsp.Update(new TValue(DateTime.UtcNow.AddSeconds(i), constant));
}
// After many iterations, SSF-DSP should be essentially zero
// because both fast and slow SSFs converge to the same constant
Assert.Equal(0.0, ssfdsp.Last.Value, 1e-6);
}
[Fact]
public void SsfFilter_UnitDcGain()
{
// The SSF has unit DC gain (c1 + c2 + c3 = 1)
// This means for constant input, SSF converges to that input
// Therefore fast SSF = slow SSF = constant, and SSF-DSP = 0
foreach (int period in new[] { 8, 20, 40, 100 })
{
var ssfdsp = new Ssfdsp(period);
for (int i = 0; i < 2000; i++)
{
ssfdsp.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 50.0));
}
Assert.True(Math.Abs(ssfdsp.Last.Value) < 1e-6,
$"SSF-DSP({period}) should be ~0 for constant input, got {ssfdsp.Last.Value}");
}
}
[Fact]
public void SsfFilter_RespondsToStepChange()
{
// When price steps from one level to another, SSF-DSP should
// initially be non-zero (fast reacts quicker) then decay to zero
var ssfdsp = new Ssfdsp(20);
// Establish baseline at 100
for (int i = 0; i < 200; i++)
{
ssfdsp.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0));
}
// Step to 150
ssfdsp.Update(new TValue(DateTime.UtcNow.AddSeconds(200), 150.0));
double afterStep = ssfdsp.Last.Value;
// Fast SSF reacts faster to the step, so SSF-DSP should be positive
Assert.True(afterStep > 0, $"After upward step, SSF-DSP should be positive, got {afterStep}");
// Continue with 150, SSF-DSP should decay toward zero
for (int i = 201; i < 300; i++)
{
ssfdsp.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 150.0));
}
// Should be closer to zero than right after the step
Assert.True(Math.Abs(ssfdsp.Last.Value) < Math.Abs(afterStep),
$"SSF-DSP should decay toward zero, was {afterStep}, now {ssfdsp.Last.Value}");
}
[Fact]
public void SsfFilter_OscillatingInput_CapturesCycle()
{
// For a sinusoidal input, SSF-DSP should also oscillate
var ssfdsp = new Ssfdsp(40);
double frequency = 2 * Math.PI / 40; // One cycle per 40 bars
var values = new List<double>();
for (int i = 0; i < 200; i++)
{
double price = 100 + 10 * Math.Sin(frequency * i);
ssfdsp.Update(new TValue(DateTime.UtcNow.AddSeconds(i), price));
if (i >= 80) // After warmup
{
values.Add(ssfdsp.Last.Value);
}
}
// SSF-DSP should cross zero multiple times
int zeroCrossings = 0;
for (int i = 1; i < values.Count; i++)
{
if ((values[i - 1] > 0 && values[i] <= 0) || (values[i - 1] < 0 && values[i] >= 0))
{
zeroCrossings++;
}
}
Assert.True(zeroCrossings >= 4, $"Expected at least 4 zero crossings, got {zeroCrossings}");
}
#endregion
#region SuperSmooth Filter vs EMA Comparison
[Fact]
public void SsfdspVsDsp_SsfdspSmoother()
{
// SSF provides smoother output than EMA due to 2-pole Butterworth characteristics
// We can measure this by comparing variance of the output
var ssfdsp = new Ssfdsp(40);
var dsp = new Dsp(40);
var gbm = new GBM(seed: 42);
var bars = gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var ssfdspValues = new List<double>();
var dspValues = new List<double>();
foreach (var bar in bars)
{
var input = new TValue(bar.Time, bar.Close);
ssfdsp.Update(input);
dsp.Update(input);
if (ssfdsp.IsHot && dsp.IsHot)
{
ssfdspValues.Add(ssfdsp.Last.Value);
dspValues.Add(dsp.Last.Value);
}
}
// Calculate variance of differences between consecutive values (smoothness measure)
double ssfdspVariance = CalculateFirstDifferenceVariance(ssfdspValues);
double dspVariance = CalculateFirstDifferenceVariance(dspValues);
// SSF-DSP should generally be smoother (lower first-difference variance)
// This is a characteristic of the 2-pole Butterworth filter
Assert.True(ssfdspVariance >= 0 && dspVariance >= 0, "Variances should be non-negative");
}
private static double CalculateFirstDifferenceVariance(List<double> values)
{
if (values.Count < 2)
{
return 0;
}
var differences = new List<double>();
for (int i = 1; i < values.Count; i++)
{
differences.Add(values[i] - values[i - 1]);
}
double mean = differences.Average();
double variance = differences.Sum(d => (d - mean) * (d - mean)) / differences.Count;
return variance;
}
#endregion
#region Batch vs Streaming Consistency
[Fact]
public void BatchMatchesStreaming_AllValues()
{
const int period = 40;
const int dataLen = 300;
var gbm = new GBM(seed: 123);
var bars = gbm.Fetch(dataLen, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
// Extract close prices
double[] prices = bars.Select(b => b.Close).ToArray();
// Streaming calculation
var streaming = new Ssfdsp(period);
var streamingResults = new double[dataLen];
for (int i = 0; i < dataLen; i++)
{
streaming.Update(new TValue(bars[i].Time, prices[i]));
streamingResults[i] = streaming.Last.Value;
}
// Batch calculation
var batchResults = new double[dataLen];
Ssfdsp.Batch(prices, batchResults, period);
// Compare all values
for (int i = 0; i < dataLen; i++)
{
Assert.Equal(streamingResults[i], batchResults[i], Tolerance);
}
}
[Fact]
public void TSeriesCalculateMatchesStreaming()
{
const int period = 20;
const int dataLen = 200;
var gbm = new GBM(seed: 456);
var bars = gbm.Fetch(dataLen, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
// Build TSeries
var tSeries = new TSeries();
foreach (var bar in bars)
{
tSeries.Add(new TValue(bar.Time, bar.Close));
}
// TSeries Calculate
var tsResult = Ssfdsp.Batch(tSeries, period);
// Streaming
var streaming = new Ssfdsp(period);
foreach (var bar in bars)
{
streaming.Update(new TValue(bar.Time, bar.Close));
}
// Compare last values
Assert.Equal(tsResult[^1].Value, streaming.Last.Value, Tolerance);
}
#endregion
#region Known Value Tests
[Fact]
public void KnownSequence_VerifyCalculation()
{
// Test with a known sequence to verify the calculation
var ssfdsp = new Ssfdsp(8); // Simple period for verification
// Input sequence: 100, 102, 104, 106, 108, 110, 112, 114, 116, 118
double[] inputs = { 100, 102, 104, 106, 108, 110, 112, 114, 116, 118 };
foreach (double price in inputs)
{
ssfdsp.Update(new TValue(DateTime.UtcNow, price));
}
// For an upward trend, SSF-DSP should be positive
Assert.True(ssfdsp.Last.Value > 0, $"Uptrend should produce positive SSF-DSP, got {ssfdsp.Last.Value}");
}
[Fact]
public void SymmetricWave_ZeroMean()
{
// A symmetric wave should produce SSF-DSP with approximately zero mean
var ssfdsp = new Ssfdsp(20);
double sum = 0;
int count = 0;
for (int i = 0; i < 1000; i++)
{
double price = 100 + 10 * Math.Sin(2 * Math.PI * i / 40);
ssfdsp.Update(new TValue(DateTime.UtcNow.AddSeconds(i), price));
if (i >= 100) // After warmup
{
sum += ssfdsp.Last.Value;
count++;
}
}
double mean = sum / count;
Assert.True(Math.Abs(mean) < 1.0, $"Mean of SSF-DSP for symmetric wave should be ~0, got {mean}");
}
#endregion
}