Files
QuanTAlib/lib/oscillators/bbs/Bbs.cs
T
Miha Kralj 67ad6f0cba v0.8.7: Replace periodic ResyncInterval with Kahan compensated summation
Comprehensive refactor across all indicators replacing the periodic
ResyncInterval-based drift correction (every 1000 ticks recalculate
from scratch) with Kahan compensated summation for running sums.

Key changes:
- Remove ResyncInterval constants and TickCount fields from all State records
- Add Kahan compensation fields (SumComp, SumSqComp, etc.) to State records
- Replace naive sum += val - removed with Kahan delta pattern
- Remove Resync()/RecalculateSum() methods that did O(N) recalculation
- Update batch/SIMD paths to use Kahan compensation instead of resync loops
- IIR filters (EMA, REMA, RGMA) simplified: inherently self-correcting
- Version bump to 0.8.7
- Build system: README version stamping via Directory.Build.props
- Minor doc/test tolerance adjustments for new numerical characteristics

Affected modules: channels, core, cycles, dynamics, errors, momentum,
oscillators, statistics, trends_FIR, trends_IIR, volatility, volume
2026-03-13 22:01:31 -07:00

688 lines
20 KiB
C#

using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// BBS: Bollinger Band Squeeze
/// </summary>
/// <remarks>
/// <para>
/// Detects when Bollinger Bands contract inside Keltner Channels,
/// indicating low volatility consolidation that typically precedes breakouts.
/// </para>
///
/// Squeeze Detection:
/// <c>SqueezeOn = BB_Upper &lt; KC_Upper AND BB_Lower &gt; KC_Lower</c>
///
/// Bandwidth Output:
/// <c>Bandwidth = ((BB_Upper - BB_Lower) / BB_Middle) * 100</c>
///
/// Bollinger Bands:
/// <c>BB_Middle = SMA(close, bbPeriod)</c>
/// <c>BB_Dev = sqrt(E[x^2] - E[x]^2)</c>
/// <c>BB_Upper = BB_Middle + bbMult * BB_Dev</c>
/// <c>BB_Lower = BB_Middle - bbMult * BB_Dev</c>
///
/// Keltner Channels:
/// <c>KC_Middle = SMA(close, kcPeriod)</c>
/// <c>ATR = EMA-smoothed True Range with warmup compensation</c>
/// <c>KC_Upper = KC_Middle + kcMult * ATR</c>
/// <c>KC_Lower = KC_Middle - kcMult * ATR</c>
///
/// References:
/// - John Bollinger, "Bollinger on Bollinger Bands"
/// - PineScript reference: bbs.pine
/// </remarks>
[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 BbSumComp,
double BbSumSqComp,
double KcSumComp,
double AtrRaw,
double AtrE,
double PrevClose,
double LastValidClose,
double LastValidHigh,
double LastValidLow,
int Bars,
bool IsHot);
private State _state;
private State _p_state;
// Saved squeeze state for SqueezeFired detection
private bool _prevSqueezeOn;
private bool _p_prevSqueezeOn;
/// <summary>
/// Display name for the indicator.
/// </summary>
public string Name { get; }
/// <summary>
/// Event publisher for value updates.
/// </summary>
public event TValuePublishedHandler? Pub;
/// <summary>
/// The bandwidth value: ((BB_Upper - BB_Lower) / BB_Middle) * 100.
/// Primary numeric output.
/// </summary>
public TValue Last { get; private set; }
/// <summary>
/// True when Bollinger Bands are inside Keltner Channel (squeeze condition).
/// </summary>
public bool SqueezeOn { get; private set; }
/// <summary>
/// True when squeeze just ended (first bar where squeeze transitions Off).
/// </summary>
public bool SqueezeFired { get; private set; }
/// <summary>
/// True when indicator has enough data for valid output.
/// </summary>
public bool IsHot => _state.IsHot;
/// <summary>
/// Number of bars required for warmup.
/// </summary>
public int WarmupPeriod { get; }
/// <summary>
/// Bollinger Band period.
/// </summary>
public int BbPeriod => _bbPeriod;
/// <summary>
/// Bollinger Band standard deviation multiplier.
/// </summary>
public double BbMult => _bbMult;
/// <summary>
/// Keltner Channel period.
/// </summary>
public int KcPeriod => _kcPeriod;
/// <summary>
/// Keltner Channel ATR multiplier.
/// </summary>
public double KcMult => _kcMult;
/// <summary>
/// Creates BBS indicator with specified parameters.
/// </summary>
/// <param name="bbPeriod">Bollinger Band period (default 20, must be &gt; 0)</param>
/// <param name="bbMult">Bollinger Band standard deviation multiplier (default 2.0, must be &gt; 0)</param>
/// <param name="kcPeriod">Keltner Channel period (default 20, must be &gt; 0)</param>
/// <param name="kcMult">Keltner Channel ATR multiplier (default 1.5, must be &gt; 0)</param>
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, 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);
}
/// <summary>
/// Updates the BBS indicator with a new bar.
/// </summary>
/// <param name="input">The price bar (requires OHLC)</param>
/// <param name="isNew">True for new bar, false for update of current bar</param>
/// <returns>The bandwidth value</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TValue Update(TBar input, bool isNew = true)
{
if (isNew)
{
_p_state = _state;
_p_prevSqueezeOn = _prevSqueezeOn;
}
else
{
_state = _p_state;
_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: Kahan compensated SMA + population stddev ===
if (_bbBuffer.IsFull)
{
double oldest = _bbBuffer.Oldest;
double bbDelta = close - oldest;
double bbSqDelta = (close * close) - (oldest * oldest);
{
double y = bbDelta - _state.BbSumComp;
double t = _state.BbSum + y;
double newComp = (t - _state.BbSum) - y;
double y2 = bbSqDelta - _state.BbSumSqComp;
double t2 = _state.BbSumSq + y2;
double newSqComp = (t2 - _state.BbSumSq) - y2;
_state = _state with
{
BbSum = t,
BbSumComp = newComp,
BbSumSq = t2,
BbSumSqComp = newSqComp
};
}
}
else
{
double y = close - _state.BbSumComp;
double t = _state.BbSum + y;
double newComp = (t - _state.BbSum) - y;
double y2 = (close * close) - _state.BbSumSqComp;
double t2 = _state.BbSumSq + y2;
double newSqComp = (t2 - _state.BbSumSq) - y2;
_state = _state with
{
BbSum = t,
BbSumComp = newComp,
BbSumSq = t2,
BbSumSqComp = newSqComp
};
}
_bbBuffer.Add(close, isNew);
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: Kahan compensated SMA middle + EMA-smoothed ATR ===
if (_kcBuffer.IsFull)
{
double oldest = _kcBuffer.Oldest;
double kcDelta = close - oldest;
double y = kcDelta - _state.KcSumComp;
double t = _state.KcSum + y;
_state = _state with { KcSum = t, KcSumComp = (t - _state.KcSum) - y };
// Fix: need to use pre-update KcSum for comp calc
}
else
{
double y = close - _state.KcSumComp;
double t = _state.KcSum + y;
_state = _state with { KcSum = t, KcSumComp = (t - _state.KcSum) - y };
}
_kcBuffer.Add(close, isNew);
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
// === IsHot ===
if (!_state.IsHot && _state.Bars >= WarmupPeriod)
{
_state = _state with { IsHot = true };
}
Last = new TValue(input.Time, bandwidth);
PubEvent(Last, isNew);
return Last;
}
/// <summary>
/// Calculates BBS for the entire bar series.
/// </summary>
public TSeries Update(TBarSeries source)
{
if (source.Count == 0)
{
return new TSeries([], []);
}
int len = source.Count;
var tList = new List<long>(len);
var vList = new List<double>(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);
}
/// <summary>
/// Primes the indicator with historical bar data.
/// </summary>
public void Prime(TBarSeries source)
{
Reset();
for (int i = 0; i < source.Count; i++)
{
Update(source[i], isNew: true);
}
}
/// <summary>
/// Calculates BBS for the entire bar series using default parameters.
/// </summary>
public static TSeries Batch(TBarSeries source)
{
var bbs = new Bbs();
return bbs.Update(source);
}
/// <summary>
/// Calculates BBS for the entire bar series using custom parameters.
/// </summary>
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);
}
/// <summary>
/// Batch BBS calculation using spans (zero allocation hot path).
/// Outputs bandwidth values.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Batch(
ReadOnlySpan<double> high,
ReadOnlySpan<double> low,
ReadOnlySpan<double> close,
Span<double> 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;
}
}
/// <summary>
/// Batch BBS calculation returning squeeze detection array alongside bandwidth.
/// </summary>
public static void Batch(
ReadOnlySpan<double> high,
ReadOnlySpan<double> low,
ReadOnlySpan<double> close,
Span<double> bandwidth,
Span<bool> 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
}
}
/// <summary>
/// Calculates BBS and returns both results and the warm indicator.
/// </summary>
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, BbSumComp = 0, BbSumSqComp = 0, KcSumComp = 0 };
}
/// <summary>
/// Resets the indicator state.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Reset()
{
_bbBuffer.Clear();
_kcBuffer.Clear();
_state = new State(0, 0, 0, 0, 0, 0, 0, 1.0, double.NaN, double.NaN, double.NaN, double.NaN, 0, false);
_p_state = _state;
_prevSqueezeOn = false;
_p_prevSqueezeOn = false;
Last = default;
SqueezeOn = false;
SqueezeFired = false;
}
}