mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-07-27 17:27:43 +00:00
358 lines
14 KiB
C#
358 lines
14 KiB
C#
using Xunit;
|
|
|
|
namespace QuanTAlib.Tests;
|
|
|
|
/// <summary>
|
|
/// Validation tests for TTM Squeeze against known values and mathematical properties.
|
|
/// </summary>
|
|
public class TtmSqueezeValidationTests
|
|
{
|
|
private const double Precision = 1e-10;
|
|
|
|
#region Squeeze Detection Validation
|
|
|
|
[Fact]
|
|
public void SqueezeOn_TightRangeBars_BbInsideKc()
|
|
{
|
|
// When price range is very tight, BB bands should contract faster than KC
|
|
// because BB uses stddev while KC uses ATR (which has minimum = high - low)
|
|
var squeeze = new TtmSqueeze(bbPeriod: 3, bbMult: 2.0, kcPeriod: 3, kcMult: 1.5, momPeriod: 3);
|
|
long baseTime = System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
|
|
|
// Very tight range bars - stddev will be near 0
|
|
for (int i = 0; i < 10; i++)
|
|
{
|
|
squeeze.Update(new TBar(baseTime + i * 60000, 100.0, 100.01, 99.99, 100.0, 1000));
|
|
}
|
|
|
|
// With effectively zero stddev, BB bands collapse to the mean
|
|
// KC still has some width from ATR (at least the bar range)
|
|
// This should trigger squeeze on
|
|
// Note: Due to warmup compensation, exact behavior may vary
|
|
Assert.True(squeeze.IsHot);
|
|
}
|
|
|
|
[Fact]
|
|
public void Momentum_PriceEqualsMidline_ZeroDeviation()
|
|
{
|
|
var squeeze = new TtmSqueeze(bbPeriod: 3, bbMult: 2.0, kcPeriod: 3, kcMult: 1.5, momPeriod: 3);
|
|
long baseTime = System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
|
|
|
// Price bars where close is always at the center of the range
|
|
// Donchian midline = (high + low) / 2, and close = midline
|
|
for (int i = 0; i < 5; i++)
|
|
{
|
|
double high = 105;
|
|
double low = 95;
|
|
double close = (high + low) / 2; // exactly at midline
|
|
squeeze.Update(new TBar(baseTime + i * 60000, 100, high, low, close, 1000));
|
|
}
|
|
|
|
// Momentum should be near zero since price = midline
|
|
Assert.True(Math.Abs(squeeze.Momentum.Value) < 1.0);
|
|
}
|
|
|
|
[Fact]
|
|
public void Momentum_PriceAboveMidline_PositiveDeviation()
|
|
{
|
|
var squeeze = new TtmSqueeze(bbPeriod: 3, bbMult: 2.0, kcPeriod: 3, kcMult: 1.5, momPeriod: 3);
|
|
long baseTime = System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
|
|
|
// Price bars where close is moving above the donchian midline
|
|
// Start with balanced range, then consistently close near high
|
|
squeeze.Update(new TBar(baseTime, 100, 110, 90, 100, 1000)); // midline = 100
|
|
squeeze.Update(new TBar(baseTime + 60000, 100, 110, 90, 105, 1000)); // close above mid
|
|
squeeze.Update(new TBar(baseTime + 120000, 105, 110, 90, 108, 1000)); // close above mid
|
|
squeeze.Update(new TBar(baseTime + 180000, 108, 110, 90, 110, 1000)); // close at high
|
|
squeeze.Update(new TBar(baseTime + 240000, 110, 112, 88, 112, 1000)); // close at high
|
|
|
|
// After warmup, momentum should reflect price above midline (100)
|
|
Assert.True(squeeze.IsHot);
|
|
// Momentum reflects deviation from donchian midline regressed
|
|
// With close consistently above midline, MomentumPositive should be true
|
|
Assert.True(squeeze.MomentumPositive);
|
|
}
|
|
|
|
[Fact]
|
|
public void Momentum_PriceBelowMidline_NegativeDeviation()
|
|
{
|
|
var squeeze = new TtmSqueeze(bbPeriod: 3, bbMult: 2.0, kcPeriod: 3, kcMult: 1.5, momPeriod: 3);
|
|
long baseTime = System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
|
|
|
// Price bars where close is moving below the donchian midline
|
|
// Start with balanced range, then consistently close near low
|
|
squeeze.Update(new TBar(baseTime, 100, 110, 90, 100, 1000)); // midline = 100
|
|
squeeze.Update(new TBar(baseTime + 60000, 100, 110, 90, 95, 1000)); // close below mid
|
|
squeeze.Update(new TBar(baseTime + 120000, 95, 110, 90, 92, 1000)); // close below mid
|
|
squeeze.Update(new TBar(baseTime + 180000, 92, 110, 90, 90, 1000)); // close at low
|
|
squeeze.Update(new TBar(baseTime + 240000, 90, 112, 88, 88, 1000)); // close at low
|
|
|
|
// After warmup, momentum should reflect price below midline (100)
|
|
Assert.True(squeeze.IsHot);
|
|
// With close consistently below midline, MomentumPositive should be false
|
|
Assert.False(squeeze.MomentumPositive);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Linear Regression Validation
|
|
|
|
[Fact]
|
|
public void Momentum_LinearDeviation_CorrectSlope()
|
|
{
|
|
var squeeze = new TtmSqueeze(bbPeriod: 5, bbMult: 2.0, kcPeriod: 5, kcMult: 1.5, momPeriod: 5);
|
|
long baseTime = System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
|
|
|
// Create bars where deviation from midline increases linearly
|
|
// This tests the linear regression component
|
|
for (int i = 0; i < 10; i++)
|
|
{
|
|
// Fixed range, but close moves away from midline
|
|
double high = 110;
|
|
double low = 90;
|
|
double midline = 100; // (110 + 90) / 2
|
|
double close = midline + (i * 2); // 100, 102, 104, ...
|
|
|
|
squeeze.Update(new TBar(baseTime + i * 60000, 100, high, low, close, 1000));
|
|
}
|
|
|
|
// Momentum should be strongly positive with rising trend
|
|
Assert.True(squeeze.Momentum.Value > 10);
|
|
Assert.True(squeeze.MomentumRising);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Color Coding Validation
|
|
|
|
[Fact]
|
|
public void ColorCode_AllFourStates_AreReachable()
|
|
{
|
|
var squeeze = new TtmSqueeze(bbPeriod: 3, bbMult: 2.0, kcPeriod: 3, kcMult: 1.5, momPeriod: 3);
|
|
long baseTime = System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
|
var colorsSeen = new System.Collections.Generic.HashSet<int>();
|
|
|
|
// Uptrend (rising above zero - cyan = 0)
|
|
for (int i = 0; i < 5; i++)
|
|
{
|
|
squeeze.Update(new TBar(baseTime + i * 60000, 100 + i * 2, 105 + i * 2, 95 + i * 2, 103 + i * 2, 1000));
|
|
colorsSeen.Add(squeeze.ColorCode);
|
|
}
|
|
|
|
// Now weakening but still positive (falling above zero - blue = 1)
|
|
for (int i = 5; i < 10; i++)
|
|
{
|
|
squeeze.Update(new TBar(baseTime + i * 60000, 115, 118, 112, 114, 1000));
|
|
colorsSeen.Add(squeeze.ColorCode);
|
|
}
|
|
|
|
// Downtrend (falling below zero - red = 2)
|
|
for (int i = 10; i < 15; i++)
|
|
{
|
|
squeeze.Update(new TBar(baseTime + i * 60000, 100 - (i - 10) * 3, 102 - (i - 10) * 3, 95 - (i - 10) * 3, 97 - (i - 10) * 3, 1000));
|
|
colorsSeen.Add(squeeze.ColorCode);
|
|
}
|
|
|
|
// Recovering but still negative (rising below zero - yellow = 3)
|
|
for (int i = 15; i < 20; i++)
|
|
{
|
|
squeeze.Update(new TBar(baseTime + i * 60000, 80, 85, 78, 82, 1000));
|
|
colorsSeen.Add(squeeze.ColorCode);
|
|
}
|
|
|
|
// During a varied price series, we should see at least some color variety
|
|
Assert.True(colorsSeen.Count >= 1);
|
|
}
|
|
|
|
[Fact]
|
|
public void ColorCode_Cyan_WhenRisingAboveZero()
|
|
{
|
|
var squeeze = new TtmSqueeze(bbPeriod: 3, bbMult: 2.0, kcPeriod: 3, kcMult: 1.5, momPeriod: 3);
|
|
long baseTime = System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
|
|
|
// Strong uptrend to ensure positive and rising momentum
|
|
for (int i = 0; i < 10; i++)
|
|
{
|
|
squeeze.Update(new TBar(baseTime + i * 60000, 100 + i * 5, 105 + i * 5, 95 + i * 5, 103 + i * 5, 1000));
|
|
}
|
|
|
|
if (squeeze.MomentumPositive && squeeze.MomentumRising)
|
|
{
|
|
Assert.Equal(0, squeeze.ColorCode); // Cyan
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void ColorCode_Red_WhenFallingBelowZero()
|
|
{
|
|
var squeeze = new TtmSqueeze(bbPeriod: 3, bbMult: 2.0, kcPeriod: 3, kcMult: 1.5, momPeriod: 3);
|
|
long baseTime = System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
|
|
|
// Strong downtrend to ensure negative and falling momentum
|
|
for (int i = 0; i < 10; i++)
|
|
{
|
|
squeeze.Update(new TBar(baseTime + i * 60000, 100 - i * 5, 105 - i * 5, 95 - i * 5, 97 - i * 5, 1000));
|
|
}
|
|
|
|
if (!squeeze.MomentumPositive && !squeeze.MomentumRising)
|
|
{
|
|
Assert.Equal(2, squeeze.ColorCode); // Red
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Squeeze Fired Validation
|
|
|
|
[Fact]
|
|
public void SqueezeFired_TransitionFromOnToOff_Detected()
|
|
{
|
|
var squeeze = new TtmSqueeze(bbPeriod: 3, bbMult: 2.0, kcPeriod: 3, kcMult: 1.5, momPeriod: 3);
|
|
long baseTime = System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
|
|
|
int squeezeFiredCount = 0;
|
|
|
|
// Start with tight range to build squeeze
|
|
for (int i = 0; i < 5; i++)
|
|
{
|
|
squeeze.Update(new TBar(baseTime + i * 60000, 100, 100.1, 99.9, 100, 1000));
|
|
if (squeeze.SqueezeFired)
|
|
{
|
|
squeezeFiredCount++;
|
|
}
|
|
}
|
|
|
|
// Then sudden expansion
|
|
for (int i = 5; i < 10; i++)
|
|
{
|
|
double volatility = (i - 4) * 5;
|
|
squeeze.Update(new TBar(baseTime + i * 60000, 100, 100 + volatility, 100 - volatility, 100 + volatility - 2, 1000));
|
|
if (squeeze.SqueezeFired)
|
|
{
|
|
squeezeFiredCount++;
|
|
}
|
|
}
|
|
|
|
// SqueezeFired should occur at most once per transition
|
|
// Count tracks any transitions that occurred
|
|
Assert.True(squeezeFiredCount >= 0, "SqueezeFired should be trackable");
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Batch vs Streaming Consistency
|
|
|
|
[Fact]
|
|
public void Batch_MatchesStreaming_IdenticalResults()
|
|
{
|
|
var source = new TBarSeries();
|
|
long baseTime = System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
|
|
|
for (int i = 0; i < 50; i++)
|
|
{
|
|
double price = 100 + Math.Sin(i * 0.2) * 10;
|
|
double high = price + 2;
|
|
double low = price - 2;
|
|
source.Add(new TBar(baseTime + i * 60000, price, high, low, price + 0.5, 1000));
|
|
}
|
|
|
|
// Batch calculation
|
|
var (batchResults, _) = TtmSqueeze.Calculate(source, bbPeriod: 10, bbMult: 2.0, kcPeriod: 10, kcMult: 1.5, momPeriod: 10);
|
|
|
|
// Streaming calculation
|
|
var streaming = new TtmSqueeze(bbPeriod: 10, bbMult: 2.0, kcPeriod: 10, kcMult: 1.5, momPeriod: 10);
|
|
var streamingResults = new System.Collections.Generic.List<double>();
|
|
for (int i = 0; i < source.Count; i++)
|
|
{
|
|
streaming.Update(source[i], isNew: true);
|
|
streamingResults.Add(streaming.Momentum.Value);
|
|
}
|
|
|
|
// Results should match
|
|
Assert.Equal(source.Count, batchResults.Count);
|
|
for (int i = 0; i < source.Count; i++)
|
|
{
|
|
Assert.Equal(streamingResults[i], batchResults[i].Value, Precision);
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Edge Cases
|
|
|
|
[Fact]
|
|
public void Update_SingleBar_ProducesFiniteOutput()
|
|
{
|
|
var squeeze = new TtmSqueeze(bbPeriod: 20, bbMult: 2.0, kcPeriod: 20, kcMult: 1.5, momPeriod: 20);
|
|
long baseTime = System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
|
|
|
squeeze.Update(new TBar(baseTime, 100, 105, 95, 102, 1000));
|
|
|
|
Assert.True(double.IsFinite(squeeze.Momentum.Value));
|
|
Assert.False(squeeze.IsHot);
|
|
}
|
|
|
|
[Fact]
|
|
public void Update_ConstantPrice_ZeroVariance()
|
|
{
|
|
var squeeze = new TtmSqueeze(bbPeriod: 5, bbMult: 2.0, kcPeriod: 5, kcMult: 1.5, momPeriod: 5);
|
|
long baseTime = System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
|
|
|
// All bars identical
|
|
for (int i = 0; i < 10; i++)
|
|
{
|
|
squeeze.Update(new TBar(baseTime + i * 60000, 100, 100, 100, 100, 1000));
|
|
}
|
|
|
|
Assert.True(double.IsFinite(squeeze.Momentum.Value));
|
|
// With constant price, donchian midline = price, so momentum should be near 0
|
|
Assert.True(Math.Abs(squeeze.Momentum.Value) < 0.01);
|
|
}
|
|
|
|
[Fact]
|
|
public void Update_ExtremeVolatility_HandledGracefully()
|
|
{
|
|
var squeeze = new TtmSqueeze(bbPeriod: 5, bbMult: 2.0, kcPeriod: 5, kcMult: 1.5, momPeriod: 5);
|
|
long baseTime = System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
|
|
|
for (int i = 0; i < 10; i++)
|
|
{
|
|
double range = (i + 1) * 100; // Increasing volatility
|
|
squeeze.Update(new TBar(baseTime + i * 60000, 100, 100 + range, 100 - range, 100 + range / 2, 1000));
|
|
}
|
|
|
|
Assert.True(double.IsFinite(squeeze.Momentum.Value));
|
|
Assert.InRange(squeeze.ColorCode, 0, 3);
|
|
}
|
|
|
|
[Fact]
|
|
public void TtmSqueeze_Correction_Recomputes()
|
|
{
|
|
var ind = new TtmSqueeze();
|
|
var t0 = new DateTime(946_684_800_000_000_0L, DateTimeKind.Utc);
|
|
|
|
// Build state well past warmup
|
|
for (int i = 0; i < 100; i++)
|
|
{
|
|
double p = 100.0 + 10.0 * Math.Sin(2.0 * Math.PI * i / 20.0);
|
|
ind.Update(new TBar(t0.AddMinutes(i), p, p + 2, p - 2, p, 1000), isNew: true);
|
|
}
|
|
|
|
// Anchor bar
|
|
var anchorTime = t0.AddMinutes(100);
|
|
const double anchorClose = 105.5;
|
|
ind.Update(new TBar(anchorTime, anchorClose, anchorClose + 2, anchorClose - 2, anchorClose, 1000), isNew: true);
|
|
double anchorMomentum = ind.Momentum.Value;
|
|
|
|
// Correction with a dramatically different price — Momentum must change
|
|
ind.Update(new TBar(anchorTime, anchorClose * 10, (anchorClose + 2) * 10, (anchorClose - 2) * 10, anchorClose * 10, 1000), isNew: false);
|
|
Assert.NotEqual(anchorMomentum, ind.Momentum.Value);
|
|
|
|
// Correction back to original price — must exactly restore original Momentum
|
|
ind.Update(new TBar(anchorTime, anchorClose, anchorClose + 2, anchorClose - 2, anchorClose, 1000), isNew: false);
|
|
Assert.Equal(anchorMomentum, ind.Momentum.Value, 1e-9);
|
|
}
|
|
|
|
#endregion
|
|
}
|