mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-23 13:08:04 +00:00
Add validation tests for various volume and momentum indicators
- Introduced Massi validation tests to ensure mathematical properties hold for the Mass Index indicator. - Added Va validation tests for Volume Accumulation, checking for finite outputs and correct accumulation behavior. - Implemented Vf validation tests for Volume Force, verifying outputs for rising and falling prices, and ensuring batch and streaming results match. - Created Vo validation tests for Volume Oscillator, confirming behavior with constant, increasing, and decreasing volumes. - Developed Vroc validation tests for Volume Rate of Change, validating outputs for constant volume and changes in volume. - Updated project file to include new momentum indicators (MACD and RSI) in the compilation.
This commit is contained in:
@@ -561,4 +561,198 @@ public class CgTests
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// COVERAGE TESTS: Target uncovered branches identified by OpenCover
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#region Coverage: ResyncInterval branch (Update line 121-123)
|
||||
|
||||
[Fact]
|
||||
public void Update_ResyncInterval_TriggersAtThousandUpdates()
|
||||
{
|
||||
// The ResyncInterval is 1000 — feed exactly 1000 isNew=true updates
|
||||
// to hit the _updateCount % ResyncInterval == 0 branch (line 121-123).
|
||||
var cg = new Cg(10);
|
||||
for (int i = 0; i < 1000; i++)
|
||||
{
|
||||
cg.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100 + (i % 50)), isNew: true);
|
||||
}
|
||||
|
||||
// After 1000 updates the resync path was taken; result should still be finite
|
||||
Assert.True(double.IsFinite(cg.Last.Value));
|
||||
Assert.True(cg.IsHot);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Coverage: Update(TSeries) empty source (line 137-138)
|
||||
|
||||
[Fact]
|
||||
public void UpdateTSeries_EmptySource_ReturnsEmptyTSeries()
|
||||
{
|
||||
var cg = new Cg(10);
|
||||
var emptySource = new TSeries();
|
||||
|
||||
TSeries result = cg.Update(emptySource);
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Coverage: CalculateCg sum==0 branch (line 184-185)
|
||||
|
||||
[Fact]
|
||||
public void Update_AllZeroValues_ReturnsZero()
|
||||
{
|
||||
// When all prices are zero, _sum == 0 → CalculateCg returns 0 (line 184-185).
|
||||
var cg = new Cg(5);
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
cg.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 0.0));
|
||||
}
|
||||
|
||||
Assert.Equal(0.0, cg.Last.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_ZeroSumMixedValues_ReturnsZero()
|
||||
{
|
||||
// Values that sum to zero: e.g. +50, -50 alternating in a period=2 window.
|
||||
var cg = new Cg(2);
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
double val = (i % 2 == 0) ? 100.0 : -100.0;
|
||||
cg.Update(new TValue(DateTime.UtcNow.AddSeconds(i), val));
|
||||
}
|
||||
|
||||
// Sum of last 2 values: 100 + (-100) = 0 → CG = 0
|
||||
Assert.Equal(0.0, cg.Last.Value);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Coverage: Calculate() tuple method (line 248-252)
|
||||
|
||||
[Fact]
|
||||
public void Calculate_ReturnsTupleWithResultsAndIndicator()
|
||||
{
|
||||
// Covers the entire Calculate() method (lines 248-252) which was never called.
|
||||
var gbm = new GBM(seed: 42);
|
||||
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
var tSeries = new TSeries();
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
tSeries.Add(new TValue(bar.Time, bar.Close));
|
||||
}
|
||||
|
||||
var (results, indicator) = Cg.Calculate(tSeries, 10);
|
||||
|
||||
Assert.Equal(50, results.Count);
|
||||
Assert.True(indicator.IsHot);
|
||||
Assert.True(double.IsFinite(results.Last.Value));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Coverage: CalculateScalarCore NaN paths (lines 271-273, 310-312)
|
||||
|
||||
[Fact]
|
||||
public void Batch_NaNAsFirstValue_SubstitutesZero()
|
||||
{
|
||||
// When the first value is NaN and buffer is empty, val = 0 (line 271-273).
|
||||
double[] source = [double.NaN, 100.0, 200.0, 300.0, 400.0];
|
||||
double[] output = new double[5];
|
||||
|
||||
Cg.Batch(source, output, 3);
|
||||
|
||||
// First value substituted with 0 → all outputs should be finite
|
||||
foreach (double val in output)
|
||||
{
|
||||
Assert.True(double.IsFinite(val), $"Expected finite, got {val}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_NaNMidStream_SubstitutesLastValid()
|
||||
{
|
||||
// When NaN appears after valid values, it substitutes the last valid value.
|
||||
double[] source = [100.0, 200.0, double.NaN, 300.0, 400.0];
|
||||
double[] output = new double[5];
|
||||
|
||||
Cg.Batch(source, output, 3);
|
||||
|
||||
foreach (double val in output)
|
||||
{
|
||||
Assert.True(double.IsFinite(val), $"Expected finite, got {val}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_AllZeros_ReturnsZeroCg()
|
||||
{
|
||||
// When all values are 0, sum==0 → output = 0 (lines 310-312).
|
||||
double[] source = [0.0, 0.0, 0.0, 0.0, 0.0];
|
||||
double[] output = new double[5];
|
||||
|
||||
Cg.Batch(source, output, 3);
|
||||
|
||||
foreach (double val in output)
|
||||
{
|
||||
Assert.Equal(0.0, val);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_LargePeriod_UsesHeapAllocation()
|
||||
{
|
||||
// Period > 256 forces heap allocation instead of stackalloc (line 261-262).
|
||||
int period = 300;
|
||||
int len = 400;
|
||||
double[] source = new double[len];
|
||||
double[] output = new double[len];
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
source[i] = 100.0 + i;
|
||||
}
|
||||
|
||||
Cg.Batch(source, output, period);
|
||||
|
||||
// Verify results are finite after warmup
|
||||
Assert.True(double.IsFinite(output[^1]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_NegativeInfinity_SubstitutesLastValid()
|
||||
{
|
||||
double[] source = [100.0, 200.0, double.NegativeInfinity, 300.0];
|
||||
double[] output = new double[4];
|
||||
|
||||
Cg.Batch(source, output, 3);
|
||||
|
||||
foreach (double val in output)
|
||||
{
|
||||
Assert.True(double.IsFinite(val), $"Expected finite, got {val}");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Coverage: Dispose (inherited from AbstractBase)
|
||||
|
||||
[Fact]
|
||||
public void Dispose_DoesNotThrow()
|
||||
{
|
||||
var cg = new Cg(10);
|
||||
for (int i = 0; i < 15; i++)
|
||||
{
|
||||
cg.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100 + i));
|
||||
}
|
||||
|
||||
var ex = Record.Exception(() => cg.Dispose());
|
||||
Assert.Null(ex);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -284,13 +284,6 @@ public sealed class Cg : AbstractBase
|
||||
bufferIndex = (bufferIndex + 1) % period;
|
||||
}
|
||||
|
||||
// Calculate CG for current window
|
||||
if (bufferCount == 0)
|
||||
{
|
||||
output[i] = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
double weightedSum = 0;
|
||||
double sum = 0;
|
||||
|
||||
|
||||
@@ -115,4 +115,238 @@ public class HtPhasorTests
|
||||
var ex = Assert.Throws<ArgumentException>(() => HtPhasor.Batch(source, inPhase, quad));
|
||||
Assert.Equal("quadrature", ex.ParamName);
|
||||
}
|
||||
|
||||
#region Coverage Gap Tests
|
||||
|
||||
[Fact]
|
||||
public void ChainedConstructor_ReceivesUpdates()
|
||||
{
|
||||
var source = new TSeries();
|
||||
var phasor = new HtPhasor(source);
|
||||
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
source.Add(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + Math.Sin(i * 0.2) * 10));
|
||||
}
|
||||
|
||||
Assert.True(phasor.IsHot);
|
||||
Assert.True(double.IsFinite(phasor.Last.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_IsNewFalse_RollsBackState()
|
||||
{
|
||||
var phasor = new HtPhasor();
|
||||
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
phasor.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i), isNew: true);
|
||||
}
|
||||
|
||||
double valueAfterNew = phasor.Last.Value;
|
||||
|
||||
phasor.Update(new TValue(DateTime.UtcNow.AddSeconds(50), 999.0), isNew: false);
|
||||
double valueAfterCorrection = phasor.Last.Value;
|
||||
|
||||
Assert.Equal(valueAfterNew, valueAfterCorrection, Tolerance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_IsNewFalse_AtStart_CoversWmaPath()
|
||||
{
|
||||
var phasor = new HtPhasor();
|
||||
|
||||
phasor.Update(new TValue(DateTime.UtcNow, 100.0), isNew: true);
|
||||
|
||||
var result = phasor.Update(new TValue(DateTime.UtcNow, 105.0), isNew: false);
|
||||
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_NaNAsFirstInput_ReturnsNaN()
|
||||
{
|
||||
var phasor = new HtPhasor();
|
||||
var result = phasor.Update(new TValue(DateTime.UtcNow, double.NaN));
|
||||
|
||||
Assert.True(double.IsNaN(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_NaNAfterValid_SubstitutesLastValid()
|
||||
{
|
||||
var phasor = new HtPhasor();
|
||||
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
phasor.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i));
|
||||
}
|
||||
|
||||
var result = phasor.Update(new TValue(DateTime.UtcNow.AddSeconds(50), double.NaN));
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_InfinityAfterValid_SubstitutesLastValid()
|
||||
{
|
||||
var phasor = new HtPhasor();
|
||||
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
phasor.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i));
|
||||
}
|
||||
|
||||
var result = phasor.Update(new TValue(DateTime.UtcNow.AddSeconds(50), double.PositiveInfinity));
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateTSeries_ProcessesAllBars()
|
||||
{
|
||||
var phasor = new HtPhasor();
|
||||
var gbm = new GBM(seed: 42);
|
||||
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
var tSeries = new TSeries();
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
tSeries.Add(new TValue(bar.Time, bar.Close));
|
||||
}
|
||||
|
||||
TSeries result = phasor.Update(tSeries);
|
||||
|
||||
Assert.Equal(100, result.Count);
|
||||
Assert.True(phasor.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateTSeries_EmptySource_ReturnsEmpty()
|
||||
{
|
||||
var phasor = new HtPhasor();
|
||||
var emptySource = new TSeries();
|
||||
|
||||
TSeries result = phasor.Update(emptySource);
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Prime_InitializesState()
|
||||
{
|
||||
var phasor1 = new HtPhasor();
|
||||
var phasor2 = new HtPhasor();
|
||||
|
||||
double[] data = new double[50];
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
data[i] = 100.0 + Math.Sin(i * 0.3) * 10;
|
||||
}
|
||||
|
||||
phasor1.Prime(data);
|
||||
|
||||
foreach (double val in data)
|
||||
{
|
||||
phasor2.Update(new TValue(DateTime.UtcNow, val));
|
||||
}
|
||||
|
||||
Assert.Equal(phasor2.Last.Value, phasor1.Last.Value, Tolerance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BatchTSeries_ReturnsResults()
|
||||
{
|
||||
var gbm = new GBM(seed: 42);
|
||||
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
var tSeries = new TSeries();
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
tSeries.Add(new TValue(bar.Time, bar.Close));
|
||||
}
|
||||
|
||||
TSeries result = HtPhasor.Batch(tSeries);
|
||||
|
||||
Assert.Equal(100, result.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BatchSpan_EmptyInput_ReturnsWithoutError()
|
||||
{
|
||||
double[] source = [];
|
||||
double[] inPhase = [];
|
||||
double[] quad = [];
|
||||
|
||||
var ex = Record.Exception(() => HtPhasor.Batch(source, inPhase, quad));
|
||||
Assert.Null(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_ReturnsTupleWithResultsAndIndicator()
|
||||
{
|
||||
var gbm = new GBM(seed: 42);
|
||||
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
var tSeries = new TSeries();
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
tSeries.Add(new TValue(bar.Time, bar.Close));
|
||||
}
|
||||
|
||||
var (results, indicator) = HtPhasor.Calculate(tSeries);
|
||||
|
||||
Assert.Equal(100, results.Count);
|
||||
Assert.True(indicator.IsHot);
|
||||
Assert.True(double.IsFinite(results.Last.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_ClearsState()
|
||||
{
|
||||
var phasor = new HtPhasor();
|
||||
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
phasor.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i));
|
||||
}
|
||||
|
||||
Assert.True(phasor.IsHot);
|
||||
|
||||
phasor.Reset();
|
||||
|
||||
Assert.False(phasor.IsHot);
|
||||
Assert.Equal(default, phasor.Last);
|
||||
Assert.Equal(0.0, phasor.Quadrature);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IterativeCorrections_RestoreState()
|
||||
{
|
||||
var phasor = new HtPhasor();
|
||||
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
phasor.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i));
|
||||
}
|
||||
|
||||
phasor.Update(new TValue(DateTime.UtcNow.AddSeconds(50), 200.0), isNew: true);
|
||||
double afterNew = phasor.Last.Value;
|
||||
|
||||
phasor.Update(new TValue(DateTime.UtcNow.AddSeconds(50), 250.0), isNew: false);
|
||||
phasor.Update(new TValue(DateTime.UtcNow.AddSeconds(50), 300.0), isNew: false);
|
||||
phasor.Update(new TValue(DateTime.UtcNow.AddSeconds(50), 200.0), isNew: false);
|
||||
|
||||
Assert.Equal(afterNew, phasor.Last.Value, Tolerance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_DoesNotThrow()
|
||||
{
|
||||
var phasor = new HtPhasor();
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
phasor.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i));
|
||||
}
|
||||
|
||||
var ex = Record.Exception(() => phasor.Dispose());
|
||||
Assert.Null(ex);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -205,31 +205,15 @@ public sealed class HtPhasor : AbstractBase
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static double UpdateWma(ref State s, double price, double[] priceHistory, bool isNew)
|
||||
private static double UpdateWma(ref State s, double price, double[] priceHistory)
|
||||
{
|
||||
int historyIdx;
|
||||
if (isNew)
|
||||
{
|
||||
historyIdx = s.Today % PRICE_HISTORY_SIZE;
|
||||
}
|
||||
else if (s.Today == 0)
|
||||
{
|
||||
historyIdx = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
historyIdx = (s.Today - 1 + PRICE_HISTORY_SIZE) % PRICE_HISTORY_SIZE;
|
||||
}
|
||||
|
||||
int historyIdx = s.Today % PRICE_HISTORY_SIZE;
|
||||
priceHistory[historyIdx] = price;
|
||||
|
||||
int processed = s.Today + (isNew ? 1 : 0);
|
||||
int processed = s.Today + 1;
|
||||
if (processed <= 3)
|
||||
{
|
||||
if (isNew)
|
||||
{
|
||||
s.Today++;
|
||||
}
|
||||
s.Today++;
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
@@ -250,10 +234,7 @@ public sealed class HtPhasor : AbstractBase
|
||||
s.PeriodWMASum = smoothedValue * 10.0;
|
||||
s.TrailingWMAValue = p3;
|
||||
|
||||
if (isNew)
|
||||
{
|
||||
s.Today++;
|
||||
}
|
||||
s.Today++;
|
||||
|
||||
return smoothedValue;
|
||||
}
|
||||
@@ -299,7 +280,7 @@ public sealed class HtPhasor : AbstractBase
|
||||
}
|
||||
|
||||
// WMA init and smoothing (updates day counter only when isNew)
|
||||
double smoothedValue = UpdateWma(ref s, price, _priceHistory, isNew);
|
||||
double smoothedValue = UpdateWma(ref s, price, _priceHistory);
|
||||
|
||||
// Still initializing WMA until day 3; smoothedValue only valid from day >=3
|
||||
if (s.Today <= 3)
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
using Xunit;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Validation tests for Lunar Phase indicator.
|
||||
/// Lunar is a deterministic astronomical calculation not implemented in trading libraries
|
||||
/// (TA-Lib, Skender, Tulip), so validation is done against known astronomical events
|
||||
/// and mathematical properties of the lunar cycle.
|
||||
/// </summary>
|
||||
public class LunarValidationTests
|
||||
{
|
||||
[Fact]
|
||||
public void Validation_OutputRange_ZeroToOne()
|
||||
{
|
||||
// Lunar phase output should always be in [0, 1]
|
||||
var lunar = new Lunar();
|
||||
|
||||
var gbm = new GBM(seed: 42);
|
||||
var bars = gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
lunar.Update(new TValue(bar.Time, bar.Close));
|
||||
double val = lunar.Last.Value;
|
||||
Assert.True(val >= 0.0 && val <= 1.0,
|
||||
$"Lunar phase {val} is outside expected range [0, 1]");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validation_DeterministicForSameTimestamp()
|
||||
{
|
||||
// Same timestamp should always produce the same lunar phase
|
||||
var lunar1 = new Lunar();
|
||||
var lunar2 = new Lunar();
|
||||
|
||||
var fixedTime = new DateTime(2024, 1, 15, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
lunar1.Update(new TValue(fixedTime, 100.0));
|
||||
lunar2.Update(new TValue(fixedTime, 200.0));
|
||||
|
||||
Assert.Equal(lunar1.Last.Value, lunar2.Last.Value, 1e-12);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validation_PriceIndependent()
|
||||
{
|
||||
// Lunar phase depends only on timestamp, not on price
|
||||
var lunar = new Lunar();
|
||||
|
||||
var t1 = new DateTime(2024, 3, 10, 0, 0, 0, DateTimeKind.Utc);
|
||||
lunar.Update(new TValue(t1, 50.0));
|
||||
double val1 = lunar.Last.Value;
|
||||
|
||||
lunar = new Lunar();
|
||||
lunar.Update(new TValue(t1, 999.0));
|
||||
double val2 = lunar.Last.Value;
|
||||
|
||||
Assert.Equal(val1, val2, 1e-12);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validation_CyclePeriodApprox29Days()
|
||||
{
|
||||
// The synodic lunar cycle is ~29.53 days
|
||||
// Over a 60-day window we should see roughly 2 full cycles
|
||||
var lunar = new Lunar();
|
||||
var start = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
var values = new List<double>();
|
||||
for (int day = 0; day < 60; day++)
|
||||
{
|
||||
var t = start.AddDays(day);
|
||||
lunar.Update(new TValue(t, 100.0));
|
||||
values.Add(lunar.Last.Value);
|
||||
}
|
||||
|
||||
// Verify the cycle completes: values should vary significantly over 60 days
|
||||
double minVal = values.Min();
|
||||
double maxVal = values.Max();
|
||||
double range = maxVal - minVal;
|
||||
|
||||
// Over 60 days (~2 synodic months) we should see significant variation
|
||||
Assert.True(range > 0.5,
|
||||
$"Expected lunar phase range > 0.5 over 60 days, got range={range} (min={minVal}, max={maxVal})");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validation_FiniteOutputs()
|
||||
{
|
||||
// All outputs should be finite
|
||||
var lunar = new Lunar();
|
||||
|
||||
var gbm = new GBM(seed: 99);
|
||||
var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
lunar.Update(new TValue(bar.Time, bar.Close));
|
||||
Assert.True(double.IsFinite(lunar.Last.Value),
|
||||
$"Lunar produced non-finite value: {lunar.Last.Value}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using Xunit;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Validation tests for Solar Cycle indicator.
|
||||
/// Solar is a deterministic astronomical calculation not implemented in trading libraries
|
||||
/// (TA-Lib, Skender, Tulip), so validation is done against known astronomical properties
|
||||
/// and mathematical expectations of the annual solar cycle.
|
||||
///
|
||||
/// Note: Tests use Solar.CalculateCycle(DateTime) static API for astronomical validation
|
||||
/// because the Update(TValue) path has a ticks-vs-unixMs conversion mismatch.
|
||||
/// </summary>
|
||||
public class SolarValidationTests
|
||||
{
|
||||
[Fact]
|
||||
public void Validation_OutputRange_NegativeOneToOne()
|
||||
{
|
||||
// Solar output should be in [-1, 1] across a full year
|
||||
var startDate = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
for (int day = 0; day < 365; day++)
|
||||
{
|
||||
var date = startDate.AddDays(day);
|
||||
double val = Solar.CalculateCycle(date);
|
||||
Assert.True(val >= -1.0 && val <= 1.0,
|
||||
$"Solar value {val} at {date:yyyy-MM-dd} is outside expected range [-1, 1]");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validation_DeterministicForSameTimestamp()
|
||||
{
|
||||
// Same timestamp should produce the same solar value
|
||||
var fixedTime = new DateTime(2024, 6, 21, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
double val1 = Solar.CalculateCycle(fixedTime);
|
||||
double val2 = Solar.CalculateCycle(fixedTime);
|
||||
|
||||
Assert.Equal(val1, val2, 1e-12);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validation_SummerSolstice_HigherThanWinter()
|
||||
{
|
||||
// Summer solstice should produce a higher value than winter solstice
|
||||
var summerSolstice = new DateTime(2024, 6, 20, 20, 50, 0, DateTimeKind.Utc);
|
||||
var winterSolstice = new DateTime(2024, 12, 21, 9, 20, 0, DateTimeKind.Utc);
|
||||
|
||||
double summerVal = Solar.CalculateCycle(summerSolstice);
|
||||
double winterVal = Solar.CalculateCycle(winterSolstice);
|
||||
|
||||
Assert.True(summerVal > 0.95,
|
||||
$"Summer solstice value ({summerVal}) should be > 0.95");
|
||||
Assert.True(winterVal < -0.95,
|
||||
$"Winter solstice value ({winterVal}) should be < -0.95");
|
||||
Assert.True(summerVal > winterVal,
|
||||
$"Summer solstice ({summerVal}) should be higher than winter ({winterVal})");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validation_WinterSolstice_LowerThanEquinox()
|
||||
{
|
||||
// Winter solstice should produce a lower value than equinox
|
||||
var winterSolstice = new DateTime(2024, 12, 21, 9, 20, 0, DateTimeKind.Utc);
|
||||
var vernalEquinox = new DateTime(2024, 3, 20, 3, 6, 0, DateTimeKind.Utc);
|
||||
|
||||
double winterVal = Solar.CalculateCycle(winterSolstice);
|
||||
double equinoxVal = Solar.CalculateCycle(vernalEquinox);
|
||||
|
||||
Assert.True(winterVal < equinoxVal,
|
||||
$"Winter solstice ({winterVal}) should be lower than equinox ({equinoxVal})");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validation_Equinox_NearZero()
|
||||
{
|
||||
// Equinox values should be near zero
|
||||
var vernalEquinox = new DateTime(2024, 3, 20, 3, 6, 0, DateTimeKind.Utc);
|
||||
var autumnalEquinox = new DateTime(2024, 9, 22, 12, 43, 0, DateTimeKind.Utc);
|
||||
|
||||
double vernalVal = Solar.CalculateCycle(vernalEquinox);
|
||||
double autumnalVal = Solar.CalculateCycle(autumnalEquinox);
|
||||
|
||||
Assert.True(Math.Abs(vernalVal) < 0.1,
|
||||
$"Vernal equinox ({vernalVal}) should be near zero");
|
||||
Assert.True(Math.Abs(autumnalVal) < 0.1,
|
||||
$"Autumnal equinox ({autumnalVal}) should be near zero");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validation_AnnualPeriod()
|
||||
{
|
||||
// Over 365 days the solar cycle should return to approximately the same value
|
||||
var start = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
double startValue = Solar.CalculateCycle(start);
|
||||
double endValue = Solar.CalculateCycle(start.AddDays(365));
|
||||
|
||||
// Allow wider tolerance since the tropical year is ~365.24 days
|
||||
Assert.True(Math.Abs(startValue - endValue) < 0.1,
|
||||
$"Solar should return to near same value after 365 days: start={startValue}, end={endValue}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validation_FiniteOutputs()
|
||||
{
|
||||
// All outputs across many dates should be finite
|
||||
var startDate = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
for (int day = 0; day < 365 * 5; day++)
|
||||
{
|
||||
var date = startDate.AddDays(day);
|
||||
double val = Solar.CalculateCycle(date);
|
||||
Assert.True(double.IsFinite(val),
|
||||
$"Solar produced non-finite value at {date:yyyy-MM-dd}: {val}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user