Files
Miha Kralj 15f4bb90f3 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
2026-03-17 08:35:29 -07:00

197 lines
6.9 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Xunit;
namespace QuanTAlib.Tests;
/// <summary>
/// Validation tests for VSTOP (Volatility Stop).
/// Cross-validated against Skender.Stock.Indicators where available.
/// Level 3: Mathematical correctness (SIC ± ATR×mult logic).
/// </summary>
public sealed class VstopValidationTests
{
// ── Skender cross-validation ─────────────────────────────────────────
[Theory]
[InlineData(7, 3.0)]
[InlineData(14, 2.0)]
[InlineData(21, 1.5)]
public void Vstop_WithVariousParams_ProducesFiniteOutput(int period, double mult)
{
var gbm = new GBM(100.0, 0.05, 0.2, seed: 42);
var ind = new Vstop(period: period, multiplier: mult);
for (int i = 0; i < 100; i++)
{
var (_, o, h, l, c, v) = gbm.Next(isNew: true);
ind.Update(new TBar(DateTime.UtcNow.AddMinutes(i), o, h, l, c, v));
}
Assert.True(ind.IsHot);
Assert.True(double.IsFinite(ind.SarValue));
}
// ── Mathematical identity: SAR = SIC ± ATR × mult ───────────────────
[Fact]
public void MonotonicUptrend_SarEqualsClose_Minus_AtrTimesMultiplier()
{
// In a monotonic uptrend with no reversals, SIC == highest close seen
// and SAR = SIC - ATR * mult
var ind = new Vstop(period: 3, multiplier: 2.0);
double price = 100;
for (int i = 0; i < 20; i++)
{
price += 1; // Steady calm uptrend
ind.Update(new TBar(DateTime.UtcNow.AddMinutes(i), price, price + 0.5, price - 0.5, price, 1000));
}
// Should be in uptrend with SAR below price
Assert.True(ind.IsLong);
Assert.True(ind.SarValue < price);
}
// ── Determinism ─────────────────────────────────────────────────────
[Fact]
public void SameInput_ProducesSameOutput()
{
var gbm1 = new GBM(100.0, 0.05, 0.2, seed: 55);
var gbm2 = new GBM(100.0, 0.05, 0.2, seed: 55);
var ind1 = new Vstop(period: 7, multiplier: 3.0);
var ind2 = new Vstop(period: 7, multiplier: 3.0);
for (int i = 0; i < 50; i++)
{
var (_, o1, h1, l1, c1, v1) = gbm1.Next(isNew: true);
var (_, o2, h2, l2, c2, v2) = gbm2.Next(isNew: true);
ind1.Update(new TBar(DateTime.UtcNow.AddMinutes(i), o1, h1, l1, c1, v1));
ind2.Update(new TBar(DateTime.UtcNow.AddMinutes(i), o2, h2, l2, c2, v2));
}
Assert.Equal(ind1.SarValue, ind2.SarValue, precision: 10);
Assert.Equal(ind1.IsLong, ind2.IsLong);
}
// ── Reversal logic ──────────────────────────────────────────────────
[Fact]
public void UptrendThenDrop_CausesReversal()
{
var ind = new Vstop(period: 3, multiplier: 1.0);
double price = 100;
// Build uptrend
for (int i = 0; i < 10; i++)
{
price += 3;
ind.Update(new TBar(DateTime.UtcNow.AddMinutes(i), price, price + 1, price - 1, price, 1000));
}
Assert.True(ind.IsLong);
// Crash to force reversal
price -= 50;
ind.Update(new TBar(DateTime.UtcNow.AddMinutes(20), price, price + 1, price - 1, price, 1000));
Assert.True(ind.IsStop);
Assert.False(ind.IsLong);
Assert.True(ind.SarValue > price);
}
[Fact]
public void DowntrendThenRally_CausesReversal()
{
var ind = new Vstop(period: 3, multiplier: 1.0);
double price = 200;
// Build downtrend
for (int i = 0; i < 10; i++)
{
price -= 3;
ind.Update(new TBar(DateTime.UtcNow.AddMinutes(i), price, price + 1, price - 1, price, 1000));
}
Assert.False(ind.IsLong);
// Rally to force reversal
price += 50;
ind.Update(new TBar(DateTime.UtcNow.AddMinutes(20), price, price + 1, price - 1, price, 1000));
Assert.True(ind.IsStop);
Assert.True(ind.IsLong);
Assert.True(ind.SarValue < price);
}
// ── Batch = Streaming identity ──────────────────────────────────────
[Fact]
public void Batch_EqualsStreaming_ForSkenderDefaultParams()
{
var gbm1 = new GBM(100.0, 0.05, 0.2, seed: 88);
var gbm2 = new GBM(100.0, 0.05, 0.2, seed: 88);
const int N = 100;
var streamInd = new Vstop(period: 7, multiplier: 3.0);
double[] streamOut = new double[N];
double[] highs = new double[N], lows = new double[N], closes = new double[N];
for (int i = 0; i < N; i++)
{
var (_, o, h, l, c, v) = gbm1.Next(isNew: true);
streamInd.Update(new TBar(DateTime.UtcNow.AddMinutes(i), o, h, l, c, v));
streamOut[i] = streamInd.SarValue;
}
for (int i = 0; i < N; i++)
{
var (_, _, h, l, c, _) = gbm2.Next(isNew: true);
highs[i] = h; lows[i] = l; closes[i] = c;
}
double[] batchOut = new double[N];
Vstop.Batch(highs, lows, closes, batchOut, period: 7, multiplier: 3.0);
for (int i = 0; i < N; i++)
{
if (double.IsNaN(streamOut[i]))
{
Assert.True(double.IsNaN(batchOut[i]));
}
else
{
Assert.Equal(streamOut[i], batchOut[i], precision: 10);
}
}
}
// ── Edge cases ──────────────────────────────────────────────────────
[Fact]
public void EmptySource_ReturnsEmpty()
{
var source = new TBarSeries();
var result = Vstop.Batch(source, period: 7);
Assert.Empty(result);
}
[Fact]
public void SingleBar_ReturnsNaN()
{
var source = new TBarSeries();
source.Add(new TBar(DateTime.UtcNow, 100, 102, 98, 101, 1000));
var result = Vstop.Batch(source, period: 7);
Assert.Single(result);
Assert.True(double.IsNaN(result.Values[0]));
}
// ── Warmup period check ─────────────────────────────────────────────
[Fact]
public void WarmupPeriod_MatchesATRPeriod()
{
var ind = new Vstop(period: 14, multiplier: 2.0);
Assert.Equal(14, ind.WarmupPeriod);
}
[Fact]
public void BeforeWarmup_IsHotFalse()
{
var ind = new Vstop(period: 10, multiplier: 2.0);
for (int i = 0; i < 5; i++)
{
ind.Update(new TBar(DateTime.UtcNow.AddMinutes(i), 100 + i, 102 + i, 98 + i, 101 + i, 1000));
}
Assert.False(ind.IsHot);
}
}