feat: add 8 new indicators with full integration

New indicators:
- HWC (Holt-Winters Channel) — channels, 27 tests
- VWMACD (Volume-Weighted MACD) — momentum, 38 tests
- Squeeze Pro — oscillators, 69 tests
- BW_MFI (Bill Williams MFI) — oscillators
- DSTOCH (Double Stochastic) — oscillators
- ATRSTOP (ATR Trailing Stop) — reversals
- VSTOP (Volatility Stop) — reversals
- Convexity (Beta Convexity) — statistics, 23 tests

Integration:
- Python bridge: Exports.cs, _bridge.py, wrapper modules
- Documentation: _sidebar.md, _index.md pages, SPEC.md
- All analyzer warnings fixed (MA0074, xUnit2013, S2699)

Build: 0 warnings, 0 errors | Tests: 15,933 passed, 0 failed
This commit is contained in:
Miha Kralj
2026-03-17 08:35:29 -07:00
parent 6f0a339c9b
commit 15f4bb90f3
71 changed files with 10194 additions and 44 deletions
@@ -0,0 +1,96 @@
using TradingPlatform.BusinessLayer;
using QuanTAlib;
namespace QuanTAlib.Tests;
public sealed class DstochIndicatorTests
{
[Fact]
public void DstochIndicator_Constructor_SetsDefaults()
{
var indicator = new DstochIndicator();
Assert.True(indicator.ShowColdValues);
Assert.Equal("DSTOCH", indicator.Name);
Assert.True(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void DstochIndicator_MinHistoryDepths_EqualsZero()
{
Assert.Equal(0, DstochIndicator.MinHistoryDepths);
IWatchlistIndicator watchlistIndicator = new DstochIndicator();
Assert.Equal(0, watchlistIndicator.MinHistoryDepths);
}
[Fact]
public void DstochIndicator_ShortName_IsCorrect()
{
var indicator = new DstochIndicator();
indicator.Initialize();
Assert.Equal("DSTOCH 21", indicator.ShortName);
}
[Fact]
public void DstochIndicator_SourceCodeLink_IsValid()
{
var indicator = new DstochIndicator();
Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal);
Assert.Contains("Dstoch.cs", indicator.SourceCodeLink, StringComparison.Ordinal);
}
[Fact]
public void DstochIndicator_Initialize_CreatesOneLineSeries()
{
var indicator = new DstochIndicator();
indicator.Initialize();
Assert.Single(indicator.LinesSeries);
}
[Fact]
public void DstochIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new DstochIndicator { Period = 5 };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 10; i++)
{
double basePrice = 100.0 + i;
indicator.HistoricalData.AddBar(
now.AddMinutes(i),
open: basePrice,
high: basePrice + 5.0,
low: basePrice - 5.0,
close: basePrice + 1.0);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
double dssValue = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(dssValue));
}
[Fact]
public void DstochIndicator_ProcessUpdate_NewBar_UpdatesValue()
{
var indicator = new DstochIndicator { Period = 5 };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 10; 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(10), 110, 120, 100, 115);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
Assert.True(indicator.LinesSeries[0].Count >= 2);
}
}
@@ -0,0 +1,341 @@
using System.Runtime.CompilerServices;
using Xunit;
namespace QuanTAlib.Tests;
public sealed class DstochTests
{
private readonly GBM _gbm = new(100.0, 0.05, 0.5, seed: 42);
// ── A. Constructor / defaults ──
[Fact]
public void Constructor_Default_SetsName()
{
var d = new Dstoch();
Assert.Equal("Dstoch(21)", d.Name);
}
[Fact]
public void Constructor_Custom_SetsName()
{
var d = new Dstoch(10);
Assert.Equal("Dstoch(10)", d.Name);
}
[Fact]
public void Constructor_Default_WarmupPeriodIsPeriod()
{
var d = new Dstoch(10);
Assert.Equal(10, d.WarmupPeriod);
}
[Fact]
public void Constructor_Default_NotHotBeforeFirstBar()
{
var d = new Dstoch();
Assert.False(d.IsHot);
}
[Fact]
public void Constructor_ZeroPeriod_Throws()
{
Assert.Throws<ArgumentException>(() => new Dstoch(0));
}
[Fact]
public void Constructor_NegativePeriod_Throws()
{
Assert.Throws<ArgumentException>(() => new Dstoch(-5));
}
// ── B. Core update behavior ──
[Fact]
public void Update_BasicBar_ProducesFiniteResult()
{
var d = new Dstoch(5);
var bar = new TBar(DateTime.UtcNow, 105, 110, 100, 107, 1000);
var result = d.Update(bar);
Assert.True(double.IsFinite(result.Value));
}
[Fact]
public void Update_LastMatchesReturnValue()
{
var d = new Dstoch(5);
var bar = new TBar(DateTime.UtcNow, 105, 110, 100, 107, 1000);
var result = d.Update(bar);
Assert.Equal(result.Value, d.Last.Value, 15);
}
[Fact]
public void IsHot_FalseForFirstBar_TrueAfterPeriod()
{
var d = new Dstoch(3);
var gbm = new GBM(100.0, 0.05, 0.2, seed: 7);
for (int i = 0; i < 10; i++)
{
d.Update(gbm.Next(isNew: true));
if (i < 2) { Assert.False(d.IsHot); }
else { Assert.True(d.IsHot); }
}
}
// ── C. Boundedness [0, 100] ──
[Fact]
public void Output_BoundedZeroToHundred()
{
var d = new Dstoch(10);
var gbm = new GBM(100.0, 0.05, 0.3, seed: 11);
for (int i = 0; i < 200; i++)
{
d.Update(gbm.Next(isNew: true));
if (d.IsHot)
{
Assert.InRange(d.Last.Value, -0.01, 100.01);
}
}
}
[Fact]
public void Output_ConstantBars_IsZero()
{
var d = new Dstoch(5);
for (int i = 0; i < 20; i++)
{
d.Update(new TBar(DateTime.UtcNow.AddDays(i), 100, 100, 100, 100, 1000));
}
Assert.Equal(0.0, d.Last.Value, 10);
}
// ── D. NaN / edge cases ──
[Fact]
public void Update_NaNHigh_ResultIsFinite()
{
var d = new Dstoch(3);
d.Update(new TBar(DateTime.UtcNow, 100, 110, 90, 105, 500));
d.Update(new TBar(DateTime.UtcNow.AddDays(1), 102, double.NaN, 92, 100, 500));
Assert.True(double.IsFinite(d.Last.Value));
}
[Fact]
public void Update_NaNVolume_NoImpact()
{
var d = new Dstoch(3);
var result = d.Update(new TBar(DateTime.UtcNow, 100, 110, 90, 105, double.NaN));
Assert.True(double.IsFinite(result.Value));
}
[Fact]
public void Update_AllNaN_ReturnsNaN()
{
var d = new Dstoch(3);
var result = d.Update(new TBar(DateTime.UtcNow, double.NaN, double.NaN, double.NaN, double.NaN, 0));
Assert.True(double.IsNaN(result.Value));
}
// ── E. isNew=false bar correction ──
[Fact]
public void Update_IsNewFalse_RewritesLastBar()
{
var d = new Dstoch(5);
var gbm = new GBM(100.0, 0.05, 0.2, seed: 99);
for (int i = 0; i < 8; i++) { d.Update(gbm.Next(isNew: true)); }
d.Update(gbm.Next(isNew: true));
double original = d.Last.Value;
// Correct with a different bar
var corrected = new TBar(DateTime.UtcNow.AddDays(99), 200, 250, 150, 220, 5000);
d.Update(corrected, isNew: false);
double correctedVal = d.Last.Value;
Assert.NotEqual(original, correctedVal);
}
[Fact]
public void Update_BarCorrection_PreservesCount()
{
var d = new Dstoch(3);
var gbm = new GBM(100.0, 0.05, 0.2, seed: 33);
for (int i = 0; i < 5; i++) { d.Update(gbm.Next(isNew: true)); }
bool hotBefore = d.IsHot;
d.Update(new TBar(DateTime.UtcNow.AddDays(99), 100, 110, 90, 105, 500), isNew: false);
Assert.Equal(hotBefore, d.IsHot);
}
// ── F. Reset ──
[Fact]
public void Reset_ClearsState()
{
var d = new Dstoch(5);
var gbm = new GBM(100.0, 0.05, 0.2, seed: 44);
for (int i = 0; i < 20; i++) { d.Update(gbm.Next(isNew: true)); }
Assert.True(d.IsHot);
d.Reset();
Assert.False(d.IsHot);
Assert.Equal(0.0, d.Last.Value);
}
// ── G. Pub event ──
[Fact]
public void PubEvent_Fires_OnUpdate()
{
var d = new Dstoch(3);
int count = 0;
d.Pub += (object? _, in TValueEventArgs _) => count++;
d.Update(new TBar(DateTime.UtcNow, 100, 110, 90, 105, 500));
Assert.Equal(1, count);
}
// ── H. TBarSeries chaining ──
[Fact]
public void TBarSeries_Chaining_Works()
{
var source = new TBarSeries();
var gbm = new GBM(100.0, 0.05, 0.2, seed: 55);
for (int i = 0; i < 30; i++)
{
source.Add(gbm.Next(isNew: true));
}
var d = new Dstoch(source, 10);
Assert.True(d.IsHot);
Assert.True(double.IsFinite(d.Last.Value));
}
// ── I. Batch methods ──
[Fact]
public void Batch_EmptySpans_NoThrow()
{
Span<double> empty = [];
Span<double> output = [];
Dstoch.Batch(empty, empty, empty, output, 5);
Assert.True(true);
}
[Fact]
public void Batch_MismatchedLength_Throws()
{
double[] h = [1, 2, 3];
double[] l = [1, 2];
double[] c = [1, 2, 3];
double[] o = new double[3];
Assert.Throws<ArgumentException>(() =>
Dstoch.Batch(h, l, c, o, 5));
}
[Fact]
public void Batch_OutputTooShort_Throws()
{
double[] h = [1, 2, 3];
double[] l = [1, 2, 3];
double[] c = [1, 2, 3];
double[] o = new double[2];
Assert.Throws<ArgumentException>(() =>
Dstoch.Batch(h, l, c, o, 5));
}
[Fact]
public void Batch_KnownValues_BoundedOutput()
{
var source = new TBarSeries();
var gbm = new GBM(100.0, 0.05, 0.2, seed: 66);
for (int i = 0; i < 50; i++) { source.Add(gbm.Next(isNew: true)); }
var result = Dstoch.Batch(source, 10);
for (int i = 10; i < result.Count; i++)
{
Assert.InRange(result[i].Value, -0.01, 100.01);
}
}
// ── J. Streaming ↔ Batch consistency ──
[Fact]
public void Consistency_StreamingMatchesBatch()
{
const int period = 10;
var source = new TBarSeries();
var gbm = new GBM(100.0, 0.05, 0.2, seed: 77);
for (int i = 0; i < 100; i++) { source.Add(gbm.Next(isNew: true)); }
var batch = Dstoch.Batch(source, period);
var streaming = new Dstoch(period);
for (int i = 0; i < source.Count; i++)
{
streaming.Update(source[i]);
Assert.Equal(batch[i].Value, streaming.Last.Value, 10);
}
}
[Fact]
public void Consistency_EventBasedMatchesStreaming()
{
const int period = 7;
var gbm = new GBM(100.0, 0.05, 0.2, seed: 88);
var d1 = new Dstoch(period);
var d2 = new Dstoch(period);
var eventValues = new List<double>();
d2.Pub += (object? _, in TValueEventArgs e) => eventValues.Add(e.Value.Value);
for (int i = 0; i < 50; i++)
{
var bar = gbm.Next(isNew: true);
d1.Update(bar);
d2.Update(bar);
}
Assert.Equal(50, eventValues.Count);
}
// ── K. Large dataset stability ──
[Fact]
public void Batch_LargeDataset_NoStackOverflow()
{
const int N = 5000;
var source = new TBarSeries();
var gbm = new GBM(100.0, 0.05, 0.3, seed: 123);
for (int i = 0; i < N; i++) { source.Add(gbm.Next(isNew: true)); }
var result = Dstoch.Batch(source, 21);
Assert.Equal(N, result.Count);
}
[Fact]
public void Batch_ZeroPeriod_Throws()
{
double[] h = [1, 2, 3];
double[] l = [1, 2, 3];
double[] c = [1, 2, 3];
double[] o = new double[3];
Assert.Throws<ArgumentException>(() =>
Dstoch.Batch(h, l, c, o, 0));
}
// ── L. Calculate factory ──
[Fact]
public void Calculate_ReturnsIndicatorAndResults()
{
var source = new TBarSeries();
var gbm = new GBM(100.0, 0.05, 0.2, seed: 99);
for (int i = 0; i < 50; i++) { source.Add(gbm.Next(isNew: true)); }
var (results, indicator) = Dstoch.Calculate(source, 10);
Assert.Equal(50, results.Count);
Assert.True(indicator.IsHot);
}
}
@@ -0,0 +1,231 @@
using Xunit;
namespace QuanTAlib.Tests;
public sealed class DstochValidationTests
{
// ── Self-consistency: streaming == batch ──
[Fact]
public void StreamingMatchesBatch()
{
const int period = 14;
var source = new TBarSeries();
var gbm = new GBM(100.0, 0.05, 0.3, seed: 42);
for (int i = 0; i < 100; i++) { source.Add(gbm.Next(isNew: true)); }
var batch = Dstoch.Batch(source, period);
var streaming = new Dstoch(period);
for (int i = 0; i < source.Count; i++)
{
streaming.Update(source[i]);
Assert.Equal(batch[i].Value, streaming.Last.Value, 10);
}
}
// ── Span matches TBarSeries batch ──
[Fact]
public void SpanMatchesTBarSeries()
{
const int period = 10;
var source = new TBarSeries();
var gbm = new GBM(100.0, 0.05, 0.2, seed: 55);
for (int i = 0; i < 80; i++) { source.Add(gbm.Next(isNew: true)); }
var tbResult = Dstoch.Batch(source, period);
var spanOut = new double[source.Count];
Dstoch.Batch(source.HighValues, source.LowValues, source.CloseValues,
spanOut.AsSpan(), period);
for (int i = 0; i < source.Count; i++)
{
Assert.Equal(tbResult[i].Value, spanOut[i], 10);
}
}
// ── Determinism ──
[Fact]
public void Deterministic_AcrossRuns()
{
const int period = 10;
var source = new TBarSeries();
var gbm = new GBM(100.0, 0.05, 0.2, seed: 77);
for (int i = 0; i < 60; i++) { source.Add(gbm.Next(isNew: true)); }
var r1 = Dstoch.Batch(source, period);
var r2 = Dstoch.Batch(source, period);
for (int i = 0; i < source.Count; i++)
{
Assert.Equal(r1[i].Value, r2[i].Value, 15);
}
}
// ── Constant input ──
[Fact]
public void ConstantBars_OutputIsZero()
{
const int period = 5;
var bars = new TBarSeries();
for (int i = 0; i < 30; i++)
{
bars.Add(new TBar(DateTime.UtcNow.AddDays(i), 50, 50, 50, 50, 100));
}
var result = Dstoch.Batch(bars, period);
for (int i = period; i < result.Count; i++)
{
Assert.Equal(0.0, result[i].Value, 10);
}
}
// ── Boundedness ──
[Fact]
public void Output_AlwaysBoundedZeroToHundred()
{
const int period = 14;
var source = new TBarSeries();
var gbm = new GBM(100.0, 0.05, 0.3, seed: 88);
for (int i = 0; i < 200; i++) { source.Add(gbm.Next(isNew: true)); }
var result = Dstoch.Batch(source, period);
for (int i = period; i < result.Count; i++)
{
Assert.InRange(result[i].Value, -0.01, 100.01);
}
}
// ── Different periods produce different results ──
[Fact]
public void DifferentPeriods_ProduceDifferentResults()
{
var source = new TBarSeries();
var gbm = new GBM(100.0, 0.05, 0.3, seed: 99);
for (int i = 0; i < 100; i++) { source.Add(gbm.Next(isNew: true)); }
var r5 = Dstoch.Batch(source, 5);
var r21 = Dstoch.Batch(source, 21);
bool anyDifferent = false;
for (int i = 25; i < source.Count; i++)
{
if (Math.Abs(r5[i].Value - r21[i].Value) > 1e-6)
{
anyDifferent = true;
break;
}
}
Assert.True(anyDifferent);
}
// ── Monotonic-up → high DSS ──
[Fact]
public void MonotonicUp_ConvergesHighDSS()
{
var d = new Dstoch(5);
for (int i = 0; i < 30; i++)
{
double price = 100 + i;
d.Update(new TBar(DateTime.UtcNow.AddDays(i), price, price + 1, price - 1, price, 1000));
}
Assert.True(d.Last.Value > 50.0);
}
// ── Monotonic-down → low DSS ──
[Fact]
public void MonotonicDown_ConvergesLowDSS()
{
var d = new Dstoch(5);
for (int i = 0; i < 30; i++)
{
double price = 200 - i;
d.Update(new TBar(DateTime.UtcNow.AddDays(i), price, price + 1, price - 1, price, 1000));
}
Assert.True(d.Last.Value < 50.0);
}
// ── Reset+replay matches fresh run ──
[Fact]
public void ResetReplay_MatchesFreshRun()
{
const int period = 7;
var gbm = new GBM(100.0, 0.05, 0.2, seed: 111);
var bars = new List<TBar>();
for (int i = 0; i < 50; i++) { bars.Add(gbm.Next(isNew: true)); }
var d = new Dstoch(period);
foreach (var bar in bars) { d.Update(bar); }
double firstRun = d.Last.Value;
d.Reset();
foreach (var bar in bars) { d.Update(bar); }
Assert.Equal(firstRun, d.Last.Value, 12);
}
// ── Primed indicator matches manual feed ──
[Fact]
public void PrimedIndicator_MatchesManualFeed()
{
const int period = 10;
var source = new TBarSeries();
var gbm = new GBM(100.0, 0.05, 0.2, seed: 222);
for (int i = 0; i < 60; i++) { source.Add(gbm.Next(isNew: true)); }
var manual = new Dstoch(period);
for (int i = 0; i < source.Count; i++) { manual.Update(source[i]); }
var primed = new Dstoch(period);
primed.Prime(source);
Assert.Equal(manual.Last.Value, primed.Last.Value, 12);
}
// ── Calculate factory consistency ──
[Fact]
public void Calculate_MatchesBatch()
{
const int period = 10;
var source = new TBarSeries();
var gbm = new GBM(100.0, 0.05, 0.2, seed: 333);
for (int i = 0; i < 50; i++) { source.Add(gbm.Next(isNew: true)); }
var batch = Dstoch.Batch(source, period);
var (calcResult, _) = Dstoch.Calculate(source, period);
for (int i = 0; i < source.Count; i++)
{
Assert.Equal(batch[i].Value, calcResult[i].Value, 12);
}
}
// ── NaN propagation safety ──
[Fact]
public void BatchNaN_NoPropagation()
{
var d = new Dstoch(5);
for (int i = 0; i < 10; i++)
{
d.Update(new TBar(DateTime.UtcNow.AddDays(i), 100 + i, 105 + i, 95 + i, 102 + i, 500));
}
// Feed a NaN bar
d.Update(new TBar(DateTime.UtcNow.AddDays(10), double.NaN, double.NaN, double.NaN, double.NaN, 0));
// Then valid data
d.Update(new TBar(DateTime.UtcNow.AddDays(11), 112, 117, 107, 114, 500));
Assert.True(double.IsFinite(d.Last.Value));
}
}