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:
Miha Kralj
2026-03-17 08:35:29 -07:00
parent 6f0a339c9b
commit 15f4bb90f3
71 changed files with 10194 additions and 44 deletions
@@ -0,0 +1,94 @@
using System.Drawing;
using System.Runtime.CompilerServices;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
[SkipLocalsInit]
public sealed class SqueezeProIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Period", sortIndex: 1, 1, 500, 1, 0)]
public int Period { get; set; } = 20;
[InputParameter("BB Multiplier", sortIndex: 2, 0.001, 10.0, 0.1, 1)]
public double BbMult { get; set; } = 2.0;
[InputParameter("KC Wide Multiplier", sortIndex: 3, 0.001, 10.0, 0.1, 1)]
public double KcMultWide { get; set; } = 2.0;
[InputParameter("KC Normal Multiplier", sortIndex: 4, 0.001, 10.0, 0.1, 1)]
public double KcMultNormal { get; set; } = 1.5;
[InputParameter("KC Narrow Multiplier", sortIndex: 5, 0.001, 10.0, 0.1, 1)]
public double KcMultNarrow { get; set; } = 1.0;
[InputParameter("Momentum Length", sortIndex: 6, 1, 500, 1, 0)]
public int MomLength { get; set; } = 12;
[InputParameter("Momentum Smooth", sortIndex: 7, 1, 500, 1, 0)]
public int MomSmooth { get; set; } = 6;
[InputParameter("Use SMA (unchecked = EMA)", sortIndex: 8)]
public bool UseSma { get; set; } = true;
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private SqueezePro _squeezePro = null!;
private readonly LineSeries _momentumSeries;
private readonly LineSeries _squeezeSeries;
public static int MinHistoryDepths => 0;
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => $"SQZ_PRO {Period},{BbMult},{KcMultWide},{KcMultNormal},{KcMultNarrow}";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/oscillators/squeeze_pro/SqueezePro.cs";
public SqueezeProIndicator()
{
OnBackGround = true;
SeparateWindow = true;
Name = "SQUEEZE_PRO";
Description = "Squeeze Pro: Multi-level BB vs KC squeeze detection with MOM-smoothed momentum";
_momentumSeries = new LineSeries(name: "Momentum", color: Color.Lime, width: 2, style: LineStyle.Histogramm);
_squeezeSeries = new LineSeries(name: "SqueezeLevel", color: Color.Red, width: 4, style: LineStyle.Dot);
AddLineSeries(_momentumSeries);
AddLineSeries(_squeezeSeries);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnInit()
{
_squeezePro = new SqueezePro(Period, BbMult, KcMultWide, KcMultNormal, KcMultNarrow,
MomLength, MomSmooth, UseSma);
base.OnInit();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnUpdate(UpdateArgs args)
{
_ = _squeezePro.Update(this.GetInputBar(args), args.IsNewBar());
_momentumSeries.SetValue(_squeezePro.Momentum, _squeezePro.IsHot, ShowColdValues);
// Plot squeeze level dot at 0 (colored by level), NaN when off
double sqDot = _squeezePro.SqueezeLevel > 0 ? 0.0 : double.NaN;
_squeezeSeries.SetValue(sqDot, _squeezePro.IsHot, ShowColdValues);
// Color squeeze dot: Red=narrow(3), Orange=normal(2), Yellow=wide(1)
if (_squeezePro.SqueezeLevel == 3)
{
_squeezeSeries.Color = Color.Red;
}
else if (_squeezePro.SqueezeLevel == 2)
{
_squeezeSeries.Color = Color.Orange;
}
else if (_squeezePro.SqueezeLevel == 1)
{
_squeezeSeries.Color = Color.Yellow;
}
}
}
+714
View File
@@ -0,0 +1,714 @@
using System.Buffers;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// SQUEEZE_PRO: LazyBear's Squeeze Pro (enhanced TTM Squeeze)
/// Detects multi-level volatility compressions using three Keltner Channel widths
/// (wide, normal, narrow) against Bollinger Bands. Momentum is computed as
/// MOM(close, momLength) smoothed by SMA or EMA.
/// Outputs: Momentum (smoothed histogram) and SqueezeLevel (0=off, 1=wide, 2=normal, 3=narrow).
/// </summary>
[SkipLocalsInit]
public sealed class SqueezePro : ITValuePublisher
{
private readonly int _period;
private readonly double _bbMult;
private readonly double _kcMultWide;
private readonly double _kcMultNormal;
private readonly double _kcMultNarrow;
private readonly int _momLength;
private readonly int _momSmooth;
private readonly bool _useSma;
// Circular buffers
private readonly double[] _smaBuf; // close values for SMA + variance (period)
private readonly double[] _closeBuf; // close values for MOM (momLength)
private readonly double[] _smoothBuf; // MOM values for SMA smoothing (momSmooth)
// Snapshots for bar-correction rollback
private readonly double[] _smaBufSnap;
private readonly double[] _closeBufSnap;
private readonly double[] _smoothBufSnap;
[StructLayout(LayoutKind.Auto)]
private record struct State(
// SMA + variance for Bollinger Bands
double SmaSum, double SmaSumSq, int SmaHead, int SmaCount,
// EMA for KC midline (bias-corrected)
double RawEma, double EEma,
// ATR via Wilder RMA (bias-corrected)
double RawRma, double ERma, double PrevClose,
// MOM close buffer tracking
int MomHead, int MomCount,
// SMA smoothing of MOM
double SmoothSum, int SmoothHead, int SmoothCount,
// EMA smoothing of MOM (for useSma=false mode)
double RawSmoothEma, double ESmoothEma,
// NaN substitution tracking
double LastValidHigh, double LastValidLow, double LastValidClose);
private State _s;
private State _ps;
private readonly TBarPublishedHandler _barHandler;
public string Name { get; }
public int WarmupPeriod { get; }
public TValue Last { get; private set; }
/// <summary>Smoothed momentum value (MOM smoothed by SMA or EMA).</summary>
public double Momentum { get; private set; }
/// <summary>
/// Squeeze level: 0=off/no squeeze, 1=wide squeeze, 2=normal squeeze, 3=narrow squeeze.
/// Higher values indicate tighter compression.
/// </summary>
public int SqueezeLevel { get; private set; }
public bool IsHot => _s.SmoothCount >= _momSmooth && _s.MomCount >= _momLength;
public event TValuePublishedHandler? Pub;
public SqueezePro(int period = 20, double bbMult = 2.0,
double kcMultWide = 2.0, double kcMultNormal = 1.5, double kcMultNarrow = 1.0,
int momLength = 12, int momSmooth = 6, bool useSma = true)
{
if (period <= 0)
{
throw new ArgumentException("Period must be greater than 0", nameof(period));
}
if (bbMult <= 0.0)
{
throw new ArgumentException("BB multiplier must be greater than 0", nameof(bbMult));
}
if (kcMultWide <= 0.0)
{
throw new ArgumentException("KC wide multiplier must be greater than 0", nameof(kcMultWide));
}
if (kcMultNormal <= 0.0)
{
throw new ArgumentException("KC normal multiplier must be greater than 0", nameof(kcMultNormal));
}
if (kcMultNarrow <= 0.0)
{
throw new ArgumentException("KC narrow multiplier must be greater than 0", nameof(kcMultNarrow));
}
if (momLength <= 0)
{
throw new ArgumentException("Momentum length must be greater than 0", nameof(momLength));
}
if (momSmooth <= 0)
{
throw new ArgumentException("Momentum smooth must be greater than 0", nameof(momSmooth));
}
_period = period;
_bbMult = bbMult;
_kcMultWide = kcMultWide;
_kcMultNormal = kcMultNormal;
_kcMultNarrow = kcMultNarrow;
_momLength = momLength;
_momSmooth = momSmooth;
_useSma = useSma;
_smaBuf = new double[period];
_closeBuf = new double[momLength];
_smoothBuf = new double[momSmooth];
_smaBufSnap = new double[period];
_closeBufSnap = new double[momLength];
_smoothBufSnap = new double[momSmooth];
Array.Fill(_smaBuf, double.NaN);
Array.Fill(_closeBuf, double.NaN);
Array.Fill(_smoothBuf, double.NaN);
_s = MakeInitialState();
_ps = _s;
Name = $"SqueezePro({period},{bbMult},{kcMultWide},{kcMultNormal},{kcMultNarrow})";
WarmupPeriod = Math.Max(period, momLength + momSmooth);
_barHandler = HandleBar;
}
public SqueezePro(TBarSeries source, int period = 20, double bbMult = 2.0,
double kcMultWide = 2.0, double kcMultNormal = 1.5, double kcMultNarrow = 1.0,
int momLength = 12, int momSmooth = 6, bool useSma = true)
: this(period, bbMult, kcMultWide, kcMultNormal, kcMultNarrow, momLength, momSmooth, useSma)
{
Prime(source);
source.Pub += _barHandler;
}
private static State MakeInitialState() =>
new(SmaSum: 0.0, SmaSumSq: 0.0, SmaHead: 0, SmaCount: 0,
RawEma: 0.0, EEma: 1.0,
RawRma: 0.0, ERma: 1.0, PrevClose: double.NaN,
MomHead: 0, MomCount: 0,
SmoothSum: 0.0, SmoothHead: 0, SmoothCount: 0,
RawSmoothEma: 0.0, ESmoothEma: 1.0,
LastValidHigh: double.NaN, LastValidLow: double.NaN, LastValidClose: double.NaN);
private void HandleBar(object? sender, in TBarEventArgs e) => Update(e.Value, e.IsNew);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void PubEvent(TValue value, bool isNew = true) =>
Pub?.Invoke(this, new TValueEventArgs { Value = value, IsNew = isNew });
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void UpdateSmaBuf(ref State s, double close)
{
double oldVal = _smaBuf[s.SmaHead];
if (double.IsNaN(oldVal))
{
s.SmaCount++;
}
else
{
s.SmaSum -= oldVal;
s.SmaSumSq -= oldVal * oldVal;
}
s.SmaSum += close;
s.SmaSumSq += close * close;
_smaBuf[s.SmaHead] = close;
s.SmaHead = (s.SmaHead + 1) % _period;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private double UpdateMomBuf(ref State s, double close)
{
double laggedClose = _closeBuf[s.MomHead];
_closeBuf[s.MomHead] = close;
s.MomHead = (s.MomHead + 1) % _momLength;
if (s.MomCount < _momLength)
{
s.MomCount++;
return double.NaN; // not enough data for MOM yet
}
// MOM = close - close[momLength bars ago]
return close - laggedClose;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private double UpdateSmoothBuf(ref State s, double mom)
{
if (_useSma)
{
// SMA smoothing
double oldVal = _smoothBuf[s.SmoothHead];
if (double.IsNaN(oldVal))
{
s.SmoothCount++;
}
else
{
s.SmoothSum -= oldVal;
}
s.SmoothSum += mom;
_smoothBuf[s.SmoothHead] = mom;
s.SmoothHead = (s.SmoothHead + 1) % _momSmooth;
return s.SmoothSum / Math.Max(1, s.SmoothCount);
}
else
{
// EMA smoothing (bias-corrected)
const double EPSILON = 1e-10;
double alpha = 2.0 / (_momSmooth + 1.0);
double beta = 1.0 - alpha;
s.RawSmoothEma = Math.FusedMultiplyAdd(s.RawSmoothEma, beta, alpha * mom);
s.ESmoothEma *= beta;
double c = s.ESmoothEma > EPSILON ? 1.0 / (1.0 - s.ESmoothEma) : 1.0;
s.SmoothCount = Math.Min(s.SmoothCount + 1, _momSmooth);
return s.RawSmoothEma * c;
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TValue Update(TBar input, bool isNew = true)
{
if (isNew)
{
_ps = _s;
Array.Copy(_smaBuf, _smaBufSnap, _period);
Array.Copy(_closeBuf, _closeBufSnap, _momLength);
Array.Copy(_smoothBuf, _smoothBufSnap, _momSmooth);
}
else
{
_s = _ps;
Array.Copy(_smaBufSnap, _smaBuf, _period);
Array.Copy(_closeBufSnap, _closeBuf, _momLength);
Array.Copy(_smoothBufSnap, _smoothBuf, _momSmooth);
}
var s = _s;
// === NaN/Infinity substitution (last-valid-value) ===
double high = input.High;
double low = input.Low;
double close = input.Close;
if (double.IsFinite(high)) { s.LastValidHigh = high; }
else { high = s.LastValidHigh; }
if (double.IsFinite(low)) { s.LastValidLow = low; }
else { low = s.LastValidLow; }
if (double.IsFinite(close)) { s.LastValidClose = close; }
else { close = s.LastValidClose; }
if (double.IsNaN(high) || double.IsNaN(low) || double.IsNaN(close))
{
_s = s;
Last = new TValue(input.Time, double.NaN);
Momentum = double.NaN;
SqueezeLevel = 0;
PubEvent(Last, isNew);
return Last;
}
// ===== STAGE 1: SMA + Variance → Bollinger Bands =====
UpdateSmaBuf(ref s, close);
int n = Math.Max(1, s.SmaCount);
double smaVal = s.SmaSum / n;
double variance = Math.Max(0.0, (s.SmaSumSq / n) - (smaVal * smaVal));
double stddev = Math.Sqrt(variance);
double bbUpper = Math.FusedMultiplyAdd(_bbMult, stddev, smaVal);
double bbLower = Math.FusedMultiplyAdd(-_bbMult, stddev, smaVal);
// ===== STAGE 2: EMA + ATR via RMA → Keltner Channels =====
const double EPSILON = 1e-10;
double emaAlpha = 2.0 / (_period + 1.0);
double emaBeta = 1.0 - emaAlpha;
double rmaAlpha = 1.0 / _period;
double rmaBeta = 1.0 - rmaAlpha;
s.RawEma = Math.FusedMultiplyAdd(s.RawEma, emaBeta, emaAlpha * close);
s.EEma *= emaBeta;
double cEma = s.EEma > EPSILON ? 1.0 / (1.0 - s.EEma) : 1.0;
double emaVal = s.RawEma * cEma;
// True Range
double tr = high - low;
if (double.IsFinite(s.PrevClose))
{
double hiPrev = Math.Abs(high - s.PrevClose);
double loPrev = Math.Abs(low - s.PrevClose);
if (hiPrev > tr) { tr = hiPrev; }
if (loPrev > tr) { tr = loPrev; }
}
s.PrevClose = close;
s.RawRma = Math.FusedMultiplyAdd(s.RawRma, rmaBeta, rmaAlpha * tr);
s.ERma *= rmaBeta;
double cRma = s.ERma > EPSILON ? 1.0 / (1.0 - s.ERma) : 1.0;
double atr = s.RawRma * cRma;
// Three KC widths
double kcWideUpper = Math.FusedMultiplyAdd(_kcMultWide, atr, emaVal);
double kcWideLower = Math.FusedMultiplyAdd(-_kcMultWide, atr, emaVal);
double kcNormalUpper = Math.FusedMultiplyAdd(_kcMultNormal, atr, emaVal);
double kcNormalLower = Math.FusedMultiplyAdd(-_kcMultNormal, atr, emaVal);
double kcNarrowUpper = Math.FusedMultiplyAdd(_kcMultNarrow, atr, emaVal);
double kcNarrowLower = Math.FusedMultiplyAdd(-_kcMultNarrow, atr, emaVal);
// ===== STAGE 3: Squeeze level classification =====
// 3 = narrow (tightest): BB inside KC_narrow
// 2 = normal: BB inside KC_normal but not KC_narrow
// 1 = wide: BB inside KC_wide but not KC_normal
// 0 = off: BB outside KC_wide (expansion)
int sqLevel;
bool insideNarrow = bbUpper < kcNarrowUpper && bbLower > kcNarrowLower;
bool insideNormal = bbUpper < kcNormalUpper && bbLower > kcNormalLower;
bool insideWide = bbUpper < kcWideUpper && bbLower > kcWideLower;
if (insideNarrow) { sqLevel = 3; }
else if (insideNormal) { sqLevel = 2; }
else if (insideWide) { sqLevel = 1; }
else { sqLevel = 0; }
// ===== STAGE 4: MOM = close - close[momLength ago] =====
double rawMom = UpdateMomBuf(ref s, close);
// ===== STAGE 5: Smooth MOM via SMA or EMA =====
// Use 0.0 for insufficient MOM data (matches batch path)
double momVal = double.IsNaN(rawMom) ? 0.0 : rawMom;
double momentum = UpdateSmoothBuf(ref s, momVal);
_s = s;
Momentum = momentum;
SqueezeLevel = sqLevel;
Last = new TValue(input.Time, momentum);
PubEvent(Last, isNew);
return Last;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TValue Update(TValue input, bool isNew = true) =>
Update(new TBar(input.Time, input.Value, input.Value, input.Value, input.Value, 0), isNew);
public (TSeries Momentum, TSeries SqueezeLevel) Update(TBarSeries source)
{
if (source.Count == 0)
{
return (new TSeries([], []), new TSeries([], []));
}
int len = source.Count;
var tMom = new List<long>(len);
var vMom = new List<double>(len);
var tSq = new List<long>(len);
var vSq = new List<double>(len);
CollectionsMarshal.SetCount(tMom, len);
CollectionsMarshal.SetCount(vMom, len);
CollectionsMarshal.SetCount(tSq, len);
CollectionsMarshal.SetCount(vSq, len);
var vMomSpan = CollectionsMarshal.AsSpan(vMom);
var vSqSpan = CollectionsMarshal.AsSpan(vSq);
Batch(source.HighValues, source.LowValues, source.CloseValues,
vMomSpan, vSqSpan, _period, _bbMult, _kcMultWide, _kcMultNormal, _kcMultNarrow,
_momLength, _momSmooth, _useSma);
var tSpan = CollectionsMarshal.AsSpan(tMom);
source.Times.CopyTo(tSpan);
tSpan.CopyTo(CollectionsMarshal.AsSpan(tSq));
Prime(source);
if (len > 0)
{
Momentum = vMomSpan[^1];
SqueezeLevel = (int)vSqSpan[^1];
Last = new TValue(new DateTime(source.Times[^1], DateTimeKind.Utc), Momentum);
}
return (new TSeries(tMom, vMom), new TSeries(tSq, vSq));
}
public void Prime(TBarSeries source)
{
Reset();
for (int i = 0; i < source.Count; i++)
{
Update(source[i], isNew: true);
}
}
public void Reset()
{
Array.Fill(_smaBuf, double.NaN);
Array.Fill(_closeBuf, double.NaN);
Array.Fill(_smoothBuf, double.NaN);
Array.Fill(_smaBufSnap, double.NaN);
Array.Fill(_closeBufSnap, double.NaN);
Array.Fill(_smoothBufSnap, double.NaN);
_s = MakeInitialState();
_ps = _s;
Last = default;
Momentum = 0.0;
SqueezeLevel = 0;
}
/// <summary>
/// Span-based batch Squeeze Pro calculation.
/// </summary>
public static void Batch(
ReadOnlySpan<double> high,
ReadOnlySpan<double> low,
ReadOnlySpan<double> close,
Span<double> momOut,
Span<double> sqOut,
int period = 20,
double bbMult = 2.0,
double kcMultWide = 2.0,
double kcMultNormal = 1.5,
double kcMultNarrow = 1.0,
int momLength = 12,
int momSmooth = 6,
bool useSma = true)
{
if (period <= 0)
{
throw new ArgumentException("Period must be greater than 0", nameof(period));
}
if (bbMult <= 0.0)
{
throw new ArgumentException("BB multiplier must be greater than 0", nameof(bbMult));
}
if (kcMultWide <= 0.0)
{
throw new ArgumentException("KC wide multiplier must be greater than 0", nameof(kcMultWide));
}
if (kcMultNormal <= 0.0)
{
throw new ArgumentException("KC normal multiplier must be greater than 0", nameof(kcMultNormal));
}
if (kcMultNarrow <= 0.0)
{
throw new ArgumentException("KC narrow multiplier must be greater than 0", nameof(kcMultNarrow));
}
if (momLength <= 0)
{
throw new ArgumentException("Momentum length must be greater than 0", nameof(momLength));
}
if (momSmooth <= 0)
{
throw new ArgumentException("Momentum smooth must be greater than 0", nameof(momSmooth));
}
if (high.Length != low.Length || high.Length != close.Length)
{
throw new ArgumentException("Input spans must have the same length", nameof(high));
}
if (momOut.Length < high.Length)
{
throw new ArgumentException("Momentum output span must be at least as long as input", nameof(momOut));
}
if (sqOut.Length < high.Length)
{
throw new ArgumentException("SqueezeLevel output span must be at least as long as input", nameof(sqOut));
}
int len = high.Length;
if (len == 0) { return; }
const int StackallocThreshold = 256;
int totalBuf = period + momLength + momSmooth;
double[]? rented = null;
scoped Span<double> smaBuf;
scoped Span<double> closeBuf;
scoped Span<double> smoothBuf;
if (totalBuf <= StackallocThreshold)
{
Span<double> allBuf = stackalloc double[totalBuf];
smaBuf = allBuf.Slice(0, period);
closeBuf = allBuf.Slice(period, momLength);
smoothBuf = allBuf.Slice(period + momLength, momSmooth);
}
else
{
rented = ArrayPool<double>.Shared.Rent(totalBuf);
smaBuf = rented.AsSpan(0, period);
closeBuf = rented.AsSpan(period, momLength);
smoothBuf = rented.AsSpan(period + momLength, momSmooth);
}
smaBuf.Fill(double.NaN);
closeBuf.Fill(double.NaN);
smoothBuf.Fill(double.NaN);
try
{
BatchCore(high, low, close, momOut, sqOut, period, bbMult,
kcMultWide, kcMultNormal, kcMultNarrow, momLength, momSmooth, useSma,
smaBuf, closeBuf, smoothBuf);
}
finally
{
if (rented != null) { ArrayPool<double>.Shared.Return(rented); }
}
}
public static (TSeries Momentum, TSeries SqueezeLevel) Batch(
TBarSeries source, int period = 20, double bbMult = 2.0,
double kcMultWide = 2.0, double kcMultNormal = 1.5, double kcMultNarrow = 1.0,
int momLength = 12, int momSmooth = 6, bool useSma = true)
{
if (source == null || source.Count == 0)
{
return (new TSeries([], []), new TSeries([], []));
}
int len = source.Count;
var tMom = new List<long>(len);
var vMom = new List<double>(len);
var tSq = new List<long>(len);
var vSq = new List<double>(len);
CollectionsMarshal.SetCount(tMom, len);
CollectionsMarshal.SetCount(vMom, len);
CollectionsMarshal.SetCount(tSq, len);
CollectionsMarshal.SetCount(vSq, len);
Batch(source.HighValues, source.LowValues, source.CloseValues,
CollectionsMarshal.AsSpan(vMom),
CollectionsMarshal.AsSpan(vSq),
period, bbMult, kcMultWide, kcMultNormal, kcMultNarrow, momLength, momSmooth, useSma);
var tSpan = CollectionsMarshal.AsSpan(tMom);
source.Times.CopyTo(tSpan);
tSpan.CopyTo(CollectionsMarshal.AsSpan(tSq));
return (new TSeries(tMom, vMom), new TSeries(tSq, vSq));
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static ((TSeries Momentum, TSeries SqueezeLevel) Results, SqueezePro Indicator) Calculate(
TBarSeries source, int period = 20, double bbMult = 2.0,
double kcMultWide = 2.0, double kcMultNormal = 1.5, double kcMultNarrow = 1.0,
int momLength = 12, int momSmooth = 6, bool useSma = true)
{
var indicator = new SqueezePro(period, bbMult, kcMultWide, kcMultNormal, kcMultNarrow, momLength, momSmooth, useSma);
var results = indicator.Update(source);
return (results, indicator);
}
private static void BatchCore(
ReadOnlySpan<double> high, ReadOnlySpan<double> low, ReadOnlySpan<double> close,
Span<double> momOut, Span<double> sqOut,
int period, double bbMult,
double kcMultWide, double kcMultNormal, double kcMultNarrow,
int momLength, int momSmooth, bool useSma,
Span<double> smaBuf, Span<double> closeBuf, Span<double> smoothBuf)
{
int len = high.Length;
int smaHead = 0, smaCount = 0;
double smaSum = 0.0, smaSumSq = 0.0;
double rawEma = 0.0, eEma = 1.0;
double rawRma = 0.0, eRma = 1.0;
double prevClose = double.NaN;
int momHead = 0, momCount = 0;
double smoothSum = 0.0;
int smoothHead = 0, smoothCount = 0;
double rawSmoothEma = 0.0, eSmoothEma = 1.0;
double emaAlpha = 2.0 / (period + 1.0);
double emaBeta = 1.0 - emaAlpha;
double rmaAlpha = 1.0 / period;
double rmaBeta = 1.0 - rmaAlpha;
const double EPSILON = 1e-10;
for (int i = 0; i < len; i++)
{
double h = high[i];
double l = low[i];
double c = close[i];
if (!double.IsFinite(h)) { h = 0.0; }
if (!double.IsFinite(l)) { l = 0.0; }
if (!double.IsFinite(c)) { c = 0.0; }
// Stage 1: SMA + StdDev for BB
double oldSma = smaBuf[smaHead];
if (double.IsNaN(oldSma))
{
smaCount++;
}
else
{
smaSum -= oldSma;
smaSumSq -= oldSma * oldSma;
}
smaSum += c;
smaSumSq += c * c;
smaBuf[smaHead] = c;
smaHead = (smaHead + 1) % period;
int n = Math.Max(1, smaCount);
double smaVal = smaSum / n;
double vari = Math.Max(0.0, (smaSumSq / n) - (smaVal * smaVal));
double sd = Math.Sqrt(vari);
double bbUpper = Math.FusedMultiplyAdd(bbMult, sd, smaVal);
double bbLower = Math.FusedMultiplyAdd(-bbMult, sd, smaVal);
// Stage 2: EMA + ATR for KC
rawEma = Math.FusedMultiplyAdd(rawEma, emaBeta, emaAlpha * c);
eEma *= emaBeta;
double cEma = eEma > EPSILON ? 1.0 / (1.0 - eEma) : 1.0;
double emaVal = rawEma * cEma;
double tr = h - l;
if (double.IsFinite(prevClose))
{
double hp = Math.Abs(h - prevClose);
double lp = Math.Abs(l - prevClose);
if (hp > tr) { tr = hp; }
if (lp > tr) { tr = lp; }
}
prevClose = c;
rawRma = Math.FusedMultiplyAdd(rawRma, rmaBeta, rmaAlpha * tr);
eRma *= rmaBeta;
double cRma = eRma > EPSILON ? 1.0 / (1.0 - eRma) : 1.0;
double atr = rawRma * cRma;
// Three KC widths
double kcWU = Math.FusedMultiplyAdd(kcMultWide, atr, emaVal);
double kcWL = Math.FusedMultiplyAdd(-kcMultWide, atr, emaVal);
double kcNU = Math.FusedMultiplyAdd(kcMultNormal, atr, emaVal);
double kcNL = Math.FusedMultiplyAdd(-kcMultNormal, atr, emaVal);
double kcRU = Math.FusedMultiplyAdd(kcMultNarrow, atr, emaVal);
double kcRL = Math.FusedMultiplyAdd(-kcMultNarrow, atr, emaVal);
// Stage 3: Squeeze classification
bool insideNarrow = bbUpper < kcRU && bbLower > kcRL;
bool insideNormal = bbUpper < kcNU && bbLower > kcNL;
bool insideWide = bbUpper < kcWU && bbLower > kcWL;
double sqVal;
if (insideNarrow) { sqVal = 3.0; }
else if (insideNormal) { sqVal = 2.0; }
else if (insideWide) { sqVal = 1.0; }
else { sqVal = 0.0; }
// Stage 4: MOM = close - close[momLength ago]
double laggedClose = closeBuf[momHead];
closeBuf[momHead] = c;
momHead = (momHead + 1) % momLength;
double rawMom;
if (momCount < momLength)
{
momCount++;
rawMom = 0.0; // not enough data yet
}
else
{
rawMom = c - laggedClose;
}
// Stage 5: Smooth MOM
double momentum;
if (useSma)
{
double oldSmooth = smoothBuf[smoothHead];
if (double.IsNaN(oldSmooth))
{
smoothCount++;
}
else
{
smoothSum -= oldSmooth;
}
smoothSum += rawMom;
smoothBuf[smoothHead] = rawMom;
smoothHead = (smoothHead + 1) % momSmooth;
momentum = smoothSum / Math.Max(1, smoothCount);
}
else
{
double smAlpha = 2.0 / (momSmooth + 1.0);
double smBeta = 1.0 - smAlpha;
rawSmoothEma = Math.FusedMultiplyAdd(rawSmoothEma, smBeta, smAlpha * rawMom);
eSmoothEma *= smBeta;
double smC = eSmoothEma > EPSILON ? 1.0 / (1.0 - eSmoothEma) : 1.0;
momentum = rawSmoothEma * smC;
}
momOut[i] = momentum;
sqOut[i] = sqVal;
}
}
}
+107
View File
@@ -0,0 +1,107 @@
# SQUEEZE_PRO: LazyBear's Squeeze Pro
> *Standard Squeeze uses one Keltner width. Squeeze Pro adds two more — because the market doesn't only compress one way.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Oscillator |
| **Inputs** | OHLCV bar (TBar) |
| **Parameters** | `period` (20), `bbMult` (2.0), `kcMultWide` (2.0), `kcMultNormal` (1.5), `kcMultNarrow` (1.0), `momLength` (12), `momSmooth` (6), `useSma` (true) |
| **Outputs** | Dual: Momentum (double) + SqueezeLevel (int 03) |
| **Output range** | Momentum: unbounded; SqueezeLevel: {0, 1, 2, 3} |
| **Warmup** | `max(period, momLength + momSmooth)` bars |
| **PineScript** | [squeeze_pro.pine](squeeze_pro.pine) |
- LazyBear's Squeeze Pro enhances the standard TTM Squeeze by replacing the single Keltner Channel width with three graduated Keltner widths (wide, normal, narrow), and substituting MOM+SMA smoothing for linear regression momentum.
- **Similar:** [SQUEEZE](../squeeze/Squeeze.md), [TTM_SQUEEZE](../../dynamics/ttm_squeeze/TtmSqueeze.md), [BBS](../bbs/Bbs.md) | **Complementary:** ATR, BB | **Trading note:** Level 3 (narrow) = tightest compression, expect explosive breakout. Level 0 = expansion phase.
- Cross-validated streaming vs batch and across SMA/EMA smoothing modes.
## Historical Context
LazyBear's Squeeze Pro appeared on TradingView as an enhanced version of John Carter's TTM Squeeze, addressing a fundamental limitation: the original Squeeze only uses a single Keltner Channel width, providing a binary "squeeze on/off" signal. In practice, volatility compression exists on a spectrum — a market can be lightly compressed (BB barely inside KC) or severely compressed (BB well inside even a narrow KC). The three-level classification captures this gradient: wide squeeze (initial compression), normal squeeze (significant compression), and narrow squeeze (extreme compression that often precedes the largest moves). The momentum component was simplified from Carter's linear regression approach to a straightforward MOM(close, n) smoothed by SMA or EMA, making the indicator more responsive and easier to interpret.
## Architecture & Physics
### Computational Stages
1. **SMA + Standard Deviation** (Bollinger Bands): Circular buffer with running sum and sum-of-squares for O(1) variance computation. BB upper/lower = SMA $\pm$ bbMult $\times$ StdDev.
2. **EMA + ATR via RMA** (Keltner Channels): A single EMA and ATR computation shared across all three KC widths. Only the multiplier differs:
- KC Wide: EMA $\pm$ kcMultWide $\times$ ATR
- KC Normal: EMA $\pm$ kcMultNormal $\times$ ATR
- KC Narrow: EMA $\pm$ kcMultNarrow $\times$ ATR
3. **Squeeze classification:** Hierarchical check from tightest to widest:
- Level 3 (narrow): BB inside KC_narrow
- Level 2 (normal): BB inside KC_normal but not KC_narrow
- Level 1 (wide): BB inside KC_wide but not KC_normal
- Level 0 (off): BB outside KC_wide
4. **Momentum (MOM):** Simple momentum = close $-$ close\[momLength bars ago\]. Requires a circular buffer of `momLength` close values.
5. **Smooth MOM:** SMA or EMA of the raw momentum values over `momSmooth` period.
### Warmup Compensation
EMA and RMA stages use the $e = \beta^n$ warmup tracking with correction factor $c = 1/(1-e)$ to eliminate initial bias.
## Mathematical Foundation
**Bollinger Bands** (SMA + StdDev via running sums):
$$\mu = \frac{\Sigma x}{n}, \quad \sigma = \sqrt{\frac{\Sigma x^2}{n} - \mu^2}$$
$$BB_{upper} = \mu + m_{bb} \cdot \sigma, \quad BB_{lower} = \mu - m_{bb} \cdot \sigma$$
**Keltner Channel** (EMA + ATR):
$$EMA_t = \frac{\hat{E}_t}{1 - \beta^t}, \quad ATR_t = \frac{\hat{R}_t}{1 - \beta_r^t}$$
$$KC_{upper}^{(w)} = EMA + m_w \cdot ATR, \quad KC_{lower}^{(w)} = EMA - m_w \cdot ATR$$
where $w \in \{wide, normal, narrow\}$.
**Squeeze level:**
$$SqueezeLevel = \begin{cases} 3 & \text{if } BB \subset KC_{narrow} \\ 2 & \text{if } BB \subset KC_{normal} \setminus KC_{narrow} \\ 1 & \text{if } BB \subset KC_{wide} \setminus KC_{normal} \\ 0 & \text{otherwise (expansion)} \end{cases}$$
**Momentum:**
$$MOM_t = close_t - close_{t - momLength}$$
$$Momentum_t = SMA(MOM, momSmooth) \text{ or } EMA(MOM, momSmooth)$$
## Performance Profile
| Operation | Count per bar |
| --- | --- |
| ADD/SUB | ~20 |
| MUL | ~12 |
| DIV | 4 |
| CMP | 6 |
| SQRT | 1 |
| FMA | 8 |
Three circular buffers (`period` + `momLength` + `momSmooth`) with snapshot/rollback for bar correction. Memory: $O(period + momLength + momSmooth)$ per instance.
## Validation
| Library | Status | Notes |
| --- | --- | --- |
| pandas-ta | Algorithm reference | Verified algorithm from source |
| Self-consistency | ✅ Pass | Streaming = Batch = Eventing |
| Determinism | ✅ Pass | Same seed → identical output |
## Common Pitfalls
1. **KC multiplier ordering:** Ensure kcMultWide > kcMultNormal > kcMultNarrow for meaningful level classification. The algorithm works with any positive values, but inverted ordering produces unintuitive results.
2. **Momentum warmup:** First `momLength` bars produce MOM = 0 (no lagged close available). Full momentum accuracy requires `momLength + momSmooth` bars.
3. **SMA vs EMA smoothing:** SMA produces equal-weight smoothing (more stable); EMA gives more weight to recent momentum (more responsive). Both produce valid signals but differ numerically.
4. **Squeeze level vs squeeze state:** Level 0 doesn't mean "no squeeze ever happened" — it means BB is currently outside KC_wide (expansion phase). The transition from level 3→0 is the breakout signal.
5. **Memory footprint:** Three circular buffers plus three snapshot arrays. For very large `period`, ArrayPool is used automatically in batch mode.
## References
- LazyBear, "Squeeze Momentum Indicator [LazyBear]" (TradingView)
- pandas-ta `squeeze_pro` implementation (GitHub)
- John Carter, *Mastering the Trade* (2005) — original TTM Squeeze concept
@@ -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);
}
}
}