adding missing validations

This commit is contained in:
Miha Kralj
2026-02-26 09:59:44 -08:00
parent 467a8c1cef
commit 9ab37c1200
231 changed files with 60015 additions and 302 deletions
+135
View File
@@ -0,0 +1,135 @@
using TradingPlatform.BusinessLayer;
using QuanTAlib;
namespace QuanTAlib.Tests;
public sealed class BbiIndicatorTests
{
[Fact]
public void BbiIndicator_Constructor_SetsDefaults()
{
var indicator = new BbiIndicator();
Assert.Equal(3, indicator.Period1);
Assert.Equal(6, indicator.Period2);
Assert.Equal(12, indicator.Period3);
Assert.Equal(24, indicator.Period4);
Assert.True(indicator.ShowColdValues);
Assert.Equal("BBI - Bulls Bears Index", indicator.Name);
Assert.False(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void BbiIndicator_MinHistoryDepths_EqualsZero()
{
var indicator = new BbiIndicator();
Assert.Equal(0, BbiIndicator.MinHistoryDepths);
IWatchlistIndicator watchlistIndicator = indicator;
Assert.Equal(0, watchlistIndicator.MinHistoryDepths);
}
[Fact]
public void BbiIndicator_ShortName_IncludesParameters()
{
var indicator = new BbiIndicator { Period1 = 3, Period2 = 6, Period3 = 12, Period4 = 24 };
indicator.Initialize();
Assert.Contains("BBI", indicator.ShortName, StringComparison.Ordinal);
Assert.Contains("3", indicator.ShortName, StringComparison.Ordinal);
Assert.Contains("24", indicator.ShortName, StringComparison.Ordinal);
}
[Fact]
public void BbiIndicator_SourceCodeLink_IsValid()
{
var indicator = new BbiIndicator();
Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal);
Assert.Contains("Bbi", indicator.SourceCodeLink, StringComparison.Ordinal);
}
[Fact]
public void BbiIndicator_Initialize_CreatesOneLineSeries()
{
var indicator = new BbiIndicator();
indicator.Initialize();
Assert.Single(indicator.LinesSeries);
}
[Fact]
public void BbiIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new BbiIndicator { Period1 = 3, Period2 = 6, Period3 = 12, Period4 = 24 };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 30; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i);
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
}
double bbi = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(bbi));
}
[Fact]
public void BbiIndicator_ProcessUpdate_NewBar_ComputesValue()
{
var indicator = new BbiIndicator { Period1 = 3, Period2 = 6, Period3 = 12, Period4 = 24 };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 25; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
indicator.HistoricalData.AddBar(now.AddMinutes(25), 125, 135, 115, 130);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
double bbi = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(bbi));
}
[Fact]
public void BbiIndicator_DifferentSourceTypes_ProcessCorrectly()
{
foreach (var sourceType in new[] { SourceType.Open, SourceType.High, SourceType.Low, SourceType.Close })
{
var indicator = new BbiIndicator
{
Period1 = 3,
Period2 = 6,
Period3 = 12,
Period4 = 24,
Source = sourceType
};
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 30; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i * 0.5, 110 + i * 0.5, 90 + i * 0.5, 105 + i * 0.5);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0)));
}
}
[Fact]
public void BbiIndicator_CustomPeriods_SetsNameCorrectly()
{
var indicator = new BbiIndicator { Period1 = 5, Period2 = 10, Period3 = 20, Period4 = 40 };
indicator.Initialize();
Assert.Contains("5", indicator.ShortName, StringComparison.Ordinal);
Assert.Contains("40", indicator.ShortName, StringComparison.Ordinal);
}
}
+72
View File
@@ -0,0 +1,72 @@
using System.Drawing;
using System.Runtime.CompilerServices;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
[SkipLocalsInit]
public sealed class BbiIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Period 1 (Ultra-Short)", sortIndex: 1, 1, 5000, 1, 0)]
public int Period1 { get; set; } = 3;
[InputParameter("Period 2 (Short)", sortIndex: 2, 1, 5000, 1, 0)]
public int Period2 { get; set; } = 6;
[InputParameter("Period 3 (Medium)", sortIndex: 3, 1, 5000, 1, 0)]
public int Period3 { get; set; } = 12;
[InputParameter("Period 4 (Long)", sortIndex: 4, 1, 5000, 1, 0)]
public int Period4 { get; set; } = 24;
[IndicatorExtensions.DataSourceInput(sortIndex: 5)]
public SourceType Source { get; set; } = SourceType.Close;
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private Bbi _bbi = null!;
private readonly LineSeries _series;
public static int MinHistoryDepths => 0;
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => $"BBI ({Period1},{Period2},{Period3},{Period4})";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/oscillators/bbi/Bbi.Quantower.cs";
public BbiIndicator()
{
OnBackGround = true;
SeparateWindow = false;
Name = "BBI - Bulls Bears Index";
Description = "Arithmetic mean of four SMAs across geometrically spaced periods";
_series = new LineSeries("BBI", Color.Yellow, 2, LineStyle.Solid);
AddLineSeries(_series);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnInit()
{
_bbi = new Bbi(Period1, Period2, Period3, Period4);
base.OnInit();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnUpdate(UpdateArgs args)
{
var priceSelector = Source.GetPriceSelector();
var item = HistoricalData[0, SeekOriginHistory.End];
double price = priceSelector(item);
TValue input = new(item.TimeLeft, price);
TValue result = _bbi.Update(input, args.IsNewBar());
if (!_bbi.IsHot && !ShowColdValues)
{
return;
}
_series.SetValue(result.Value);
}
}
+457
View File
@@ -0,0 +1,457 @@
using Xunit;
namespace QuanTAlib.Tests;
public sealed class BbiTests
{
private const double Tolerance = 1e-10;
// ───── A) Constructor validation ─────
[Fact]
public void Constructor_P1Zero_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() => new Bbi(p1: 0));
Assert.Equal("p1", ex.ParamName);
}
[Fact]
public void Constructor_P2Negative_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() => new Bbi(p2: -1));
Assert.Equal("p2", ex.ParamName);
}
[Fact]
public void Constructor_P3Zero_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() => new Bbi(p3: 0));
Assert.Equal("p3", ex.ParamName);
}
[Fact]
public void Constructor_P4Negative_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() => new Bbi(p4: -5));
Assert.Equal("p4", ex.ParamName);
}
[Fact]
public void Constructor_Defaults_SetsProperties()
{
var bbi = new Bbi();
Assert.Equal("Bbi(3,6,12,24)", bbi.Name);
Assert.Equal(24, bbi.WarmupPeriod);
Assert.False(bbi.IsHot);
}
[Fact]
public void Constructor_CustomParams_SetsName()
{
var bbi = new Bbi(p1: 5, p2: 10, p3: 20, p4: 40);
Assert.Equal("Bbi(5,10,20,40)", bbi.Name);
Assert.Equal(40, bbi.WarmupPeriod);
}
[Fact]
public void Constructor_WarmupPeriod_IsMaxPeriod()
{
var bbi = new Bbi(p1: 2, p2: 7, p3: 14, p4: 30);
Assert.Equal(30, bbi.WarmupPeriod);
}
// ───── B) Basic calculation ─────
[Fact]
public void Update_ReturnsTValue()
{
var bbi = new Bbi();
var result = bbi.Update(new TValue(DateTime.UtcNow, 100.0));
Assert.IsType<TValue>(result);
}
[Fact]
public void Update_Last_IsAccessible()
{
var bbi = new Bbi();
bbi.Update(new TValue(DateTime.UtcNow, 100.0));
Assert.NotEqual(default, bbi.Last);
Assert.False(bbi.IsHot);
Assert.Equal("Bbi(3,6,12,24)", bbi.Name);
}
[Fact]
public void Update_ConstantInput_BbiEqualsConstant()
{
// When all values are constant, every SMA == constant, so BBI == constant.
var bbi = new Bbi(p1: 3, p2: 6, p3: 12, p4: 24);
for (int i = 0; i < 30; i++)
{
bbi.Update(new TValue(DateTime.UtcNow, 50.0));
}
Assert.Equal(50.0, bbi.Last.Value, Tolerance);
}
[Fact]
public void Update_SingleBar_ValueIsFinite()
{
var bbi = new Bbi();
var result = bbi.Update(new TValue(DateTime.UtcNow, 100.0));
Assert.True(double.IsFinite(result.Value));
}
[Fact]
public void Update_KnownValue_FirstBar()
{
// With 1 bar at 100.0, all 4 SMAs = 100.0 → BBI = 100.0
var bbi = new Bbi(p1: 3, p2: 6, p3: 12, p4: 24);
var result = bbi.Update(new TValue(DateTime.UtcNow, 100.0));
Assert.Equal(100.0, result.Value, Tolerance);
}
// ───── C) State + bar correction ─────
[Fact]
public void Update_IsNew_True_AdvancesState()
{
var bbi = new Bbi();
bbi.Update(new TValue(DateTime.UtcNow, 100.0), isNew: true);
bbi.Update(new TValue(DateTime.UtcNow, 110.0), isNew: true);
Assert.NotEqual(default, bbi.Last);
}
[Fact]
public void Update_IsNew_False_RollsBack()
{
var bbi = new Bbi(p1: 3, p2: 6, p3: 12, p4: 24);
for (int i = 0; i < 25; i++)
{
bbi.Update(new TValue(DateTime.UtcNow, 100.0 + i), isNew: true);
}
// Bar correction: rewrite last bar
bbi.Update(new TValue(DateTime.UtcNow, 105.0), isNew: false);
double corrected1 = bbi.Last.Value;
// Same correction again — must produce identical result
bbi.Update(new TValue(DateTime.UtcNow, 105.0), isNew: false);
double corrected2 = bbi.Last.Value;
Assert.Equal(corrected1, corrected2, Tolerance);
}
[Fact]
public void Update_IterativeCorrections_RestoreBaseline()
{
var bbi = new Bbi(p1: 3, p2: 6, p3: 12, p4: 24);
double[] data = [100, 102, 104, 106, 108, 110, 112, 114, 116, 118,
120, 122, 124, 126, 128, 130, 132, 134, 136, 138,
140, 142, 144, 146, 148];
for (int i = 0; i < data.Length; i++)
{
bbi.Update(new TValue(DateTime.UtcNow, data[i]), isNew: true);
}
double baseline = bbi.Last.Value;
// Correct last bar several times, restore original
bbi.Update(new TValue(DateTime.UtcNow, 999.0), isNew: false);
bbi.Update(new TValue(DateTime.UtcNow, 888.0), isNew: false);
bbi.Update(new TValue(DateTime.UtcNow, data[^1]), isNew: false);
Assert.Equal(baseline, bbi.Last.Value, Tolerance);
}
[Fact]
public void Reset_ClearsState()
{
var bbi = new Bbi();
for (int i = 0; i < 30; i++)
{
bbi.Update(new TValue(DateTime.UtcNow, 100.0 + i));
}
Assert.True(bbi.IsHot);
bbi.Reset();
Assert.False(bbi.IsHot);
Assert.Equal(default, bbi.Last);
}
// ───── D) Warmup/convergence ─────
[Fact]
public void IsHot_FlipsAtWarmupPeriod()
{
var bbi = new Bbi(p1: 3, p2: 6, p3: 12, p4: 24);
// IsHot = (index >= WarmupPeriod) = (index >= 24)
for (int i = 0; i < 23; i++)
{
bbi.Update(new TValue(DateTime.UtcNow, 100.0 + i));
Assert.False(bbi.IsHot);
}
bbi.Update(new TValue(DateTime.UtcNow, 100.0));
Assert.True(bbi.IsHot);
}
[Fact]
public void WarmupPeriod_EqualsMaxPeriod()
{
var bbi = new Bbi(p1: 3, p2: 6, p3: 12, p4: 24);
Assert.Equal(24, bbi.WarmupPeriod);
}
// ───── E) Robustness ─────
[Fact]
public void Update_NaN_UsesLastValid()
{
var bbi = new Bbi(p1: 3, p2: 6, p3: 12, p4: 24);
for (int i = 0; i < 25; i++)
{
bbi.Update(new TValue(DateTime.UtcNow, 100.0 + i));
}
bbi.Update(new TValue(DateTime.UtcNow, double.NaN));
Assert.True(double.IsFinite(bbi.Last.Value));
}
[Fact]
public void Update_PositiveInfinity_UsesLastValid()
{
var bbi = new Bbi();
for (int i = 0; i < 25; i++)
{
bbi.Update(new TValue(DateTime.UtcNow, 100.0 + i));
}
bbi.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity));
Assert.True(double.IsFinite(bbi.Last.Value));
}
[Fact]
public void Update_NegativeInfinity_UsesLastValid()
{
var bbi = new Bbi();
for (int i = 0; i < 25; i++)
{
bbi.Update(new TValue(DateTime.UtcNow, 100.0 + i));
}
bbi.Update(new TValue(DateTime.UtcNow, double.NegativeInfinity));
Assert.True(double.IsFinite(bbi.Last.Value));
}
[Fact]
public void Update_BatchNaN_Safe()
{
var bbi = new Bbi();
for (int i = 0; i < 5; i++)
{
bbi.Update(new TValue(DateTime.UtcNow, double.NaN));
}
Assert.True(double.IsFinite(bbi.Last.Value));
}
// ───── F) Consistency (streaming == batch TSeries == batch Span == eventing) ─────
[Fact]
public void AllModes_ProduceSameResults()
{
int p1 = 3, p2 = 6, p3 = 12, p4 = 24;
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42);
var bars = gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
TSeries source = bars.Close;
// 1. Streaming
var streaming = new Bbi(p1, p2, p3, p4);
var streamResults = new double[source.Count];
for (int i = 0; i < source.Count; i++)
{
streamResults[i] = streaming.Update(source[i]).Value;
}
// 2. Batch TSeries
TSeries batchSeries = Bbi.Batch(source, p1, p2, p3, p4);
// 3. Batch Span
var spanOutput = new double[source.Count];
Bbi.Batch(source.Values, spanOutput, p1, p2, p3, p4);
// 4. Event-based
var eventSource = new TSeries();
var eventIndicator = new Bbi(eventSource, p1, p2, p3, p4);
var eventResults = new double[source.Count];
for (int i = 0; i < source.Count; i++)
{
eventSource.Add(source[i]);
eventResults[i] = eventIndicator.Last.Value;
}
for (int i = 0; i < source.Count; i++)
{
Assert.Equal(streamResults[i], batchSeries.Values[i], Tolerance);
Assert.Equal(streamResults[i], spanOutput[i], Tolerance);
Assert.Equal(streamResults[i], eventResults[i], Tolerance);
}
}
// ───── G) Span API tests ─────
[Fact]
public void Batch_Span_MismatchedLength_ThrowsArgumentException()
{
var source = new double[10];
var output = new double[5];
var ex = Assert.Throws<ArgumentException>(() => Bbi.Batch(source.AsSpan(), output.AsSpan()));
Assert.Equal("output", ex.ParamName);
}
[Fact]
public void Batch_Span_ZeroP1_ThrowsArgumentException()
{
var source = new double[10];
var output = new double[10];
var ex = Assert.Throws<ArgumentException>(() => Bbi.Batch(source.AsSpan(), output.AsSpan(), p1: 0));
Assert.Equal("p1", ex.ParamName);
}
[Fact]
public void Batch_Span_ZeroP2_ThrowsArgumentException()
{
var source = new double[10];
var output = new double[10];
var ex = Assert.Throws<ArgumentException>(() => Bbi.Batch(source.AsSpan(), output.AsSpan(), p2: 0));
Assert.Equal("p2", ex.ParamName);
}
[Fact]
public void Batch_Span_ZeroP3_ThrowsArgumentException()
{
var source = new double[10];
var output = new double[10];
var ex = Assert.Throws<ArgumentException>(() => Bbi.Batch(source.AsSpan(), output.AsSpan(), p3: 0));
Assert.Equal("p3", ex.ParamName);
}
[Fact]
public void Batch_Span_ZeroP4_ThrowsArgumentException()
{
var source = new double[10];
var output = new double[10];
var ex = Assert.Throws<ArgumentException>(() => Bbi.Batch(source.AsSpan(), output.AsSpan(), p4: 0));
Assert.Equal("p4", ex.ParamName);
}
[Fact]
public void Batch_Span_Empty_NoException()
{
double[] source = [];
double[] output = [];
var ex = Record.Exception(() => Bbi.Batch(source.AsSpan(), output.AsSpan()));
Assert.Null(ex);
}
[Fact]
public void Batch_Span_MatchesTSeries()
{
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 7);
var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
TSeries source = bars.Close;
TSeries batchTs = Bbi.Batch(source);
var spanOutput = new double[source.Count];
Bbi.Batch(source.Values, spanOutput);
for (int i = 0; i < source.Count; i++)
{
Assert.Equal(batchTs.Values[i], spanOutput[i], Tolerance);
}
}
[Fact]
public void Batch_Span_NaN_Handled()
{
double[] src = [1, 2, double.NaN, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25];
var output = new double[src.Length];
var ex = Record.Exception(() => Bbi.Batch(src.AsSpan(), output.AsSpan()));
Assert.Null(ex);
Assert.All(output, v => Assert.True(double.IsFinite(v)));
}
[Fact]
public void Batch_Span_LargeInput_NoStackOverflow()
{
int n = 10_000;
var source = new double[n];
var output = new double[n];
for (int i = 0; i < n; i++) { source[i] = 100.0 + i * 0.01; }
var ex = Record.Exception(() => Bbi.Batch(source.AsSpan(), output.AsSpan()));
Assert.Null(ex);
}
// ───── H) Chainability ─────
[Fact]
public void PubEvent_FiresOnUpdate()
{
var bbi = new Bbi();
int firedCount = 0;
bbi.Pub += (object? _, in TValueEventArgs _) => firedCount++;
bbi.Update(new TValue(DateTime.UtcNow, 100.0));
Assert.Equal(1, firedCount);
}
[Fact]
public void EventChaining_Works()
{
var source = new TSeries();
var bbi = new Bbi(source);
var downstream = new TSeries();
bbi.Pub += (object? _, in TValueEventArgs e) => downstream.Add(e.Value);
for (int i = 0; i < 30; i++)
{
source.Add(new TValue(DateTime.UtcNow, 100.0 + i));
}
Assert.Equal(30, downstream.Count);
}
// ───── Calculate ─────
[Fact]
public void Calculate_ReturnsResultsAndHotIndicator()
{
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42);
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
TSeries source = bars.Close;
var (results, indicator) = Bbi.Calculate(source);
Assert.Equal(source.Count, results.Count);
Assert.True(indicator.IsHot);
}
// ───── Update(TSeries) ─────
[Fact]
public void UpdateTSeries_MatchesStreaming()
{
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42);
var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
TSeries source = bars.Close;
var streaming = new Bbi();
var streamResults = new double[source.Count];
for (int i = 0; i < source.Count; i++)
{
streamResults[i] = streaming.Update(source[i]).Value;
}
var batch = new Bbi();
TSeries batchResults = batch.Update(source);
for (int i = 0; i < source.Count; i++)
{
Assert.Equal(streamResults[i], batchResults.Values[i], Tolerance);
}
}
}
+218
View File
@@ -0,0 +1,218 @@
using Xunit;
namespace QuanTAlib.Tests;
/// <summary>
/// Self-consistency validation: batch TSeries == streaming == span == eventing.
/// No external library implements BBI, so cross-library comparison is N/A.
/// </summary>
public sealed class BbiValidationTests
{
private const double Tolerance = 1e-10;
private static TSeries BuildGbmSeries(int count, int seed = 1)
{
var gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.2, seed: seed);
var bars = gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
return bars.Close;
}
// ── Batch == Streaming ───────────────────────────────────────────────────
[Fact]
public void Batch_EqualsStreaming_DefaultPeriods()
{
TSeries source = BuildGbmSeries(500, seed: 1);
// Streaming
var bbi = new Bbi();
var streamVals = new double[source.Count];
for (int i = 0; i < source.Count; i++)
{
streamVals[i] = bbi.Update(source[i]).Value;
}
// Batch TSeries
TSeries batch = Bbi.Batch(source);
for (int i = 0; i < source.Count; i++)
{
Assert.Equal(streamVals[i], batch.Values[i], Tolerance);
}
}
[Fact]
public void Batch_EqualsStreaming_CustomPeriods()
{
TSeries source = BuildGbmSeries(300, seed: 2);
int p1 = 5, p2 = 10, p3 = 20, p4 = 40;
var bbi = new Bbi(p1, p2, p3, p4);
var streamVals = new double[source.Count];
for (int i = 0; i < source.Count; i++)
{
streamVals[i] = bbi.Update(source[i]).Value;
}
TSeries batch = Bbi.Batch(source, p1, p2, p3, p4);
for (int i = 0; i < source.Count; i++)
{
Assert.Equal(streamVals[i], batch.Values[i], Tolerance);
}
}
// ── Batch(Span) == Batch(TSeries) ────────────────────────────────────────
[Fact]
public void BatchSpan_EqualsBatchTSeries_DefaultPeriods()
{
TSeries source = BuildGbmSeries(400, seed: 3);
TSeries batchTs = Bbi.Batch(source);
var spanOut = new double[source.Count];
Bbi.Batch(source.Values, spanOut);
for (int i = 0; i < source.Count; i++)
{
Assert.Equal(batchTs.Values[i], spanOut[i], Tolerance);
}
}
[Fact]
public void BatchSpan_EqualsBatchTSeries_CustomPeriods()
{
TSeries source = BuildGbmSeries(200, seed: 4);
int p1 = 4, p2 = 8, p3 = 16, p4 = 32;
TSeries batchTs = Bbi.Batch(source, p1, p2, p3, p4);
var spanOut = new double[source.Count];
Bbi.Batch(source.Values, spanOut, p1, p2, p3, p4);
for (int i = 0; i < source.Count; i++)
{
Assert.Equal(batchTs.Values[i], spanOut[i], Tolerance);
}
}
// ── Eventing == Streaming ────────────────────────────────────────────────
[Fact]
public void Eventing_EqualsStreaming_DefaultPeriods()
{
TSeries source = BuildGbmSeries(300, seed: 5);
// Streaming
var bbi = new Bbi();
var streamVals = new double[source.Count];
for (int i = 0; i < source.Count; i++)
{
streamVals[i] = bbi.Update(source[i]).Value;
}
// Eventing
var eventSource = new TSeries();
var eventBbi = new Bbi(eventSource);
var eventVals = new double[source.Count];
for (int i = 0; i < source.Count; i++)
{
eventSource.Add(source[i]);
eventVals[i] = eventBbi.Last.Value;
}
for (int i = 0; i < source.Count; i++)
{
Assert.Equal(streamVals[i], eventVals[i], Tolerance);
}
}
// ── Mathematical properties ──────────────────────────────────────────────
[Fact]
public void ConstantInput_BbiEqualsConstant()
{
// Constant price → all SMAs == price → BBI == price
const double price = 75.0;
var bbi = new Bbi();
for (int i = 0; i < 100; i++)
{
bbi.Update(new TValue(DateTime.UtcNow, price));
}
Assert.Equal(price, bbi.Last.Value, Tolerance);
}
[Fact]
public void ConstantInput_BatchBbiEqualsConstant()
{
const double price = 125.0;
int n = 100;
var source = new double[n];
var output = new double[n];
for (int i = 0; i < n; i++) { source[i] = price; }
Bbi.Batch(source.AsSpan(), output.AsSpan());
// After full warmup (bar 24+), every output should equal price
for (int i = 24; i < n; i++)
{
Assert.Equal(price, output[i], Tolerance);
}
}
[Fact]
public void AllPeriodsOne_BbiEqualsInput()
{
// With all periods=1, each SMA is just the current value → BBI == current value
var bbi = new Bbi(p1: 1, p2: 1, p3: 1, p4: 1);
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 6);
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
TSeries source = bars.Close;
for (int i = 0; i < source.Count; i++)
{
var result = bbi.Update(source[i]);
Assert.Equal(source.Values[i], result.Value, Tolerance);
}
}
// ── UpdateTSeries primes streaming state correctly ───────────────────────
[Fact]
public void UpdateTSeries_ContinuedStreaming_Consistent()
{
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 7);
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
TSeries source = bars.Close;
// Instance-based batch (internally resets + re-streams)
var batchBbi = new Bbi();
batchBbi.Update(source);
// Pure streaming
var streamBbi = new Bbi();
for (int i = 0; i < source.Count; i++)
{
streamBbi.Update(source[i]);
}
// Both should have identical Last values after processing same data
Assert.Equal(streamBbi.Last.Value, batchBbi.Last.Value, Tolerance);
}
// ── Calculate bridge ─────────────────────────────────────────────────────
[Fact]
public void Calculate_ResultsMatchBatch()
{
TSeries source = BuildGbmSeries(200, seed: 8);
var (results, indicator) = Bbi.Calculate(source);
TSeries batch = Bbi.Batch(source);
Assert.Equal(batch.Count, results.Count);
for (int i = 0; i < batch.Count; i++)
{
Assert.Equal(batch.Values[i], results.Values[i], Tolerance);
}
Assert.True(indicator.IsHot);
}
}
+372
View File
@@ -0,0 +1,372 @@
// BBI: Bulls Bears Index
// Average of four SMAs with geometrically spaced periods (default 3, 6, 12, 24).
// Formula: BBI = (SMA(p1) + SMA(p2) + SMA(p3) + SMA(p4)) / 4
// Origin: Chinese technical analysis community.
// Source: bbi.pine
using System.Buffers;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// BBI: Bulls Bears Index
/// </summary>
/// <remarks>
/// Computes the arithmetic mean of four independent Simple Moving Averages with
/// geometrically spaced periods (default 3, 6, 12, 24). The composite line captures
/// trend consensus across ultra-short, short, medium, and long timeframes simultaneously.
/// Price above BBI signals bullish regime; price below BBI signals bearish regime.
///
/// Calculation (O(1) per bar via four independent circular-buffer SMAs):
/// BBI = (SMA(src, p1) + SMA(src, p2) + SMA(src, p3) + SMA(src, p4)) / 4
///
/// Default parameters: p1=3, p2=6, p3=12, p4=24
/// WarmupPeriod = max(p1, p2, p3, p4)
///
/// Sources:
/// - Chinese Securities Association technical analysis specifications
/// - TradingView community: "BBI - Bull and Bear Index"
/// </remarks>
[SkipLocalsInit]
public sealed class Bbi : AbstractBase
{
private const int DefaultP1 = 3;
private const int DefaultP2 = 6;
private const int DefaultP3 = 12;
private const int DefaultP4 = 24;
private readonly int _p1, _p2, _p3, _p4;
// Four independent O(1) circular-buffer SMAs
private readonly double[] _buf1, _buf2, _buf3, _buf4;
// All scalar state in one record struct for atomic _ps=_s snapshot (bar correction).
// PrevSlotX = the value that was at buf[headX] BEFORE the most recent isNew=true write.
// On isNew=false, restore buf[_ps.HeadX] = _s.PrevSlotX, then _s = _ps.
[StructLayout(LayoutKind.Auto)]
private record struct State(
double Sum1, int Head1, int Count1, double PrevSlot1,
double Sum2, int Head2, int Count2, double PrevSlot2,
double Sum3, int Head3, int Count3, double PrevSlot3,
double Sum4, int Head4, int Count4, double PrevSlot4,
int Index, double LastValid);
private State _s;
private State _ps;
/// <summary>
/// Creates BBI with four customizable SMA periods.
/// </summary>
/// <param name="p1">Ultra-short SMA period (must be &gt; 0)</param>
/// <param name="p2">Short SMA period (must be &gt; 0)</param>
/// <param name="p3">Medium SMA period (must be &gt; 0)</param>
/// <param name="p4">Long SMA period (must be &gt; 0)</param>
public Bbi(int p1 = DefaultP1, int p2 = DefaultP2, int p3 = DefaultP3, int p4 = DefaultP4)
{
if (p1 <= 0)
{
throw new ArgumentException("Period 1 must be greater than 0", nameof(p1));
}
if (p2 <= 0)
{
throw new ArgumentException("Period 2 must be greater than 0", nameof(p2));
}
if (p3 <= 0)
{
throw new ArgumentException("Period 3 must be greater than 0", nameof(p3));
}
if (p4 <= 0)
{
throw new ArgumentException("Period 4 must be greater than 0", nameof(p4));
}
_p1 = p1; _p2 = p2; _p3 = p3; _p4 = p4;
_buf1 = new double[p1];
_buf2 = new double[p2];
_buf3 = new double[p3];
_buf4 = new double[p4];
WarmupPeriod = Math.Max(Math.Max(p1, p2), Math.Max(p3, p4));
Name = $"Bbi({p1},{p2},{p3},{p4})";
_s = default;
_ps = _s;
}
/// <summary>
/// Creates BBI subscribed to a source publisher.
/// </summary>
public Bbi(ITValuePublisher source,
int p1 = DefaultP1, int p2 = DefaultP2, int p3 = DefaultP3, int p4 = DefaultP4)
: this(p1, p2, p3, p4)
{
source.Pub += Handle;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void Handle(object? sender, in TValueEventArgs e) => Update(e.Value, e.IsNew);
/// <summary>True when enough bars have been processed for valid (full-window) output.</summary>
public override bool IsHot => _s.Index >= WarmupPeriod;
/// <inheritdoc/>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override TValue Update(TValue input, bool isNew = true)
{
if (isNew)
{
_ps = _s;
}
else
{
// Restore the ring-buffer slots that were overwritten by the most-recent isNew=true pass.
// _ps.HeadX = the write-head position used during that pass.
// _s.PrevSlotX = the value that was at that head BEFORE the write.
_buf1[_ps.Head1] = _s.PrevSlot1;
_buf2[_ps.Head2] = _s.PrevSlot2;
_buf3[_ps.Head3] = _s.PrevSlot3;
_buf4[_ps.Head4] = _s.PrevSlot4;
_s = _ps;
}
// Local copy for JIT register promotion
double sum1 = _s.Sum1; int h1 = _s.Head1; int c1 = _s.Count1;
double sum2 = _s.Sum2; int h2 = _s.Head2; int c2 = _s.Count2;
double sum3 = _s.Sum3; int h3 = _s.Head3; int c3 = _s.Count3;
double sum4 = _s.Sum4; int h4 = _s.Head4; int c4 = _s.Count4;
int index = _s.Index;
double lastValid = _s.LastValid;
// NaN/Infinity substitution
double val = input.Value;
if (!double.IsFinite(val))
{
val = double.IsFinite(lastValid) ? lastValid : 0.0;
}
else
{
lastValid = val;
}
if (isNew)
{
index++;
}
// ── SMA 1: capture slot BEFORE writing (for bar-correction restore next time) ──
double prev1 = _buf1[h1];
sum1 = c1 < _p1 ? sum1 + val - prev1 : sum1 - prev1 + val;
if (c1 < _p1) { c1++; }
_buf1[h1] = val;
int newH1 = isNew ? (h1 + 1) % _p1 : h1;
// ── SMA 2 ────────────────────────────────────────────────────────────
double prev2 = _buf2[h2];
sum2 = c2 < _p2 ? sum2 + val - prev2 : sum2 - prev2 + val;
if (c2 < _p2) { c2++; }
_buf2[h2] = val;
int newH2 = isNew ? (h2 + 1) % _p2 : h2;
// ── SMA 3 ────────────────────────────────────────────────────────────
double prev3 = _buf3[h3];
sum3 = c3 < _p3 ? sum3 + val - prev3 : sum3 - prev3 + val;
if (c3 < _p3) { c3++; }
_buf3[h3] = val;
int newH3 = isNew ? (h3 + 1) % _p3 : h3;
// ── SMA 4 ────────────────────────────────────────────────────────────
double prev4 = _buf4[h4];
sum4 = c4 < _p4 ? sum4 + val - prev4 : sum4 - prev4 + val;
if (c4 < _p4) { c4++; }
_buf4[h4] = val;
int newH4 = isNew ? (h4 + 1) % _p4 : h4;
// ── Composite BBI ────────────────────────────────────────────────────
double sma1 = sum1 / Math.Max(1, c1);
double sma2 = sum2 / Math.Max(1, c2);
double sma3 = sum3 / Math.Max(1, c3);
double sma4 = sum4 / Math.Max(1, c4);
double bbi = (sma1 + sma2 + sma3 + sma4) * 0.25;
// Write back state — store PrevSlotX for next bar-correction restore
_s = new State(
sum1, newH1, c1, prev1,
sum2, newH2, c2, prev2,
sum3, newH3, c3, prev3,
sum4, newH4, c4, prev4,
index, lastValid);
Last = new TValue(input.Time, bbi);
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, _p1, _p2, _p3, _p4);
source.Times.CopyTo(tSpan);
// Prime streaming state for continued updates
Reset();
for (int i = 0; i < len; i++)
{
Update(new TValue(source.Times[i], source.Values[i]), isNew: true);
}
return new TSeries(t, 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()
{
Array.Clear(_buf1);
Array.Clear(_buf2);
Array.Clear(_buf3);
Array.Clear(_buf4);
_s = default;
_ps = _s;
Last = default;
}
// ── Static Batch (TSeries) ───────────────────────────────────────────────
/// <summary>Calculates BBI for an entire <see cref="TSeries"/>.</summary>
public static TSeries Batch(
TSeries source,
int p1 = DefaultP1, int p2 = DefaultP2, int p3 = DefaultP3, int p4 = DefaultP4)
{
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, p1, p2, p3, p4);
source.Times.CopyTo(tSpan);
return new TSeries(t, v);
}
// ── Static Batch (Span) ──────────────────────────────────────────────────
/// <summary>
/// Zero-allocation span-based BBI calculation using ArrayPool for ring buffers.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Batch(
ReadOnlySpan<double> source,
Span<double> output,
int p1 = DefaultP1, int p2 = DefaultP2, int p3 = DefaultP3, int p4 = DefaultP4)
{
if (source.Length != output.Length)
{
throw new ArgumentException("Source and output must have the same length", nameof(output));
}
if (p1 <= 0)
{
throw new ArgumentException("Period 1 must be greater than 0", nameof(p1));
}
if (p2 <= 0)
{
throw new ArgumentException("Period 2 must be greater than 0", nameof(p2));
}
if (p3 <= 0)
{
throw new ArgumentException("Period 3 must be greater than 0", nameof(p3));
}
if (p4 <= 0)
{
throw new ArgumentException("Period 4 must be greater than 0", nameof(p4));
}
int len = source.Length;
if (len == 0)
{
return;
}
double[] b1 = ArrayPool<double>.Shared.Rent(p1);
double[] b2 = ArrayPool<double>.Shared.Rent(p2);
double[] b3 = ArrayPool<double>.Shared.Rent(p3);
double[] b4 = ArrayPool<double>.Shared.Rent(p4);
b1.AsSpan(0, p1).Clear();
b2.AsSpan(0, p2).Clear();
b3.AsSpan(0, p3).Clear();
b4.AsSpan(0, p4).Clear();
try
{
double sum1 = 0, sum2 = 0, sum3 = 0, sum4 = 0;
int h1 = 0, h2 = 0, h3 = 0, h4 = 0;
int c1 = 0, c2 = 0, c3 = 0, c4 = 0;
double lastValid = 0.0;
for (int i = 0; i < len; i++)
{
double val = source[i];
if (!double.IsFinite(val))
{
val = lastValid;
}
else
{
lastValid = val;
}
double old1 = b1[h1]; sum1 = c1 < p1 ? sum1 + val - old1 : sum1 - old1 + val; if (c1 < p1) { c1++; }
b1[h1] = val; h1 = (h1 + 1) % p1;
double old2 = b2[h2]; sum2 = c2 < p2 ? sum2 + val - old2 : sum2 - old2 + val; if (c2 < p2) { c2++; }
b2[h2] = val; h2 = (h2 + 1) % p2;
double old3 = b3[h3]; sum3 = c3 < p3 ? sum3 + val - old3 : sum3 - old3 + val; if (c3 < p3) { c3++; }
b3[h3] = val; h3 = (h3 + 1) % p3;
double old4 = b4[h4]; sum4 = c4 < p4 ? sum4 + val - old4 : sum4 - old4 + val; if (c4 < p4) { c4++; }
b4[h4] = val; h4 = (h4 + 1) % p4;
output[i] = (sum1 / Math.Max(1, c1) + sum2 / Math.Max(1, c2)
+ sum3 / Math.Max(1, c3) + sum4 / Math.Max(1, c4)) * 0.25;
}
}
finally
{
ArrayPool<double>.Shared.Return(b1);
ArrayPool<double>.Shared.Return(b2);
ArrayPool<double>.Shared.Return(b3);
ArrayPool<double>.Shared.Return(b4);
}
}
/// <summary>Creates a BBI instance and calculates results for the source series.</summary>
public static (TSeries Results, Bbi Indicator) Calculate(
TSeries source,
int p1 = DefaultP1, int p2 = DefaultP2, int p3 = DefaultP3, int p4 = DefaultP4)
{
var indicator = new Bbi(p1, p2, p3, p4);
TSeries results = indicator.Update(source);
return (results, indicator);
}
}