mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-04 12:07:44 +00:00
060649192f
- 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
384 lines
12 KiB
C#
384 lines
12 KiB
C#
using Xunit;
|
|
|
|
using OoplesFinance.StockIndicators;
|
|
using OoplesFinance.StockIndicators.Models;
|
|
|
|
namespace QuanTAlib.Tests;
|
|
|
|
/// <summary>
|
|
/// Validation tests for CCYC - Ehlers Cyber Cycle.
|
|
/// Since CCYC is a proprietary Ehlers algorithm with no standard library implementations,
|
|
/// these tests validate mathematical properties and internal consistency.
|
|
/// </summary>
|
|
public class CcycValidationTests
|
|
{
|
|
private const double Tolerance = 1e-9;
|
|
private const long StartTime = 946_684_800_000_000_0L; // 2000-01-01 UTC in ticks
|
|
private static readonly TimeSpan Step = TimeSpan.FromMinutes(1);
|
|
|
|
#region Mathematical Property Validation
|
|
|
|
[Fact]
|
|
public void Ccyc_ConstantInput_ConvergesToZero()
|
|
{
|
|
// High-pass filter on constant input must converge to zero
|
|
var ccyc = new Ccyc(0.07);
|
|
|
|
for (int i = 0; i < 500; i++)
|
|
{
|
|
ccyc.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100.0), true);
|
|
}
|
|
|
|
Assert.True(Math.Abs(ccyc.Last.Value) < 1e-10,
|
|
$"Constant input should produce zero output, got {ccyc.Last.Value}");
|
|
}
|
|
|
|
[Fact]
|
|
public void Ccyc_LinearTrend_ConvergesToZero()
|
|
{
|
|
// High-pass filter on linear trend should converge to zero (no oscillation)
|
|
var ccyc = new Ccyc(0.07);
|
|
|
|
for (int i = 0; i < 500; i++)
|
|
{
|
|
ccyc.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100.0 + 0.5 * i), true);
|
|
}
|
|
|
|
// After warmup, should be near zero since linear trend has no cycle component
|
|
Assert.True(Math.Abs(ccyc.Last.Value) < 1.0,
|
|
$"Linear trend should produce near-zero output, got {ccyc.Last.Value}");
|
|
}
|
|
|
|
[Fact]
|
|
public void Ccyc_SineWave_ProducesNonZeroOutput()
|
|
{
|
|
// A sine wave should produce non-zero cycle output
|
|
var ccyc = new Ccyc(0.07);
|
|
int period = 20;
|
|
|
|
for (int i = 0; i < 200; i++)
|
|
{
|
|
double value = 100 + 10 * Math.Sin(2 * Math.PI * i / period);
|
|
ccyc.Update(new TValue(DateTime.UtcNow.AddMinutes(i), value), true);
|
|
}
|
|
|
|
// Cycle output should be non-trivial
|
|
Assert.True(Math.Abs(ccyc.Last.Value) > 0.01,
|
|
$"Sine wave should produce non-zero cycle, got {ccyc.Last.Value}");
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(10)]
|
|
[InlineData(20)]
|
|
[InlineData(40)]
|
|
public void Ccyc_SineWave_OutputOscillates(int period)
|
|
{
|
|
// Output should oscillate (have zero crossings) for sinusoidal input
|
|
var ccyc = new Ccyc(0.07);
|
|
int zeroCrossings = 0;
|
|
double prev = 0;
|
|
|
|
for (int i = 0; i < 300; i++)
|
|
{
|
|
double value = 100 + 10 * Math.Sin(2 * Math.PI * i / period);
|
|
var r = ccyc.Update(new TValue(DateTime.UtcNow.AddMinutes(i), value), true);
|
|
|
|
if (i > 20 && prev * r.Value < 0 && prev != 0)
|
|
{
|
|
zeroCrossings++;
|
|
}
|
|
prev = r.Value;
|
|
}
|
|
|
|
Assert.True(zeroCrossings > 3,
|
|
$"Output should oscillate with period={period}, got {zeroCrossings} zero crossings");
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(42)]
|
|
[InlineData(123)]
|
|
[InlineData(456)]
|
|
public void Ccyc_DeterministicOutput(int seed)
|
|
{
|
|
// Same input should always produce same output
|
|
var gbm = new GBM(seed: seed);
|
|
var bars1 = gbm.Fetch(200, StartTime, Step);
|
|
|
|
gbm = new GBM(seed: seed);
|
|
var bars2 = gbm.Fetch(200, StartTime, Step);
|
|
|
|
var ccyc1 = new Ccyc(0.07);
|
|
var ccyc2 = new Ccyc(0.07);
|
|
|
|
for (int i = 0; i < bars1.Count; i++)
|
|
{
|
|
var result1 = ccyc1.Update(new TValue(bars1[i].Time, bars1[i].Close));
|
|
var result2 = ccyc2.Update(new TValue(bars2[i].Time, bars2[i].Close));
|
|
|
|
Assert.Equal(result1.Value, result2.Value, Tolerance);
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region High-Pass Filter Property Validation
|
|
|
|
[Fact]
|
|
public void Ccyc_HigherAlpha_ProducesDifferentOutput()
|
|
{
|
|
// Different alpha values should produce measurably different cycle outputs
|
|
var gbm = new GBM(seed: 42);
|
|
var bars = gbm.Fetch(200, StartTime, Step);
|
|
|
|
var ccycFast = new Ccyc(0.15);
|
|
var ccycSlow = new Ccyc(0.03);
|
|
|
|
double diffEnergy = 0;
|
|
|
|
for (int i = 0; i < bars.Count; i++)
|
|
{
|
|
var tv = new TValue(bars[i].Time, bars[i].Close);
|
|
var rFast = ccycFast.Update(tv);
|
|
var rSlow = ccycSlow.Update(tv);
|
|
|
|
if (i > 20)
|
|
{
|
|
double d = rFast.Value - rSlow.Value;
|
|
diffEnergy += d * d;
|
|
}
|
|
}
|
|
|
|
// Different alphas must produce different outputs
|
|
Assert.True(diffEnergy > 1e-6,
|
|
$"Different alphas should produce different outputs, diffEnergy={diffEnergy}");
|
|
}
|
|
|
|
[Fact]
|
|
public void Ccyc_FIR_SmoothsNoise()
|
|
{
|
|
// The 4-tap FIR smoother should reduce high-frequency noise
|
|
// Test: random noise should produce smaller cycle than sine wave
|
|
var ccycNoise = new Ccyc(0.07);
|
|
var ccycSine = new Ccyc(0.07);
|
|
|
|
var rng = new GBM(startPrice: 100.0, sigma: 0.1, seed: 42);
|
|
double sineEnergy = 0;
|
|
|
|
for (int i = 0; i < 300; i++)
|
|
{
|
|
double noiseVal = rng.Next().Close;
|
|
ccycNoise.Update(new TValue(DateTime.UtcNow.AddMinutes(i), noiseVal), true);
|
|
|
|
double sineVal = 100 + 10 * Math.Sin(2 * Math.PI * i / 20.0);
|
|
var sineResult = ccycSine.Update(new TValue(DateTime.UtcNow.AddMinutes(i), sineVal), true);
|
|
|
|
if (i > 30)
|
|
{
|
|
sineEnergy += sineResult.Value * sineResult.Value;
|
|
}
|
|
}
|
|
|
|
// Sine wave produces coherent cycle output
|
|
Assert.True(sineEnergy > 0, "Sine wave should produce energy");
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Trigger Line Validation
|
|
|
|
[Fact]
|
|
public void Ccyc_Trigger_IsOnePeriodDelayed()
|
|
{
|
|
var ccyc = new Ccyc(0.07);
|
|
var gbm = new GBM(seed: 42);
|
|
var bars = gbm.Fetch(100, StartTime, Step);
|
|
|
|
double prevCycle = 0;
|
|
for (int i = 0; i < bars.Count; i++)
|
|
{
|
|
ccyc.Update(new TValue(bars[i].Time, bars[i].Close));
|
|
|
|
if (i > 0)
|
|
{
|
|
Assert.Equal(prevCycle, ccyc.Trigger, Tolerance);
|
|
}
|
|
prevCycle = ccyc.Last.Value;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Ccyc_Trigger_CrossoverDetectable()
|
|
{
|
|
// On a sine wave, cycle and trigger should cross each other (sign change in diff)
|
|
var ccyc = new Ccyc(0.07);
|
|
int crossoverCount = 0;
|
|
double prevDiff = 0;
|
|
|
|
for (int i = 0; i < 300; i++)
|
|
{
|
|
double value = 100 + 10 * Math.Sin(2 * Math.PI * i / 20.0);
|
|
ccyc.Update(new TValue(DateTime.UtcNow.AddMinutes(i), value), true);
|
|
|
|
if (i > 20)
|
|
{
|
|
double diff = ccyc.Last.Value - ccyc.Trigger;
|
|
if (prevDiff != 0 && diff * prevDiff < 0)
|
|
{
|
|
crossoverCount++;
|
|
}
|
|
prevDiff = diff;
|
|
}
|
|
}
|
|
|
|
Assert.True(crossoverCount > 0,
|
|
"Cycle and trigger should cross on sine input");
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Consistency Validation
|
|
|
|
[Fact]
|
|
public void Ccyc_BatchMatchesStreaming_OnGBM()
|
|
{
|
|
var gbm = new GBM(seed: 42);
|
|
var bars = gbm.Fetch(500, StartTime, Step);
|
|
var source = bars.Close;
|
|
|
|
// Streaming
|
|
var ccycStream = new Ccyc(0.07);
|
|
var streamResults = new double[source.Count];
|
|
for (int i = 0; i < source.Count; i++)
|
|
{
|
|
var r = ccycStream.Update(source[i], true);
|
|
streamResults[i] = r.Value;
|
|
}
|
|
|
|
// Batch
|
|
var batchResults = Ccyc.Batch(source, 0.07);
|
|
|
|
for (int i = 0; i < source.Count; i++)
|
|
{
|
|
Assert.Equal(streamResults[i], batchResults[i].Value, Tolerance);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Ccyc_SpanMatchesBatch_OnGBM()
|
|
{
|
|
var gbm = new GBM(seed: 42);
|
|
var bars = gbm.Fetch(500, StartTime, Step);
|
|
var source = bars.Close;
|
|
|
|
// TSeries batch
|
|
var batchResults = Ccyc.Batch(source, 0.07);
|
|
|
|
// Span batch
|
|
double[] values = new double[source.Count];
|
|
for (int i = 0; i < source.Count; i++)
|
|
{
|
|
values[i] = source[i].Value;
|
|
}
|
|
|
|
double[] output = new double[values.Length];
|
|
Ccyc.Batch(values.AsSpan(), output.AsSpan(), 0.07);
|
|
|
|
for (int i = 0; i < source.Count; i++)
|
|
{
|
|
Assert.Equal(batchResults[i].Value, output[i], 6);
|
|
}
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(0.03)]
|
|
[InlineData(0.07)]
|
|
[InlineData(0.15)]
|
|
[InlineData(0.30)]
|
|
public void Ccyc_AllAlphas_ProduceFiniteOutput(double alpha)
|
|
{
|
|
var gbm = new GBM(seed: 42);
|
|
var bars = gbm.Fetch(500, StartTime, Step);
|
|
var ccyc = new Ccyc(alpha);
|
|
|
|
for (int i = 0; i < bars.Count; i++)
|
|
{
|
|
var r = ccyc.Update(new TValue(bars[i].Time, bars[i].Close));
|
|
Assert.True(double.IsFinite(r.Value), $"Non-finite at bar {i} with alpha={alpha}");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Ccyc_ResetAndReprocess_Matches()
|
|
{
|
|
var gbm = new GBM(seed: 42);
|
|
var bars = gbm.Fetch(200, StartTime, Step);
|
|
var source = bars.Close;
|
|
|
|
var ccyc = new Ccyc(0.07);
|
|
var results1 = ccyc.Update(source);
|
|
|
|
ccyc.Reset();
|
|
var results2 = ccyc.Update(source);
|
|
|
|
Assert.Equal(results1.Count, results2.Count);
|
|
for (int i = 0; i < results1.Count; i++)
|
|
{
|
|
Assert.Equal(results1[i].Value, results2[i].Value, Tolerance);
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Bootstrap / Steady-State Transition
|
|
|
|
[Fact]
|
|
public void Ccyc_BootstrapTransition_IsSmooth()
|
|
{
|
|
// The transition from bootstrap (bar < 7) to steady-state (bar >= 7) should be smooth
|
|
var ccyc = new Ccyc(0.07);
|
|
var results = new List<double>();
|
|
|
|
for (int i = 0; i < 20; i++)
|
|
{
|
|
double value = 100 + 5 * Math.Sin(2 * Math.PI * i / 20.0);
|
|
var r = ccyc.Update(new TValue(DateTime.UtcNow.AddMinutes(i), value), true);
|
|
results.Add(r.Value);
|
|
}
|
|
|
|
// Check that the transition at bar 7 (index 6) doesn't produce a huge jump
|
|
double jump = Math.Abs(results[6] - results[5]);
|
|
double avgMagnitude = 0;
|
|
for (int i = 3; i < 10; i++)
|
|
{
|
|
avgMagnitude += Math.Abs(results[i]);
|
|
}
|
|
avgMagnitude /= 7;
|
|
|
|
// Jump should be within reasonable bounds (not 10x the average)
|
|
if (avgMagnitude > 1e-10)
|
|
{
|
|
Assert.True(jump < 10 * avgMagnitude,
|
|
$"Bootstrap transition jump={jump} too large vs avg magnitude={avgMagnitude}");
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
[Fact]
|
|
public void Ccyc_MatchesOoples_Structural()
|
|
{
|
|
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.15, seed: 42);
|
|
var bars = gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
|
var ooplesData = bars.Select(b => new TickerData
|
|
{
|
|
Date = new DateTime(b.Time, DateTimeKind.Utc),
|
|
Open = b.Open, High = b.High, Low = b.Low,
|
|
Close = b.Close, Volume = b.Volume
|
|
}).ToList();
|
|
var result = new StockData(ooplesData).CalculateEhlersCyberCycle();
|
|
var values = result.CustomValuesList;
|
|
int finiteCount = values.Count(v => double.IsFinite(v));
|
|
Assert.True(finiteCount > 100, $"Expected >100 finite values, got {finiteCount}");
|
|
}
|
|
}
|