Files
Miha Kralj 15f4bb90f3 feat: add 8 new indicators with full integration
New indicators:
- HWC (Holt-Winters Channel) — channels, 27 tests
- VWMACD (Volume-Weighted MACD) — momentum, 38 tests
- Squeeze Pro — oscillators, 69 tests
- BW_MFI (Bill Williams MFI) — oscillators
- DSTOCH (Double Stochastic) — oscillators
- ATRSTOP (ATR Trailing Stop) — reversals
- VSTOP (Volatility Stop) — reversals
- Convexity (Beta Convexity) — statistics, 23 tests

Integration:
- Python bridge: Exports.cs, _bridge.py, wrapper modules
- Documentation: _sidebar.md, _index.md pages, SPEC.md
- All analyzer warnings fixed (MA0074, xUnit2013, S2699)

Build: 0 warnings, 0 errors | Tests: 15,933 passed, 0 failed
2026-03-17 08:35:29 -07:00

253 lines
8.0 KiB
C#

using Xunit;
namespace QuanTAlib.Tests;
/// <summary>
/// Validation tests for SqueezePro indicator.
/// Tests determinism, identity properties, and mathematical invariants.
/// </summary>
public sealed class SqueezeProValidationTests
{
private static TBarSeries GenerateBars(int count, int seed = 42)
{
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.15, seed: seed);
return gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
}
// === Determinism ===
[Theory]
[InlineData(10, 2.0, 2.0, 1.5, 1.0, 5, 3, true)]
[InlineData(20, 2.0, 2.0, 1.5, 1.0, 12, 6, true)]
[InlineData(15, 1.5, 3.0, 2.0, 1.0, 8, 4, false)]
public void DifferentParams_Deterministic(int period, double bbMult,
double kcWide, double kcNormal, double kcNarrow, int momLen, int momSmooth, bool useSma)
{
var bars = GenerateBars(50);
var sq1 = new SqueezePro(period, bbMult, kcWide, kcNormal, kcNarrow, momLen, momSmooth, useSma);
var sq2 = new SqueezePro(period, bbMult, kcWide, kcNormal, kcNarrow, momLen, momSmooth, useSma);
for (int i = 0; i < 50; i++)
{
sq1.Update(bars[i], isNew: true);
sq2.Update(bars[i], isNew: true);
}
Assert.Equal(sq1.Momentum, sq2.Momentum, precision: 12);
Assert.Equal(sq1.SqueezeLevel, sq2.SqueezeLevel);
}
// === Streaming vs Batch consistency ===
[Fact]
public void Streaming_Equals_Batch_AllBars()
{
var bars = GenerateBars(80);
const int period = 15;
const int momLen = 8;
const int momSmooth = 4;
// Streaming
var sq = new SqueezePro(period, momLength: momLen, momSmooth: momSmooth);
double[] streamMom = new double[80];
int[] streamSq = new int[80];
for (int i = 0; i < 80; i++)
{
sq.Update(bars[i], isNew: true);
streamMom[i] = sq.Momentum;
streamSq[i] = sq.SqueezeLevel;
}
// Batch
double[] batchMom = new double[80];
double[] batchSq = new double[80];
SqueezePro.Batch(bars.HighValues, bars.LowValues, bars.CloseValues,
batchMom, batchSq, period, momLength: momLen, momSmooth: momSmooth);
for (int i = 0; i < 80; i++)
{
Assert.Equal(streamMom[i], batchMom[i], precision: 6);
Assert.Equal(streamSq[i], (int)batchSq[i]);
}
}
// === Squeeze hierarchy: narrow ⊂ normal ⊂ wide ===
[Fact]
public void SqueezeHierarchy_NarrowImpliesNormal()
{
var bars = GenerateBars(200, seed: 99);
var sq = new SqueezePro(period: 20, momLength: 12, momSmooth: 6);
for (int i = 0; i < 200; i++)
{
sq.Update(bars[i], isNew: true);
// If narrow squeeze (3), then it must also satisfy normal squeeze
// Since level is classified as max level, if level=3, it means insideNarrow was true
// which implies insideNormal was also true
if (sq.SqueezeLevel == 3)
{
// Narrow squeeze is only possible when also inside normal and wide
Assert.True(sq.SqueezeLevel >= 2);
}
}
}
// === Momentum sign under trending conditions ===
[Fact]
public void StrongUptrend_PersistentPositiveMomentum()
{
var sq = new SqueezePro(period: 10, momLength: 5, momSmooth: 3);
int positiveCount = 0;
int totalHot = 0;
for (int i = 0; i < 100; i++)
{
double price = 100.0 + (i * 2.0); // strong uptrend
var bar = new TBar(DateTime.UtcNow.AddMinutes(i), price, price + 1, price - 1, price, 1000);
sq.Update(bar);
if (sq.IsHot)
{
totalHot++;
if (sq.Momentum > 0) { positiveCount++; }
}
}
// In a strong uptrend, momentum should be positive most of the time
Assert.True(totalHot > 0);
double ratio = (double)positiveCount / totalHot;
Assert.True(ratio > 0.9, $"Expected >90% positive momentum in uptrend, got {ratio:P1}");
}
[Fact]
public void StrongDowntrend_PersistentNegativeMomentum()
{
var sq = new SqueezePro(period: 10, momLength: 5, momSmooth: 3);
int negativeCount = 0;
int totalHot = 0;
for (int i = 0; i < 100; i++)
{
double price = 500.0 - (i * 2.0); // strong downtrend
var bar = new TBar(DateTime.UtcNow.AddMinutes(i), price, price + 1, price - 1, price, 1000);
sq.Update(bar);
if (sq.IsHot)
{
totalHot++;
if (sq.Momentum < 0) { negativeCount++; }
}
}
Assert.True(totalHot > 0);
double ratio = (double)negativeCount / totalHot;
Assert.True(ratio > 0.9, $"Expected >90% negative momentum in downtrend, got {ratio:P1}");
}
// === KC multiplier ordering ===
[Fact]
public void LargerKcMult_MoreSqueeze()
{
// Larger KC multiplier = wider KC = easier for BB to be inside = more squeeze
var bars = GenerateBars(100, seed: 77);
var sqTight = new SqueezePro(period: 20, kcMultWide: 1.0, kcMultNormal: 0.8, kcMultNarrow: 0.5);
var sqWide = new SqueezePro(period: 20, kcMultWide: 3.0, kcMultNormal: 2.5, kcMultNarrow: 2.0);
int tightSqueezeCount = 0;
int wideSqueezeCount = 0;
for (int i = 0; i < 100; i++)
{
sqTight.Update(bars[i], isNew: true);
sqWide.Update(bars[i], isNew: true);
if (sqTight.SqueezeLevel > 0) { tightSqueezeCount++; }
if (sqWide.SqueezeLevel > 0) { wideSqueezeCount++; }
}
// Wider KC should detect more squeeze instances
Assert.True(wideSqueezeCount >= tightSqueezeCount,
$"Wide KC squeeze count ({wideSqueezeCount}) should be >= tight KC ({tightSqueezeCount})");
}
// === Reset and replay ===
[Fact]
public void ResetAndReplay_SameResults()
{
var bars = GenerateBars(50);
var sq = new SqueezePro(period: 10, momLength: 5, momSmooth: 3);
for (int i = 0; i < 50; i++)
{
sq.Update(bars[i], isNew: true);
}
double mom1 = sq.Momentum;
int level1 = sq.SqueezeLevel;
sq.Reset();
for (int i = 0; i < 50; i++)
{
sq.Update(bars[i], isNew: true);
}
Assert.Equal(mom1, sq.Momentum, precision: 10);
Assert.Equal(level1, sq.SqueezeLevel);
}
// === Boundary: period=1 ===
[Fact]
public void MinimalPeriod_NoThrow()
{
var sq = new SqueezePro(period: 1, momLength: 1, momSmooth: 1);
var bars = GenerateBars(20);
for (int i = 0; i < 20; i++)
{
sq.Update(bars[i], isNew: true);
}
Assert.True(double.IsFinite(sq.Momentum));
}
// === Large period — ArrayPool path ===
[Fact]
public void LargePeriod_ArrayPoolPath()
{
var bars = GenerateBars(500, seed: 88);
double[] mom = new double[500];
double[] sq = new double[500];
// total buffers = 300 + 50 + 20 = 370 > 256 → ArrayPool
SqueezePro.Batch(bars.HighValues, bars.LowValues, bars.CloseValues,
mom, sq, period: 300, momLength: 50, momSmooth: 20);
Assert.True(double.IsFinite(mom[499]));
}
// === EMA vs SMA smoothing same seed ===
[Fact]
public void EmaVsSma_SameSqueezeLevel()
{
// Smoothing mode only affects momentum, not squeeze detection
var bars = GenerateBars(50);
var sqSma = new SqueezePro(period: 10, momLength: 5, momSmooth: 3, useSma: true);
var sqEma = new SqueezePro(period: 10, momLength: 5, momSmooth: 3, useSma: false);
for (int i = 0; i < 50; i++)
{
sqSma.Update(bars[i], isNew: true);
sqEma.Update(bars[i], isNew: true);
// Squeeze level should be identical regardless of smoothing mode
Assert.Equal(sqSma.SqueezeLevel, sqEma.SqueezeLevel);
}
}
}