mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-19 19:18:05 +00:00
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
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
using TradingPlatform.BusinessLayer;
|
||||
using Xunit;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public sealed class SqueezeProIndicatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Indicator_Can_Be_Constructed()
|
||||
{
|
||||
var indicator = new SqueezeProIndicator();
|
||||
Assert.NotNull(indicator);
|
||||
Assert.Equal("SQUEEZE_PRO", indicator.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Indicator_Default_Period()
|
||||
{
|
||||
var indicator = new SqueezeProIndicator();
|
||||
Assert.Equal(20, indicator.Period);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Indicator_Default_BbMult()
|
||||
{
|
||||
var indicator = new SqueezeProIndicator();
|
||||
Assert.Equal(2.0, indicator.BbMult);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Indicator_Default_KcMultWide()
|
||||
{
|
||||
var indicator = new SqueezeProIndicator();
|
||||
Assert.Equal(2.0, indicator.KcMultWide);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Indicator_Default_KcMultNormal()
|
||||
{
|
||||
var indicator = new SqueezeProIndicator();
|
||||
Assert.Equal(1.5, indicator.KcMultNormal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Indicator_Default_KcMultNarrow()
|
||||
{
|
||||
var indicator = new SqueezeProIndicator();
|
||||
Assert.Equal(1.0, indicator.KcMultNarrow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Indicator_Default_MomLength()
|
||||
{
|
||||
var indicator = new SqueezeProIndicator();
|
||||
Assert.Equal(12, indicator.MomLength);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Indicator_Default_MomSmooth()
|
||||
{
|
||||
var indicator = new SqueezeProIndicator();
|
||||
Assert.Equal(6, indicator.MomSmooth);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Indicator_Default_UseSma()
|
||||
{
|
||||
var indicator = new SqueezeProIndicator();
|
||||
Assert.True(indicator.UseSma);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Indicator_ShortName_Format()
|
||||
{
|
||||
var indicator = new SqueezeProIndicator();
|
||||
Assert.Contains("SQZ_PRO", indicator.ShortName, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Indicator_Properties_Can_Be_Set()
|
||||
{
|
||||
var indicator = new SqueezeProIndicator
|
||||
{
|
||||
Period = 30,
|
||||
BbMult = 2.5,
|
||||
KcMultWide = 3.0,
|
||||
KcMultNormal = 2.0,
|
||||
KcMultNarrow = 1.5,
|
||||
MomLength = 15,
|
||||
MomSmooth = 8,
|
||||
UseSma = false
|
||||
};
|
||||
|
||||
Assert.Equal(30, indicator.Period);
|
||||
Assert.Equal(2.5, indicator.BbMult);
|
||||
Assert.Equal(3.0, indicator.KcMultWide);
|
||||
Assert.Equal(2.0, indicator.KcMultNormal);
|
||||
Assert.Equal(1.5, indicator.KcMultNarrow);
|
||||
Assert.Equal(15, indicator.MomLength);
|
||||
Assert.Equal(8, indicator.MomSmooth);
|
||||
Assert.False(indicator.UseSma);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Indicator_SourceCodeLink_Valid()
|
||||
{
|
||||
var indicator = new SqueezeProIndicator();
|
||||
Assert.Contains("SqueezePro.cs", indicator.SourceCodeLink, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Indicator_ShowColdValues_Default()
|
||||
{
|
||||
var indicator = new SqueezeProIndicator();
|
||||
Assert.True(indicator.ShowColdValues);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,619 @@
|
||||
using Xunit;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public sealed class SqueezeProTests
|
||||
{
|
||||
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));
|
||||
}
|
||||
|
||||
// === A) Constructor validation ===
|
||||
|
||||
[Fact]
|
||||
public void Constructor_InvalidPeriod_Throws()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new SqueezePro(period: 0));
|
||||
Assert.Equal("period", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NegativePeriod_Throws()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new SqueezePro(period: -1));
|
||||
Assert.Equal("period", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_InvalidBbMult_Throws()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new SqueezePro(bbMult: 0.0));
|
||||
Assert.Equal("bbMult", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NegativeBbMult_Throws()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new SqueezePro(bbMult: -1.0));
|
||||
Assert.Equal("bbMult", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_InvalidKcMultWide_Throws()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new SqueezePro(kcMultWide: 0.0));
|
||||
Assert.Equal("kcMultWide", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_InvalidKcMultNormal_Throws()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new SqueezePro(kcMultNormal: 0.0));
|
||||
Assert.Equal("kcMultNormal", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_InvalidKcMultNarrow_Throws()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new SqueezePro(kcMultNarrow: 0.0));
|
||||
Assert.Equal("kcMultNarrow", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_InvalidMomLength_Throws()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new SqueezePro(momLength: 0));
|
||||
Assert.Equal("momLength", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_InvalidMomSmooth_Throws()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new SqueezePro(momSmooth: 0));
|
||||
Assert.Equal("momSmooth", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_DefaultParams()
|
||||
{
|
||||
var sq = new SqueezePro();
|
||||
Assert.Equal("SqueezePro(20,2,2,1.5,1)", sq.Name);
|
||||
Assert.Equal(20, sq.WarmupPeriod); // Max(20, 12+6=18) = 20
|
||||
}
|
||||
|
||||
// === B) Basic calculation ===
|
||||
|
||||
[Fact]
|
||||
public void Update_ReturnsTValue()
|
||||
{
|
||||
var sq = new SqueezePro(period: 5, momLength: 3, momSmooth: 2);
|
||||
var bar = new TBar(DateTime.UtcNow, 100, 105, 95, 101, 1000);
|
||||
TValue result = sq.Update(bar);
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_Last_Momentum_Accessible()
|
||||
{
|
||||
var sq = new SqueezePro(period: 5, momLength: 3, momSmooth: 2);
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
var bar = new TBar(DateTime.UtcNow.AddMinutes(i), 100 + i, 105 + i, 95 + i, 101 + i, 1000);
|
||||
sq.Update(bar);
|
||||
}
|
||||
Assert.True(double.IsFinite(sq.Last.Value));
|
||||
Assert.True(double.IsFinite(sq.Momentum));
|
||||
Assert.NotEmpty(sq.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SqueezeLevel_IsInRange()
|
||||
{
|
||||
var sq = new SqueezePro(period: 5, momLength: 3, momSmooth: 2);
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
var bar = new TBar(DateTime.UtcNow.AddMinutes(i), 100, 101, 99, 100, 1000);
|
||||
sq.Update(bar);
|
||||
}
|
||||
Assert.InRange(sq.SqueezeLevel, 0, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConstantBars_MomentumNearZero()
|
||||
{
|
||||
var sq = new SqueezePro(period: 5, momLength: 3, momSmooth: 2);
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
var bar = new TBar(DateTime.UtcNow.AddMinutes(i), 100, 100, 100, 100, 1000);
|
||||
sq.Update(bar);
|
||||
}
|
||||
// With constant price, MOM = 0 at all times, smooth of zero = 0
|
||||
Assert.Equal(0.0, sq.Momentum, precision: 10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConstantBars_SqueezeLevel3_NarrowSqueeze()
|
||||
{
|
||||
// With constant price, BB width = 0, all KCs have width > 0 from ATR
|
||||
// Actually with constant price, ATR → 0 too, so both BB and KC collapse
|
||||
// BB upper < KC upper when stddev * bbMult < atr * kcMult
|
||||
// For constant bars: stddev=0, atr=0, so bbUpper = smaVal = kcUpper → not inside
|
||||
var sq = new SqueezePro(period: 5, momLength: 3, momSmooth: 2);
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
var bar = new TBar(DateTime.UtcNow.AddMinutes(i), 100, 100, 100, 100, 1000);
|
||||
sq.Update(bar);
|
||||
}
|
||||
// Both collapse to same value, so bbUpper == kcUpper (not strictly less) → level 0
|
||||
Assert.Equal(0, sq.SqueezeLevel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RisingBars_PositiveMomentum_AfterWarmup()
|
||||
{
|
||||
var sq = new SqueezePro(period: 10, momLength: 5, momSmooth: 3);
|
||||
for (int i = 0; i < 40; i++)
|
||||
{
|
||||
double price = 100.0 + i;
|
||||
var bar = new TBar(DateTime.UtcNow.AddMinutes(i), price, price + 1, price - 1, price, 1000);
|
||||
sq.Update(bar);
|
||||
}
|
||||
Assert.True(sq.IsHot);
|
||||
Assert.True(sq.Momentum > 0.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FallingBars_NegativeMomentum_AfterWarmup()
|
||||
{
|
||||
var sq = new SqueezePro(period: 10, momLength: 5, momSmooth: 3);
|
||||
for (int i = 0; i < 40; i++)
|
||||
{
|
||||
double price = 200.0 - i;
|
||||
var bar = new TBar(DateTime.UtcNow.AddMinutes(i), price, price + 1, price - 1, price, 1000);
|
||||
sq.Update(bar);
|
||||
}
|
||||
Assert.True(sq.IsHot);
|
||||
Assert.True(sq.Momentum < 0.0);
|
||||
}
|
||||
|
||||
// === C) Squeeze level detection ===
|
||||
|
||||
[Fact]
|
||||
public void HighVolatility_SqueezeLevelZero()
|
||||
{
|
||||
// Wide BB (high vol) with tight KC should push BB outside KC → squeeze off
|
||||
// Use very small KC multipliers so KC is narrow relative to BB
|
||||
var sq = new SqueezePro(period: 10, momLength: 3, momSmooth: 2,
|
||||
kcMultWide: 0.1, kcMultNormal: 0.05, kcMultNarrow: 0.01);
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
// Alternating large swings to create wide BB
|
||||
double swing = (i % 2 == 0) ? 50.0 : -50.0;
|
||||
double price = 100.0 + swing;
|
||||
var bar = new TBar(DateTime.UtcNow.AddMinutes(i), price, price + 20, price - 20, price, 1000);
|
||||
sq.Update(bar);
|
||||
}
|
||||
Assert.Equal(0, sq.SqueezeLevel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TightRange_SqueezeOn()
|
||||
{
|
||||
// Very tight range should create narrow BB inside KC
|
||||
var sq = new SqueezePro(period: 10, momLength: 3, momSmooth: 2);
|
||||
// First seed with some volatility to build ATR
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
double price = 100.0 + (i * 2);
|
||||
var bar = new TBar(DateTime.UtcNow.AddMinutes(i), price, price + 5, price - 5, price, 1000);
|
||||
sq.Update(bar);
|
||||
}
|
||||
// Then go very tight
|
||||
for (int i = 20; i < 50; i++)
|
||||
{
|
||||
double price = 140.0 + (i % 2 == 0 ? 0.01 : -0.01);
|
||||
var bar = new TBar(DateTime.UtcNow.AddMinutes(i), price, price + 0.01, price - 0.01, price, 1000);
|
||||
sq.Update(bar);
|
||||
}
|
||||
// After many tight bars, squeeze should be active (level > 0)
|
||||
Assert.True(sq.SqueezeLevel > 0);
|
||||
}
|
||||
|
||||
// === D) State + bar correction ===
|
||||
|
||||
[Fact]
|
||||
public void IsNew_True_Advances_State()
|
||||
{
|
||||
var sq = new SqueezePro(period: 5, momLength: 3, momSmooth: 2);
|
||||
var bars = GenerateBars(10);
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
sq.Update(bars[i], isNew: true);
|
||||
}
|
||||
double momBefore = sq.Momentum;
|
||||
|
||||
var nextBar = new TBar(DateTime.UtcNow.AddMinutes(100), 200, 210, 190, 205, 1000);
|
||||
sq.Update(nextBar, isNew: true);
|
||||
|
||||
Assert.True(double.IsFinite(sq.Momentum));
|
||||
_ = momBefore;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsNew_False_Rewrites()
|
||||
{
|
||||
var sq = new SqueezePro(period: 5, momLength: 3, momSmooth: 2);
|
||||
var bars = GenerateBars(10);
|
||||
for (int i = 0; i < 9; i++)
|
||||
{
|
||||
sq.Update(bars[i], isNew: true);
|
||||
}
|
||||
|
||||
sq.Update(bars[9], isNew: true);
|
||||
double momAfterNew = sq.Momentum;
|
||||
|
||||
var corrected = new TBar(bars[9].Time, 999, 1005, 990, 1000, 1000);
|
||||
sq.Update(corrected, isNew: false);
|
||||
double momAfterCorrect = sq.Momentum;
|
||||
|
||||
Assert.NotEqual(momAfterNew, momAfterCorrect);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IterativeCorrection_Restores()
|
||||
{
|
||||
var sq = new SqueezePro(period: 5, momLength: 3, momSmooth: 2);
|
||||
var bars = GenerateBars(15);
|
||||
for (int i = 0; i < 14; i++)
|
||||
{
|
||||
sq.Update(bars[i], isNew: true);
|
||||
}
|
||||
|
||||
sq.Update(bars[14], isNew: true);
|
||||
double momAfterTrue = sq.Momentum;
|
||||
|
||||
for (int j = 0; j < 3; j++)
|
||||
{
|
||||
sq.Update(bars[14], isNew: false);
|
||||
}
|
||||
|
||||
Assert.Equal(momAfterTrue, sq.Momentum, precision: 10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_ClearsState()
|
||||
{
|
||||
var sq = new SqueezePro(period: 5, momLength: 3, momSmooth: 2);
|
||||
var bars = GenerateBars(20);
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
sq.Update(bars[i], isNew: true);
|
||||
}
|
||||
|
||||
sq.Reset();
|
||||
|
||||
Assert.False(sq.IsHot);
|
||||
Assert.Equal(0.0, sq.Momentum);
|
||||
Assert.Equal(0, sq.SqueezeLevel);
|
||||
}
|
||||
|
||||
// === E) Warmup/convergence ===
|
||||
|
||||
[Fact]
|
||||
public void IsHot_FlipsCorrectly()
|
||||
{
|
||||
var sq = new SqueezePro(period: 5, momLength: 3, momSmooth: 2);
|
||||
var bars = GenerateBars(20);
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
sq.Update(bars[i], isNew: true);
|
||||
}
|
||||
// After enough bars (momLength + momSmooth worth), should be hot
|
||||
Assert.True(sq.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WarmupPeriod_IsMaxOfPeriodAndMomTotal()
|
||||
{
|
||||
var sq1 = new SqueezePro(period: 30, momLength: 5, momSmooth: 3);
|
||||
Assert.Equal(30, sq1.WarmupPeriod); // Max(30, 5+3=8) = 30
|
||||
|
||||
var sq2 = new SqueezePro(period: 5, momLength: 20, momSmooth: 10);
|
||||
Assert.Equal(30, sq2.WarmupPeriod); // Max(5, 20+10=30) = 30
|
||||
}
|
||||
|
||||
// === F) Robustness ===
|
||||
|
||||
[Fact]
|
||||
public void NaN_Input_UsesLastValid()
|
||||
{
|
||||
var sq = new SqueezePro(period: 5, momLength: 3, momSmooth: 2);
|
||||
var bars = GenerateBars(10);
|
||||
for (int i = 0; i < 9; i++)
|
||||
{
|
||||
sq.Update(bars[i], isNew: true);
|
||||
}
|
||||
|
||||
var nanBar = new TBar(DateTime.UtcNow.AddMinutes(100), double.NaN, double.NaN, double.NaN, double.NaN, 0);
|
||||
sq.Update(nanBar, isNew: true);
|
||||
// Should not throw
|
||||
Assert.True(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Infinity_Input_Handled()
|
||||
{
|
||||
var sq = new SqueezePro(period: 5, momLength: 3, momSmooth: 2);
|
||||
var bars = GenerateBars(10);
|
||||
for (int i = 0; i < 9; i++)
|
||||
{
|
||||
sq.Update(bars[i], isNew: true);
|
||||
}
|
||||
|
||||
var infBar = new TBar(DateTime.UtcNow.AddMinutes(100),
|
||||
double.PositiveInfinity, double.PositiveInfinity, double.NegativeInfinity, double.PositiveInfinity, 0);
|
||||
sq.Update(infBar, isNew: true);
|
||||
Assert.True(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MixedNaN_NoThrow()
|
||||
{
|
||||
var sq = new SqueezePro(period: 5, momLength: 3, momSmooth: 2);
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
TBar bar;
|
||||
if (i % 5 == 0)
|
||||
{
|
||||
bar = new TBar(DateTime.UtcNow.AddMinutes(i), double.NaN, double.NaN, double.NaN, double.NaN, 0);
|
||||
}
|
||||
else
|
||||
{
|
||||
bar = new TBar(DateTime.UtcNow.AddMinutes(i), 100 + i, 105 + i, 95 + i, 101 + i, 1000);
|
||||
}
|
||||
sq.Update(bar, isNew: true);
|
||||
}
|
||||
Assert.True(true);
|
||||
}
|
||||
|
||||
// === G) EMA smoothing mode ===
|
||||
|
||||
[Fact]
|
||||
public void EmaMode_ProducesFiniteValues()
|
||||
{
|
||||
var sq = new SqueezePro(period: 10, momLength: 5, momSmooth: 3, useSma: false);
|
||||
var bars = GenerateBars(40);
|
||||
for (int i = 0; i < 40; i++)
|
||||
{
|
||||
sq.Update(bars[i], isNew: true);
|
||||
}
|
||||
Assert.True(double.IsFinite(sq.Momentum));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmaMode_DiffersFromSma()
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
// SMA and EMA smoothing should produce different momentum values
|
||||
Assert.NotEqual(sqSma.Momentum, sqEma.Momentum);
|
||||
}
|
||||
|
||||
// === H) Consistency ===
|
||||
|
||||
[Fact]
|
||||
public void BatchCalc_MatchesStreaming()
|
||||
{
|
||||
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 streamMom = sq.Momentum;
|
||||
|
||||
var (batchMom, _) = SqueezePro.Batch(bars, period: 10, momLength: 5, momSmooth: 3);
|
||||
double batchLast = batchMom[^1].Value;
|
||||
|
||||
Assert.Equal(streamMom, batchLast, precision: 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpanBatch_MatchesStreaming()
|
||||
{
|
||||
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 streamMom = sq.Momentum;
|
||||
|
||||
double[] momOut = new double[50];
|
||||
double[] sqOut = new double[50];
|
||||
SqueezePro.Batch(bars.HighValues, bars.LowValues, bars.CloseValues,
|
||||
momOut, sqOut, period: 10, momLength: 5, momSmooth: 3);
|
||||
double spanLast = momOut[49];
|
||||
|
||||
Assert.Equal(streamMom, spanLast, precision: 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EventingMode_MatchesStreaming()
|
||||
{
|
||||
var bars = GenerateBars(50);
|
||||
|
||||
var sqStream = new SqueezePro(period: 10, momLength: 5, momSmooth: 3);
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
sqStream.Update(bars[i], isNew: true);
|
||||
}
|
||||
double streamMom = sqStream.Momentum;
|
||||
|
||||
var sqEvent = new SqueezePro(bars, period: 10, momLength: 5, momSmooth: 3);
|
||||
Assert.Equal(streamMom, sqEvent.Momentum, precision: 6);
|
||||
}
|
||||
|
||||
// === I) Span API tests ===
|
||||
|
||||
[Fact]
|
||||
public void BatchSpan_ThrowsOnInvalidPeriod()
|
||||
{
|
||||
double[] h = [100, 101, 102];
|
||||
double[] l = [99, 100, 101];
|
||||
double[] c = [100, 101, 102];
|
||||
double[] mom = new double[3];
|
||||
double[] sq = new double[3];
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
SqueezePro.Batch(h, l, c, mom, sq, period: 0));
|
||||
Assert.Equal("period", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BatchSpan_ThrowsOnMismatchedLengths()
|
||||
{
|
||||
double[] h = [100, 101];
|
||||
double[] l = [99];
|
||||
double[] c = [100, 101];
|
||||
double[] mom = new double[2];
|
||||
double[] sq = new double[2];
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
SqueezePro.Batch(h, l, c, mom, sq, period: 5));
|
||||
Assert.Equal("high", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BatchSpan_ThrowsOnShortMomOutput()
|
||||
{
|
||||
double[] h = [100, 101, 102, 103, 104];
|
||||
double[] l = [99, 100, 101, 102, 103];
|
||||
double[] c = [100, 101, 102, 103, 104];
|
||||
double[] mom = new double[2]; // too short
|
||||
double[] sq = new double[5];
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
SqueezePro.Batch(h, l, c, mom, sq, period: 3));
|
||||
Assert.Equal("momOut", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BatchSpan_ThrowsOnShortSqOutput()
|
||||
{
|
||||
double[] h = [100, 101, 102, 103, 104];
|
||||
double[] l = [99, 100, 101, 102, 103];
|
||||
double[] c = [100, 101, 102, 103, 104];
|
||||
double[] mom = new double[5];
|
||||
double[] sq = new double[2]; // too short
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
SqueezePro.Batch(h, l, c, mom, sq, period: 3));
|
||||
Assert.Equal("sqOut", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BatchSpan_ThrowsOnInvalidMomLength()
|
||||
{
|
||||
double[] h = [100, 101, 102];
|
||||
double[] l = [99, 100, 101];
|
||||
double[] c = [100, 101, 102];
|
||||
double[] mom = new double[3];
|
||||
double[] sq = new double[3];
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
SqueezePro.Batch(h, l, c, mom, sq, momLength: 0));
|
||||
Assert.Equal("momLength", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BatchSpan_ThrowsOnInvalidMomSmooth()
|
||||
{
|
||||
double[] h = [100, 101, 102];
|
||||
double[] l = [99, 100, 101];
|
||||
double[] c = [100, 101, 102];
|
||||
double[] mom = new double[3];
|
||||
double[] sq = new double[3];
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
SqueezePro.Batch(h, l, c, mom, sq, momSmooth: 0));
|
||||
Assert.Equal("momSmooth", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BatchSpan_LargeData_NoStackOverflow()
|
||||
{
|
||||
const int size = 2000;
|
||||
var gbm = new GBM(100.0, 0.02, 0.15, seed: 1);
|
||||
var bars = gbm.Fetch(size, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
double[] mom = new double[size];
|
||||
double[] sq = new double[size];
|
||||
// period=300 forces ArrayPool path
|
||||
SqueezePro.Batch(bars.HighValues, bars.LowValues, bars.CloseValues, mom, sq, period: 300);
|
||||
Assert.True(double.IsFinite(mom[size - 1]));
|
||||
}
|
||||
|
||||
// === J) Chainability ===
|
||||
|
||||
[Fact]
|
||||
public void PubEvent_Fires()
|
||||
{
|
||||
var sq = new SqueezePro(period: 5, momLength: 3, momSmooth: 2);
|
||||
int fireCount = 0;
|
||||
sq.Pub += (_, in e) => fireCount++;
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
var bar = new TBar(DateTime.UtcNow.AddMinutes(i), 100, 105, 95, 101, 1000);
|
||||
sq.Update(bar, isNew: true);
|
||||
}
|
||||
Assert.Equal(10, fireCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TBarSeries_Constructor_Subscribes()
|
||||
{
|
||||
var bars = GenerateBars(30);
|
||||
var sq = new SqueezePro(bars, period: 10, momLength: 5, momSmooth: 3);
|
||||
|
||||
Assert.True(sq.IsHot);
|
||||
Assert.True(double.IsFinite(sq.Momentum));
|
||||
}
|
||||
|
||||
// === K) Calculate factory ===
|
||||
|
||||
[Fact]
|
||||
public void Calculate_ReturnsResultsAndIndicator()
|
||||
{
|
||||
var bars = GenerateBars(30);
|
||||
var ((momSeries, sqSeries), indicator) = SqueezePro.Calculate(bars, period: 10, momLength: 5, momSmooth: 3);
|
||||
|
||||
Assert.Equal(30, momSeries.Count);
|
||||
Assert.Equal(30, sqSeries.Count);
|
||||
Assert.NotNull(indicator);
|
||||
Assert.True(double.IsFinite(indicator.Momentum));
|
||||
}
|
||||
|
||||
// === L) Squeeze level output values ===
|
||||
|
||||
[Fact]
|
||||
public void BatchSqueezeLevels_AreInRange()
|
||||
{
|
||||
var bars = GenerateBars(100);
|
||||
double[] mom = new double[100];
|
||||
double[] sq = new double[100];
|
||||
SqueezePro.Batch(bars.HighValues, bars.LowValues, bars.CloseValues, mom, sq, period: 10, momLength: 5, momSmooth: 3);
|
||||
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
Assert.InRange(sq[i], 0.0, 3.0);
|
||||
Assert.True(sq[i] == 0.0 || sq[i] == 1.0 || sq[i] == 2.0 || sq[i] == 3.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user