Files
QuanTAlib/lib/reversals/swings/Swings.Validation.Tests.cs
T

267 lines
8.3 KiB
C#

// SWINGS Validation Tests - Swing High/Low Detection
// No external library validation available (Skender/TA-Lib/Tulip/Ooples don't have configurable swings)
// Tests focus on mathematical correctness and self-consistency
namespace QuanTAlib.Tests;
public sealed class SwingsValidationTests
{
private static TBarSeries CreateGbmBars(int count = 500, int seed = 42)
{
var gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.20, seed: seed);
return gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
}
// -- Self-Consistency: Streaming == Batch (SwingHigh) --------------------------
[Fact]
public void StreamingMatchesBatch_SwingHigh()
{
var bars = CreateGbmBars();
// Streaming
var streaming = new Swings(lookback: 3);
var streamHigh = new double[bars.Count];
for (int i = 0; i < bars.Count; i++)
{
_ = streaming.Update(bars[i], isNew: true);
streamHigh[i] = streaming.SwingHigh;
}
// Batch
var batchResults = Swings.Batch(bars, lookback: 3);
int warmup = 6; // windowSize - 1
for (int i = warmup; i < bars.Count; i++)
{
if (double.IsNaN(streamHigh[i]))
{
Assert.True(double.IsNaN(batchResults[i].Value), $"Mismatch at {i}: streaming=NaN, batch={batchResults[i].Value}");
}
else
{
Assert.Equal(streamHigh[i], batchResults[i].Value, precision: 10);
}
}
}
// -- Self-Consistency: Streaming == Span (Both outputs) -----------------------
[Fact]
public void StreamingMatchesSpan_BothSwings()
{
var bars = CreateGbmBars();
// Streaming
var streaming = new Swings(lookback: 3);
var streamHigh = new double[bars.Count];
var streamLow = new double[bars.Count];
for (int i = 0; i < bars.Count; i++)
{
_ = streaming.Update(bars[i], isNew: true);
streamHigh[i] = streaming.SwingHigh;
streamLow[i] = streaming.SwingLow;
}
// Span
var spanHigh = new double[bars.Count];
var spanLow = new double[bars.Count];
Swings.Batch(bars.HighValues, bars.LowValues, spanHigh, spanLow, lookback: 3);
for (int i = 6; i < bars.Count; i++)
{
if (double.IsNaN(streamHigh[i]))
{
Assert.True(double.IsNaN(spanHigh[i]));
}
else
{
Assert.Equal(streamHigh[i], spanHigh[i], precision: 10);
}
if (double.IsNaN(streamLow[i]))
{
Assert.True(double.IsNaN(spanLow[i]));
}
else
{
Assert.Equal(streamLow[i], spanLow[i], precision: 10);
}
}
}
// -- Mathematical correctness: swing highs are actual local maxima -------------
[Fact]
public void SwingHigh_IsActualLocalMaximum()
{
var bars = CreateGbmBars(count: 200, seed: 77);
var spanHigh = new double[bars.Count];
var spanLow = new double[bars.Count];
int lookback = 3;
Swings.Batch(bars.HighValues, bars.LowValues, spanHigh, spanLow, lookback);
int windowSize = 2 * lookback + 1;
for (int i = windowSize - 1; i < bars.Count; i++)
{
if (double.IsNaN(spanHigh[i]))
{
continue;
}
// The swing high value should be the center bar's high
int center = i - lookback;
double centerHigh = bars.HighValues[center];
Assert.Equal(centerHigh, spanHigh[i], precision: 10);
// Verify it's strictly greater than all neighbors
for (int j = center - lookback; j <= center + lookback; j++)
{
if (j != center)
{
Assert.True(centerHigh > bars.HighValues[j],
$"Bar {center} high={centerHigh} should be > bar {j} high={bars.HighValues[j]}");
}
}
}
}
// -- Mathematical correctness: swing lows are actual local minima --------------
[Fact]
public void SwingLow_IsActualLocalMinimum()
{
var bars = CreateGbmBars(count: 200, seed: 77);
var spanHigh = new double[bars.Count];
var spanLow = new double[bars.Count];
int lookback = 3;
Swings.Batch(bars.HighValues, bars.LowValues, spanHigh, spanLow, lookback);
int windowSize = 2 * lookback + 1;
for (int i = windowSize - 1; i < bars.Count; i++)
{
if (double.IsNaN(spanLow[i]))
{
continue;
}
// The swing low value should be the center bar's low
int center = i - lookback;
double centerLow = bars.LowValues[center];
Assert.Equal(centerLow, spanLow[i], precision: 10);
// Verify it's strictly less than all neighbors
for (int j = center - lookback; j <= center + lookback; j++)
{
if (j != center)
{
Assert.True(centerLow < bars.LowValues[j],
$"Bar {center} low={centerLow} should be < bar {j} low={bars.LowValues[j]}");
}
}
}
}
// -- Determinism ---------------------------------------------------------------
[Fact]
public void SameInput_ProducesSameOutput()
{
var bars = CreateGbmBars(count: 200, seed: 123);
var sw1 = new Swings(lookback: 4);
var sw2 = new Swings(lookback: 4);
for (int i = 0; i < bars.Count; i++)
{
_ = sw1.Update(bars[i], isNew: true);
_ = sw2.Update(bars[i], isNew: true);
}
Assert.Equal(sw1.SwingHigh, sw2.SwingHigh);
Assert.Equal(sw1.SwingLow, sw2.SwingLow);
Assert.Equal(sw1.LastSwingHigh, sw2.LastSwingHigh);
Assert.Equal(sw1.LastSwingLow, sw2.LastSwingLow);
}
// -- Calculate Returns Valid Indicator -----------------------------------------
[Fact]
public void Calculate_ReturnsValidIndicatorAndResults()
{
var bars = CreateGbmBars(count: 100);
var (results, indicator) = Swings.Calculate(bars);
Assert.NotNull(results);
Assert.Equal(bars.Count, results.Count);
Assert.True(indicator.IsHot);
}
// -- BatchDual Returns Both Swings -------------------------------------------
[Fact]
public void BatchDual_ReturnsBothSeries()
{
var bars = CreateGbmBars(count: 100);
var (swingHighs, swingLows) = Swings.BatchDual(bars, lookback: 3);
Assert.Equal(bars.Count, swingHighs.Count);
Assert.Equal(bars.Count, swingLows.Count);
// At least some swings should be detected in 100 bars
bool hasHigh = false;
bool hasLow = false;
for (int i = 0; i < swingHighs.Count; i++)
{
if (double.IsFinite(swingHighs[i].Value))
{
hasHigh = true;
}
if (double.IsFinite(swingLows[i].Value))
{
hasLow = true;
}
}
Assert.True(hasHigh, "Should detect at least one swing high in 100 bars");
Assert.True(hasLow, "Should detect at least one swing low in 100 bars");
}
// -- Different lookback periods produce different results ----------------------
[Fact]
public void DifferentLookbacks_ProduceDifferentResults()
{
var bars = CreateGbmBars(count: 200, seed: 55);
var sw2 = new Swings(lookback: 2);
var sw5 = new Swings(lookback: 5);
int swingCount2 = 0;
int swingCount5 = 0;
for (int i = 0; i < bars.Count; i++)
{
_ = sw2.Update(bars[i], isNew: true);
_ = sw5.Update(bars[i], isNew: true);
if (double.IsFinite(sw2.SwingHigh))
{
swingCount2++;
}
if (double.IsFinite(sw5.SwingHigh))
{
swingCount5++;
}
}
// Larger lookback should generally yield fewer swings
Assert.True(swingCount2 > swingCount5 || swingCount5 == 0,
$"lookback=2 detected {swingCount2} swings, lookback=5 detected {swingCount5} — shorter lookback should detect more");
}
}