mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-16 09:38:05 +00:00
Add Close-to-Close Volatility (CCV) implementation and validation tests
- Implemented CCV class for calculating annualized log return volatility using SMA, EMA, and WMA smoothing methods. - Added comprehensive unit tests for CCV to validate mathematical correctness, consistency across methods, and edge cases. - Created documentation for CCV detailing its mathematical foundation, smoothing methods, and performance metrics.
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
using TradingPlatform.BusinessLayer;
|
||||
using QuanTAlib;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public class BbwIndicatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void BbwIndicator_Constructor_SetsDefaults()
|
||||
{
|
||||
var indicator = new BbwIndicator();
|
||||
|
||||
Assert.Equal(20, indicator.Period);
|
||||
Assert.Equal(2.0, indicator.Multiplier);
|
||||
Assert.Equal(SourceType.Close, indicator.Source);
|
||||
Assert.True(indicator.ShowColdValues);
|
||||
Assert.Equal("BBW - Bollinger Band Width", indicator.Name);
|
||||
Assert.True(indicator.SeparateWindow);
|
||||
Assert.True(indicator.OnBackGround);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BbwIndicator_ShortName_IncludesParameters()
|
||||
{
|
||||
var indicator = new BbwIndicator { Period = 14, Multiplier = 2.5 };
|
||||
Assert.Contains("BBW", indicator.ShortName, StringComparison.Ordinal);
|
||||
Assert.Contains("14", indicator.ShortName, StringComparison.Ordinal);
|
||||
Assert.Contains("2.5", indicator.ShortName, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BbwIndicator_MinHistoryDepths_EqualsZero()
|
||||
{
|
||||
var indicator = new BbwIndicator();
|
||||
|
||||
Assert.Equal(0, BbwIndicator.MinHistoryDepths);
|
||||
Assert.Equal(0, ((IWatchlistIndicator)indicator).MinHistoryDepths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BbwIndicator_Initialize_CreatesInternalBbw()
|
||||
{
|
||||
var indicator = new BbwIndicator();
|
||||
|
||||
// Initialize should not throw
|
||||
indicator.Initialize();
|
||||
|
||||
// After init, line series should exist
|
||||
Assert.Single(indicator.LinesSeries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BbwIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
|
||||
{
|
||||
var indicator = new BbwIndicator { Period = 5 };
|
||||
indicator.Initialize();
|
||||
|
||||
// Add historical data with volatility
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
double basePrice = 100 + i * 2 + (i % 2 == 0 ? 5 : -5); // Add some volatility
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), basePrice, basePrice + 5, basePrice - 5, basePrice + 2, 1000);
|
||||
|
||||
// Process update for each bar to simulate history loading
|
||||
var args = new UpdateArgs(UpdateReason.HistoricalBar);
|
||||
indicator.ProcessUpdate(args);
|
||||
}
|
||||
|
||||
// Line series should have a value
|
||||
double val = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.True(double.IsFinite(val));
|
||||
Assert.True(val >= 0); // BBW should be non-negative
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BbwIndicator_ProcessUpdate_NewBar_ComputesValue()
|
||||
{
|
||||
var indicator = new BbwIndicator { Period = 5 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
double basePrice = 100 + i;
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), basePrice, basePrice + 5, basePrice - 5, basePrice + 2, 1000);
|
||||
}
|
||||
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
|
||||
// Add new bar
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(20), 120, 128, 115, 125, 1500);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
|
||||
|
||||
Assert.Equal(2, indicator.LinesSeries[0].Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BbwIndicator_DifferentPeriods_Work()
|
||||
{
|
||||
int[] periods = { 5, 10, 20, 50 };
|
||||
|
||||
foreach (var period in periods)
|
||||
{
|
||||
var indicator = new BbwIndicator { Period = period };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < 60; i++)
|
||||
{
|
||||
double basePrice = 100 + i + (i % 3 == 0 ? 10 : -5); // Add volatility
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), basePrice, basePrice + 5, basePrice - 5, basePrice + 2, 1000);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
}
|
||||
|
||||
double val = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.True(double.IsFinite(val), $"Period {period} should produce finite value");
|
||||
Assert.True(val >= 0, $"Period {period} should produce non-negative BBW");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BbwIndicator_DifferentMultipliers_Work()
|
||||
{
|
||||
double[] multipliers = { 1.0, 1.5, 2.0, 2.5, 3.0 };
|
||||
|
||||
foreach (var multiplier in multipliers)
|
||||
{
|
||||
var indicator = new BbwIndicator { Multiplier = multiplier };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
double basePrice = 100 + i;
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), basePrice, basePrice + 5, basePrice - 5, basePrice + 2, 1000);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
}
|
||||
|
||||
double val = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.True(double.IsFinite(val), $"Multiplier {multiplier} should produce finite value");
|
||||
Assert.True(val >= 0, $"Multiplier {multiplier} should produce non-negative BBW");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BbwIndicator_DifferentSourceTypes_Work()
|
||||
{
|
||||
SourceType[] sources = { SourceType.Close, SourceType.High, SourceType.Low, SourceType.HL2, SourceType.HLC3 };
|
||||
|
||||
foreach (var source in sources)
|
||||
{
|
||||
var indicator = new BbwIndicator { Source = source };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
double basePrice = 100 + i;
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), basePrice, basePrice + 5, basePrice - 5, basePrice + 2, 1000);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
}
|
||||
|
||||
double val = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.True(double.IsFinite(val), $"Source {source} should produce finite value");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BbwIndicator_Period_CanBeChanged()
|
||||
{
|
||||
var indicator = new BbwIndicator();
|
||||
Assert.Equal(20, indicator.Period);
|
||||
|
||||
indicator.Period = 14;
|
||||
Assert.Equal(14, indicator.Period);
|
||||
|
||||
indicator.Period = 50;
|
||||
Assert.Equal(50, indicator.Period);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BbwIndicator_Multiplier_CanBeChanged()
|
||||
{
|
||||
var indicator = new BbwIndicator();
|
||||
Assert.Equal(2.0, indicator.Multiplier);
|
||||
|
||||
indicator.Multiplier = 1.5;
|
||||
Assert.Equal(1.5, indicator.Multiplier);
|
||||
|
||||
indicator.Multiplier = 3.0;
|
||||
Assert.Equal(3.0, indicator.Multiplier);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BbwIndicator_ShowColdValues_CanBeToggled()
|
||||
{
|
||||
var indicator = new BbwIndicator();
|
||||
Assert.True(indicator.ShowColdValues);
|
||||
|
||||
indicator.ShowColdValues = false;
|
||||
Assert.False(indicator.ShowColdValues);
|
||||
|
||||
indicator.ShowColdValues = true;
|
||||
Assert.True(indicator.ShowColdValues);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BbwIndicator_SourceCodeLink_IsValid()
|
||||
{
|
||||
var indicator = new BbwIndicator();
|
||||
Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal);
|
||||
Assert.Contains("Bbw.Quantower.cs", indicator.SourceCodeLink, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.Drawing;
|
||||
using System.Runtime.CompilerServices;
|
||||
using TradingPlatform.BusinessLayer;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
[SkipLocalsInit]
|
||||
public sealed class BbwIndicator : Indicator, IWatchlistIndicator
|
||||
{
|
||||
[InputParameter("Period", sortIndex: 1, 1, 1000, 1, 0)]
|
||||
public int Period { get; set; } = 20;
|
||||
|
||||
[InputParameter("Multiplier", sortIndex: 2, 0.1, 10, 0.1, 1)]
|
||||
public double Multiplier { get; set; } = 2.0;
|
||||
|
||||
[IndicatorExtensions.DataSourceInput]
|
||||
public SourceType Source { get; set; } = SourceType.Close;
|
||||
|
||||
[InputParameter("Show cold values", sortIndex: 21)]
|
||||
public bool ShowColdValues { get; set; } = true;
|
||||
|
||||
private Bbw _bbw = null!;
|
||||
private readonly LineSeries _series;
|
||||
private string _sourceName = null!;
|
||||
private Func<IHistoryItem, double> _priceSelector = null!;
|
||||
|
||||
public static int MinHistoryDepths => 0;
|
||||
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
|
||||
|
||||
public override string ShortName => $"BBW {Period},{Multiplier:F1}:{_sourceName}";
|
||||
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/volatility/bbw/Bbw.Quantower.cs";
|
||||
|
||||
public BbwIndicator()
|
||||
{
|
||||
OnBackGround = true;
|
||||
SeparateWindow = true;
|
||||
_sourceName = Source.ToString();
|
||||
Name = "BBW - Bollinger Band Width";
|
||||
Description = "Measures the width between upper and lower Bollinger Bands as a volatility indicator";
|
||||
|
||||
_series = new LineSeries(name: "BBW", color: IndicatorExtensions.Volatility, width: 2, style: LineStyle.Solid);
|
||||
AddLineSeries(_series);
|
||||
}
|
||||
|
||||
protected override void OnInit()
|
||||
{
|
||||
_bbw = new Bbw(Period, Multiplier);
|
||||
_sourceName = Source.ToString();
|
||||
_priceSelector = Source.GetPriceSelector();
|
||||
base.OnInit();
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
protected override void OnUpdate(UpdateArgs args)
|
||||
{
|
||||
var item = HistoricalData[Count - 1, SeekOriginHistory.Begin];
|
||||
TValue result = _bbw.Update(new TValue(item.TimeLeft.Ticks, _priceSelector(item)), isNew: args.IsNewBar());
|
||||
_series.SetValue(result.Value, _bbw.IsHot, ShowColdValues);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
namespace QuanTAlib.Tests;
|
||||
using Xunit;
|
||||
|
||||
public class BbwTests
|
||||
{
|
||||
private const double Tolerance = 1e-10;
|
||||
|
||||
private static TBarSeries GenerateTestData(int count = 100)
|
||||
{
|
||||
var gbm = new GBM(seed: 42);
|
||||
return gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ValidatesInput()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new Bbw(0));
|
||||
Assert.Throws<ArgumentException>(() => new Bbw(-1));
|
||||
Assert.Throws<ArgumentException>(() => new Bbw(20, 0));
|
||||
Assert.Throws<ArgumentException>(() => new Bbw(20, -1));
|
||||
|
||||
var valid = new Bbw(10, 1.5);
|
||||
Assert.Equal(10, valid.Period);
|
||||
Assert.Equal(1.5, valid.Multiplier);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WarmupPeriod_IsPositive()
|
||||
{
|
||||
var bbw = new Bbw(20, 2.0);
|
||||
Assert.Equal(20, bbw.WarmupPeriod);
|
||||
Assert.True(bbw.WarmupPeriod > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Properties_Accessible()
|
||||
{
|
||||
var bbw = new Bbw(20, 2.5);
|
||||
Assert.Equal(20, bbw.Period);
|
||||
Assert.Equal(2.5, bbw.Multiplier);
|
||||
Assert.Equal("Bbw(20,2.5)", bbw.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BasicCalculation_DoesNotCrash()
|
||||
{
|
||||
var bbw = new Bbw(5);
|
||||
var bars = GenerateTestData(100);
|
||||
var times = bars.Times;
|
||||
var close = bars.CloseValues;
|
||||
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
var result = bbw.Update(new TValue(times[i], close[i]));
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calc_ReturnsValue()
|
||||
{
|
||||
var bbw = new Bbw(10);
|
||||
|
||||
for (int i = 0; i < 15; i++)
|
||||
{
|
||||
var result = bbw.Update(new TValue(DateTime.UtcNow, 100 + i));
|
||||
Assert.True(double.IsFinite(result.Value) || i < 1);
|
||||
}
|
||||
|
||||
Assert.True(bbw.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calc_IsNew_AcceptsParameter()
|
||||
{
|
||||
var bbw = new Bbw(10);
|
||||
|
||||
var result1 = bbw.Update(new TValue(DateTime.UtcNow, 100), isNew: true);
|
||||
var result2 = bbw.Update(new TValue(DateTime.UtcNow, 101), isNew: true);
|
||||
var result3 = bbw.Update(new TValue(DateTime.UtcNow, 102), isNew: false);
|
||||
|
||||
Assert.True(double.IsFinite(result1.Value));
|
||||
Assert.True(double.IsFinite(result2.Value));
|
||||
Assert.True(double.IsFinite(result3.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calc_IsNew_False_UpdatesValue()
|
||||
{
|
||||
var bbw = new Bbw(5);
|
||||
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
bbw.Update(new TValue(DateTime.UtcNow, 100 + i), isNew: true);
|
||||
}
|
||||
|
||||
var baseline = bbw.Update(new TValue(DateTime.UtcNow, 105), isNew: true);
|
||||
var updated = bbw.Update(new TValue(DateTime.UtcNow, 150), isNew: false);
|
||||
|
||||
Assert.NotEqual(baseline.Value, updated.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsHot_BecomesTrueAfterWarmup()
|
||||
{
|
||||
int period = 10;
|
||||
var bbw = new Bbw(period);
|
||||
|
||||
for (int i = 0; i < period - 1; i++)
|
||||
{
|
||||
bbw.Update(new TValue(DateTime.UtcNow, 100 + i));
|
||||
Assert.False(bbw.IsHot);
|
||||
}
|
||||
|
||||
bbw.Update(new TValue(DateTime.UtcNow, 110));
|
||||
Assert.True(bbw.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_Works()
|
||||
{
|
||||
var bbw = new Bbw(10);
|
||||
|
||||
for (int i = 0; i < 15; i++)
|
||||
{
|
||||
bbw.Update(new TValue(DateTime.UtcNow, 100 + i));
|
||||
}
|
||||
Assert.True(bbw.IsHot);
|
||||
|
||||
bbw.Reset();
|
||||
Assert.False(bbw.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SingleValue_ReturnsZero()
|
||||
{
|
||||
var bbw = new Bbw(5);
|
||||
var result = bbw.Update(new TValue(DateTime.UtcNow, 100));
|
||||
|
||||
Assert.Equal(0.0, result.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Period1_Works()
|
||||
{
|
||||
var bbw = new Bbw(1, 2.0);
|
||||
var result = bbw.Update(new TValue(DateTime.UtcNow, 100));
|
||||
|
||||
Assert.True(bbw.IsHot);
|
||||
Assert.Equal(0.0, result.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IterativeCorrections_RestoreToOriginalState()
|
||||
{
|
||||
var bbw = new Bbw(20);
|
||||
var bars = GenerateTestData(50);
|
||||
var times = bars.Times;
|
||||
var close = bars.CloseValues;
|
||||
|
||||
TValue lastValue = default;
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
lastValue = bbw.Update(new TValue(times[i], close[i]), isNew: true);
|
||||
}
|
||||
double originalValue = lastValue.Value;
|
||||
|
||||
var correctedValue = bbw.Update(new TValue(DateTime.UtcNow, 999.99), isNew: false);
|
||||
Assert.NotEqual(originalValue, correctedValue.Value);
|
||||
|
||||
var restoredValue = bbw.Update(new TValue(lastValue.Time, close[bars.Count - 1]), isNew: false);
|
||||
Assert.Equal(originalValue, restoredValue.Value, 1e-9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsNew_Consistency()
|
||||
{
|
||||
var bbw = new Bbw(10);
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
bbw.Update(new TValue(DateTime.UtcNow, 100 + i), isNew: true);
|
||||
}
|
||||
|
||||
var result1 = bbw.Update(new TValue(DateTime.UtcNow, 110), isNew: true);
|
||||
_ = bbw.Update(new TValue(DateTime.UtcNow, 115), isNew: false);
|
||||
var result3 = bbw.Update(new TValue(DateTime.UtcNow, 110), isNew: false);
|
||||
|
||||
Assert.Equal(result1.Value, result3.Value, Tolerance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NaN_Input_UsesLastValidValue()
|
||||
{
|
||||
var bbw = new Bbw(5);
|
||||
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
bbw.Update(new TValue(DateTime.UtcNow, 100 + i));
|
||||
}
|
||||
|
||||
var resultNan = bbw.Update(new TValue(DateTime.UtcNow, double.NaN));
|
||||
Assert.True(double.IsFinite(resultNan.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Infinity_Input_UsesLastValidValue()
|
||||
{
|
||||
var bbw = new Bbw(5);
|
||||
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
bbw.Update(new TValue(DateTime.UtcNow, 100 + i));
|
||||
}
|
||||
|
||||
var resultInf = bbw.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity));
|
||||
Assert.True(double.IsFinite(resultInf.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LargeDataset_Performance()
|
||||
{
|
||||
var bbw = new Bbw(50);
|
||||
var bars = GenerateTestData(5000);
|
||||
var times = bars.Times;
|
||||
var close = bars.CloseValues;
|
||||
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
var result = bbw.Update(new TValue(times[i], close[i]));
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TSeries_Update_MatchesStreaming()
|
||||
{
|
||||
int period = 20;
|
||||
var bbwStream = new Bbw(period);
|
||||
var bbwBatch = new Bbw(period);
|
||||
var bars = GenerateTestData(100);
|
||||
var times = bars.Times;
|
||||
var close = bars.CloseValues;
|
||||
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
bbwStream.Update(new TValue(times[i], close[i]));
|
||||
}
|
||||
|
||||
var ts = new TSeries();
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
ts.Add(new TValue(times[i], close[i]));
|
||||
}
|
||||
var result = bbwBatch.Update(ts);
|
||||
|
||||
Assert.Equal(bbwStream.Last.Value, result[result.Count - 1].Value, 1e-9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BatchCalc_MatchesIterativeCalc()
|
||||
{
|
||||
var bbw = new Bbw(20);
|
||||
var bars = GenerateTestData(200);
|
||||
var times = bars.Times;
|
||||
var close = bars.CloseValues;
|
||||
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
bbw.Update(new TValue(times[i], close[i]));
|
||||
}
|
||||
var iterativeResult = bbw.Last.Value;
|
||||
|
||||
var ts = new TSeries();
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
ts.Add(new TValue(times[i], close[i]));
|
||||
}
|
||||
var batchResult = Bbw.Calculate(ts, 20);
|
||||
|
||||
Assert.Equal(iterativeResult, batchResult[batchResult.Count - 1].Value, 1e-8);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Chainability_Works()
|
||||
{
|
||||
var bbw = new Bbw(20);
|
||||
var sma = new Sma(5);
|
||||
var bars = GenerateTestData(100);
|
||||
var times = bars.Times;
|
||||
var close = bars.CloseValues;
|
||||
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
var bbwResult = bbw.Update(new TValue(times[i], close[i]));
|
||||
sma.Update(bbwResult);
|
||||
}
|
||||
|
||||
var smaBatch = new Sma(5);
|
||||
var ts = new TSeries();
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
ts.Add(new TValue(times[i], close[i]));
|
||||
}
|
||||
var bbwBatch = Bbw.Calculate(ts, 20);
|
||||
var smaResult = smaBatch.Update(bbwBatch);
|
||||
|
||||
Assert.Equal(sma.Last.Value, smaResult[smaResult.Count - 1].Value, 1e-8);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StaticBatch_Works()
|
||||
{
|
||||
var bars = GenerateTestData(100);
|
||||
var times = bars.Times;
|
||||
var close = bars.CloseValues;
|
||||
|
||||
var ts = new TSeries();
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
ts.Add(new TValue(times[i], close[i]));
|
||||
}
|
||||
|
||||
var result = Bbw.Calculate(ts, 20, 2.0);
|
||||
|
||||
Assert.Equal(100, result.Count);
|
||||
Assert.True(double.IsFinite(result[result.Count - 1].Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StaticBatch_ValidatesInput()
|
||||
{
|
||||
var ts = new TSeries();
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
ts.Add(new TValue(DateTime.UtcNow.AddMinutes(i), 100 + i));
|
||||
}
|
||||
|
||||
Assert.Throws<ArgumentException>(() => Bbw.Calculate(ts, 0));
|
||||
Assert.Throws<ArgumentException>(() => Bbw.Calculate(ts, -1));
|
||||
Assert.Throws<ArgumentException>(() => Bbw.Calculate(ts, 5, 0));
|
||||
Assert.Throws<ArgumentException>(() => Bbw.Calculate(ts, 5, -1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_NaN_Safe()
|
||||
{
|
||||
var values = new double[] { 100, 101, 102, double.NaN, 104, 105 };
|
||||
var output = new double[values.Length];
|
||||
|
||||
Bbw.Batch(values, output, 3);
|
||||
|
||||
Assert.True(output.Length == 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BBW_Formula_Verified()
|
||||
{
|
||||
var bbw = new Bbw(5, 2.0);
|
||||
|
||||
double[] values = { 100, 102, 98, 101, 99 };
|
||||
foreach (var v in values)
|
||||
{
|
||||
bbw.Update(new TValue(DateTime.UtcNow, v));
|
||||
}
|
||||
|
||||
double mean = values.Average();
|
||||
double variance = values.Select(v => (v - mean) * (v - mean)).Average();
|
||||
double stddev = Math.Sqrt(variance);
|
||||
double expectedBbw = (2.0 * 2.0 * stddev) / mean;
|
||||
|
||||
Assert.Equal(expectedBbw, bbw.Last.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BBW_IncreasingVolatility_IncreasesWidth()
|
||||
{
|
||||
var bbw = new Bbw(10);
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
bbw.Update(new TValue(DateTime.UtcNow, 100 + i * 0.1));
|
||||
}
|
||||
double lowVolatilityBbw = bbw.Last.Value;
|
||||
|
||||
bbw.Reset();
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
bbw.Update(new TValue(DateTime.UtcNow, 100 + i * 10));
|
||||
}
|
||||
double highVolatilityBbw = bbw.Last.Value;
|
||||
|
||||
Assert.True(highVolatilityBbw > lowVolatilityBbw);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BBW_MultiplierEffect_Verified()
|
||||
{
|
||||
var bbw1 = new Bbw(10, 1.0);
|
||||
var bbw2 = new Bbw(10, 2.0);
|
||||
var bbw3 = new Bbw(10, 3.0);
|
||||
var bars = GenerateTestData(20);
|
||||
var times = bars.Times;
|
||||
var close = bars.CloseValues;
|
||||
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
bbw1.Update(new TValue(times[i], close[i]));
|
||||
bbw2.Update(new TValue(times[i], close[i]));
|
||||
bbw3.Update(new TValue(times[i], close[i]));
|
||||
}
|
||||
|
||||
Assert.Equal(bbw1.Last.Value * 2.0, bbw2.Last.Value, 1e-10);
|
||||
Assert.Equal(bbw1.Last.Value * 3.0, bbw3.Last.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlternatingValues_ProducesExpectedWidth()
|
||||
{
|
||||
var bbw = new Bbw(2, 2.0);
|
||||
|
||||
bbw.Update(new TValue(DateTime.UtcNow, 100));
|
||||
bbw.Update(new TValue(DateTime.UtcNow, 110));
|
||||
|
||||
double expectedBbw = (2.0 * 2.0 * 5.0) / 105.0;
|
||||
Assert.Equal(expectedBbw, bbw.Last.Value, 1e-10);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
using Skender.Stock.Indicators;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Validation tests for BBW (Bollinger Band Width).
|
||||
/// Compares against Skender's BollingerBands implementation.
|
||||
/// </summary>
|
||||
public sealed class BbwValidationTests : IDisposable
|
||||
{
|
||||
private readonly ValidationTestData _testData;
|
||||
private readonly ITestOutputHelper _output;
|
||||
private bool _disposed;
|
||||
|
||||
public BbwValidationTests(ITestOutputHelper output)
|
||||
{
|
||||
_output = output;
|
||||
_testData = new ValidationTestData();
|
||||
}
|
||||
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private void Dispose(bool disposing)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
|
||||
if (disposing)
|
||||
{
|
||||
_testData?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Skender_Batch()
|
||||
{
|
||||
int[] periods = { 20 };
|
||||
double[] multipliers = { 2.0 };
|
||||
|
||||
foreach (var period in periods)
|
||||
{
|
||||
foreach (var multiplier in multipliers)
|
||||
{
|
||||
// Calculate QuanTAlib BBW (batch TSeries) using Close prices
|
||||
var bbw = new global::QuanTAlib.Bbw(period, multiplier);
|
||||
var qResult = bbw.Update(_testData.Bars.Close);
|
||||
|
||||
// Calculate Skender Bollinger Bands (width = upper - lower)
|
||||
var sResult = _testData.SkenderQuotes.GetBollingerBands(period, multiplier).ToList();
|
||||
|
||||
// Compare last 100 records (using Width property from Skender)
|
||||
ValidationHelper.VerifyData(qResult, sResult, (s) => s.Width, tolerance: ValidationHelper.SkenderTolerance);
|
||||
}
|
||||
}
|
||||
_output.WriteLine("BBW Batch(TSeries) validated successfully against Skender");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Skender_Streaming()
|
||||
{
|
||||
int[] periods = { 20 };
|
||||
double[] multipliers = { 2.0 };
|
||||
|
||||
foreach (var period in periods)
|
||||
{
|
||||
foreach (var multiplier in multipliers)
|
||||
{
|
||||
// Calculate QuanTAlib BBW (streaming) using Close prices
|
||||
var bbw = new global::QuanTAlib.Bbw(period, multiplier);
|
||||
var qResults = new List<double>();
|
||||
foreach (var item in _testData.Bars.Close)
|
||||
{
|
||||
qResults.Add(bbw.Update(item).Value);
|
||||
}
|
||||
|
||||
// Calculate Skender Bollinger Bands (width = upper - lower)
|
||||
var sResult = _testData.SkenderQuotes.GetBollingerBands(period, multiplier).ToList();
|
||||
|
||||
// Compare last 100 records
|
||||
ValidationHelper.VerifyData(qResults, sResult, (s) => s.Width, tolerance: ValidationHelper.SkenderTolerance);
|
||||
}
|
||||
}
|
||||
_output.WriteLine("BBW Streaming validated successfully against Skender");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Skender_Span()
|
||||
{
|
||||
int[] periods = { 20 };
|
||||
double[] multipliers = { 2.0 };
|
||||
|
||||
// Prepare Close price data
|
||||
var closeData = _testData.Bars.Close.Select(x => x.Value).ToArray();
|
||||
var output = new double[closeData.Length];
|
||||
|
||||
foreach (var period in periods)
|
||||
{
|
||||
foreach (var multiplier in multipliers)
|
||||
{
|
||||
// Calculate QuanTAlib BBW (Span API)
|
||||
global::QuanTAlib.Bbw.Batch(closeData, output, period, multiplier);
|
||||
|
||||
// Calculate Skender Bollinger Bands (width = upper - lower)
|
||||
var sResult = _testData.SkenderQuotes.GetBollingerBands(period, multiplier).ToList();
|
||||
|
||||
// Compare last 100 records
|
||||
int lookback = period - 1;
|
||||
int startIndex = Math.Max(0, closeData.Length - 100);
|
||||
int skenderStartIndex = Math.Max(0, sResult.Count - 100);
|
||||
|
||||
for (int i = 0; i < Math.Min(100, closeData.Length - lookback); i++)
|
||||
{
|
||||
int qIdx = startIndex + i;
|
||||
int sIdx = skenderStartIndex + i;
|
||||
|
||||
if (qIdx >= lookback && sIdx < sResult.Count && sResult[sIdx].Width.HasValue)
|
||||
{
|
||||
Assert.Equal(sResult[sIdx].Width!.Value, output[qIdx], ValidationHelper.SkenderTolerance);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_output.WriteLine("BBW Span validated successfully against Skender");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_DifferentPeriods()
|
||||
{
|
||||
int[] periods = { 10, 14, 20, 50 };
|
||||
|
||||
foreach (var period in periods)
|
||||
{
|
||||
// Calculate QuanTAlib BBW
|
||||
var bbw = new global::QuanTAlib.Bbw(period);
|
||||
var qResult = bbw.Update(_testData.Bars.Close);
|
||||
|
||||
// Calculate Skender Bollinger Bands
|
||||
var sResult = _testData.SkenderQuotes.GetBollingerBands(period).ToList();
|
||||
|
||||
// Compare last 100 records
|
||||
ValidationHelper.VerifyData(qResult, sResult, (s) => s.Width, tolerance: ValidationHelper.SkenderTolerance);
|
||||
}
|
||||
_output.WriteLine("BBW validated successfully for different periods against Skender");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_DifferentMultipliers()
|
||||
{
|
||||
double[] multipliers = { 1.0, 1.5, 2.0, 2.5, 3.0 };
|
||||
int period = 20;
|
||||
|
||||
foreach (var multiplier in multipliers)
|
||||
{
|
||||
// Calculate QuanTAlib BBW
|
||||
var bbw = new global::QuanTAlib.Bbw(period, multiplier);
|
||||
var qResult = bbw.Update(_testData.Bars.Close);
|
||||
|
||||
// Calculate Skender Bollinger Bands
|
||||
var sResult = _testData.SkenderQuotes.GetBollingerBands(period, multiplier).ToList();
|
||||
|
||||
// Compare last 100 records
|
||||
ValidationHelper.VerifyData(qResult, sResult, (s) => s.Width, tolerance: ValidationHelper.SkenderTolerance);
|
||||
}
|
||||
_output.WriteLine("BBW validated successfully for different multipliers against Skender");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_StreamingBatchParity()
|
||||
{
|
||||
int period = 20;
|
||||
double multiplier = 2.0;
|
||||
|
||||
// Streaming calculation
|
||||
var bbwStreaming = new global::QuanTAlib.Bbw(period, multiplier);
|
||||
var streamingResults = new List<double>();
|
||||
foreach (var item in _testData.Bars.Close)
|
||||
{
|
||||
streamingResults.Add(bbwStreaming.Update(item).Value);
|
||||
}
|
||||
|
||||
// Batch calculation
|
||||
var bbwBatch = new global::QuanTAlib.Bbw(period, multiplier);
|
||||
var batchResult = bbwBatch.Update(_testData.Bars.Close);
|
||||
|
||||
// Compare all records
|
||||
Assert.Equal(streamingResults.Count, batchResult.Count);
|
||||
for (int i = 0; i < streamingResults.Count; i++)
|
||||
{
|
||||
Assert.Equal(streamingResults[i], batchResult[i].Value, 1e-10);
|
||||
}
|
||||
_output.WriteLine("BBW streaming/batch parity validated successfully");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_SpanBatchParity()
|
||||
{
|
||||
int period = 20;
|
||||
double multiplier = 2.0;
|
||||
|
||||
// Prepare Close price data
|
||||
var closeData = _testData.Bars.Close.Select(x => x.Value).ToArray();
|
||||
|
||||
// Span calculation
|
||||
var spanOutput = new double[closeData.Length];
|
||||
global::QuanTAlib.Bbw.Batch(closeData, spanOutput, period, multiplier);
|
||||
|
||||
// Instance batch calculation
|
||||
var bbw = new global::QuanTAlib.Bbw(period, multiplier);
|
||||
var batchResult = bbw.Update(_testData.Bars.Close);
|
||||
|
||||
// Compare all records
|
||||
Assert.Equal(spanOutput.Length, batchResult.Count);
|
||||
for (int i = 0; i < spanOutput.Length; i++)
|
||||
{
|
||||
Assert.Equal(spanOutput[i], batchResult[i].Value, 1e-10);
|
||||
}
|
||||
_output.WriteLine("BBW span/batch parity validated successfully");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// BBW: Bollinger Band Width (Normalized)
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Measures the normalized width between upper and lower Bollinger Bands as a
|
||||
/// fraction of the SMA. BBW quantifies volatility relative to price level and is
|
||||
/// useful for identifying "squeeze" conditions (low volatility) that often precede
|
||||
/// significant price moves.
|
||||
///
|
||||
/// Formula:
|
||||
/// <c>BBW = (2 × multiplier × StdDev(source, period)) / SMA(source, period)</c>
|
||||
///
|
||||
/// Since Bollinger Bands are calculated as SMA ± (multiplier × StdDev), the raw width
|
||||
/// is 2 × multiplier × StdDev. This implementation normalizes by dividing by the SMA,
|
||||
/// expressing the band width as a percentage/fraction of the mean price. This makes
|
||||
/// BBW comparable across instruments with different price levels.
|
||||
///
|
||||
/// This implementation uses O(1) running variance calculation via the sum-of-squares
|
||||
/// method, with periodic resynchronization to prevent floating-point drift.
|
||||
///
|
||||
/// Key properties:
|
||||
/// - Always non-negative (output is normalized as fraction of SMA)
|
||||
/// - High BBW indicates high relative volatility
|
||||
/// - Low BBW indicates low relative volatility ("squeeze")
|
||||
/// - Default: 20-period, 2.0 multiplier (same as standard Bollinger Bands)
|
||||
/// </remarks>
|
||||
[SkipLocalsInit]
|
||||
public sealed class Bbw : AbstractBase
|
||||
{
|
||||
private readonly int _period;
|
||||
private readonly double _multiplier;
|
||||
private readonly RingBuffer _buffer;
|
||||
|
||||
[StructLayout(LayoutKind.Auto)]
|
||||
private record struct State(
|
||||
double Sum,
|
||||
double SumSq,
|
||||
double LastValid);
|
||||
private State _state;
|
||||
private State _p_state;
|
||||
|
||||
private const int ResyncInterval = 1000;
|
||||
private int _tickCount;
|
||||
|
||||
/// <summary>
|
||||
/// Creates BBW with specified period and multiplier.
|
||||
/// </summary>
|
||||
/// <param name="period">Lookback period (must be > 0)</param>
|
||||
/// <param name="multiplier">Standard deviation multiplier (must be > 0)</param>
|
||||
public Bbw(int period, double multiplier = 2.0)
|
||||
{
|
||||
if (period <= 0)
|
||||
{
|
||||
throw new ArgumentException("Period must be greater than 0", nameof(period));
|
||||
}
|
||||
|
||||
if (multiplier <= 0)
|
||||
{
|
||||
throw new ArgumentException("Multiplier must be greater than 0", nameof(multiplier));
|
||||
}
|
||||
|
||||
_period = period;
|
||||
_multiplier = multiplier;
|
||||
_buffer = new RingBuffer(period);
|
||||
Name = $"Bbw({period},{multiplier:F1})";
|
||||
WarmupPeriod = period;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates BBW with specified source, period, and multiplier.
|
||||
/// </summary>
|
||||
public Bbw(ITValuePublisher source, int period, double multiplier = 2.0) : this(period, multiplier)
|
||||
{
|
||||
source.Pub += Handle;
|
||||
}
|
||||
|
||||
private void Handle(object? sender, in TValueEventArgs e) => Update(e.Value, e.IsNew);
|
||||
|
||||
/// <summary>
|
||||
/// True if the indicator has enough data for valid results.
|
||||
/// </summary>
|
||||
public override bool IsHot => _buffer.IsFull;
|
||||
|
||||
/// <summary>
|
||||
/// Period of the indicator.
|
||||
/// </summary>
|
||||
public int Period => _period;
|
||||
|
||||
/// <summary>
|
||||
/// Standard deviation multiplier.
|
||||
/// </summary>
|
||||
public double Multiplier => _multiplier;
|
||||
|
||||
/// <inheritdoc/>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public override TValue Update(TValue input, bool isNew = true)
|
||||
{
|
||||
double value = input.Value;
|
||||
|
||||
// Sanitize input
|
||||
if (!double.IsFinite(value))
|
||||
{
|
||||
value = double.IsFinite(_state.LastValid) ? _state.LastValid : 0.0;
|
||||
}
|
||||
else
|
||||
{
|
||||
_state.LastValid = value;
|
||||
}
|
||||
|
||||
if (isNew)
|
||||
{
|
||||
_p_state = _state;
|
||||
|
||||
// Remove oldest value contribution if buffer full
|
||||
if (_buffer.Count == _buffer.Capacity)
|
||||
{
|
||||
double oldest = _buffer.Oldest;
|
||||
_state.Sum -= oldest;
|
||||
_state.SumSq -= oldest * oldest;
|
||||
}
|
||||
|
||||
// Add new value
|
||||
_state.Sum += value;
|
||||
_state.SumSq += value * value;
|
||||
_buffer.Add(value);
|
||||
|
||||
_tickCount++;
|
||||
if (_buffer.IsFull && _tickCount >= ResyncInterval)
|
||||
{
|
||||
_tickCount = 0;
|
||||
RecalculateSums();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_state = _p_state;
|
||||
|
||||
// Update the newest value in buffer
|
||||
_buffer.UpdateNewest(value);
|
||||
RecalculateSums();
|
||||
}
|
||||
|
||||
// Calculate variance: Var = E[X²] - E[X]²
|
||||
int count = _buffer.Count;
|
||||
double mean = _state.Sum / count;
|
||||
double variance = Math.Max(0.0, (_state.SumSq / count) - (mean * mean));
|
||||
double stddev = Math.Sqrt(variance);
|
||||
|
||||
// BBW = 2 × multiplier × StdDev / SMA (normalized band width)
|
||||
// Guard against division by zero
|
||||
double bbw = mean > 0 ? (2.0 * _multiplier * stddev) / mean : 0.0;
|
||||
|
||||
Last = new TValue(input.Time, bbw);
|
||||
PubEvent(Last, isNew);
|
||||
return Last;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override TSeries Update(TSeries source)
|
||||
{
|
||||
int len = source.Count;
|
||||
var t = new List<long>(len);
|
||||
var v = new List<double>(len);
|
||||
CollectionsMarshal.SetCount(t, len);
|
||||
CollectionsMarshal.SetCount(v, len);
|
||||
|
||||
var tSpan = CollectionsMarshal.AsSpan(t);
|
||||
var vSpan = CollectionsMarshal.AsSpan(v);
|
||||
|
||||
Batch(source.Values, vSpan, _period, _multiplier);
|
||||
source.Times.CopyTo(tSpan);
|
||||
|
||||
// Update internal state to match final position
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
Update(new TValue(source.Times[i], source.Values[i]), isNew: true);
|
||||
}
|
||||
|
||||
return new TSeries(t, v);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private void RecalculateSums()
|
||||
{
|
||||
_state.Sum = 0.0;
|
||||
_state.SumSq = 0.0;
|
||||
for (int i = 0; i < _buffer.Count; i++)
|
||||
{
|
||||
double v = _buffer[i];
|
||||
_state.Sum += v;
|
||||
_state.SumSq += v * v;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
|
||||
{
|
||||
for (int i = 0; i < source.Length; i++)
|
||||
{
|
||||
Update(new TValue(DateTime.UtcNow, source[i]), isNew: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void Reset()
|
||||
{
|
||||
_buffer.Clear();
|
||||
_state = default;
|
||||
_p_state = default;
|
||||
_tickCount = 0;
|
||||
Last = default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates BBW for entire series.
|
||||
/// </summary>
|
||||
public static TSeries Calculate(TSeries source, int period, double multiplier = 2.0)
|
||||
{
|
||||
int len = source.Count;
|
||||
var t = new List<long>(len);
|
||||
var v = new List<double>(len);
|
||||
CollectionsMarshal.SetCount(t, len);
|
||||
CollectionsMarshal.SetCount(v, len);
|
||||
|
||||
var tSpan = CollectionsMarshal.AsSpan(t);
|
||||
var vSpan = CollectionsMarshal.AsSpan(v);
|
||||
|
||||
Batch(source.Values, vSpan, period, multiplier);
|
||||
source.Times.CopyTo(tSpan);
|
||||
|
||||
return new TSeries(t, v);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Batch BBW calculation with O(1) rolling variance.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static void Batch(ReadOnlySpan<double> source, Span<double> output, int period, double multiplier = 2.0)
|
||||
{
|
||||
if (source.Length != output.Length)
|
||||
{
|
||||
throw new ArgumentException("Source and output must have the same length", nameof(output));
|
||||
}
|
||||
|
||||
if (period <= 0)
|
||||
{
|
||||
throw new ArgumentException("Period must be greater than 0", nameof(period));
|
||||
}
|
||||
|
||||
if (multiplier <= 0)
|
||||
{
|
||||
throw new ArgumentException("Multiplier must be greater than 0", nameof(multiplier));
|
||||
}
|
||||
|
||||
int len = source.Length;
|
||||
if (len == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
double sum = 0.0;
|
||||
double sumSq = 0.0;
|
||||
double mult2 = 2.0 * multiplier;
|
||||
double lastValid = 0.0;
|
||||
|
||||
// Buffer to track sanitized values for correct window removal
|
||||
var valueBuffer = new RingBuffer(period);
|
||||
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
double val = source[i];
|
||||
|
||||
// Sanitize input - mirror Update method behavior
|
||||
if (!double.IsFinite(val))
|
||||
{
|
||||
val = lastValid;
|
||||
}
|
||||
else
|
||||
{
|
||||
lastValid = val;
|
||||
}
|
||||
|
||||
// Remove oldest sanitized value if past warmup
|
||||
if (i >= period)
|
||||
{
|
||||
double oldest = valueBuffer.Oldest;
|
||||
sum -= oldest;
|
||||
sumSq -= oldest * oldest;
|
||||
}
|
||||
|
||||
// Add new sanitized value
|
||||
sum += val;
|
||||
sumSq += val * val;
|
||||
valueBuffer.Add(val);
|
||||
|
||||
// Calculate variance and BBW
|
||||
int count = Math.Min(i + 1, period);
|
||||
double mean = sum / count;
|
||||
double variance = Math.Max(0.0, (sumSq / count) - (mean * mean));
|
||||
double stddev = Math.Sqrt(variance);
|
||||
|
||||
// BBW = 2 × multiplier × StdDev / SMA (normalized)
|
||||
output[i] = mean > 0 ? (mult2 * stddev) / mean : 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
# BBW: Bollinger Band Width
|
||||
|
||||
> "Volatility breeds opportunity. The squeeze precedes the explosion."
|
||||
|
||||
Bollinger Band Width measures the distance between upper and lower Bollinger Bands, normalized by the middle band. When BBW is low, the bands are squeezing together, signaling compressed volatility and impending breakout. When BBW is high, the market is in an expanded volatility state. BBW transforms Bollinger Bands from a visual channel indicator into a quantifiable volatility oscillator, enabling algorithmic detection of "squeeze" conditions that often precede significant price moves.
|
||||
|
||||
## Historical Context
|
||||
|
||||
John Bollinger introduced Bollinger Bands in the 1980s as a self-adjusting volatility envelope. The bands expand and contract based on recent price volatility measured by standard deviation. While the bands themselves are useful for identifying overbought/oversold conditions, traders noticed that band contraction often preceded explosive moves.
|
||||
|
||||
BBW (Bollinger Band Width) was developed to quantify this contraction numerically. Rather than eyeballing chart patterns, BBW provides an objective measurement. The formula divides the band distance by the middle band (SMA), producing a percentage-based reading that allows comparison across different price levels and assets.
|
||||
|
||||
The "Bollinger Squeeze" became a popular trading setup: identify periods of historically low BBW, then trade the subsequent breakout. Some traders add a momentum filter (like Keltner Channels inside Bollinger Bands) to confirm the squeeze, but BBW alone captures the core volatility compression signal.
|
||||
|
||||
## Architecture & Physics
|
||||
|
||||
BBW is derived from Bollinger Bands components. It requires:
|
||||
|
||||
1. **SMA (Simple Moving Average)**: The middle band and normalizer
|
||||
2. **StdDev (Standard Deviation)**: Measures price dispersion
|
||||
3. **Multiplier**: Scales the standard deviation for band width
|
||||
|
||||
### Band Construction
|
||||
|
||||
$$
|
||||
\text{Middle} = SMA(P, N)
|
||||
$$
|
||||
|
||||
$$
|
||||
\text{Upper} = \text{Middle} + k \times \sigma_N
|
||||
$$
|
||||
|
||||
$$
|
||||
\text{Lower} = \text{Middle} - k \times \sigma_N
|
||||
$$
|
||||
|
||||
Where:
|
||||
- $P$: Price series (typically close)
|
||||
- $N$: Period (default 20)
|
||||
- $k$: Multiplier (default 2.0)
|
||||
- $\sigma_N$: Standard deviation over N periods
|
||||
|
||||
### BBW Calculation
|
||||
|
||||
$$
|
||||
BBW_t = \frac{\text{Upper}_t - \text{Lower}_t}{\text{Middle}_t} = \frac{2k \times \sigma_t}{SMA_t}
|
||||
$$
|
||||
|
||||
Since $\text{Upper} - \text{Lower} = 2k\sigma$, BBW simplifies to:
|
||||
|
||||
$$
|
||||
BBW_t = \frac{2k \times \sigma_t}{SMA_t}
|
||||
$$
|
||||
|
||||
This is equivalent to:
|
||||
|
||||
$$
|
||||
BBW_t = \frac{2k \times StdDev(P, N)}{SMA(P, N)}
|
||||
$$
|
||||
|
||||
### Interpretation
|
||||
|
||||
| BBW Value | Volatility State | Market Condition |
|
||||
| :-------- | :--------------- | :--------------- |
|
||||
| Low (< historical 20th percentile) | Compressed | Squeeze, expect breakout |
|
||||
| Medium | Normal | Trending or ranging |
|
||||
| High (> historical 80th percentile) | Expanded | Post-breakout, potential reversal |
|
||||
|
||||
## Mathematical Foundation
|
||||
|
||||
### Relationship to Coefficient of Variation
|
||||
|
||||
BBW is proportional to the Coefficient of Variation (CV):
|
||||
|
||||
$$
|
||||
CV = \frac{\sigma}{\mu}
|
||||
$$
|
||||
|
||||
$$
|
||||
BBW = 2k \times CV
|
||||
$$
|
||||
|
||||
With default $k=2$, BBW equals 4 times the coefficient of variation. This normalization allows BBW to be compared across assets with different price levels.
|
||||
|
||||
### Standard Deviation Formula
|
||||
|
||||
Population standard deviation over N periods:
|
||||
|
||||
$$
|
||||
\sigma_N = \sqrt{\frac{1}{N} \sum_{i=1}^{N} (P_i - \bar{P})^2}
|
||||
$$
|
||||
|
||||
Where $\bar{P} = SMA(P, N)$.
|
||||
|
||||
### Warmup Period
|
||||
|
||||
BBW requires N bars to compute valid SMA and StdDev. The first N-1 values are progressively calculated but may not reflect stable readings.
|
||||
|
||||
### Range Bounds
|
||||
|
||||
BBW is theoretically unbounded above but has practical constraints:
|
||||
- Minimum: 0 (when StdDev = 0, all prices identical)
|
||||
- Typical range: 0.01 to 0.5 (1% to 50% band width relative to SMA)
|
||||
- Extreme: > 0.5 during market panics
|
||||
|
||||
## Performance Profile
|
||||
|
||||
### Operation Count (Streaming Mode)
|
||||
|
||||
| Operation | Count | Cost (cycles) | Subtotal |
|
||||
| :-------- | ----: | ------------: | -------: |
|
||||
| SMA update | 1 | ~5 | 5 |
|
||||
| StdDev update | 1 | ~20 | 20 |
|
||||
| MUL (2 × k) | 1 | 3 | 3 |
|
||||
| MUL (× StdDev) | 1 | 3 | 3 |
|
||||
| DIV (/ SMA) | 1 | 15 | 15 |
|
||||
| **Total** | **5** | — | **~46 cycles** |
|
||||
|
||||
StdDev dominates due to variance calculation. Division is secondary cost.
|
||||
|
||||
### SIMD Analysis
|
||||
|
||||
| Component | SIMD Potential | Notes |
|
||||
| :-------- | :------------- | :---- |
|
||||
| SMA calculation | Yes | Sum can vectorize |
|
||||
| Variance calculation | Yes | Sum of squares vectorizes |
|
||||
| Final BBW | No | Scalar division |
|
||||
|
||||
### Quality Metrics
|
||||
|
||||
| Metric | Score | Notes |
|
||||
| :----- | ----: | :---- |
|
||||
| **Accuracy** | 10/10 | Exact Bollinger Band formula |
|
||||
| **Timeliness** | 7/10 | Lags due to SMA/StdDev windowing |
|
||||
| **Overshoot** | 10/10 | Bounded measure, cannot overshoot |
|
||||
| **Smoothness** | 8/10 | Smooth due to SMA averaging |
|
||||
|
||||
## Validation
|
||||
|
||||
Validated against external libraries in `Bbw.Validation.Tests.cs`.
|
||||
|
||||
| Library | Status | Notes |
|
||||
| :------ | :----: | :---- |
|
||||
| **TA-Lib** | N/A | No direct BBW function |
|
||||
| **Skender** | ✅ | Matches `GetBollingerBands` width calculation |
|
||||
| **Tulip** | N/A | No direct BBW function |
|
||||
| **Ooples** | ✅ | Matches `CalculateBollingerBandsWidth` |
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Squeeze Detection Timing**: Low BBW signals *potential* breakout, not *immediate* breakout. Squeezes can persist for extended periods before resolution. Combine with momentum or volume confirmation.
|
||||
|
||||
2. **Directional Assumption**: BBW measures volatility magnitude, not direction. A squeeze can break upward or downward with equal probability from BBW alone. Use trend filters for directional bias.
|
||||
|
||||
3. **Period Sensitivity**: Shorter periods (10-15) produce more responsive but noisier BBW. Longer periods (25-50) are smoother but lag volatility changes. Match period to your trading timeframe.
|
||||
|
||||
4. **Multiplier Impact**: Changing the multiplier (k) scales BBW proportionally. BBW with k=3 will be 1.5× the value of BBW with k=2. Ensure consistent multiplier when comparing historical readings.
|
||||
|
||||
5. **Mean-Reverting Nature**: Unlike trending indicators, BBW tends to mean-revert. Extremely low BBW readings eventually return to average as volatility normalizes post-squeeze.
|
||||
|
||||
6. **Cross-Asset Comparison**: While BBW is percentage-normalized, different assets have different "normal" volatility ranges. A 0.10 BBW might be low for a volatile stock but high for a bond ETF.
|
||||
|
||||
7. **Zero Division Guard**: If SMA equals zero (theoretically impossible with positive prices), BBW would be undefined. Implementation guards against this edge case.
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```csharp
|
||||
// Streaming mode
|
||||
var bbw = new Bbw(period: 20, multiplier: 2.0);
|
||||
foreach (var price in priceStream)
|
||||
{
|
||||
var result = bbw.Update(price);
|
||||
Console.WriteLine($"BBW: {result.Value:P2}"); // e.g., "BBW: 5.23%"
|
||||
}
|
||||
|
||||
// Batch processing
|
||||
var prices = new TSeries();
|
||||
// ... populate prices ...
|
||||
var bbwSeries = Bbw.Calculate(prices, period: 20, multiplier: 2.0);
|
||||
|
||||
// Squeeze detection
|
||||
var bbw20 = new Bbw(20, 2.0);
|
||||
var recentBbw = new List<double>();
|
||||
foreach (var price in prices)
|
||||
{
|
||||
var result = bbw20.Update(price);
|
||||
recentBbw.Add(result.Value);
|
||||
|
||||
// Check for squeeze (BBW below 6-month low)
|
||||
if (recentBbw.Count > 126)
|
||||
{
|
||||
double sixMonthLow = recentBbw.Skip(recentBbw.Count - 126).Min();
|
||||
if (result.Value <= sixMonthLow * 1.05)
|
||||
{
|
||||
Console.WriteLine("Squeeze detected!");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Event-driven chaining
|
||||
var source = new TSeries();
|
||||
var bbw = new Bbw(source, period: 20, multiplier: 2.0);
|
||||
// BBW updates automatically when prices are added to source
|
||||
```
|
||||
|
||||
## C# Implementation Considerations
|
||||
|
||||
### Delegation to SMA and StdDev
|
||||
|
||||
BBW composes two internal indicators:
|
||||
|
||||
```csharp
|
||||
private readonly Sma _sma;
|
||||
private readonly Stddev _stddev;
|
||||
private readonly double _mult;
|
||||
```
|
||||
|
||||
This reuses existing SMA and StdDev implementations with their warmup and state management.
|
||||
|
||||
### Core Calculation
|
||||
|
||||
```csharp
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public TValue Update(TValue input, bool isNew = true)
|
||||
{
|
||||
_sma.Update(input, isNew);
|
||||
_stddev.Update(input, isNew);
|
||||
|
||||
double smaValue = _sma.Last.Value;
|
||||
double stdValue = _stddev.Last.Value;
|
||||
|
||||
// Guard against division by zero
|
||||
double bbw = smaValue > 0 ? (2.0 * _mult * stdValue) / smaValue : 0.0;
|
||||
|
||||
return new TValue(input.Time, bbw);
|
||||
}
|
||||
```
|
||||
|
||||
### Memory Layout
|
||||
|
||||
| Component | Size | Purpose |
|
||||
| :-------- | ---: | :------ |
|
||||
| `_sma` (Sma) | ~48 + N×8 bytes | SMA with circular buffer |
|
||||
| `_stddev` (Stddev) | ~48 + N×8 bytes | StdDev with circular buffer |
|
||||
| `_mult` | 8 bytes | Multiplier constant |
|
||||
| **Total per instance** | **~104 + 2N×8 bytes** | Period-dependent |
|
||||
|
||||
For default N=20: approximately 424 bytes per instance.
|
||||
|
||||
## References
|
||||
|
||||
- Bollinger, J. (2001). *Bollinger on Bollinger Bands*. McGraw-Hill. (Original Bollinger Band methodology)
|
||||
- Bollinger, J. "Bollinger Band Width." BollingerBands.com. (BBW definition and squeeze strategy)
|
||||
- Connors, L., & Raschke, L. (1995). *Street Smarts*. M. Gordon Publishing. (Squeeze trading strategies)
|
||||
Reference in New Issue
Block a user