Files
QuanTAlib/lib/cycles/ccor/tests/Ccor.Validation.Tests.cs
Miha Kralj 060649192f 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
2026-03-12 12:34:16 -07:00

397 lines
12 KiB
C#

using Xunit;
namespace QuanTAlib.Tests;
/// <summary>
/// Validation tests for CCOR - Ehlers Correlation Cycle.
/// Since CCOR is a proprietary Ehlers algorithm with no standard library implementations
/// (not in TA-Lib, Skender, Tulip, or Ooples), these tests validate mathematical
/// properties of Pearson correlation and internal consistency across API modes.
/// </summary>
public class CcorValidationTests
{
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 Pearson Correlation Mathematical Properties
[Fact]
public void Ccor_ConstantInput_RealAndImagAreZero()
{
// Constant price → zero variance in x → correlation undefined → returns 0
var ccor = new Ccor(period: 10);
for (int i = 0; i < 100; i++)
{
ccor.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100.0), true);
}
Assert.Equal(0.0, ccor.Real, Tolerance);
Assert.Equal(0.0, ccor.Imag, Tolerance);
}
[Fact]
public void Ccor_PerfectCosineInput_RealNearOne()
{
// If price exactly matches the cosine reference, Real correlation → +1
int period = 20;
var ccor = new Ccor(period: period);
double twoPiOverN = 2.0 * Math.PI / period;
for (int i = 0; i < 200; i++)
{
double val = Math.Cos(twoPiOverN * (i % period));
ccor.Update(new TValue(DateTime.UtcNow.AddMinutes(i), val), true);
}
// After many full cycles, Real should be very close to +1
Assert.True(ccor.Real > 0.95,
$"Perfect cosine input should give Real ≈ 1.0, got {ccor.Real:F6}");
}
[Fact]
public void Ccor_PerfectNegSineInput_ImagHighMagnitude()
{
// If price has -sin periodicity, Imag correlation magnitude should be near 1.0
int period = 20;
var ccor = new Ccor(period: period);
double twoPiOverN = 2.0 * Math.PI / period;
for (int i = 0; i < 200; i++)
{
double val = -Math.Sin(twoPiOverN * (i % period));
ccor.Update(new TValue(DateTime.UtcNow.AddMinutes(i), val), true);
}
Assert.True(Math.Abs(ccor.Imag) > 0.90,
$"Perfect -sin input should give |Imag| ≈ 1.0, got {ccor.Imag:F6}");
}
[Fact]
public void Ccor_RealAndImag_BoundedMinusOneToOne()
{
// Pearson correlation coefficient is always in [-1, +1]
var gbm = new GBM(seed: 42);
var bars = gbm.Fetch(1000, StartTime, Step);
var ccor = new Ccor(period: 20);
for (int i = 0; i < bars.Count; i++)
{
ccor.Update(new TValue(bars[i].Time, bars[i].Close), true);
Assert.InRange(ccor.Real, -1.0, 1.0);
Assert.InRange(ccor.Imag, -1.0, 1.0);
}
}
[Fact]
public void Ccor_SineWave_RealAndImagAreOrthogonal()
{
// For a pure sine wave at the indicator's period, the Real (cosine) and Imag (-sine)
// correlations should be approximately orthogonal components of a phasor
int period = 20;
var ccor = new Ccor(period: period);
for (int i = 0; i < 200; i++)
{
double val = 100.0 + (10.0 * Math.Sin(2.0 * Math.PI * i / period));
ccor.Update(new TValue(DateTime.UtcNow.AddMinutes(i), val), true);
}
// Both should be non-trivial
Assert.True(Math.Abs(ccor.Real) > 0.01 || Math.Abs(ccor.Imag) > 0.01,
$"Sine wave should produce non-trivial phasor: Real={ccor.Real:F4}, Imag={ccor.Imag:F4}");
// R² + I² should be near 1 for a pure tone at the matched frequency
double magnitude = Math.Sqrt((ccor.Real * ccor.Real) + (ccor.Imag * ccor.Imag));
Assert.True(magnitude > 0.5,
$"Phasor magnitude should be significant for matched sine: {magnitude:F4}");
}
#endregion
#region Angle Properties
[Fact]
public void Ccor_Angle_MonotonicallyNonDecreasing()
{
var gbm = new GBM(seed: 42);
var bars = gbm.Fetch(500, StartTime, Step);
var ccor = new Ccor(period: 20);
double prevAngle = double.MinValue;
for (int i = 0; i < bars.Count; i++)
{
ccor.Update(new TValue(bars[i].Time, bars[i].Close), true);
Assert.True(ccor.Angle >= prevAngle,
$"Angle decreased at bar {i}: {ccor.Angle:F4} < prev {prevAngle:F4}");
prevAngle = ccor.Angle;
}
}
[Fact]
public void Ccor_Angle_AdvancesOnCyclicInput()
{
// For cyclic input, the angle should advance significantly
int period = 20;
var ccor = new Ccor(period: period);
for (int i = 0; i < 200; i++)
{
double val = 100.0 + (10.0 * Math.Sin(2.0 * Math.PI * i / period));
ccor.Update(new TValue(DateTime.UtcNow.AddMinutes(i), val), true);
}
Assert.True(ccor.Angle > 0.0,
$"Angle should advance on cyclic input, got {ccor.Angle:F4}");
}
#endregion
#region Market State Properties
[Fact]
public void Ccor_MarketState_OnlyValidValues()
{
var gbm = new GBM(seed: 42);
var bars = gbm.Fetch(500, StartTime, Step);
var ccor = new Ccor(period: 20, threshold: 9.0);
for (int i = 0; i < bars.Count; i++)
{
ccor.Update(new TValue(bars[i].Time, bars[i].Close), true);
Assert.Contains(ccor.MarketState, new[] { -1, 0, 1 });
}
}
[Fact]
public void Ccor_MarketState_HasVariation()
{
// Over enough data, all three states should appear at least once
var gbm = new GBM(seed: 42);
var bars = gbm.Fetch(2000, StartTime, Step);
var ccor = new Ccor(period: 20, threshold: 9.0);
var states = new HashSet<int>();
for (int i = 0; i < bars.Count; i++)
{
ccor.Update(new TValue(bars[i].Time, bars[i].Close), true);
states.Add(ccor.MarketState);
}
Assert.True(states.Count >= 2,
$"Expected at least 2 distinct market states, got {states.Count}: {string.Join(",", states)}");
}
#endregion
#region Deterministic Reproducibility
[Theory]
[InlineData(42)]
[InlineData(123)]
[InlineData(456)]
public void Ccor_DeterministicOutput(int seed)
{
var gbm1 = new GBM(seed: seed);
var bars1 = gbm1.Fetch(200, StartTime, Step);
var gbm2 = new GBM(seed: seed);
var bars2 = gbm2.Fetch(200, StartTime, Step);
var ccor1 = new Ccor(period: 20, threshold: 9.0);
var ccor2 = new Ccor(period: 20, threshold: 9.0);
for (int i = 0; i < bars1.Count; i++)
{
var r1 = ccor1.Update(new TValue(bars1[i].Time, bars1[i].Close));
var r2 = ccor2.Update(new TValue(bars2[i].Time, bars2[i].Close));
Assert.Equal(r1.Value, r2.Value, Tolerance);
}
}
[Theory]
[InlineData(10)]
[InlineData(20)]
[InlineData(50)]
public void Ccor_AllPeriods_ProduceFiniteOutput(int period)
{
var gbm = new GBM(seed: 42);
var bars = gbm.Fetch(500, StartTime, Step);
var ccor = new Ccor(period: period);
for (int i = 0; i < bars.Count; i++)
{
var r = ccor.Update(new TValue(bars[i].Time, bars[i].Close));
Assert.True(double.IsFinite(r.Value), $"Non-finite at bar {i} with period={period}");
Assert.True(double.IsFinite(ccor.Real), $"Non-finite Real at bar {i}");
Assert.True(double.IsFinite(ccor.Imag), $"Non-finite Imag at bar {i}");
Assert.True(double.IsFinite(ccor.Angle), $"Non-finite Angle at bar {i}");
}
}
#endregion
#region Consistency Validation
[Fact]
public void Ccor_BatchMatchesStreaming_OnGBM()
{
var gbm = new GBM(seed: 42);
var bars = gbm.Fetch(500, StartTime, Step);
var source = bars.Close;
// Streaming
var ccorStream = new Ccor(period: 20, threshold: 9.0);
var streamResults = new double[source.Count];
for (int i = 0; i < source.Count; i++)
{
var r = ccorStream.Update(source[i], true);
streamResults[i] = r.Value;
}
// Batch
var batchResults = Ccor.Batch(source, 20, 9.0);
for (int i = 0; i < source.Count; i++)
{
Assert.Equal(streamResults[i], batchResults[i].Value, Tolerance);
}
}
[Fact]
public void Ccor_SpanMatchesBatch_OnGBM()
{
var gbm = new GBM(seed: 42);
var bars = gbm.Fetch(500, StartTime, Step);
var source = bars.Close;
// TSeries batch
var batchResults = Ccor.Batch(source, 20, 9.0);
// 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];
Ccor.Batch(values.AsSpan(), output.AsSpan(), 20, 9.0);
for (int i = 0; i < source.Count; i++)
{
Assert.Equal(batchResults[i].Value, output[i], Tolerance);
}
}
[Fact]
public void Ccor_ResetAndReprocess_Matches()
{
var gbm = new GBM(seed: 42);
var bars = gbm.Fetch(200, StartTime, Step);
var source = bars.Close;
var ccor = new Ccor(period: 20, threshold: 9.0);
var results1 = ccor.Update(source);
ccor.Reset();
var results2 = ccor.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 Period Sensitivity
[Fact]
public void Ccor_DifferentPeriods_ProduceDifferentResults()
{
var gbm = new GBM(seed: 42);
var bars = gbm.Fetch(200, StartTime, Step);
var ccor10 = new Ccor(period: 10);
var ccor30 = new Ccor(period: 30);
double diffEnergy = 0;
for (int i = 0; i < bars.Count; i++)
{
var tv = new TValue(bars[i].Time, bars[i].Close);
var r10 = ccor10.Update(tv);
var r30 = ccor30.Update(tv);
if (i > 30)
{
double d = r10.Value - r30.Value;
diffEnergy += d * d;
}
}
Assert.True(diffEnergy > 1e-6,
$"Different periods should produce different outputs, diffEnergy={diffEnergy}");
}
[Fact]
public void Ccor_DifferentThresholds_ProduceDifferentMarketStates()
{
var gbm = new GBM(seed: 42);
var bars = gbm.Fetch(500, StartTime, Step);
var ccorTight = new Ccor(period: 20, threshold: 1.0);
var ccorLoose = new Ccor(period: 20, threshold: 50.0);
int statesDiffer = 0;
for (int i = 0; i < bars.Count; i++)
{
var tv = new TValue(bars[i].Time, bars[i].Close);
ccorTight.Update(tv);
ccorLoose.Update(tv);
if (ccorTight.MarketState != ccorLoose.MarketState)
{
statesDiffer++;
}
}
// Real/Imag/Angle are independent of threshold — only MarketState differs
Assert.True(statesDiffer > 0,
"Different thresholds should produce different market state classifications");
}
[Fact]
public void Ccor_Correction_Recomputes()
{
var ind = new Ccor(period: 20);
var t0 = new DateTime(946_684_800_000_000_0L, DateTimeKind.Utc);
// Build state well past warmup
for (int i = 0; i < 100; i++)
{
ind.Update(new TValue(t0.AddMinutes(i),
100.0 + (10.0 * Math.Sin(2.0 * Math.PI * i / 20.0))), isNew: true);
}
// Anchor bar
var anchorTime = t0.AddMinutes(100);
const double anchorPrice = 105.5;
ind.Update(new TValue(anchorTime, anchorPrice), isNew: true);
double anchorResult = ind.Last.Value;
// Correction with a dramatically different price — recompute must yield different result
ind.Update(new TValue(anchorTime, anchorPrice * 10.0), isNew: false);
Assert.NotEqual(anchorResult, ind.Last.Value);
// Correction back to original price — must exactly restore original result
ind.Update(new TValue(anchorTime, anchorPrice), isNew: false);
Assert.Equal(anchorResult, ind.Last.Value, Tolerance);
}
#endregion
}