Add Stochastic Oscillator implementation and validation tests

- Implemented Stochastic Oscillator (%K and %D) in Stoch.cs with streaming and batch processing capabilities.
- Added validation tests for the Stochastic Oscillator in Stoch.Validation.Tests.cs, ensuring consistency with Skender.Stock.Indicators.
- Created documentation for the Stochastic Oscillator in Stoch.md, detailing its mathematical formula, architecture, parameters, and common pitfalls.
- Updated project file to include necessary numeric libraries for highest and lowest calculations.
This commit is contained in:
Miha Kralj
2026-02-12 14:29:54 -08:00
parent 653aafacd8
commit 92709ef2ed
73 changed files with 14721 additions and 35 deletions
+105
View File
@@ -0,0 +1,105 @@
using TradingPlatform.BusinessLayer;
namespace QuanTAlib.Tests;
public sealed class BbsIndicatorTests
{
[Fact]
public void BbsIndicator_Constructor_SetsDefaults()
{
var indicator = new BbsIndicator();
Assert.Equal(20, indicator.BbPeriod);
Assert.Equal(2.0, indicator.BbMult);
Assert.Equal(20, indicator.KcPeriod);
Assert.Equal(1.5, indicator.KcMult);
Assert.True(indicator.ShowColdValues);
Assert.Equal("BBS - Bollinger Band Squeeze", indicator.Name);
Assert.True(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void BbsIndicator_MinHistoryDepths_EqualsZero()
{
var indicator = new BbsIndicator { BbPeriod = 20 };
Assert.Equal(0, BbsIndicator.MinHistoryDepths);
IWatchlistIndicator watchlistIndicator = indicator;
Assert.Equal(0, watchlistIndicator.MinHistoryDepths);
}
[Fact]
public void BbsIndicator_ShortName_IncludesParameters()
{
var indicator = new BbsIndicator
{
BbPeriod = 15,
BbMult = 1.5,
KcPeriod = 10,
KcMult = 2.0
};
indicator.Initialize();
Assert.Contains("BBS", indicator.ShortName, StringComparison.Ordinal);
Assert.Contains("15", indicator.ShortName, StringComparison.Ordinal);
Assert.Contains("10", indicator.ShortName, StringComparison.Ordinal);
}
[Fact]
public void BbsIndicator_SourceCodeLink_IsValid()
{
var indicator = new BbsIndicator();
Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal);
Assert.Contains("Bbs.Quantower.cs", indicator.SourceCodeLink, StringComparison.Ordinal);
}
[Fact]
public void BbsIndicator_Initialize_CreatesInternalBbs()
{
var indicator = new BbsIndicator
{
BbPeriod = 20,
KcPeriod = 20
};
indicator.Initialize();
// Should have bandwidth + squeeze dot series
Assert.Equal(2, indicator.LinesSeries.Count);
}
[Fact]
public void BbsIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new BbsIndicator
{
BbPeriod = 5,
KcPeriod = 5
};
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 20; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i);
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
}
double bandwidth = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(bandwidth));
}
[Fact]
public void BbsIndicator_TwoLineSeries_Exist()
{
var indicator = new BbsIndicator();
indicator.Initialize();
// Should have bandwidth + squeeze dot series
Assert.Equal(2, indicator.LinesSeries.Count);
}
}
+86
View File
@@ -0,0 +1,86 @@
using System.Drawing;
using System.Runtime.CompilerServices;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
/// <summary>
/// BBS: Bollinger Band Squeeze - Quantower Indicator Adapter
/// Detects when Bollinger Bands contract inside Keltner Channels.
/// Outputs bandwidth histogram with squeeze dots at zero line.
/// </summary>
[SkipLocalsInit]
public sealed class BbsIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("BB Period", sortIndex: 1, 1, 500, 1, 0)]
public int BbPeriod { get; set; } = 20;
[InputParameter("BB Multiplier", sortIndex: 2, 0.1, 10.0, 0.1, 1)]
public double BbMult { get; set; } = 2.0;
[InputParameter("KC Period", sortIndex: 3, 1, 500, 1, 0)]
public int KcPeriod { get; set; } = 20;
[InputParameter("KC Multiplier", sortIndex: 4, 0.1, 10.0, 0.1, 1)]
public double KcMult { get; set; } = 1.5;
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
public static int MinHistoryDepths => 0;
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
private Bbs _bbs = null!;
private readonly LineSeries _bandwidthSeries;
private readonly LineSeries _squeezeSeries;
public override string ShortName => $"BBS({BbPeriod},{BbMult:F1},{KcPeriod},{KcMult:F1})";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/oscillators/bbs/Bbs.Quantower.cs";
public BbsIndicator()
{
Name = "BBS - Bollinger Band Squeeze";
Description = "Detects when Bollinger Bands contract inside Keltner Channels, indicating consolidation before breakout";
SeparateWindow = true;
OnBackGround = true;
_bandwidthSeries = new LineSeries("Bandwidth", Color.Cyan, 2, LineStyle.Histogramm);
_squeezeSeries = new LineSeries("Squeeze", Color.Red, 4, LineStyle.Dot);
AddLineSeries(_bandwidthSeries);
AddLineSeries(_squeezeSeries);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnInit()
{
_bbs = new Bbs(BbPeriod, BbMult, KcPeriod, KcMult);
base.OnInit();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnUpdate(UpdateArgs args)
{
TBar bar = this.GetInputBar(args);
bool isNew = args.IsNewBar();
TValue result = _bbs.Update(bar, isNew);
if (!ShowColdValues && !_bbs.IsHot)
{
return;
}
int offset = args.Reason == UpdateReason.HistoricalBar ? 0 : -1;
// Set bandwidth histogram
_bandwidthSeries.SetValue(result.Value, offset);
// Set squeeze indicator dot at zero line
_squeezeSeries.SetValue(0, offset);
// Red dot = squeeze on, Green dot = squeeze off
Color squeezeColor = _bbs.SqueezeOn ? Color.Red : Color.Green;
_squeezeSeries.SetMarker(offset, squeezeColor);
}
}
+411
View File
@@ -0,0 +1,411 @@
using Xunit;
namespace QuanTAlib.Tests;
public sealed class BbsTests
{
[Fact]
public void Constructor_DefaultParameters()
{
var bbs = new Bbs();
Assert.NotNull(bbs);
Assert.Equal("Bbs(20,2.0,20,1.5)", bbs.Name);
Assert.Equal(20, bbs.WarmupPeriod);
Assert.Equal(20, bbs.BbPeriod);
Assert.Equal(2.0, bbs.BbMult);
Assert.Equal(20, bbs.KcPeriod);
Assert.Equal(1.5, bbs.KcMult);
Assert.False(bbs.IsHot);
}
[Fact]
public void Constructor_CustomParameters()
{
var bbs = new Bbs(bbPeriod: 10, bbMult: 1.5, kcPeriod: 15, kcMult: 2.0);
Assert.Equal(10, bbs.BbPeriod);
Assert.Equal(1.5, bbs.BbMult);
Assert.Equal(15, bbs.KcPeriod);
Assert.Equal(2.0, bbs.KcMult);
Assert.Equal(15, bbs.WarmupPeriod); // max(10, 15)
}
[Fact]
public void Constructor_InvalidBbPeriod_Throws()
{
var ex = Assert.Throws<ArgumentException>(() => new Bbs(bbPeriod: 0));
Assert.Equal("bbPeriod", ex.ParamName);
}
[Fact]
public void Constructor_InvalidKcPeriod_Throws()
{
var ex = Assert.Throws<ArgumentException>(() => new Bbs(kcPeriod: 0));
Assert.Equal("kcPeriod", ex.ParamName);
}
[Fact]
public void Constructor_InvalidBbMult_Throws()
{
var ex = Assert.Throws<ArgumentException>(() => new Bbs(bbMult: 0.0));
Assert.Equal("bbMult", ex.ParamName);
}
[Fact]
public void Constructor_InvalidKcMult_Throws()
{
var ex = Assert.Throws<ArgumentException>(() => new Bbs(kcMult: 0.0));
Assert.Equal("kcMult", ex.ParamName);
}
[Fact]
public void ConstantPrice_BandwidthZero()
{
var bbs = new Bbs(bbPeriod: 3, bbMult: 2.0, kcPeriod: 3, kcMult: 1.5);
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
// Constant price => stddev = 0 => BB width = 0 => bandwidth = 0
for (int i = 0; i < 5; i++)
{
bbs.Update(new TBar(baseTime + i * 60000, 100, 100, 100, 100, 1000));
}
Assert.Equal(0.0, bbs.Last.Value, 10);
}
[Fact]
public void TightRange_SqueezeOn()
{
// Very tight range bars: stddev ≈ 0, so BB bands collapse
// ATR still has width from H-L range, so KC is wider
// => BB inside KC => squeeze on
var bbs = new Bbs(bbPeriod: 3, bbMult: 2.0, kcPeriod: 3, kcMult: 1.5);
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
// Close is always 100, but high/low create ATR
for (int i = 0; i < 10; i++)
{
bbs.Update(new TBar(baseTime + i * 60000, 100, 102, 98, 100, 1000));
}
// With constant close and non-zero ATR, BB bands (based on close stddev) should be
// narrower than KC bands (based on ATR), so squeeze should be on
Assert.True(bbs.IsHot);
Assert.True(bbs.SqueezeOn);
}
[Fact]
public void WideRange_SqueezeOff()
{
// Wide price swings create large BB stddev → BB bands wider than KC bands
// Use small kcMult so KC is narrow, large bbMult so BB is wide
var bbs = new Bbs(bbPeriod: 3, bbMult: 3.0, kcPeriod: 3, kcMult: 0.5);
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
// Alternating prices create large stddev; tight H-L keeps ATR small relative to stddev
double[] closes = { 80, 120, 80, 120, 80, 120, 80, 120, 80, 120 };
for (int i = 0; i < closes.Length; i++)
{
double c = closes[i];
// H/L track actual price so TR ≈ close-to-close gap (ATR stays proportional)
// but BB mult * stddev >> KC mult * ATR when kcMult is small
bbs.Update(new TBar(baseTime + i * 60000, c, c + 0.5, c - 0.5, c, 1000));
}
// BB bands (3 * stddev) should exceed KC bands (0.5 * ATR)
Assert.True(bbs.IsHot);
Assert.False(bbs.SqueezeOn);
}
[Fact]
public void IsNew_False_RollsBackCorrectly()
{
var bbs = new Bbs(bbPeriod: 3, bbMult: 2.0, kcPeriod: 3, kcMult: 1.5);
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
// Feed initial bars
for (int i = 0; i < 5; i++)
{
bbs.Update(new TBar(baseTime + i * 60000, 100 + i, 102 + i, 98 + i, 100 + i, 1000));
}
// Save state after bar 5 for reference
_ = bbs.Last.Value;
_ = bbs.SqueezeOn;
// Update with new bar
bbs.Update(new TBar(baseTime + 5 * 60000, 110, 112, 108, 110, 1000), isNew: true);
double afterBar6 = bbs.Last.Value;
// Roll back with isNew=false
bbs.Update(new TBar(baseTime + 5 * 60000, 105, 107, 103, 105, 1000), isNew: false);
double corrected = bbs.Last.Value;
// Corrected value should differ from bar 6 (different price) but be valid
Assert.NotEqual(afterBar6, corrected, 5);
Assert.True(double.IsFinite(corrected));
}
[Fact]
public void SqueezeFired_DetectsTransition()
{
var bbs = new Bbs(bbPeriod: 3, bbMult: 2.0, kcPeriod: 3, kcMult: 1.5);
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
// Phase 1: Tight range (squeeze on)
for (int i = 0; i < 5; i++)
{
bbs.Update(new TBar(baseTime + i * 60000, 100, 102, 98, 100, 1000));
}
_ = bbs.SqueezeOn; // capture pre-breakout state
// Phase 2: Breakout with huge price movement (squeeze off)
for (int i = 0; i < 5; i++)
{
double price = 100 + (i + 1) * 20; // 120, 140, 160, 180, 200
bbs.Update(new TBar(baseTime + (5 + i) * 60000, price, price + 1, price - 1, price, 1000));
}
// If squeeze was on and now off, SqueezeFired should have been true at transition
// We test that values are valid after the transition
Assert.True(double.IsFinite(bbs.Last.Value));
}
[Fact]
public void Bandwidth_PositiveForVariedPrices()
{
var bbs = new Bbs(bbPeriod: 5, bbMult: 2.0, kcPeriod: 5, kcMult: 1.5);
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
for (int i = 0; i < 10; i++)
{
double price = 100 + Math.Sin(i) * 5;
bbs.Update(new TBar(baseTime + i * 60000, price, price + 2, price - 2, price, 1000));
}
// With varying prices, bandwidth should be positive
Assert.True(bbs.Last.Value > 0);
Assert.True(bbs.IsHot);
}
[Fact]
public void NaN_Input_UsesLastValid()
{
var bbs = new Bbs(bbPeriod: 3, bbMult: 2.0, kcPeriod: 3, kcMult: 1.5);
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
// Feed valid bars
bbs.Update(new TBar(baseTime, 100, 102, 98, 100, 1000));
bbs.Update(new TBar(baseTime + 60000, 101, 103, 99, 101, 1000));
// Feed NaN bar
var result = bbs.Update(new TBar(baseTime + 120000, double.NaN, double.NaN, double.NaN, double.NaN, 1000));
Assert.True(double.IsFinite(result.Value));
}
[Fact]
public void Reset_ClearsState()
{
var bbs = new Bbs(bbPeriod: 3, bbMult: 2.0, kcPeriod: 3, kcMult: 1.5);
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
for (int i = 0; i < 5; i++)
{
bbs.Update(new TBar(baseTime + i * 60000, 100 + i, 102 + i, 98 + i, 100 + i, 1000));
}
Assert.True(bbs.IsHot);
bbs.Reset();
Assert.False(bbs.IsHot);
Assert.False(bbs.SqueezeOn);
Assert.False(bbs.SqueezeFired);
}
#region Batch Tests
[Fact]
public void Batch_TBarSeries_ReturnsCorrectLength()
{
var series = new TBarSeries();
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
for (int i = 0; i < 20; i++)
{
series.Add(new TBar(baseTime + i * 60000, 100 + i, 110 + i, 90 + i, 105 + i, 1000));
}
var result = Bbs.Batch(series);
Assert.Equal(20, result.Count);
}
[Fact]
public void Batch_EmptySource_ReturnsEmpty()
{
var result = Bbs.Batch(new TBarSeries());
Assert.Empty(result);
}
[Fact]
public void Batch_CustomParams_ReturnsCorrectLength()
{
var series = new TBarSeries();
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
for (int i = 0; i < 20; i++)
{
series.Add(new TBar(baseTime + i * 60000, 100 + i, 110 + i, 90 + i, 105 + i, 1000));
}
var result = Bbs.Batch(series, bbPeriod: 10, bbMult: 1.5, kcPeriod: 10, kcMult: 2.0);
Assert.Equal(20, result.Count);
}
[Fact]
public void Batch_Span_MatchesStreaming()
{
var series = new TBarSeries();
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
for (int i = 0; i < 50; i++)
{
double price = 100 + Math.Sin(i * 0.5) * 10;
series.Add(new TBar(baseTime + i * 60000, price, price + 3, price - 3, price, 1000));
}
// Streaming
var bbs = new Bbs(bbPeriod: 5, bbMult: 2.0, kcPeriod: 5, kcMult: 1.5);
var streamValues = new List<double>(50);
for (int i = 0; i < series.Count; i++)
{
streamValues.Add(bbs.Update(series[i]).Value);
}
// Span batch
double[] output = new double[50];
Bbs.Batch(series.HighValues, series.LowValues, series.CloseValues,
output.AsSpan(), bbPeriod: 5, bbMult: 2.0);
// Compare last 40 values (after warmup stabilization)
for (int i = 10; i < 50; i++)
{
Assert.Equal(streamValues[i], output[i], 8);
}
}
[Fact]
public void Batch_SpanWithSqueeze_OutputsBothArrays()
{
int len = 30;
double[] high = new double[len];
double[] low = new double[len];
double[] close = new double[len];
double[] bandwidth = new double[len];
bool[] squeezeOn = new bool[len];
for (int i = 0; i < len; i++)
{
close[i] = 100;
high[i] = 102;
low[i] = 98;
}
Bbs.Batch(high.AsSpan(), low.AsSpan(), close.AsSpan(),
bandwidth.AsSpan(), squeezeOn.AsSpan(),
bbPeriod: 5, bbMult: 2.0, kcPeriod: 5, kcMult: 1.5);
// Constant close => stddev=0 => BB width=0 => squeeze on
// Bandwidth should be 0 for constant close
for (int i = 5; i < len; i++)
{
Assert.Equal(0.0, bandwidth[i], 10);
Assert.True(squeezeOn[i]);
}
}
[Fact]
public void Batch_InvalidInputLength_Throws()
{
double[] high = new double[10];
double[] low = new double[5]; // mismatched
double[] close = new double[10];
double[] output = new double[10];
Assert.Throws<ArgumentException>(() =>
Bbs.Batch(high.AsSpan(), low.AsSpan(), close.AsSpan(), output.AsSpan()));
}
[Fact]
public void Batch_OutputTooSmall_Throws()
{
double[] high = new double[10];
double[] low = new double[10];
double[] close = new double[10];
double[] output = new double[5]; // too small
Assert.Throws<ArgumentException>(() =>
Bbs.Batch(high.AsSpan(), low.AsSpan(), close.AsSpan(), output.AsSpan()));
}
[Fact]
public void Batch_InvalidPeriod_Throws()
{
double[] data = new double[10];
double[] output = new double[10];
Assert.Throws<ArgumentException>(() =>
Bbs.Batch(data.AsSpan(), data.AsSpan(), data.AsSpan(), output.AsSpan(), bbPeriod: 0));
}
[Fact]
public void Batch_InvalidMultiplier_Throws()
{
double[] data = new double[10];
double[] output = new double[10];
Assert.Throws<ArgumentException>(() =>
Bbs.Batch(data.AsSpan(), data.AsSpan(), data.AsSpan(), output.AsSpan(), bbMult: 0.0));
}
#endregion
[Fact]
public void Calculate_ReturnsResultsAndHotIndicator()
{
var series = new TBarSeries();
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
for (int i = 0; i < 30; i++)
{
series.Add(new TBar(baseTime + i * 60000, 100 + i, 110 + i, 90 + i, 105 + i, 1000));
}
var (results, indicator) = Bbs.Calculate(series, bbPeriod: 5, bbMult: 2.0, kcPeriod: 5, kcMult: 1.5);
Assert.Equal(30, results.Count);
Assert.True(indicator.IsHot);
}
[Fact]
public void PubEvent_FiresOnUpdate()
{
var bbs = new Bbs(bbPeriod: 3, bbMult: 2.0, kcPeriod: 3, kcMult: 1.5);
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
int eventCount = 0;
bbs.Pub += (object? _, in TValueEventArgs _) => eventCount++;
for (int i = 0; i < 5; i++)
{
bbs.Update(new TBar(baseTime + i * 60000, 100 + i, 102 + i, 98 + i, 100 + i, 1000));
}
Assert.Equal(5, eventCount);
}
}
+196
View File
@@ -0,0 +1,196 @@
using Skender.Stock.Indicators;
using Xunit.Abstractions;
namespace QuanTAlib.Tests;
public sealed class BbsValidationTests : IDisposable
{
private readonly ValidationTestData _testData;
private readonly ITestOutputHelper _output;
private bool _disposed;
public BbsValidationTests(ITestOutputHelper output)
{
_output = output;
_testData = new ValidationTestData();
}
public void Dispose()
{
Dispose(true);
}
private void Dispose(bool disposing)
{
if (_disposed)
{
return;
}
_disposed = true;
if (disposing)
{
_testData?.Dispose();
}
}
[Fact]
public void Validate_Streaming_Batch_Span_Agree()
{
int bbPeriod = 20;
double bbMult = 2.0;
int kcPeriod = 20;
double kcMult = 1.5;
// Streaming
var streaming = new Bbs(bbPeriod, bbMult, kcPeriod, kcMult);
var streamValues = new List<double>(_testData.Bars.Count);
for (int i = 0; i < _testData.Bars.Count; i++)
{
streamValues.Add(streaming.Update(_testData.Bars[i]).Value);
}
// Batch (TBarSeries)
TSeries batchSeries = Bbs.Batch(_testData.Bars, bbPeriod, bbMult, kcPeriod, kcMult);
// Span
double[] spanOutput = new double[_testData.Bars.Count];
Bbs.Batch(_testData.Bars.HighValues, _testData.Bars.LowValues, _testData.Bars.CloseValues,
spanOutput.AsSpan(), bbPeriod, bbMult);
// Compare last 200 samples for stability
int start = Math.Max(0, spanOutput.Length - 200);
for (int i = start; i < spanOutput.Length; i++)
{
Assert.Equal(batchSeries[i].Value, streamValues[i], 7);
Assert.Equal(batchSeries[i].Value, spanOutput[i], 7);
}
_output.WriteLine("BBS validation: streaming, batch, and span outputs agree.");
}
[Fact]
public void Validate_SpanWithSqueeze_MatchesStreaming()
{
int bbPeriod = 20;
double bbMult = 2.0;
int kcPeriod = 20;
double kcMult = 1.5;
// Streaming - collect squeeze states
var streaming = new Bbs(bbPeriod, bbMult, kcPeriod, kcMult);
var streamBandwidths = new List<double>(_testData.Bars.Count);
var streamSqueezes = new List<bool>(_testData.Bars.Count);
for (int i = 0; i < _testData.Bars.Count; i++)
{
streaming.Update(_testData.Bars[i]);
streamBandwidths.Add(streaming.Last.Value);
streamSqueezes.Add(streaming.SqueezeOn);
}
// Span with squeeze
int len = _testData.Bars.Count;
double[] spanBw = new double[len];
bool[] spanSq = new bool[len];
Bbs.Batch(_testData.Bars.HighValues, _testData.Bars.LowValues, _testData.Bars.CloseValues,
spanBw.AsSpan(), spanSq.AsSpan(), bbPeriod, bbMult, kcPeriod, kcMult);
// Compare last 200 samples
int start = Math.Max(0, len - 200);
for (int i = start; i < len; i++)
{
Assert.Equal(streamBandwidths[i], spanBw[i], 7);
Assert.Equal(streamSqueezes[i], spanSq[i]);
}
_output.WriteLine("BBS validation: squeeze span matches streaming.");
}
[Fact]
public void Validate_Bandwidth_MatchesBbw()
{
// BBS bandwidth should match BBW (Bollinger Band Width) when using same BB parameters.
// BBS bandwidth = ((upper - lower) / middle) * 100
// BBW = ((upper - lower) / middle) * 100 (same formula)
int[] periods = { 5, 10, 20, 50 };
double multiplier = 2.0;
foreach (var period in periods)
{
// BBS (uses close for BB, needs OHLC for KC)
var bbs = new Bbs(bbPeriod: period, bbMult: multiplier, kcPeriod: period, kcMult: 1.5);
var bbsValues = new List<double>(_testData.Bars.Count);
for (int i = 0; i < _testData.Bars.Count; i++)
{
bbs.Update(_testData.Bars[i]);
bbsValues.Add(bbs.Last.Value);
}
// Skender Bollinger Bands Width
var skenderBb = _testData.SkenderQuotes.GetBollingerBands(period, multiplier).ToList();
// Compare bandwidth values where both are valid
int start = period + 10; // skip warmup
int compared = 0;
for (int i = start; i < Math.Min(bbsValues.Count, skenderBb.Count); i++)
{
var sk = skenderBb[i];
if (sk.Width is not null and not double.NaN)
{
// BBS bandwidth = width * 100 (as percentage)
// Skender Width = (Upper - Lower) / Middle
double expected = sk.Width.Value * 100.0;
Assert.Equal(expected, bbsValues[i], 4);
compared++;
}
}
Assert.True(compared > 0, $"No valid comparisons for period {period}");
}
_output.WriteLine("BBS bandwidth validated against Skender BB Width.");
}
[Fact]
public void Validate_AllOutputsFinite()
{
var bbs = new Bbs(bbPeriod: 20, bbMult: 2.0, kcPeriod: 20, kcMult: 1.5);
for (int i = 0; i < _testData.Bars.Count; i++)
{
var result = bbs.Update(_testData.Bars[i]);
Assert.True(double.IsFinite(result.Value), $"Non-finite output at bar {i}: {result.Value}");
}
_output.WriteLine("BBS validation: all outputs are finite.");
}
[Fact]
public void Validate_Calculate_ReturnsHotIndicator()
{
var (results, indicator) = Bbs.Calculate(_testData.Bars);
Assert.Equal(_testData.Bars.Count, results.Count);
Assert.True(indicator.IsHot);
Assert.True(double.IsFinite(indicator.Last.Value));
_output.WriteLine("BBS validation: Calculate returns hot indicator.");
}
[Fact]
public void Validate_LargeDataset_Stability()
{
var (results, _) = Bbs.Calculate(_testData.Bars, bbPeriod: 50, bbMult: 2.0, kcPeriod: 50, kcMult: 1.5);
// Check last 100 values are finite and non-negative
int start = Math.Max(0, results.Count - 100);
for (int i = start; i < results.Count; i++)
{
Assert.True(double.IsFinite(results[i].Value));
Assert.True(results[i].Value >= 0, $"Bandwidth should be non-negative at {i}: {results[i].Value}");
}
_output.WriteLine("BBS validation: large dataset stability verified.");
}
}
+670
View File
@@ -0,0 +1,670 @@
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 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;
/// <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, 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_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;
// === 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;
}
/// <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;
}
}
/// <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 };
}
/// <summary>
/// Resets the indicator state.
/// </summary>
[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;
}
}
+115
View File
@@ -0,0 +1,115 @@
# BBS: Bollinger Band Squeeze
> "Volatility contraction precedes expansion. The squeeze tells you when to watch."
Bollinger Band Squeeze detects when Bollinger Bands contract inside Keltner Channels — a condition signaling low volatility consolidation that typically precedes explosive price moves.
## Calculation
1. Compute Bollinger Bands using SMA and population standard deviation.
2. Compute Keltner Channels using SMA and EMA-smoothed ATR.
3. Detect squeeze: BB bands inside KC bands.
4. Output bandwidth as a percentage.
Formula:
```
BB_Middle = SMA(close, bbPeriod)
BB_StdDev = sqrt(E[x^2] - E[x]^2)
BB_Upper = BB_Middle + bbMult * BB_StdDev
BB_Lower = BB_Middle - bbMult * BB_StdDev
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
SqueezeOn = BB_Upper < KC_Upper AND BB_Lower > KC_Lower
Bandwidth = ((BB_Upper - BB_Lower) / BB_Middle) * 100
```
## Interpretation
- **Squeeze On** (red dot) → low volatility, consolidation phase. Bands are tightening.
- **Squeeze Off** (green dot) → volatility expansion, potential breakout.
- **Squeeze Fired** → first bar after squeeze ends — the breakout moment.
- **Bandwidth** → measures BB width as a percentage of the middle band.
## Parameters
| Name | Type | Default | Range | Description |
| :--- | :--- | :------ | :---- | :---------- |
| `bbPeriod` | `int` | `20` | `>0` | Bollinger Band lookback period. |
| `bbMult` | `double` | `2.0` | `>0` | BB standard deviation multiplier. |
| `kcPeriod` | `int` | `20` | `>0` | Keltner Channel lookback period. |
| `kcMult` | `double` | `1.5` | `>0` | KC ATR multiplier. |
## API
```mermaid
classDiagram
class Bbs {
+Name : string
+WarmupPeriod : int
+IsHot : bool
+SqueezeOn : bool
+SqueezeFired : bool
+Last : TValue
+Update(TBar input, bool isNew) TValue
+Update(TBarSeries source) TSeries
+Prime(TBarSeries source) void
+Reset() void
+Batch(TBarSeries source) TSeries
+Batch(TBarSeries source, int bbPeriod, double bbMult, int kcPeriod, double kcMult) TSeries
+Batch(ReadOnlySpan~double~ high, low, close, Span~double~ output, ...) void
+Batch(ReadOnlySpan~double~ high, low, close, Span~double~ bandwidth, Span~bool~ squeezeOn, ...) void
+Calculate(TBarSeries source, ...) (TSeries Results, Bbs Indicator)
}
```
## Usage Example
```csharp
using QuanTAlib;
// Initialize
var bbs = new Bbs(bbPeriod: 20, bbMult: 2.0, kcPeriod: 20, kcMult: 1.5);
foreach (var bar in bars)
{
bbs.Update(bar);
if (bbs.IsHot)
{
string state = bbs.SqueezeOn ? "SQUEEZE" : "EXPANSION";
Console.WriteLine($"{bar.Time}: Bandwidth={bbs.Last.Value:F2}% [{state}]");
if (bbs.SqueezeFired)
{
Console.WriteLine(" *** BREAKOUT DETECTED ***");
}
}
}
```
## Performance Profile
| Metric | Score | Notes |
| :--- | :--- | :--- |
| **Throughput** | 9 | O(1) rolling sums for BB and KC. |
| **Allocations** | 0 | Zero allocations in hot path. |
| **Complexity** | O(1) | Constant time per update. |
| **Accuracy** | 10 | Matches Pine reference formula. |
| **Timeliness** | 7 | Period-length lag from SMA components. |
| **Overshoot** | N/A | Boolean squeeze output, bandwidth >= 0. |
| **Smoothness** | 6 | Moderate smoothing via SMA and ATR EMA. |
## Validation
Bandwidth component validated against Skender `GetBollingerBands().Width`. Internal consistency verified across streaming, batch, and span modes. Squeeze logic cross-validated against TtmSqueeze (which uses the same BB-inside-KC condition).
## Sources
- John Bollinger, *Bollinger on Bollinger Bands*
- John Carter, *Mastering the Trade* — squeeze concept
- [PineScript reference](bbs.pine)