using System.Buffers; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; namespace QuanTAlib; /// /// BBANDS: Bollinger Bands /// A volatility-based channel indicator consisting of a moving average middle band /// with upper and lower bands positioned at a specified number of standard deviations. /// Developed by John Bollinger in the 1980s for volatility analysis. /// /// /// The BBands calculation process: /// 1. Calculate SMA of price over the period /// 2. Calculate standard deviation over the period /// 3. Upper band = SMA + (multiplier × StdDev) /// 4. Lower band = SMA - (multiplier × StdDev) /// /// Key characteristics: /// - Adapts dynamically to volatility changes /// - Wider bands indicate higher volatility /// - Narrower bands indicate lower volatility /// - Price tends to oscillate between bands /// - Can identify overbought/oversold conditions /// /// Sources: /// John Bollinger - "Bollinger on Bollinger Bands" (2001) /// https://www.bollingerbands.com/ /// [SkipLocalsInit] public sealed class Bbands : AbstractBase { private readonly Sma _sma; private readonly StdDev _stdev; private readonly int _period; private readonly double _multiplier; private const int DefaultPeriod = 20; private const double DefaultMultiplier = 2.0; private const double MinMultiplier = 0.1; private const int MinPeriod = 2; public override bool IsHot => _index >= WarmupPeriod; private int _index; /// /// Middle band (SMA of price) /// public TValue Middle { get; private set; } /// /// Upper band (SMA + multiplier × StdDev) /// public TValue Upper { get; private set; } /// /// Lower band (SMA - multiplier × StdDev) /// public TValue Lower { get; private set; } /// /// Band width (Upper - Lower) /// public TValue Width { get; private set; } /// /// Percent B: (Price - Lower) / (Upper - Lower) /// public TValue PercentB { get; private set; } [MethodImpl(MethodImplOptions.AggressiveInlining)] public Bbands(int period = DefaultPeriod, double multiplier = DefaultMultiplier) { if (period < MinPeriod) { throw new ArgumentOutOfRangeException(nameof(period), $"Period must be at least {MinPeriod}."); } if (multiplier < MinMultiplier) { throw new ArgumentOutOfRangeException(nameof(multiplier), $"Multiplier must be at least {MinMultiplier}."); } _period = period; _multiplier = multiplier; _sma = new Sma(period); _stdev = new StdDev(period, isPopulation: true); WarmupPeriod = period; Name = $"Bbands({period},{multiplier:F1})"; Init(); } [MethodImpl(MethodImplOptions.AggressiveInlining)] public Bbands(ITValuePublisher source, int period = DefaultPeriod, double multiplier = DefaultMultiplier) : this(period, multiplier) { source.Pub += Handle; } [MethodImpl(MethodImplOptions.AggressiveInlining)] private void Init() { _index = 0; Middle = new TValue(DateTime.UtcNow, 0); Upper = new TValue(DateTime.UtcNow, 0); Lower = new TValue(DateTime.UtcNow, 0); Width = new TValue(DateTime.UtcNow, 0); PercentB = new TValue(DateTime.UtcNow, 0); } [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] private static double GetFiniteValue(double value, double fallback) => double.IsFinite(value) ? value : fallback; [MethodImpl(MethodImplOptions.AggressiveInlining)] public override TValue Update(TValue input, bool isNew = true) { if (isNew) { _index++; } double finiteValue = GetFiniteValue(input.Value, Middle.Value); // Update SMA and StdDev TValue smaValue = _sma.Update(new TValue(input.Time, finiteValue), isNew); TValue stdevValue = _stdev.Update(new TValue(input.Time, finiteValue), isNew); double middle = smaValue.Value; double stdDev = stdevValue.Value; double offset = _multiplier * stdDev; double upper = middle + offset; double lower = middle - offset; double width = upper - lower; // Calculate Percent B double percentB = 0.0; if (width > double.Epsilon) { percentB = (finiteValue - lower) / width; } // Update all band values Middle = new TValue(input.Time, middle); Upper = new TValue(input.Time, upper); Lower = new TValue(input.Time, lower); Width = new TValue(input.Time, width); PercentB = new TValue(input.Time, percentB); Last = Middle; PubEvent(Middle, isNew); return Middle; } /// /// Updates the indicator with a new time series and returns the middle band series. /// public override TSeries Update(TSeries source) { if (source == null) { throw new ArgumentNullException(nameof(source)); } ReadOnlySpan sourceSpan = source.Values; ReadOnlySpan timeSpan = source.Times; int len = sourceSpan.Length; TSeries middleSeries = new(capacity: len); // Use ArrayPool to avoid stack overflow for large series double[] middleRented = ArrayPool.Shared.Rent(len); double[] upperRented = ArrayPool.Shared.Rent(len); double[] lowerRented = ArrayPool.Shared.Rent(len); try { Span middleSpan = middleRented.AsSpan(0, len); Span upperSpan = upperRented.AsSpan(0, len); Span lowerSpan = lowerRented.AsSpan(0, len); Batch(sourceSpan, middleSpan, upperSpan, lowerSpan, _period, _multiplier); for (int i = 0; i < len; i++) { middleSeries.Add(timeSpan[i], middleSpan[i], isNew: true); } } finally { ArrayPool.Shared.Return(middleRented); ArrayPool.Shared.Return(upperRented); ArrayPool.Shared.Return(lowerRented); } // Restore state from the last period values Reset(); int startIdx = Math.Max(0, len - _period); for (int i = startIdx; i < len; i++) { Update(new TValue(timeSpan[i], sourceSpan[i]), isNew: true); } return middleSeries; } [MethodImpl(MethodImplOptions.AggressiveInlining)] private void Handle(object? sender, in TValueEventArgs args) => Update(args.Value, args.IsNew); public override void Reset() { _sma.Reset(); _stdev.Reset(); Init(); } public override void Prime(ReadOnlySpan source, TimeSpan? step = null) { step ??= TimeSpan.FromSeconds(1); DateTime startTime = DateTime.UtcNow; for (int i = 0; i < source.Length; i++) { Update(new TValue(startTime + i * step.Value, source[i]), isNew: true); } } /// /// Calculates Bollinger Bands for the entire series and returns the middle band series. /// public static TSeries Batch(TSeries source, int period = DefaultPeriod, double multiplier = DefaultMultiplier) { Bbands bbands = new(period, multiplier); return bbands.Update(source); } /// /// Calculates Bollinger Bands across all input values using SIMD-optimized operations where possible. /// public static void Batch( ReadOnlySpan source, Span middle, Span upper, Span lower, int period = DefaultPeriod, double multiplier = DefaultMultiplier) { if (source.Length != middle.Length || source.Length != upper.Length || source.Length != lower.Length) { throw new ArgumentException("All spans must have the same length.", nameof(source)); } if (period < MinPeriod) { throw new ArgumentOutOfRangeException(nameof(period), $"Period must be at least {MinPeriod}."); } if (multiplier < MinMultiplier) { throw new ArgumentOutOfRangeException(nameof(multiplier), $"Multiplier must be at least {MinMultiplier}."); } int len = source.Length; if (len == 0) { return; } // Calculate SMA using static batch method Sma.Batch(source, middle, period); // Calculate standard deviation and bands using O(n) rolling sums // Instead of O(n²) nested loop, maintain running sum and sumSq // Track count of finite values to properly compute mean/variance double rollingSum = 0.0; double rollingSumSq = 0.0; int finiteCount = 0; // Initialize rolling sums for first window for (int i = 0; i < Math.Min(period, len); i++) { double val = source[i]; if (double.IsFinite(val)) { rollingSum += val; rollingSumSq += val * val; finiteCount++; } if (i < period - 1) { upper[i] = double.NaN; lower[i] = double.NaN; } } // Process first complete window if (len >= period) { if (finiteCount == period) { double mean = rollingSum / finiteCount; double variance = (rollingSumSq / finiteCount) - (mean * mean); variance = Math.Max(0.0, variance); // Guard against negative due to floating point double stdDev = Math.Sqrt(variance); double offset = multiplier * stdDev; upper[period - 1] = middle[period - 1] + offset; lower[period - 1] = middle[period - 1] - offset; } else { // Not all values in window are finite, emit NaN upper[period - 1] = double.NaN; lower[period - 1] = double.NaN; } } // Process remaining bars with O(1) rolling update for (int i = period; i < len; i++) { // Remove outgoing value (leftmost of previous window) double outgoing = source[i - period]; if (double.IsFinite(outgoing)) { rollingSum -= outgoing; rollingSumSq -= outgoing * outgoing; finiteCount--; } // Add incoming value (current) double incoming = source[i]; if (double.IsFinite(incoming)) { rollingSum += incoming; rollingSumSq += incoming * incoming; finiteCount++; } // Only compute bands when all values in window are finite if (finiteCount == period) { // Calculate variance from rolling sums: Var = E[X²] - E[X]² double mean = rollingSum / finiteCount; double variance = (rollingSumSq / finiteCount) - (mean * mean); variance = Math.Max(0.0, variance); // Guard against negative due to floating point double stdDev = Math.Sqrt(variance); double offset = multiplier * stdDev; upper[i] = middle[i] + offset; lower[i] = middle[i] - offset; } else { // Window contains non-finite values, emit NaN upper[i] = double.NaN; lower[i] = double.NaN; } } } public static (TSeries Results, Bbands Indicator) Calculate(TSeries source, int period = DefaultPeriod, double multiplier = DefaultMultiplier) { var indicator = new Bbands(period, multiplier); TSeries results = indicator.Update(source); return (results, indicator); } }