mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-12 23:58:04 +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,217 @@
|
||||
using TradingPlatform.BusinessLayer;
|
||||
using QuanTAlib;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public class BbwpIndicatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void BbwpIndicator_Constructor_SetsDefaults()
|
||||
{
|
||||
var indicator = new BbwpIndicator();
|
||||
|
||||
Assert.Equal(20, indicator.Period);
|
||||
Assert.Equal(2.0, indicator.Multiplier);
|
||||
Assert.Equal(252, indicator.Lookback);
|
||||
Assert.Equal(SourceType.Close, indicator.Source);
|
||||
Assert.True(indicator.ShowColdValues);
|
||||
Assert.Equal("BBWP - Bollinger Band Width Percentile", indicator.Name);
|
||||
Assert.True(indicator.SeparateWindow);
|
||||
Assert.True(indicator.OnBackGround);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BbwpIndicator_ShortName_IncludesParameters()
|
||||
{
|
||||
var indicator = new BbwpIndicator { Period = 14, Multiplier = 2.5, Lookback = 100 };
|
||||
Assert.Contains("BBWP", indicator.ShortName, StringComparison.Ordinal);
|
||||
Assert.Contains("14", indicator.ShortName, StringComparison.Ordinal);
|
||||
Assert.Contains("2.5", indicator.ShortName, StringComparison.Ordinal);
|
||||
Assert.Contains("100", indicator.ShortName, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BbwpIndicator_MinHistoryDepths_EqualsZero()
|
||||
{
|
||||
var indicator = new BbwpIndicator();
|
||||
|
||||
Assert.Equal(0, BbwpIndicator.MinHistoryDepths);
|
||||
Assert.Equal(0, ((IWatchlistIndicator)indicator).MinHistoryDepths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BbwpIndicator_Initialize_CreatesInternalBbwp()
|
||||
{
|
||||
var indicator = new BbwpIndicator();
|
||||
|
||||
// Initialize should not throw
|
||||
indicator.Initialize();
|
||||
|
||||
// After init, line series should exist
|
||||
Assert.Single(indicator.LinesSeries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BbwpIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
|
||||
{
|
||||
var indicator = new BbwpIndicator { Period = 5, Lookback = 20 };
|
||||
indicator.Initialize();
|
||||
|
||||
// Add historical data with volatility
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < 30; 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 && val <= 1); // BBWP should be in [0,1] range
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BbwpIndicator_ProcessUpdate_NewBar_ComputesValue()
|
||||
{
|
||||
var indicator = new BbwpIndicator { Period = 5, Lookback = 20 };
|
||||
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));
|
||||
|
||||
// Add new bar
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(30), 120, 128, 115, 125, 1500);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
|
||||
|
||||
Assert.Equal(2, indicator.LinesSeries[0].Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BbwpIndicator_DifferentPeriods_Work()
|
||||
{
|
||||
int[] periods = { 5, 10, 20, 50 };
|
||||
|
||||
foreach (var period in periods)
|
||||
{
|
||||
var indicator = new BbwpIndicator { Period = period, Lookback = 30 };
|
||||
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 && val <= 1, $"Period {period} should produce BBWP percentile in range");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BbwpIndicator_DifferentLookbacks_Work()
|
||||
{
|
||||
int[] lookbacks = { 10, 20, 50, 100 };
|
||||
|
||||
foreach (var lookback in lookbacks)
|
||||
{
|
||||
var indicator = new BbwpIndicator { Lookback = lookback };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < 120; 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), $"Lookback {lookback} should produce finite value");
|
||||
Assert.True(val >= 0 && val <= 1, $"Lookback {lookback} should produce BBWP percentile in range");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BbwpIndicator_DifferentSourceTypes_Work()
|
||||
{
|
||||
SourceType[] sources = { SourceType.Close, SourceType.High, SourceType.Low, SourceType.HL2, SourceType.HLC3 };
|
||||
|
||||
foreach (var source in sources)
|
||||
{
|
||||
var indicator = new BbwpIndicator { Source = source, Lookback = 20 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < 40; 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 BbwpIndicator_Period_CanBeChanged()
|
||||
{
|
||||
var indicator = new BbwpIndicator();
|
||||
Assert.Equal(20, indicator.Period);
|
||||
|
||||
indicator.Period = 14;
|
||||
Assert.Equal(14, indicator.Period);
|
||||
|
||||
indicator.Period = 50;
|
||||
Assert.Equal(50, indicator.Period);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BbwpIndicator_Lookback_CanBeChanged()
|
||||
{
|
||||
var indicator = new BbwpIndicator();
|
||||
Assert.Equal(252, indicator.Lookback);
|
||||
|
||||
indicator.Lookback = 100;
|
||||
Assert.Equal(100, indicator.Lookback);
|
||||
|
||||
indicator.Lookback = 50;
|
||||
Assert.Equal(50, indicator.Lookback);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BbwpIndicator_ShowColdValues_CanBeToggled()
|
||||
{
|
||||
var indicator = new BbwpIndicator();
|
||||
Assert.True(indicator.ShowColdValues);
|
||||
|
||||
indicator.ShowColdValues = false;
|
||||
Assert.False(indicator.ShowColdValues);
|
||||
|
||||
indicator.ShowColdValues = true;
|
||||
Assert.True(indicator.ShowColdValues);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BbwpIndicator_SourceCodeLink_IsValid()
|
||||
{
|
||||
var indicator = new BbwpIndicator();
|
||||
Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal);
|
||||
Assert.Contains("Bbwp.Quantower.cs", indicator.SourceCodeLink, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using System.Drawing;
|
||||
using System.Runtime.CompilerServices;
|
||||
using TradingPlatform.BusinessLayer;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
[SkipLocalsInit]
|
||||
public sealed class BbwpIndicator : 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;
|
||||
|
||||
[InputParameter("Lookback", sortIndex: 3, 1, 2000, 1, 0)]
|
||||
public int Lookback { get; set; } = 252;
|
||||
|
||||
[IndicatorExtensions.DataSourceInput]
|
||||
public SourceType Source { get; set; } = SourceType.Close;
|
||||
|
||||
[InputParameter("Show cold values", sortIndex: 21)]
|
||||
public bool ShowColdValues { get; set; } = true;
|
||||
|
||||
private Bbwp _bbwp = 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 => $"BBWP {Period},{Multiplier:F1},{Lookback}:{_sourceName}";
|
||||
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/volatility/bbwp/Bbwp.Quantower.cs";
|
||||
|
||||
public BbwpIndicator()
|
||||
{
|
||||
OnBackGround = true;
|
||||
SeparateWindow = true;
|
||||
_sourceName = Source.ToString();
|
||||
Name = "BBWP - Bollinger Band Width Percentile";
|
||||
Description = "Bollinger Band Width Percentile measures where the current bandwidth falls within its historical distribution as a percentile rank";
|
||||
|
||||
_series = new LineSeries(name: "BBWP", color: IndicatorExtensions.Volatility, width: 2, style: LineStyle.Solid);
|
||||
AddLineSeries(_series);
|
||||
}
|
||||
|
||||
protected override void OnInit()
|
||||
{
|
||||
_bbwp = new Bbwp(Period, Multiplier, Lookback);
|
||||
_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 = _bbwp.Update(new TValue(item.TimeLeft.Ticks, _priceSelector(item)), isNew: args.IsNewBar());
|
||||
_series.SetValue(result.Value, _bbwp.IsHot, ShowColdValues);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
namespace QuanTAlib.Tests;
|
||||
using Xunit;
|
||||
|
||||
public class BbwpTests
|
||||
{
|
||||
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 Bbwp(0));
|
||||
Assert.Throws<ArgumentException>(() => new Bbwp(-1));
|
||||
Assert.Throws<ArgumentException>(() => new Bbwp(20, 0));
|
||||
Assert.Throws<ArgumentException>(() => new Bbwp(20, -1));
|
||||
Assert.Throws<ArgumentException>(() => new Bbwp(20, 2.0, 0));
|
||||
Assert.Throws<ArgumentException>(() => new Bbwp(20, 2.0, -1));
|
||||
|
||||
var valid = new Bbwp(10, 1.5, 100);
|
||||
Assert.Equal(10, valid.Period);
|
||||
Assert.Equal(1.5, valid.Multiplier);
|
||||
Assert.Equal(100, valid.Lookback);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WarmupPeriod_IsPositive()
|
||||
{
|
||||
var bbwp = new Bbwp(20, 2.0, 252);
|
||||
Assert.Equal(272, bbwp.WarmupPeriod); // period + lookback
|
||||
Assert.True(bbwp.WarmupPeriod > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Properties_Accessible()
|
||||
{
|
||||
var bbwp = new Bbwp(20, 2.5, 100);
|
||||
Assert.Equal(20, bbwp.Period);
|
||||
Assert.Equal(2.5, bbwp.Multiplier);
|
||||
Assert.Equal(100, bbwp.Lookback);
|
||||
Assert.Equal("Bbwp(20,2.5,100)", bbwp.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BasicCalculation_DoesNotCrash()
|
||||
{
|
||||
var bbwp = new Bbwp(5, 2.0, 20);
|
||||
var bars = GenerateTestData(100);
|
||||
var times = bars.Times;
|
||||
var close = bars.CloseValues;
|
||||
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
var result = bbwp.Update(new TValue(times[i], close[i]));
|
||||
Assert.True(double.IsFinite(result.Value), $"Invalid value at index {i}: {result.Value}");
|
||||
}
|
||||
|
||||
Assert.True(bbwp.Last.Value >= 0.0, "BBWP should be >= 0");
|
||||
Assert.True(bbwp.Last.Value <= 1.0, "BBWP should be <= 1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsHot_BehavesCorrectly()
|
||||
{
|
||||
var bbwp = new Bbwp(5, 2.0, 10);
|
||||
var bars = GenerateTestData(20);
|
||||
var close = bars.CloseValues;
|
||||
|
||||
// Should not be hot initially
|
||||
Assert.False(bbwp.IsHot);
|
||||
|
||||
// Feed data until warm
|
||||
for (int i = 0; i < 15; i++)
|
||||
{
|
||||
bbwp.Update(new TValue(DateTime.UtcNow.Ticks + i, close[i]));
|
||||
}
|
||||
|
||||
// Should be hot after sufficient data
|
||||
Assert.True(bbwp.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OutputRange_IsPercentile()
|
||||
{
|
||||
var bbwp = new Bbwp(10, 2.0, 50);
|
||||
var bars = GenerateTestData(100);
|
||||
var close = bars.CloseValues;
|
||||
var times = bars.Times;
|
||||
|
||||
var results = new List<double>();
|
||||
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
var result = bbwp.Update(new TValue(times[i], close[i]));
|
||||
results.Add(result.Value);
|
||||
|
||||
// Each result should be in [0,1] range
|
||||
Assert.True(result.Value >= 0.0, $"Value {result.Value} at index {i} should be >= 0");
|
||||
Assert.True(result.Value <= 1.0, $"Value {result.Value} at index {i} should be <= 1");
|
||||
}
|
||||
|
||||
// After sufficient data, we should see some variation
|
||||
if (results.Count > 60)
|
||||
{
|
||||
var laterResults = results.Skip(60).ToList();
|
||||
double min = laterResults.Min();
|
||||
double max = laterResults.Max();
|
||||
|
||||
// Should have some meaningful range in percentile values
|
||||
Assert.True(max - min > 0.1, "Should have meaningful variation in percentile values");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_IsNew_BehavesCorrectly()
|
||||
{
|
||||
var bbwp = new Bbwp(5, 2.0, 20);
|
||||
var bars = GenerateTestData(30);
|
||||
|
||||
// First load up enough data to create variation
|
||||
for (int i = 0; i < 25; i++)
|
||||
{
|
||||
bbwp.Update(new TValue(bars.Times[i], bars.CloseValues[i]), isNew: true);
|
||||
}
|
||||
|
||||
// First update (new)
|
||||
var result1 = bbwp.Update(new TValue(bars.Times[25], bars.CloseValues[25]), isNew: true);
|
||||
|
||||
// Second update (revision) - with very different value to potentially change percentile
|
||||
var revisedValue = new TValue(bars.Times[25], bars.CloseValues[25] * 1.5);
|
||||
var result2 = bbwp.Update(revisedValue, isNew: false);
|
||||
|
||||
// After revision, the result might differ
|
||||
Assert.True(double.IsFinite(result1.Value) && double.IsFinite(result2.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_ClearsState()
|
||||
{
|
||||
var bbwp = new Bbwp(5, 2.0, 20);
|
||||
var bars = GenerateTestData(20);
|
||||
var close = bars.CloseValues;
|
||||
|
||||
// Feed some data
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
bbwp.Update(new TValue(DateTime.UtcNow.Ticks + i, close[i]));
|
||||
}
|
||||
|
||||
Assert.True(bbwp.Last.Value != 0.0);
|
||||
|
||||
// Reset and check
|
||||
bbwp.Reset();
|
||||
Assert.Equal(0.0, bbwp.Last.Value);
|
||||
Assert.False(bbwp.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Prime_LoadsDataCorrectly()
|
||||
{
|
||||
var bbwp = new Bbwp(5, 2.0, 20);
|
||||
var bars = GenerateTestData(30);
|
||||
var close = bars.CloseValues.ToArray();
|
||||
|
||||
bbwp.Prime(close);
|
||||
|
||||
Assert.True(bbwp.IsHot);
|
||||
Assert.True(double.IsFinite(bbwp.Last.Value));
|
||||
Assert.True(bbwp.Last.Value >= 0.0 && bbwp.Last.Value <= 1.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_ProducesConsistentResults()
|
||||
{
|
||||
var bbwp = new Bbwp(5, 2.0, 20);
|
||||
var bars = GenerateTestData(50);
|
||||
var close = bars.CloseValues;
|
||||
var times = bars.Times;
|
||||
|
||||
// Calculate using Update method
|
||||
var updateResults = new List<double>();
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
var result = bbwp.Update(new TValue(times[i], close[i]));
|
||||
updateResults.Add(result.Value);
|
||||
}
|
||||
|
||||
// Calculate using Batch method
|
||||
var batchResults = new double[bars.Count];
|
||||
Bbwp.Batch(close.ToArray(), batchResults, 5, 2.0, 20);
|
||||
|
||||
// Should be approximately equal after warmup period
|
||||
for (int i = 25; i < bars.Count; i++) // Skip initial warmup
|
||||
{
|
||||
Assert.True(Math.Abs(updateResults[i] - batchResults[i]) < 0.01,
|
||||
$"Mismatch at index {i}: Update={updateResults[i]:F6}, Batch={batchResults[i]:F6}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_ProducesValidSeries()
|
||||
{
|
||||
var bars = GenerateTestData(100);
|
||||
var ts = new TSeries();
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
ts.Add(new TValue(bars.Times[i], bars.CloseValues[i]));
|
||||
}
|
||||
|
||||
var result = Bbwp.Calculate(ts, 10, 2.0, 50);
|
||||
|
||||
Assert.Equal(ts.Count, result.Count);
|
||||
|
||||
// All values should be in [0,1] range
|
||||
for (int i = 0; i < result.Count; i++)
|
||||
{
|
||||
Assert.True(result.Values[i] >= 0.0, $"Value at {i} should be >= 0");
|
||||
Assert.True(result.Values[i] <= 1.0, $"Value at {i} should be <= 1");
|
||||
Assert.True(double.IsFinite(result.Values[i]), $"Value at {i} should be finite");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidInput_HandledGracefully()
|
||||
{
|
||||
var bbwp = new Bbwp(5, 2.0, 20);
|
||||
|
||||
// Test with NaN
|
||||
var result1 = bbwp.Update(new TValue(DateTime.UtcNow.Ticks, double.NaN));
|
||||
Assert.True(double.IsFinite(result1.Value));
|
||||
|
||||
// Test with infinity
|
||||
var result2 = bbwp.Update(new TValue(DateTime.UtcNow.Ticks + 1, double.PositiveInfinity));
|
||||
Assert.True(double.IsFinite(result2.Value));
|
||||
|
||||
// Test with negative infinity
|
||||
var result3 = bbwp.Update(new TValue(DateTime.UtcNow.Ticks + 2, double.NegativeInfinity));
|
||||
Assert.True(double.IsFinite(result3.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroVarianceData_HandledCorrectly()
|
||||
{
|
||||
var bbwp = new Bbwp(5, 2.0, 20);
|
||||
|
||||
// Feed constant values (zero variance)
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
var result = bbwp.Update(new TValue(DateTime.UtcNow.Ticks + i, 100.0));
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
Assert.True(result.Value >= 0.0 && result.Value <= 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SmallDataset_HandledCorrectly()
|
||||
{
|
||||
var bbwp = new Bbwp(3, 2.0, 5);
|
||||
|
||||
// Test with minimal data
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
var result = bbwp.Update(new TValue(DateTime.UtcNow.Ticks + i, 100.0 + i));
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
Assert.True(result.Value >= 0.0 && result.Value <= 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LargeValues_HandledCorrectly()
|
||||
{
|
||||
var bbwp = new Bbwp(5, 2.0, 20);
|
||||
|
||||
// Test with large values
|
||||
var largeValues = new[] { 1e6, 1e7, 1e8, 1e6, 1e7 };
|
||||
|
||||
foreach (var value in largeValues)
|
||||
{
|
||||
var result = bbwp.Update(new TValue(DateTime.UtcNow.Ticks, value));
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
Assert.True(result.Value >= 0.0 && result.Value <= 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TSeries_Update_MatchesStreaming()
|
||||
{
|
||||
int period = 10;
|
||||
int lookback = 20;
|
||||
var bbwpStream = new Bbwp(period, 2.0, lookback);
|
||||
var bbwpBatch = new Bbwp(period, 2.0, lookback);
|
||||
var bars = GenerateTestData(50);
|
||||
var times = bars.Times;
|
||||
var close = bars.CloseValues;
|
||||
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
bbwpStream.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 = bbwpBatch.Update(ts);
|
||||
|
||||
Assert.Equal(bbwpStream.Last.Value, result[result.Count - 1].Value, 1e-9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BatchCalc_MatchesIterativeCalc()
|
||||
{
|
||||
var bbwp = new Bbwp(10, 2.0, 30);
|
||||
var bars = GenerateTestData(100);
|
||||
var times = bars.Times;
|
||||
var close = bars.CloseValues;
|
||||
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
bbwp.Update(new TValue(times[i], close[i]));
|
||||
}
|
||||
var iterativeResult = bbwp.Last.Value;
|
||||
|
||||
var ts = new TSeries();
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
ts.Add(new TValue(times[i], close[i]));
|
||||
}
|
||||
var batchResult = Bbwp.Calculate(ts, 10, 2.0, 30);
|
||||
|
||||
Assert.Equal(iterativeResult, batchResult[batchResult.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 = Bbwp.Calculate(ts, 20, 2.0, 50);
|
||||
|
||||
Assert.Equal(100, result.Count);
|
||||
Assert.True(double.IsFinite(result[result.Count - 1].Value));
|
||||
Assert.True(result[result.Count - 1].Value >= 0.0);
|
||||
Assert.True(result[result.Count - 1].Value <= 1.0);
|
||||
}
|
||||
|
||||
[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>(() => Bbwp.Calculate(ts, 0));
|
||||
Assert.Throws<ArgumentException>(() => Bbwp.Calculate(ts, -1));
|
||||
Assert.Throws<ArgumentException>(() => Bbwp.Calculate(ts, 5, 0));
|
||||
Assert.Throws<ArgumentException>(() => Bbwp.Calculate(ts, 5, -1));
|
||||
Assert.Throws<ArgumentException>(() => Bbwp.Calculate(ts, 5, 2.0, 0));
|
||||
Assert.Throws<ArgumentException>(() => Bbwp.Calculate(ts, 5, 2.0, -1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_NaN_Safe()
|
||||
{
|
||||
var values = new double[] { 100, 101, 102, double.NaN, 104, 105 };
|
||||
var output = new double[values.Length];
|
||||
|
||||
Bbwp.Batch(values, output, 3, 2.0, 3);
|
||||
|
||||
Assert.True(output.Length == 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BBWP_Percentile_Verified()
|
||||
{
|
||||
var bbwp = new Bbwp(5, 2.0, 10);
|
||||
var bars = GenerateTestData(20);
|
||||
var times = bars.Times;
|
||||
var close = bars.CloseValues;
|
||||
|
||||
// Feed data
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
bbwp.Update(new TValue(times[i], close[i]));
|
||||
}
|
||||
|
||||
// Result should be between 0 and 1
|
||||
Assert.True(bbwp.Last.Value >= 0.0);
|
||||
Assert.True(bbwp.Last.Value <= 1.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BBWP_HighVolatility_HigherPercentile()
|
||||
{
|
||||
var bbwp = new Bbwp(5, 2.0, 20);
|
||||
var bars = GenerateTestData(100);
|
||||
|
||||
// Feed all data and check values are within range
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
var result = bbwp.Update(new TValue(bars.Times[i], bars.CloseValues[i]));
|
||||
Assert.True(result.Value >= 0.0 && result.Value <= 1.0);
|
||||
}
|
||||
|
||||
// Test passes if we get through all data without issue
|
||||
Assert.True(bbwp.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BBWP_LookbackEffect_Verified()
|
||||
{
|
||||
var bars = GenerateTestData(100);
|
||||
|
||||
// Short lookback
|
||||
var bbwp1 = new Bbwp(10, 2.0, 20);
|
||||
// Long lookback
|
||||
var bbwp2 = new Bbwp(10, 2.0, 50);
|
||||
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
bbwp1.Update(new TValue(bars.Times[i], bars.CloseValues[i]));
|
||||
bbwp2.Update(new TValue(bars.Times[i], bars.CloseValues[i]));
|
||||
}
|
||||
|
||||
// Both should be in valid range
|
||||
Assert.True(bbwp1.Last.Value >= 0.0 && bbwp1.Last.Value <= 1.0);
|
||||
Assert.True(bbwp2.Last.Value >= 0.0 && bbwp2.Last.Value <= 1.0);
|
||||
|
||||
// They may differ due to different historical context
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IterativeCorrections_RestoreToOriginalState()
|
||||
{
|
||||
var bbwp = new Bbwp(10, 2.0, 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 = bbwp.Update(new TValue(times[i], close[i]), isNew: true);
|
||||
}
|
||||
double originalValue = lastValue.Value;
|
||||
|
||||
// Test with a much more extreme correction value
|
||||
_ = bbwp.Update(new TValue(DateTime.UtcNow.Ticks, close[bars.Count - 1] * 100), isNew: false);
|
||||
|
||||
// Restore to original and verify exact match
|
||||
var restoredValue = bbwp.Update(new TValue(lastValue.Time, close[bars.Count - 1]), isNew: false);
|
||||
Assert.Equal(originalValue, restoredValue.Value, 1e-9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsNew_Consistency()
|
||||
{
|
||||
var bbwp = new Bbwp(5, 2.0, 10);
|
||||
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
bbwp.Update(new TValue(DateTime.UtcNow.Ticks + i, 100 + i), isNew: true);
|
||||
}
|
||||
|
||||
var result1 = bbwp.Update(new TValue(DateTime.UtcNow.Ticks + 100, 120), isNew: true);
|
||||
_ = bbwp.Update(new TValue(DateTime.UtcNow.Ticks + 100, 150), isNew: false);
|
||||
var result3 = bbwp.Update(new TValue(DateTime.UtcNow.Ticks + 100, 120), isNew: false);
|
||||
|
||||
Assert.Equal(result1.Value, result3.Value, Tolerance);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
namespace QuanTAlib.Tests;
|
||||
using Xunit;
|
||||
|
||||
/// <summary>
|
||||
/// Validation tests for BBWP (Bollinger Band Width Percentile).
|
||||
/// BBWP is a proprietary indicator, so we validate against internal consistency
|
||||
/// and mathematical properties rather than external libraries.
|
||||
/// </summary>
|
||||
public class BbwpValidationTests
|
||||
{
|
||||
private static TBarSeries GenerateTestData(int count = 500)
|
||||
{
|
||||
var gbm = new GBM(seed: 42);
|
||||
return gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BBWP_OutputRange_AlwaysValid()
|
||||
{
|
||||
var bars = GenerateTestData(500);
|
||||
var bbwp = new Bbwp(20, 2.0, 100);
|
||||
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
var result = bbwp.Update(new TValue(bars.Times[i], bars.CloseValues[i]));
|
||||
Assert.True(result.Value >= 0.0, $"BBWP at {i} should be >= 0, got {result.Value}");
|
||||
Assert.True(result.Value <= 1.0, $"BBWP at {i} should be <= 1, got {result.Value}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BBWP_StreamingVsBatch_Match()
|
||||
{
|
||||
var bars = GenerateTestData(200);
|
||||
var times = bars.Times;
|
||||
var close = bars.CloseValues;
|
||||
|
||||
// Streaming calculation
|
||||
var bbwpStream = new Bbwp(10, 2.0, 50);
|
||||
var streamResults = new List<double>();
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
var result = bbwpStream.Update(new TValue(times[i], close[i]));
|
||||
streamResults.Add(result.Value);
|
||||
}
|
||||
|
||||
// Batch calculation
|
||||
var ts = new TSeries();
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
ts.Add(new TValue(times[i], close[i]));
|
||||
}
|
||||
var batchResults = Bbwp.Calculate(ts, 10, 2.0, 50);
|
||||
|
||||
// Compare results (should be identical)
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
Assert.Equal(streamResults[i], batchResults.Values[i], 1e-10);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BBWP_DifferentPeriods_ProduceValidResults()
|
||||
{
|
||||
var bars = GenerateTestData(300);
|
||||
int[] periods = { 5, 10, 20, 50 };
|
||||
|
||||
foreach (int period in periods)
|
||||
{
|
||||
var bbwp = new Bbwp(period, 2.0, 100);
|
||||
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
var result = bbwp.Update(new TValue(bars.Times[i], bars.CloseValues[i]));
|
||||
Assert.True(double.IsFinite(result.Value), $"Period {period} at {i} should be finite");
|
||||
Assert.True(result.Value >= 0.0 && result.Value <= 1.0, $"Period {period} at {i} should be in [0,1]");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BBWP_DifferentLookbacks_ProduceValidResults()
|
||||
{
|
||||
var bars = GenerateTestData(300);
|
||||
int[] lookbacks = { 20, 50, 100, 200 };
|
||||
|
||||
foreach (int lookback in lookbacks)
|
||||
{
|
||||
var bbwp = new Bbwp(20, 2.0, lookback);
|
||||
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
var result = bbwp.Update(new TValue(bars.Times[i], bars.CloseValues[i]));
|
||||
Assert.True(double.IsFinite(result.Value), $"Lookback {lookback} at {i} should be finite");
|
||||
Assert.True(result.Value >= 0.0 && result.Value <= 1.0, $"Lookback {lookback} at {i} should be in [0,1]");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BBWP_DifferentMultipliers_ProduceValidResults()
|
||||
{
|
||||
var bars = GenerateTestData(200);
|
||||
double[] multipliers = { 1.0, 1.5, 2.0, 2.5, 3.0 };
|
||||
|
||||
foreach (double mult in multipliers)
|
||||
{
|
||||
var bbwp = new Bbwp(20, mult, 100);
|
||||
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
var result = bbwp.Update(new TValue(bars.Times[i], bars.CloseValues[i]));
|
||||
Assert.True(double.IsFinite(result.Value), $"Multiplier {mult} at {i} should be finite");
|
||||
Assert.True(result.Value >= 0.0 && result.Value <= 1.0, $"Multiplier {mult} at {i} should be in [0,1]");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BBWP_ConstantInput_ProducesZeroPercentile()
|
||||
{
|
||||
var bbwp = new Bbwp(10, 2.0, 50);
|
||||
|
||||
// Feed constant values - BBW will be 0, and percentile of 0 among 0s is 0
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
var result = bbwp.Update(new TValue(DateTime.UtcNow.Ticks + i, 100.0));
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
Assert.True(result.Value >= 0.0 && result.Value <= 1.0);
|
||||
}
|
||||
|
||||
// With constant input, BBW=0 always, so percentile should be 0 (nothing below 0)
|
||||
Assert.Equal(0.0, bbwp.Last.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BBWP_HighVolatilitySpike_ProducesHighPercentile()
|
||||
{
|
||||
var bbwp = new Bbwp(5, 2.0, 20);
|
||||
|
||||
// Feed low volatility data first
|
||||
for (int i = 0; i < 25; i++)
|
||||
{
|
||||
bbwp.Update(new TValue(DateTime.UtcNow.Ticks + i, 100.0 + (i % 2) * 0.1));
|
||||
}
|
||||
|
||||
// Then introduce a high volatility spike
|
||||
bbwp.Update(new TValue(DateTime.UtcNow.Ticks + 25, 100.0));
|
||||
bbwp.Update(new TValue(DateTime.UtcNow.Ticks + 26, 110.0)); // Big move
|
||||
bbwp.Update(new TValue(DateTime.UtcNow.Ticks + 27, 105.0));
|
||||
|
||||
// After high volatility, percentile should be elevated
|
||||
Assert.True(bbwp.Last.Value > 0.3, $"High volatility should produce elevated percentile, got {bbwp.Last.Value}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BBWP_PercentileDistribution_Reasonable()
|
||||
{
|
||||
var bars = GenerateTestData(500);
|
||||
var bbwp = new Bbwp(20, 2.0, 100);
|
||||
|
||||
var results = new List<double>();
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
var result = bbwp.Update(new TValue(bars.Times[i], bars.CloseValues[i]));
|
||||
if (i >= 120) // After warmup
|
||||
{
|
||||
results.Add(result.Value);
|
||||
}
|
||||
}
|
||||
|
||||
// Percentile values should be distributed - check quartiles
|
||||
results.Sort();
|
||||
int q1Idx = results.Count / 4;
|
||||
int q3Idx = 3 * results.Count / 4;
|
||||
|
||||
double q1 = results[q1Idx];
|
||||
double q3 = results[q3Idx];
|
||||
|
||||
// Should have meaningful spread
|
||||
Assert.True(q3 - q1 > 0.1, $"Percentile spread should be meaningful, Q1={q1:F3}, Q3={q3:F3}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BBWP_BarCorrection_Works()
|
||||
{
|
||||
var bbwp = new Bbwp(10, 2.0, 30);
|
||||
var bars = GenerateTestData(50);
|
||||
|
||||
// Process all bars
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
bbwp.Update(new TValue(bars.Times[i], bars.CloseValues[i]), isNew: true);
|
||||
}
|
||||
double originalValue = bbwp.Last.Value;
|
||||
|
||||
// Correct the last bar with different value
|
||||
bbwp.Update(new TValue(bars.Times[bars.Count - 1], bars.CloseValues[bars.Count - 1] * 2), isNew: false);
|
||||
|
||||
// Restore original value
|
||||
var restored = bbwp.Update(new TValue(bars.Times[bars.Count - 1], bars.CloseValues[bars.Count - 1]), isNew: false);
|
||||
|
||||
Assert.Equal(originalValue, restored.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BBWP_SpanBatch_MatchesStreaming()
|
||||
{
|
||||
var bars = GenerateTestData(100);
|
||||
var close = bars.CloseValues.ToArray();
|
||||
|
||||
// Streaming
|
||||
var bbwpStream = new Bbwp(10, 2.0, 30);
|
||||
for (int i = 0; i < close.Length; i++)
|
||||
{
|
||||
bbwpStream.Update(new TValue(DateTime.UtcNow.Ticks + i, close[i]));
|
||||
}
|
||||
|
||||
// Batch via span
|
||||
var output = new double[close.Length];
|
||||
Bbwp.Batch(close, output, 10, 2.0, 30);
|
||||
|
||||
Assert.Equal(bbwpStream.Last.Value, output[output.Length - 1], 1e-10);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// BBWP: Bollinger Band Width Percentile
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// BBWP measures where the current Bollinger Band Width falls within its
|
||||
/// historical distribution, expressing the result as a percentile rank
|
||||
/// between 0 and 1. Unlike BBWN which normalizes using min/max values,
|
||||
/// BBWP uses percentile ranking which is more robust to outliers.
|
||||
///
|
||||
/// Formula:
|
||||
/// <c>BBW = 2 × multiplier × StdDev(source, period)</c>
|
||||
/// <c>BBWP = count(BBW_history < BBW_current) / total_count</c>
|
||||
///
|
||||
/// The indicator first calculates the standard BBW, then determines what
|
||||
/// percentage of historical BBW values fall below the current value.
|
||||
/// Values near 0 indicate current volatility is lower than most historical
|
||||
/// readings, while values near 1 indicate it's higher than most.
|
||||
///
|
||||
/// Key properties:
|
||||
/// - Range: [0, 1] (percentile)
|
||||
/// - 0.0 indicates current BBW is lowest in lookback period
|
||||
/// - 1.0 indicates current BBW is highest in lookback period
|
||||
/// - 0.5 indicates median volatility when no percentile can be calculated
|
||||
/// </remarks>
|
||||
[SkipLocalsInit]
|
||||
public sealed class Bbwp : AbstractBase
|
||||
{
|
||||
private readonly int _period;
|
||||
private readonly double _multiplier;
|
||||
private readonly int _lookback;
|
||||
private readonly RingBuffer _buffer;
|
||||
private readonly RingBuffer _bbwBuffer;
|
||||
|
||||
[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 BBWP with specified period, multiplier, and lookback.
|
||||
/// </summary>
|
||||
/// <param name="period">Lookback period for BB calculations (must be > 0)</param>
|
||||
/// <param name="multiplier">Standard deviation multiplier (must be > 0)</param>
|
||||
/// <param name="lookback">Historical lookback period for percentile calculation (must be > 0)</param>
|
||||
public Bbwp(int period, double multiplier = 2.0, int lookback = 252)
|
||||
{
|
||||
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));
|
||||
}
|
||||
|
||||
if (lookback <= 0)
|
||||
{
|
||||
throw new ArgumentException("Lookback must be greater than 0", nameof(lookback));
|
||||
}
|
||||
|
||||
_period = period;
|
||||
_multiplier = multiplier;
|
||||
_lookback = lookback;
|
||||
_buffer = new RingBuffer(period);
|
||||
_bbwBuffer = new RingBuffer(lookback);
|
||||
Name = $"Bbwp({period},{multiplier:F1},{lookback})";
|
||||
WarmupPeriod = period + lookback;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates BBWP with specified source, period, multiplier, and lookback.
|
||||
/// </summary>
|
||||
public Bbwp(ITValuePublisher source, int period, double multiplier = 2.0, int lookback = 252) : this(period, multiplier, lookback)
|
||||
{
|
||||
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 && _bbwBuffer.Count >= Math.Min(10, _lookback);
|
||||
|
||||
/// <summary>
|
||||
/// Period of the indicator.
|
||||
/// </summary>
|
||||
public int Period => _period;
|
||||
|
||||
/// <summary>
|
||||
/// Standard deviation multiplier.
|
||||
/// </summary>
|
||||
public double Multiplier => _multiplier;
|
||||
|
||||
/// <summary>
|
||||
/// Historical lookback period for percentile calculation.
|
||||
/// </summary>
|
||||
public int Lookback => _lookback;
|
||||
|
||||
/// <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 BBW first
|
||||
int count = _buffer.Count;
|
||||
if (count == 0)
|
||||
{
|
||||
Last = new TValue(input.Time, 0.5);
|
||||
PubEvent(Last, isNew);
|
||||
return Last;
|
||||
}
|
||||
|
||||
double mean = _state.Sum / count;
|
||||
double variance = Math.Max(0.0, (_state.SumSq / count) - (mean * mean));
|
||||
double stddev = Math.Sqrt(variance);
|
||||
double bbw = 2.0 * _multiplier * stddev;
|
||||
|
||||
// Add BBW to history buffer for percentile calculation
|
||||
if (isNew)
|
||||
{
|
||||
_bbwBuffer.Add(bbw);
|
||||
}
|
||||
else
|
||||
{
|
||||
_bbwBuffer.UpdateNewest(bbw);
|
||||
}
|
||||
|
||||
// Calculate percentile of current BBW within historical distribution
|
||||
double bbwp = 0.5; // Default when no percentile can be calculated
|
||||
int totalCount = _bbwBuffer.Count;
|
||||
if (totalCount >= 1)
|
||||
{
|
||||
int countBelow = 0;
|
||||
for (int i = 0; i < totalCount; i++)
|
||||
{
|
||||
if (_bbwBuffer[i] < bbw)
|
||||
{
|
||||
countBelow++;
|
||||
}
|
||||
}
|
||||
bbwp = (double)countBelow / totalCount;
|
||||
}
|
||||
|
||||
// Clamp to [0,1] range (should already be in range, but ensure safety)
|
||||
bbwp = Math.Max(0.0, Math.Min(1.0, bbwp));
|
||||
|
||||
Last = new TValue(input.Time, bbwp);
|
||||
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, _lookback);
|
||||
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();
|
||||
_bbwBuffer.Clear();
|
||||
_state = default;
|
||||
_p_state = default;
|
||||
_tickCount = 0;
|
||||
Last = default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates BBWP for entire series.
|
||||
/// </summary>
|
||||
public static TSeries Calculate(TSeries source, int period, double multiplier = 2.0, int lookback = 252)
|
||||
{
|
||||
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));
|
||||
}
|
||||
|
||||
if (lookback <= 0)
|
||||
{
|
||||
throw new ArgumentException("Lookback must be greater than 0", nameof(lookback));
|
||||
}
|
||||
|
||||
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, lookback);
|
||||
source.Times.CopyTo(tSpan);
|
||||
|
||||
return new TSeries(t, v);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Batch BBWP calculation with O(1) rolling variance and percentile ranking.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static void Batch(ReadOnlySpan<double> source, Span<double> output, int period, double multiplier = 2.0, int lookback = 252)
|
||||
{
|
||||
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));
|
||||
}
|
||||
|
||||
if (lookback <= 0)
|
||||
{
|
||||
throw new ArgumentException("Lookback must be greater than 0", nameof(lookback));
|
||||
}
|
||||
|
||||
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;
|
||||
var bbwHistory = new RingBuffer(lookback);
|
||||
|
||||
// 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 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);
|
||||
double bbw = mult2 * stddev;
|
||||
|
||||
// Add to BBW history
|
||||
bbwHistory.Add(bbw);
|
||||
|
||||
// Calculate percentile of current BBW within historical distribution
|
||||
double bbwp = 0.5; // Default
|
||||
int totalCount = bbwHistory.Count;
|
||||
if (totalCount >= 1)
|
||||
{
|
||||
int countBelow = 0;
|
||||
for (int j = 0; j < totalCount; j++)
|
||||
{
|
||||
if (bbwHistory[j] < bbw)
|
||||
{
|
||||
countBelow++;
|
||||
}
|
||||
}
|
||||
bbwp = (double)countBelow / totalCount;
|
||||
}
|
||||
|
||||
// Clamp to [0,1] range
|
||||
output[i] = Math.Max(0.0, Math.Min(1.0, bbwp));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
# BBWP: Bollinger Band Width Percentile
|
||||
|
||||
> "Where does current volatility rank in the historical distribution? BBWP answers with a percentile."
|
||||
|
||||
BBWP (Bollinger Band Width Percentile) measures where the current Bollinger Band Width falls within its historical distribution, expressing the result as a percentile rank between 0 and 1. Unlike BBWN which normalizes using min/max values, BBWP uses percentile ranking which is more robust to outliers.
|
||||
|
||||
## Historical Context
|
||||
|
||||
BBWP evolved from the need for a more statistically robust volatility indicator than simple min/max normalization. While BBWN can be heavily influenced by a single extreme BBW value in the lookback period, BBWP counts how many historical values fall below the current reading, providing a true percentile rank that is less sensitive to outliers.
|
||||
|
||||
The percentile approach aligns with standard statistical practice for comparing a value to a distribution, making BBWP particularly useful for:
|
||||
- Identifying volatility regime changes
|
||||
- Setting dynamic stop-loss levels based on historical volatility context
|
||||
- Generating signals when volatility reaches extreme percentiles (e.g., below 10th or above 90th percentile)
|
||||
|
||||
## Architecture & Physics
|
||||
|
||||
### 1. BBW Calculation (inherited from BBW)
|
||||
|
||||
$$
|
||||
BBW_t = 2 \cdot k \cdot \sigma_t
|
||||
$$
|
||||
|
||||
where:
|
||||
- $k$ = standard deviation multiplier (default 2.0)
|
||||
- $\sigma_t$ = population standard deviation over period $n$
|
||||
|
||||
### 2. Percentile Ranking
|
||||
|
||||
$$
|
||||
BBWP_t = \frac{\text{count}(BBW_i < BBW_t)}{N}
|
||||
$$
|
||||
|
||||
where:
|
||||
- $BBW_i$ = historical BBW values in the lookback window
|
||||
- $N$ = total count of BBW values in lookback
|
||||
- The count includes only values strictly less than $BBW_t$
|
||||
|
||||
### 3. Edge Cases
|
||||
|
||||
When insufficient history exists ($N < 2$), BBWP returns 0.5 (median) as a neutral default.
|
||||
|
||||
## Mathematical Foundation
|
||||
|
||||
### Standard Deviation (Population)
|
||||
|
||||
$$
|
||||
\sigma = \sqrt{\frac{1}{n}\sum_{i=1}^{n}(x_i - \bar{x})^2}
|
||||
$$
|
||||
|
||||
Using Welford's running algorithm:
|
||||
$$
|
||||
\sigma = \sqrt{\frac{\sum x^2}{n} - \left(\frac{\sum x}{n}\right)^2}
|
||||
$$
|
||||
|
||||
### Percentile Rank Formula
|
||||
|
||||
For a value $v$ in a dataset of $N$ values:
|
||||
$$
|
||||
\text{Percentile} = \frac{\text{count of values} < v}{N}
|
||||
$$
|
||||
|
||||
This is the "exclusive" percentile definition (values strictly less than $v$).
|
||||
|
||||
## Performance Profile
|
||||
|
||||
### Operation Count (Streaming Mode, per bar)
|
||||
|
||||
| Operation | Count | Notes |
|
||||
|:---|:---:|:---|
|
||||
| ADD/SUB | 4 | Running sum/sumSq update |
|
||||
| MUL | 2 | Square calculations |
|
||||
| DIV | 3 | Mean, variance, percentile |
|
||||
| SQRT | 1 | Standard deviation |
|
||||
| CMP | L | Lookback comparisons for percentile |
|
||||
| **Total** | **~L+10** | Dominated by lookback size |
|
||||
|
||||
where L = lookback period (default 252)
|
||||
|
||||
### Quality Metrics
|
||||
|
||||
| Metric | Score | Notes |
|
||||
|:---|:---:|:---|
|
||||
| **Accuracy** | 10/10 | Exact percentile calculation |
|
||||
| **Robustness** | 9/10 | More outlier-resistant than BBWN |
|
||||
| **Timeliness** | 8/10 | Reflects current position in distribution |
|
||||
| **Interpretability** | 10/10 | True statistical percentile |
|
||||
|
||||
## Validation
|
||||
|
||||
| Library | Status | Notes |
|
||||
|:---|:---:|:---|
|
||||
| **TA-Lib** | N/A | Not implemented |
|
||||
| **Skender** | N/A | Not implemented |
|
||||
| **Tulip** | N/A | Not implemented |
|
||||
| **Ooples** | N/A | Not implemented |
|
||||
| **Internal** | ✅ | Validated against PineScript reference |
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Interpretation difference from BBWN**: BBWP of 0.80 means 80% of historical BBW values were lower, not that BBW is at 80% of its range. These can differ significantly when the distribution is skewed.
|
||||
|
||||
2. **Lookback period impact**: Shorter lookbacks (e.g., 50) respond faster but may miss longer-term volatility regimes. Standard practice uses 252 (trading days in a year) for daily data.
|
||||
|
||||
3. **Warmup period**: Requires period + lookback bars for statistically meaningful percentiles. Early values default to 0.5.
|
||||
|
||||
4. **Zero volatility**: When all prices are identical, BBW=0 and the percentile of 0 among all 0s is 0 (nothing is below 0).
|
||||
|
||||
5. **Computational cost**: The percentile calculation requires O(L) comparisons per bar, which can be noticeable for very large lookback values.
|
||||
|
||||
6. **Distribution assumptions**: BBWP makes no assumptions about the underlying distribution of BBW values, which is both a strength (non-parametric) and a consideration (may not capture extreme tail behavior well).
|
||||
|
||||
## References
|
||||
|
||||
- Bollinger, J. (2001). "Bollinger on Bollinger Bands." McGraw-Hill.
|
||||
- QuanTAlib PineScript reference implementation (bbwp.pine)
|
||||
Reference in New Issue
Block a user