mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-18 10:38:05 +00:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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 < KC_Upper AND BB_Lower > 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 > 0)</param>
|
||||
/// <param name="bbMult">Bollinger Band standard deviation multiplier (default 2.0, must be > 0)</param>
|
||||
/// <param name="kcPeriod">Keltner Channel period (default 20, must be > 0)</param>
|
||||
/// <param name="kcMult">Keltner Channel ATR multiplier (default 1.5, must be > 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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user