using System.Runtime.CompilerServices; using System.Runtime.InteropServices; namespace QuanTAlib; /// /// BBS: Bollinger Band Squeeze /// /// /// /// Detects when Bollinger Bands contract inside Keltner Channels, /// indicating low volatility consolidation that typically precedes breakouts. /// /// /// Squeeze Detection: /// SqueezeOn = BB_Upper < KC_Upper AND BB_Lower > KC_Lower /// /// Bandwidth Output: /// Bandwidth = ((BB_Upper - BB_Lower) / BB_Middle) * 100 /// /// Bollinger Bands: /// BB_Middle = SMA(close, bbPeriod) /// BB_Dev = sqrt(E[x^2] - E[x]^2) /// BB_Upper = BB_Middle + bbMult * BB_Dev /// BB_Lower = BB_Middle - bbMult * BB_Dev /// /// Keltner Channels: /// KC_Middle = SMA(close, kcPeriod) /// ATR = EMA-smoothed True Range with warmup compensation /// KC_Upper = KC_Middle + kcMult * ATR /// KC_Lower = KC_Middle - kcMult * ATR /// /// References: /// - John Bollinger, "Bollinger on Bollinger Bands" /// - PineScript reference: bbs.pine /// [SkipLocalsInit] public sealed class Bbs : ITValuePublisher { private readonly int _bbPeriod; private readonly double _bbMult; private readonly int _kcPeriod; private readonly double _kcMult; // Bollinger Bands: rolling sum/sumSq for O(1) SMA + stddev private readonly RingBuffer _bbBuffer; // Keltner Channel: rolling sum for SMA middle private readonly RingBuffer _kcBuffer; [StructLayout(LayoutKind.Auto)] private record struct State( double BbSum, double BbSumSq, double KcSum, double AtrRaw, double AtrE, double PrevClose, double LastValidClose, double LastValidHigh, double LastValidLow, int Bars, bool IsHot); private State _state; private State _p_state; private const int ResyncInterval = 1000; private int _tickCount; private int _p_tickCount; // Saved squeeze state for SqueezeFired detection private bool _prevSqueezeOn; private bool _p_prevSqueezeOn; /// /// Display name for the indicator. /// public string Name { get; } /// /// Event publisher for value updates. /// public event TValuePublishedHandler? Pub; /// /// The bandwidth value: ((BB_Upper - BB_Lower) / BB_Middle) * 100. /// Primary numeric output. /// public TValue Last { get; private set; } /// /// True when Bollinger Bands are inside Keltner Channel (squeeze condition). /// public bool SqueezeOn { get; private set; } /// /// True when squeeze just ended (first bar where squeeze transitions Off). /// public bool SqueezeFired { get; private set; } /// /// True when indicator has enough data for valid output. /// public bool IsHot => _state.IsHot; /// /// Number of bars required for warmup. /// public int WarmupPeriod { get; } /// /// Bollinger Band period. /// public int BbPeriod => _bbPeriod; /// /// Bollinger Band standard deviation multiplier. /// public double BbMult => _bbMult; /// /// Keltner Channel period. /// public int KcPeriod => _kcPeriod; /// /// Keltner Channel ATR multiplier. /// public double KcMult => _kcMult; /// /// Creates BBS indicator with specified parameters. /// /// Bollinger Band period (default 20, must be > 0) /// Bollinger Band standard deviation multiplier (default 2.0, must be > 0) /// Keltner Channel period (default 20, must be > 0) /// Keltner Channel ATR multiplier (default 1.5, must be > 0) public Bbs(int bbPeriod = 20, double bbMult = 2.0, int kcPeriod = 20, double kcMult = 1.5) { if (bbPeriod <= 0) { throw new ArgumentException("BB Period must be greater than 0", nameof(bbPeriod)); } if (kcPeriod <= 0) { throw new ArgumentException("KC Period must be greater than 0", nameof(kcPeriod)); } if (bbMult <= 0) { throw new ArgumentException("BB Multiplier must be greater than 0", nameof(bbMult)); } if (kcMult <= 0) { throw new ArgumentException("KC Multiplier must be greater than 0", nameof(kcMult)); } _bbPeriod = bbPeriod; _bbMult = bbMult; _kcPeriod = kcPeriod; _kcMult = kcMult; Name = $"Bbs({bbPeriod},{bbMult:F1},{kcPeriod},{kcMult:F1})"; WarmupPeriod = Math.Max(bbPeriod, kcPeriod); _bbBuffer = new RingBuffer(bbPeriod); _kcBuffer = new RingBuffer(kcPeriod); _state = new State(0, 0, 0, 0, 1.0, double.NaN, double.NaN, double.NaN, double.NaN, 0, false); _p_state = _state; } [MethodImpl(MethodImplOptions.AggressiveInlining)] private void PubEvent(TValue value, bool isNew = true) => Pub?.Invoke(this, new TValueEventArgs { Value = value, IsNew = isNew }); [MethodImpl(MethodImplOptions.AggressiveInlining)] private (double close, double high, double low) GetValidValues(double close, double high, double low) { if (double.IsFinite(close)) { _state = _state with { LastValidClose = close }; } else if (double.IsFinite(_state.LastValidClose)) { close = _state.LastValidClose; } else { close = 0.0; } if (double.IsFinite(high)) { _state = _state with { LastValidHigh = high }; } else if (double.IsFinite(_state.LastValidHigh)) { high = _state.LastValidHigh; } else { high = close; } if (double.IsFinite(low)) { _state = _state with { LastValidLow = low }; } else if (double.IsFinite(_state.LastValidLow)) { low = _state.LastValidLow; } else { low = close; } return (close, high, low); } /// /// Updates the BBS indicator with a new bar. /// /// The price bar (requires OHLC) /// True for new bar, false for update of current bar /// The bandwidth value [MethodImpl(MethodImplOptions.AggressiveInlining)] public TValue Update(TBar input, bool isNew = true) { if (isNew) { _p_state = _state; _p_tickCount = _tickCount; _p_prevSqueezeOn = _prevSqueezeOn; } else { _state = _p_state; _tickCount = _p_tickCount; _prevSqueezeOn = _p_prevSqueezeOn; } var (close, high, low) = GetValidValues(input.Close, input.High, input.Low); if (isNew) { _state = _state with { Bars = _state.Bars + 1 }; } // === Bollinger Bands: SMA + population stddev via rolling sum/sumSq === if (_bbBuffer.IsFull) { double oldest = _bbBuffer.Oldest; _state = _state with { BbSum = _state.BbSum - oldest, BbSumSq = _state.BbSumSq - (oldest * oldest) }; } _bbBuffer.Add(close, isNew); _state = _state with { BbSum = _state.BbSum + close, BbSumSq = _state.BbSumSq + (close * close) }; int bbCount = _bbBuffer.Count; double bbMean = bbCount > 0 ? _state.BbSum / bbCount : close; double bbVariance = Math.Max(0.0, (_state.BbSumSq / bbCount) - (bbMean * bbMean)); double bbStdDev = Math.Sqrt(bbVariance); double bbUpper = bbMean + (_bbMult * bbStdDev); double bbLower = bbMean - (_bbMult * bbStdDev); // === Keltner Channel: SMA middle + EMA-smoothed ATR === if (_kcBuffer.IsFull) { double oldest = _kcBuffer.Oldest; _state = _state with { KcSum = _state.KcSum - oldest }; } _kcBuffer.Add(close, isNew); _state = _state with { KcSum = _state.KcSum + close }; int kcCount = _kcBuffer.Count; double kcMid = kcCount > 0 ? _state.KcSum / kcCount : close; // True Range double tr = high - low; if (double.IsFinite(_state.PrevClose)) { tr = Math.Max(tr, Math.Max(Math.Abs(high - _state.PrevClose), Math.Abs(low - _state.PrevClose))); } _state = _state with { PrevClose = close }; // ATR using EMA smoothing with warmup compensation (matching Pine spec) double atrAlpha = 2.0 / (_kcPeriod + 1); double atrBeta = 1.0 - atrAlpha; double newAtrRaw = Math.FusedMultiplyAdd(_state.AtrRaw, atrBeta, atrAlpha * tr); double newAtrE = _state.AtrE * atrBeta; double atr; if (newAtrE > 1e-10) { atr = newAtrRaw / (1.0 - newAtrE); } else { atr = newAtrRaw; } _state = _state with { AtrRaw = newAtrRaw, AtrE = newAtrE }; double kcUpper = kcMid + (_kcMult * atr); double kcLower = kcMid - (_kcMult * atr); // === Squeeze Detection === bool wasSqueezeOn = _prevSqueezeOn; bool squeezeOn = bbUpper < kcUpper && bbLower > kcLower; SqueezeOn = squeezeOn; SqueezeFired = wasSqueezeOn && !squeezeOn; _prevSqueezeOn = squeezeOn; // === Bandwidth === double bandwidth = bbMean != 0.0 ? ((bbUpper - bbLower) / bbMean) * 100.0 : 0.0; // skipcq: CS-R1077 - Exact-zero div guard: price avg // === Resync for floating-point drift === if (isNew) { _tickCount++; if (_bbBuffer.IsFull && _tickCount >= ResyncInterval) { _tickCount = 0; RecalculateSums(); } } // === IsHot === if (!_state.IsHot && _state.Bars >= WarmupPeriod) { _state = _state with { IsHot = true }; } Last = new TValue(input.Time, bandwidth); PubEvent(Last, isNew); return Last; } /// /// Calculates BBS for the entire bar series. /// public TSeries Update(TBarSeries source) { if (source.Count == 0) { return new TSeries([], []); } int len = source.Count; var tList = new List(len); var vList = new List(len); CollectionsMarshal.SetCount(tList, len); CollectionsMarshal.SetCount(vList, len); var tSpan = CollectionsMarshal.AsSpan(tList); var vSpan = CollectionsMarshal.AsSpan(vList); Batch(source.HighValues, source.LowValues, source.CloseValues, vSpan, _bbPeriod, _bbMult); source.Times.CopyTo(tSpan); // Prime internal state for continued streaming Prime(source); return new TSeries(tList, vList); } /// /// Primes the indicator with historical bar data. /// public void Prime(TBarSeries source) { Reset(); for (int i = 0; i < source.Count; i++) { Update(source[i], isNew: true); } } /// /// Calculates BBS for the entire bar series using default parameters. /// public static TSeries Batch(TBarSeries source) { var bbs = new Bbs(); return bbs.Update(source); } /// /// Calculates BBS for the entire bar series using custom parameters. /// public static TSeries Batch(TBarSeries source, int bbPeriod, double bbMult, int kcPeriod, double kcMult) { var bbs = new Bbs(bbPeriod, bbMult, kcPeriod, kcMult); return bbs.Update(source); } /// /// Batch BBS calculation using spans (zero allocation hot path). /// Outputs bandwidth values. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void Batch( ReadOnlySpan high, ReadOnlySpan low, ReadOnlySpan close, Span output, int bbPeriod = 20, double bbMult = 2.0) { if (bbPeriod <= 0) { throw new ArgumentException("BB Period must be greater than 0", nameof(bbPeriod)); } if (bbMult <= 0) { throw new ArgumentException("BB Multiplier must be greater than 0", nameof(bbMult)); } if (high.Length != low.Length || high.Length != close.Length) { throw new ArgumentException("High, Low, and Close spans must have the same length", nameof(high)); } if (output.Length < high.Length) { throw new ArgumentException("Output span must be at least as long as inputs", nameof(output)); } int len = high.Length; if (len == 0) { return; } // BB rolling state var bbRing = new RingBuffer(bbPeriod); double bbSum = 0.0; double bbSumSq = 0.0; for (int i = 0; i < len; i++) { double c = close[i]; // === Bollinger Bands === if (bbRing.IsFull) { double oldest = bbRing.Oldest; bbSum -= oldest; bbSumSq -= oldest * oldest; } bbSum += c; bbSumSq += c * c; bbRing.Add(c); int bbCount = bbRing.Count; double bbMean = bbSum / bbCount; double bbVariance = Math.Max(0.0, (bbSumSq / bbCount) - (bbMean * bbMean)); double bbStdDev = Math.Sqrt(bbVariance); double bbUpper = bbMean + (bbMult * bbStdDev); double bbLower = bbMean - (bbMult * bbStdDev); // === Bandwidth === // Note: bandwidth only depends on BB, not KC. KC state not needed for this overload. double bandwidth = bbMean != 0.0 ? ((bbUpper - bbLower) / bbMean) * 100.0 : 0.0; output[i] = bandwidth; } } /// /// Batch BBS calculation returning squeeze detection array alongside bandwidth. /// public static void Batch( ReadOnlySpan high, ReadOnlySpan low, ReadOnlySpan close, Span bandwidth, Span squeezeOn, int bbPeriod = 20, double bbMult = 2.0, int kcPeriod = 20, double kcMult = 1.5) { if (bbPeriod <= 0) { throw new ArgumentException("BB Period must be greater than 0", nameof(bbPeriod)); } if (kcPeriod <= 0) { throw new ArgumentException("KC Period must be greater than 0", nameof(kcPeriod)); } if (bbMult <= 0) { throw new ArgumentException("BB Multiplier must be greater than 0", nameof(bbMult)); } if (kcMult <= 0) { throw new ArgumentException("KC Multiplier must be greater than 0", nameof(kcMult)); } if (high.Length != low.Length || high.Length != close.Length) { throw new ArgumentException("High, Low, and Close spans must have the same length", nameof(high)); } if (bandwidth.Length < high.Length || squeezeOn.Length < high.Length) { throw new ArgumentException("Output spans must be at least as long as inputs", nameof(bandwidth)); } int len = high.Length; if (len == 0) { return; } // BB rolling state var bbRing = new RingBuffer(bbPeriod); double bbSum = 0.0; double bbSumSq = 0.0; // KC rolling state var kcRing = new RingBuffer(kcPeriod); double kcSum = 0.0; // ATR EMA state double atrAlpha = 2.0 / (kcPeriod + 1); double atrBeta = 1.0 - atrAlpha; double atrRaw = 0.0; double atrE = 1.0; double prevClose = close[0]; for (int i = 0; i < len; i++) { double c = close[i]; double h = high[i]; double l = low[i]; // === Bollinger Bands === if (bbRing.IsFull) { double oldest = bbRing.Oldest; bbSum -= oldest; bbSumSq -= oldest * oldest; } bbSum += c; bbSumSq += c * c; bbRing.Add(c); int bbCount = bbRing.Count; double bbMean = bbSum / bbCount; double bbVariance = Math.Max(0.0, (bbSumSq / bbCount) - (bbMean * bbMean)); double bbStdDev = Math.Sqrt(bbVariance); double bbUpper = bbMean + (bbMult * bbStdDev); double bbLower = bbMean - (bbMult * bbStdDev); // === Keltner Channel === if (kcRing.IsFull) { double oldest = kcRing.Oldest; kcSum -= oldest; } kcSum += c; kcRing.Add(c); int kcCount = kcRing.Count; double kcMid = kcSum / kcCount; // True Range double tr = h - l; if (i > 0) { tr = Math.Max(tr, Math.Max(Math.Abs(h - prevClose), Math.Abs(l - prevClose))); } prevClose = c; // ATR (EMA with warmup compensation) atrRaw = Math.FusedMultiplyAdd(atrRaw, atrBeta, atrAlpha * tr); atrE *= atrBeta; double atr = atrE > 1e-10 ? atrRaw / (1.0 - atrE) : atrRaw; double kcUpper = kcMid + (kcMult * atr); double kcLower = kcMid - (kcMult * atr); // Squeeze squeezeOn[i] = bbUpper < kcUpper && bbLower > kcLower; // Bandwidth bandwidth[i] = bbMean != 0.0 ? ((bbUpper - bbLower) / bbMean) * 100.0 : 0.0; // skipcq: CS-R1077 - Exact-zero div guard: price avg } } /// /// Calculates BBS and returns both results and the warm indicator. /// public static (TSeries Results, Bbs Indicator) Calculate(TBarSeries source, int bbPeriod = 20, double bbMult = 2.0, int kcPeriod = 20, double kcMult = 1.5) { var indicator = new Bbs(bbPeriod, bbMult, kcPeriod, kcMult); var results = indicator.Update(source); return (results, indicator); } [MethodImpl(MethodImplOptions.AggressiveInlining)] private void RecalculateSums() { double bbSum = 0.0; double bbSumSq = 0.0; for (int i = 0; i < _bbBuffer.Count; i++) { double v = _bbBuffer[i]; bbSum += v; bbSumSq += v * v; } double kcSum = 0.0; for (int i = 0; i < _kcBuffer.Count; i++) { kcSum += _kcBuffer[i]; } _state = _state with { BbSum = bbSum, BbSumSq = bbSumSq, KcSum = kcSum }; } /// /// Resets the indicator state. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Reset() { _bbBuffer.Clear(); _kcBuffer.Clear(); _state = new State(0, 0, 0, 0, 1.0, double.NaN, double.NaN, double.NaN, double.NaN, 0, false); _p_state = _state; _tickCount = 0; _p_tickCount = 0; _prevSqueezeOn = false; _p_prevSqueezeOn = false; Last = default; SqueezeOn = false; SqueezeFired = false; } }