docs: remove C# Implementation Considerations sections, clean up temp scripts, reorganize test files

- Remove 'C# Implementation Considerations' sections from 34 indicator .md files
- Delete 29 temp PowerShell scripts (_fix_mojibake.ps1, _hex_scan.ps1, etc.)
- Move test files into tests/ subdirectories for consistent project structure
- Add trader-focused bullet points to indicator documentation
This commit is contained in:
Miha Kralj
2026-03-12 12:34:16 -07:00
parent 8937b0c0fa
commit 060649192f
1149 changed files with 1780 additions and 3316 deletions
@@ -0,0 +1,105 @@
using TradingPlatform.BusinessLayer;
namespace QuanTAlib.Tests;
public sealed class BbsIndicatorTests
{
[Fact]
public void BbsIndicator_Constructor_SetsDefaults()
{
var indicator = new BbsIndicator();
Assert.Equal(20, indicator.BbPeriod);
Assert.Equal(2.0, indicator.BbMult);
Assert.Equal(20, indicator.KcPeriod);
Assert.Equal(1.5, indicator.KcMult);
Assert.True(indicator.ShowColdValues);
Assert.Equal("BBS - Bollinger Band Squeeze", indicator.Name);
Assert.True(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void BbsIndicator_MinHistoryDepths_EqualsZero()
{
var indicator = new BbsIndicator { BbPeriod = 20 };
Assert.Equal(0, BbsIndicator.MinHistoryDepths);
IWatchlistIndicator watchlistIndicator = indicator;
Assert.Equal(0, watchlistIndicator.MinHistoryDepths);
}
[Fact]
public void BbsIndicator_ShortName_IncludesParameters()
{
var indicator = new BbsIndicator
{
BbPeriod = 15,
BbMult = 1.5,
KcPeriod = 10,
KcMult = 2.0
};
indicator.Initialize();
Assert.Contains("BBS", indicator.ShortName, StringComparison.Ordinal);
Assert.Contains("15", indicator.ShortName, StringComparison.Ordinal);
Assert.Contains("10", indicator.ShortName, StringComparison.Ordinal);
}
[Fact]
public void BbsIndicator_SourceCodeLink_IsValid()
{
var indicator = new BbsIndicator();
Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal);
Assert.Contains("Bbs.Quantower.cs", indicator.SourceCodeLink, StringComparison.Ordinal);
}
[Fact]
public void BbsIndicator_Initialize_CreatesInternalBbs()
{
var indicator = new BbsIndicator
{
BbPeriod = 20,
KcPeriod = 20
};
indicator.Initialize();
// Should have bandwidth + squeeze dot series
Assert.Equal(2, indicator.LinesSeries.Count);
}
[Fact]
public void BbsIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new BbsIndicator
{
BbPeriod = 5,
KcPeriod = 5
};
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 20; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i);
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
}
double bandwidth = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(bandwidth));
}
[Fact]
public void BbsIndicator_TwoLineSeries_Exist()
{
var indicator = new BbsIndicator();
indicator.Initialize();
// Should have bandwidth + squeeze dot series
Assert.Equal(2, indicator.LinesSeries.Count);
}
}
+411
View File
@@ -0,0 +1,411 @@
using Xunit;
namespace QuanTAlib.Tests;
public sealed class BbsTests
{
[Fact]
public void Constructor_DefaultParameters()
{
var bbs = new Bbs();
Assert.NotNull(bbs);
Assert.Equal("Bbs(20,2.0,20,1.5)", bbs.Name);
Assert.Equal(20, bbs.WarmupPeriod);
Assert.Equal(20, bbs.BbPeriod);
Assert.Equal(2.0, bbs.BbMult);
Assert.Equal(20, bbs.KcPeriod);
Assert.Equal(1.5, bbs.KcMult);
Assert.False(bbs.IsHot);
}
[Fact]
public void Constructor_CustomParameters()
{
var bbs = new Bbs(bbPeriod: 10, bbMult: 1.5, kcPeriod: 15, kcMult: 2.0);
Assert.Equal(10, bbs.BbPeriod);
Assert.Equal(1.5, bbs.BbMult);
Assert.Equal(15, bbs.KcPeriod);
Assert.Equal(2.0, bbs.KcMult);
Assert.Equal(15, bbs.WarmupPeriod); // max(10, 15)
}
[Fact]
public void Constructor_InvalidBbPeriod_Throws()
{
var ex = Assert.Throws<ArgumentException>(() => new Bbs(bbPeriod: 0));
Assert.Equal("bbPeriod", ex.ParamName);
}
[Fact]
public void Constructor_InvalidKcPeriod_Throws()
{
var ex = Assert.Throws<ArgumentException>(() => new Bbs(kcPeriod: 0));
Assert.Equal("kcPeriod", ex.ParamName);
}
[Fact]
public void Constructor_InvalidBbMult_Throws()
{
var ex = Assert.Throws<ArgumentException>(() => new Bbs(bbMult: 0.0));
Assert.Equal("bbMult", ex.ParamName);
}
[Fact]
public void Constructor_InvalidKcMult_Throws()
{
var ex = Assert.Throws<ArgumentException>(() => new Bbs(kcMult: 0.0));
Assert.Equal("kcMult", ex.ParamName);
}
[Fact]
public void ConstantPrice_BandwidthZero()
{
var bbs = new Bbs(bbPeriod: 3, bbMult: 2.0, kcPeriod: 3, kcMult: 1.5);
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
// Constant price => stddev = 0 => BB width = 0 => bandwidth = 0
for (int i = 0; i < 5; i++)
{
bbs.Update(new TBar(baseTime + i * 60000, 100, 100, 100, 100, 1000));
}
Assert.Equal(0.0, bbs.Last.Value, 10);
}
[Fact]
public void TightRange_SqueezeOn()
{
// Very tight range bars: stddev ≈ 0, so BB bands collapse
// ATR still has width from H-L range, so KC is wider
// => BB inside KC => squeeze on
var bbs = new Bbs(bbPeriod: 3, bbMult: 2.0, kcPeriod: 3, kcMult: 1.5);
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
// Close is always 100, but high/low create ATR
for (int i = 0; i < 10; i++)
{
bbs.Update(new TBar(baseTime + i * 60000, 100, 102, 98, 100, 1000));
}
// With constant close and non-zero ATR, BB bands (based on close stddev) should be
// narrower than KC bands (based on ATR), so squeeze should be on
Assert.True(bbs.IsHot);
Assert.True(bbs.SqueezeOn);
}
[Fact]
public void WideRange_SqueezeOff()
{
// Wide price swings create large BB stddev → BB bands wider than KC bands
// Use small kcMult so KC is narrow, large bbMult so BB is wide
var bbs = new Bbs(bbPeriod: 3, bbMult: 3.0, kcPeriod: 3, kcMult: 0.5);
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
// Alternating prices create large stddev; tight H-L keeps ATR small relative to stddev
double[] closes = { 80, 120, 80, 120, 80, 120, 80, 120, 80, 120 };
for (int i = 0; i < closes.Length; i++)
{
double c = closes[i];
// H/L track actual price so TR ≈ close-to-close gap (ATR stays proportional)
// but BB mult * stddev >> KC mult * ATR when kcMult is small
bbs.Update(new TBar(baseTime + i * 60000, c, c + 0.5, c - 0.5, c, 1000));
}
// BB bands (3 * stddev) should exceed KC bands (0.5 * ATR)
Assert.True(bbs.IsHot);
Assert.False(bbs.SqueezeOn);
}
[Fact]
public void IsNew_False_RollsBackCorrectly()
{
var bbs = new Bbs(bbPeriod: 3, bbMult: 2.0, kcPeriod: 3, kcMult: 1.5);
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
// Feed initial bars
for (int i = 0; i < 5; i++)
{
bbs.Update(new TBar(baseTime + i * 60000, 100 + i, 102 + i, 98 + i, 100 + i, 1000));
}
// Save state after bar 5 for reference
_ = bbs.Last.Value;
_ = bbs.SqueezeOn;
// Update with new bar
bbs.Update(new TBar(baseTime + 5 * 60000, 110, 112, 108, 110, 1000), isNew: true);
double afterBar6 = bbs.Last.Value;
// Roll back with isNew=false
bbs.Update(new TBar(baseTime + 5 * 60000, 105, 107, 103, 105, 1000), isNew: false);
double corrected = bbs.Last.Value;
// Corrected value should differ from bar 6 (different price) but be valid
Assert.NotEqual(afterBar6, corrected, 5);
Assert.True(double.IsFinite(corrected));
}
[Fact]
public void SqueezeFired_DetectsTransition()
{
var bbs = new Bbs(bbPeriod: 3, bbMult: 2.0, kcPeriod: 3, kcMult: 1.5);
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
// Phase 1: Tight range (squeeze on)
for (int i = 0; i < 5; i++)
{
bbs.Update(new TBar(baseTime + i * 60000, 100, 102, 98, 100, 1000));
}
_ = bbs.SqueezeOn; // capture pre-breakout state
// Phase 2: Breakout with huge price movement (squeeze off)
for (int i = 0; i < 5; i++)
{
double price = 100 + (i + 1) * 20; // 120, 140, 160, 180, 200
bbs.Update(new TBar(baseTime + (5 + i) * 60000, price, price + 1, price - 1, price, 1000));
}
// If squeeze was on and now off, SqueezeFired should have been true at transition
// We test that values are valid after the transition
Assert.True(double.IsFinite(bbs.Last.Value));
}
[Fact]
public void Bandwidth_PositiveForVariedPrices()
{
var bbs = new Bbs(bbPeriod: 5, bbMult: 2.0, kcPeriod: 5, kcMult: 1.5);
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
for (int i = 0; i < 10; i++)
{
double price = 100 + Math.Sin(i) * 5;
bbs.Update(new TBar(baseTime + i * 60000, price, price + 2, price - 2, price, 1000));
}
// With varying prices, bandwidth should be positive
Assert.True(bbs.Last.Value > 0);
Assert.True(bbs.IsHot);
}
[Fact]
public void NaN_Input_UsesLastValid()
{
var bbs = new Bbs(bbPeriod: 3, bbMult: 2.0, kcPeriod: 3, kcMult: 1.5);
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
// Feed valid bars
bbs.Update(new TBar(baseTime, 100, 102, 98, 100, 1000));
bbs.Update(new TBar(baseTime + 60000, 101, 103, 99, 101, 1000));
// Feed NaN bar
var result = bbs.Update(new TBar(baseTime + 120000, double.NaN, double.NaN, double.NaN, double.NaN, 1000));
Assert.True(double.IsFinite(result.Value));
}
[Fact]
public void Reset_ClearsState()
{
var bbs = new Bbs(bbPeriod: 3, bbMult: 2.0, kcPeriod: 3, kcMult: 1.5);
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
for (int i = 0; i < 5; i++)
{
bbs.Update(new TBar(baseTime + i * 60000, 100 + i, 102 + i, 98 + i, 100 + i, 1000));
}
Assert.True(bbs.IsHot);
bbs.Reset();
Assert.False(bbs.IsHot);
Assert.False(bbs.SqueezeOn);
Assert.False(bbs.SqueezeFired);
}
#region Batch Tests
[Fact]
public void Batch_TBarSeries_ReturnsCorrectLength()
{
var series = new TBarSeries();
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
for (int i = 0; i < 20; i++)
{
series.Add(new TBar(baseTime + i * 60000, 100 + i, 110 + i, 90 + i, 105 + i, 1000));
}
var result = Bbs.Batch(series);
Assert.Equal(20, result.Count);
}
[Fact]
public void Batch_EmptySource_ReturnsEmpty()
{
var result = Bbs.Batch(new TBarSeries());
Assert.Empty(result);
}
[Fact]
public void Batch_CustomParams_ReturnsCorrectLength()
{
var series = new TBarSeries();
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
for (int i = 0; i < 20; i++)
{
series.Add(new TBar(baseTime + i * 60000, 100 + i, 110 + i, 90 + i, 105 + i, 1000));
}
var result = Bbs.Batch(series, bbPeriod: 10, bbMult: 1.5, kcPeriod: 10, kcMult: 2.0);
Assert.Equal(20, result.Count);
}
[Fact]
public void Batch_Span_MatchesStreaming()
{
var series = new TBarSeries();
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
for (int i = 0; i < 50; i++)
{
double price = 100 + Math.Sin(i * 0.5) * 10;
series.Add(new TBar(baseTime + i * 60000, price, price + 3, price - 3, price, 1000));
}
// Streaming
var bbs = new Bbs(bbPeriod: 5, bbMult: 2.0, kcPeriod: 5, kcMult: 1.5);
var streamValues = new List<double>(50);
for (int i = 0; i < series.Count; i++)
{
streamValues.Add(bbs.Update(series[i]).Value);
}
// Span batch
double[] output = new double[50];
Bbs.Batch(series.HighValues, series.LowValues, series.CloseValues,
output.AsSpan(), bbPeriod: 5, bbMult: 2.0);
// Compare last 40 values (after warmup stabilization)
for (int i = 10; i < 50; i++)
{
Assert.Equal(streamValues[i], output[i], 8);
}
}
[Fact]
public void Batch_SpanWithSqueeze_OutputsBothArrays()
{
int len = 30;
double[] high = new double[len];
double[] low = new double[len];
double[] close = new double[len];
double[] bandwidth = new double[len];
bool[] squeezeOn = new bool[len];
for (int i = 0; i < len; i++)
{
close[i] = 100;
high[i] = 102;
low[i] = 98;
}
Bbs.Batch(high.AsSpan(), low.AsSpan(), close.AsSpan(),
bandwidth.AsSpan(), squeezeOn.AsSpan(),
bbPeriod: 5, bbMult: 2.0, kcPeriod: 5, kcMult: 1.5);
// Constant close => stddev=0 => BB width=0 => squeeze on
// Bandwidth should be 0 for constant close
for (int i = 5; i < len; i++)
{
Assert.Equal(0.0, bandwidth[i], 10);
Assert.True(squeezeOn[i]);
}
}
[Fact]
public void Batch_InvalidInputLength_Throws()
{
double[] high = new double[10];
double[] low = new double[5]; // mismatched
double[] close = new double[10];
double[] output = new double[10];
Assert.Throws<ArgumentException>(() =>
Bbs.Batch(high.AsSpan(), low.AsSpan(), close.AsSpan(), output.AsSpan()));
}
[Fact]
public void Batch_OutputTooSmall_Throws()
{
double[] high = new double[10];
double[] low = new double[10];
double[] close = new double[10];
double[] output = new double[5]; // too small
Assert.Throws<ArgumentException>(() =>
Bbs.Batch(high.AsSpan(), low.AsSpan(), close.AsSpan(), output.AsSpan()));
}
[Fact]
public void Batch_InvalidPeriod_Throws()
{
double[] data = new double[10];
double[] output = new double[10];
Assert.Throws<ArgumentException>(() =>
Bbs.Batch(data.AsSpan(), data.AsSpan(), data.AsSpan(), output.AsSpan(), bbPeriod: 0));
}
[Fact]
public void Batch_InvalidMultiplier_Throws()
{
double[] data = new double[10];
double[] output = new double[10];
Assert.Throws<ArgumentException>(() =>
Bbs.Batch(data.AsSpan(), data.AsSpan(), data.AsSpan(), output.AsSpan(), bbMult: 0.0));
}
#endregion
[Fact]
public void Calculate_ReturnsResultsAndHotIndicator()
{
var series = new TBarSeries();
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
for (int i = 0; i < 30; i++)
{
series.Add(new TBar(baseTime + i * 60000, 100 + i, 110 + i, 90 + i, 105 + i, 1000));
}
var (results, indicator) = Bbs.Calculate(series, bbPeriod: 5, bbMult: 2.0, kcPeriod: 5, kcMult: 1.5);
Assert.Equal(30, results.Count);
Assert.True(indicator.IsHot);
}
[Fact]
public void PubEvent_FiresOnUpdate()
{
var bbs = new Bbs(bbPeriod: 3, bbMult: 2.0, kcPeriod: 3, kcMult: 1.5);
long baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
int eventCount = 0;
bbs.Pub += (object? _, in TValueEventArgs _) => eventCount++;
for (int i = 0; i < 5; i++)
{
bbs.Update(new TBar(baseTime + i * 60000, 100 + i, 102 + i, 98 + i, 100 + i, 1000));
}
Assert.Equal(5, eventCount);
}
}
@@ -0,0 +1,213 @@
using OoplesFinance.StockIndicators;
using OoplesFinance.StockIndicators.Models;
using Skender.Stock.Indicators;
using Xunit.Abstractions;
namespace QuanTAlib.Tests;
public sealed class BbsValidationTests : IDisposable
{
private readonly ValidationTestData _testData;
private readonly ITestOutputHelper _output;
private bool _disposed;
public BbsValidationTests(ITestOutputHelper output)
{
_output = output;
_testData = new ValidationTestData();
}
public void Dispose()
{
Dispose(true);
}
private void Dispose(bool disposing)
{
if (_disposed)
{
return;
}
_disposed = true;
if (disposing)
{
_testData?.Dispose();
}
}
[Fact]
public void Validate_Streaming_Batch_Span_Agree()
{
int bbPeriod = 20;
double bbMult = 2.0;
int kcPeriod = 20;
double kcMult = 1.5;
// Streaming
var streaming = new Bbs(bbPeriod, bbMult, kcPeriod, kcMult);
var streamValues = new List<double>(_testData.Bars.Count);
for (int i = 0; i < _testData.Bars.Count; i++)
{
streamValues.Add(streaming.Update(_testData.Bars[i]).Value);
}
// Batch (TBarSeries)
TSeries batchSeries = Bbs.Batch(_testData.Bars, bbPeriod, bbMult, kcPeriod, kcMult);
// Span
double[] spanOutput = new double[_testData.Bars.Count];
Bbs.Batch(_testData.Bars.HighValues, _testData.Bars.LowValues, _testData.Bars.CloseValues,
spanOutput.AsSpan(), bbPeriod, bbMult);
// Compare last 200 samples for stability
int start = Math.Max(0, spanOutput.Length - 200);
for (int i = start; i < spanOutput.Length; i++)
{
Assert.Equal(batchSeries[i].Value, streamValues[i], 7);
Assert.Equal(batchSeries[i].Value, spanOutput[i], 7);
}
_output.WriteLine("BBS validation: streaming, batch, and span outputs agree.");
}
[Fact]
public void Validate_SpanWithSqueeze_MatchesStreaming()
{
int bbPeriod = 20;
double bbMult = 2.0;
int kcPeriod = 20;
double kcMult = 1.5;
// Streaming - collect squeeze states
var streaming = new Bbs(bbPeriod, bbMult, kcPeriod, kcMult);
var streamBandwidths = new List<double>(_testData.Bars.Count);
var streamSqueezes = new List<bool>(_testData.Bars.Count);
for (int i = 0; i < _testData.Bars.Count; i++)
{
streaming.Update(_testData.Bars[i]);
streamBandwidths.Add(streaming.Last.Value);
streamSqueezes.Add(streaming.SqueezeOn);
}
// Span with squeeze
int len = _testData.Bars.Count;
double[] spanBw = new double[len];
bool[] spanSq = new bool[len];
Bbs.Batch(_testData.Bars.HighValues, _testData.Bars.LowValues, _testData.Bars.CloseValues,
spanBw.AsSpan(), spanSq.AsSpan(), bbPeriod, bbMult, kcPeriod, kcMult);
// Compare last 200 samples
int start = Math.Max(0, len - 200);
for (int i = start; i < len; i++)
{
Assert.Equal(streamBandwidths[i], spanBw[i], 7);
Assert.Equal(streamSqueezes[i], spanSq[i]);
}
_output.WriteLine("BBS validation: squeeze span matches streaming.");
}
[Fact]
public void Validate_Bandwidth_MatchesBbw()
{
// BBS bandwidth should match BBW (Bollinger Band Width) when using same BB parameters.
// BBS bandwidth = ((upper - lower) / middle) * 100
// BBW = ((upper - lower) / middle) * 100 (same formula)
int[] periods = { 5, 10, 20, 50 };
double multiplier = 2.0;
foreach (var period in periods)
{
// BBS (uses close for BB, needs OHLC for KC)
var bbs = new Bbs(bbPeriod: period, bbMult: multiplier, kcPeriod: period, kcMult: 1.5);
var bbsValues = new List<double>(_testData.Bars.Count);
for (int i = 0; i < _testData.Bars.Count; i++)
{
bbs.Update(_testData.Bars[i]);
bbsValues.Add(bbs.Last.Value);
}
// Skender Bollinger Bands Width
var skenderBb = _testData.SkenderQuotes.GetBollingerBands(period, multiplier).ToList();
// Compare bandwidth values where both are valid
int start = period + 10; // skip warmup
int compared = 0;
for (int i = start; i < Math.Min(bbsValues.Count, skenderBb.Count); i++)
{
var sk = skenderBb[i];
if (sk.Width is not null and not double.NaN)
{
// BBS bandwidth = width * 100 (as percentage)
// Skender Width = (Upper - Lower) / Middle
double expected = sk.Width.Value * 100.0;
Assert.Equal(expected, bbsValues[i], 4);
compared++;
}
}
Assert.True(compared > 0, $"No valid comparisons for period {period}");
}
_output.WriteLine("BBS bandwidth validated against Skender BB Width.");
}
[Fact]
public void Validate_AllOutputsFinite()
{
var bbs = new Bbs(bbPeriod: 20, bbMult: 2.0, kcPeriod: 20, kcMult: 1.5);
for (int i = 0; i < _testData.Bars.Count; i++)
{
var result = bbs.Update(_testData.Bars[i]);
Assert.True(double.IsFinite(result.Value), $"Non-finite output at bar {i}: {result.Value}");
}
_output.WriteLine("BBS validation: all outputs are finite.");
}
[Fact]
public void Validate_Calculate_ReturnsHotIndicator()
{
var (results, indicator) = Bbs.Calculate(_testData.Bars);
Assert.Equal(_testData.Bars.Count, results.Count);
Assert.True(indicator.IsHot);
Assert.True(double.IsFinite(indicator.Last.Value));
_output.WriteLine("BBS validation: Calculate returns hot indicator.");
}
[Fact]
public void Validate_LargeDataset_Stability()
{
var (results, _) = Bbs.Calculate(_testData.Bars, bbPeriod: 50, bbMult: 2.0, kcPeriod: 50, kcMult: 1.5);
// Check last 100 values are finite and non-negative
int start = Math.Max(0, results.Count - 100);
for (int i = start; i < results.Count; i++)
{
Assert.True(double.IsFinite(results[i].Value));
Assert.True(results[i].Value >= 0, $"Bandwidth should be non-negative at {i}: {results[i].Value}");
}
_output.WriteLine("BBS validation: large dataset stability verified.");
}
[Fact]
public void Bbs_MatchesOoples_Structural()
{
// CalculateSqueezeMomentumIndicator — structural test (BBands width / KC width)
var ooplesData = _testData.SkenderQuotes
.Select(q => new TickerData { Date = q.Date, Open = (double)q.Open, High = (double)q.High, Low = (double)q.Low, Close = (double)q.Close, Volume = (double)q.Volume })
.ToList();
var result = new StockData(ooplesData).CalculateSqueezeMomentumIndicator();
var values = result.CustomValuesList;
int finiteCount = values.Count(v => double.IsFinite(v));
Assert.True(finiteCount > 100, $"Expected >100 finite Ooples BBS/Squeeze values, got {finiteCount}");
}
}