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
+54
View File
@@ -0,0 +1,54 @@
using System.Drawing;
using System.Runtime.CompilerServices;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
[SkipLocalsInit]
public sealed class VstopIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Period", sortIndex: 0, 2, 500, 1, 0)]
public int Period { get; set; } = 7;
[InputParameter("Multiplier", sortIndex: 1, 0.1, 20.0, 0.1, 1)]
public double Multiplier { get; set; } = 3.0;
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private Vstop _indicator = null!;
private readonly LineSeries _sarSeries;
public static int MinHistoryDepths => 0;
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => $"VSTOP({Period},{Multiplier:F1})";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/reversals/vstop/Vstop.cs";
public VstopIndicator()
{
OnBackGround = true;
SeparateWindow = false;
Name = "VSTOP - Volatility Stop";
Description = "ATR-based trailing stop. Tracks SIC (Significant Close) and flips on reversal.";
_sarSeries = new LineSeries(name: "VSTOP", color: Color.OrangeRed, width: 2, style: LineStyle.Dot);
AddLineSeries(_sarSeries);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnInit()
{
_indicator = new Vstop(Period, Multiplier);
base.OnInit();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnUpdate(UpdateArgs args)
{
_ = _indicator.Update(this.GetInputBar(args), args.IsNewBar());
_sarSeries.SetValue(_indicator.SarValue, _indicator.IsHot, ShowColdValues);
}
}
+350
View File
@@ -0,0 +1,350 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// VSTOP: Volatility Stop (Wilder's Volatility System)
/// ATR-based trailing stop that tracks trend direction and flips on reversal.
/// Uses Significant Close (SIC) tracking: highest close in uptrend, lowest in downtrend.
/// SAR = SIC ± ATR × multiplier.
/// </summary>
/// <seealso href="https://dotnet.stockindicators.dev/indicators/VolatilityStop/">Skender reference</seealso>
[SkipLocalsInit]
public sealed class Vstop : ITValuePublisher
{
private readonly int _period;
private readonly double _multiplier;
private readonly Atr _atr;
private int _count;
[StructLayout(LayoutKind.Auto)]
private record struct State(
bool IsLong,
double Sic,
double LastValidHigh,
double LastValidLow,
double LastValidClose);
private State _s;
private State _ps;
private readonly TBarPublishedHandler _barHandler;
/// <summary>Display name.</summary>
public string Name { get; }
/// <summary>ATR lookback period.</summary>
public int Period => _period;
/// <summary>ATR multiplier for stop offset.</summary>
public double Multiplier => _multiplier;
/// <summary>Bars required for valid output.</summary>
public int WarmupPeriod { get; }
/// <summary>Current SAR (Stop and Reverse) value.</summary>
public double SarValue { get; private set; }
/// <summary>True when the indicator is in uptrend mode.</summary>
public bool IsLong => _s.IsLong;
/// <summary>True when a stop reversal occurred on the current bar.</summary>
public bool IsStop { get; private set; }
/// <summary>Primary output value (SAR as TValue for overlay plotting).</summary>
public TValue Last { get; private set; }
/// <summary>True when enough bars have been processed.</summary>
public bool IsHot => _count >= _period;
public event TValuePublishedHandler? Pub;
/// <summary>
/// Creates a Volatility Stop indicator.
/// </summary>
/// <param name="period">ATR lookback period (default 7).</param>
/// <param name="multiplier">ATR multiplier (default 3.0).</param>
public Vstop(int period = 7, double multiplier = 3.0)
{
if (period <= 1)
{
throw new ArgumentException("Period must be greater than 1.", nameof(period));
}
if (multiplier <= 0)
{
throw new ArgumentException("Multiplier must be greater than 0.", nameof(multiplier));
}
_period = period;
_multiplier = multiplier;
_atr = new Atr(period);
_count = 0;
_s = new State(
IsLong: true,
Sic: double.NaN,
LastValidHigh: double.NaN,
LastValidLow: double.NaN,
LastValidClose: double.NaN);
_ps = _s;
Name = $"Vstop({period},{multiplier:F1})";
WarmupPeriod = period;
SarValue = double.NaN;
_barHandler = HandleBar;
}
/// <summary>
/// Creates a Volatility Stop chained to a TBarSeries source.
/// </summary>
public Vstop(TBarSeries source, int period = 7, double multiplier = 3.0)
: this(period, multiplier)
{
Prime(source);
source.Pub += _barHandler;
}
private void HandleBar(object? sender, in TBarEventArgs e) => Update(e.Value, e.IsNew);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void PubEvent(TValue value, bool isNew = true) =>
Pub?.Invoke(this, new TValueEventArgs { Value = value, IsNew = isNew });
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TValue Update(TBar input, bool isNew = true)
{
if (isNew)
{
_ps = _s;
_count++;
}
else
{
_s = _ps;
}
var s = _s;
// Validate inputs — substitute last-valid on NaN/Infinity
double high = input.High;
double low = input.Low;
double close = input.Close;
if (double.IsFinite(high)) { s.LastValidHigh = high; }
else { high = s.LastValidHigh; }
if (double.IsFinite(low)) { s.LastValidLow = low; }
else { low = s.LastValidLow; }
if (double.IsFinite(close)) { s.LastValidClose = close; }
else { close = s.LastValidClose; }
if (double.IsNaN(high) || double.IsNaN(low) || double.IsNaN(close))
{
_s = s;
Last = new TValue(input.Time, double.NaN);
PubEvent(Last, isNew);
return Last;
}
// Update internal ATR
TValue atrResult = _atr.Update(input, isNew);
double atrValue = atrResult.Value;
double sarResult;
IsStop = false;
if (_count == 1)
{
// First bar: initialize SIC, no SAR yet
s.Sic = close;
s.IsLong = true;
sarResult = double.NaN;
}
else if (!_atr.IsHot)
{
// Warmup: track initial trend direction
if (_count == _period)
{
// At warmup end: determine initial trend from first close vs current
// (we stored the first close in Sic on bar 1)
s.IsLong = close >= s.Sic;
s.Sic = close;
}
else
{
s.Sic = s.IsLong
? Math.Max(s.Sic, close)
: Math.Min(s.Sic, close);
}
sarResult = double.NaN;
}
else
{
// Update SIC (Significant Close)
s.Sic = s.IsLong
? Math.Max(s.Sic, close)
: Math.Min(s.Sic, close);
// Calculate SAR
double arc = atrValue * _multiplier;
sarResult = s.IsLong ? s.Sic - arc : s.Sic + arc;
// Evaluate stop and reverse
if ((s.IsLong && close < sarResult) || (!s.IsLong && close > sarResult))
{
IsStop = true;
s.Sic = close;
s.IsLong = !s.IsLong;
// Recalculate SAR with new direction
sarResult = s.IsLong ? s.Sic - arc : s.Sic + arc;
}
}
SarValue = sarResult;
_s = s;
Last = new TValue(input.Time, sarResult);
PubEvent(Last, isNew);
return Last;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TValue Update(TValue input, bool isNew = true) =>
Update(new TBar(input.Time, input.Value, input.Value, input.Value, input.Value, 0), isNew);
public TSeries Update(TBarSeries source)
{
if (source.Count == 0)
{
return new TSeries([], []);
}
int len = source.Count;
var t = new List<long>(len);
var v = new List<double>(len);
CollectionsMarshal.SetCount(t, len);
CollectionsMarshal.SetCount(v, len);
Batch(source.HighValues, source.LowValues, source.CloseValues,
CollectionsMarshal.AsSpan(v), _period, _multiplier);
source.Times.CopyTo(CollectionsMarshal.AsSpan(t));
Prime(source);
var lastTime = new DateTime(source.Times[^1], DateTimeKind.Utc);
Last = new TValue(lastTime, CollectionsMarshal.AsSpan(v)[^1]);
return new TSeries(t, v);
}
public void Prime(TBarSeries source)
{
Reset();
if (source.Count == 0)
{
return;
}
for (int i = 0; i < source.Count; i++)
{
Update(source[i], isNew: true);
}
}
public void Reset()
{
_atr.Reset();
_count = 0;
_s = new State(
IsLong: true,
Sic: double.NaN,
LastValidHigh: double.NaN,
LastValidLow: double.NaN,
LastValidClose: double.NaN);
_ps = _s;
SarValue = double.NaN;
IsStop = false;
Last = default;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Batch(
ReadOnlySpan<double> high,
ReadOnlySpan<double> low,
ReadOnlySpan<double> close,
Span<double> output,
int period = 7,
double multiplier = 3.0)
{
if (period <= 1)
{
throw new ArgumentException("Period must be greater than 1.", nameof(period));
}
if (multiplier <= 0)
{
throw new ArgumentException("Multiplier must be greater than 0.", nameof(multiplier));
}
if (high.Length != low.Length || high.Length != close.Length)
{
throw new ArgumentException("Input spans must have the same length.", nameof(high));
}
if (output.Length < high.Length)
{
throw new ArgumentException("Output span must be at least as long as input.", nameof(output));
}
int len = high.Length;
if (len == 0)
{
return;
}
// State machine precludes SIMD — use streaming instance
var indicator = new Vstop(period, multiplier);
long baseTime = DateTime.UtcNow.Ticks;
for (int i = 0; i < len; i++)
{
_ = indicator.Update(
new TBar(baseTime + i, high[i], high[i], low[i], close[i], 0),
isNew: true);
output[i] = indicator.SarValue;
}
}
public static TSeries Batch(TBarSeries source, int period = 7, double multiplier = 3.0)
{
if (source == null || source.Count == 0)
{
return new TSeries([], []);
}
int len = source.Count;
var t = new List<long>(len);
var v = new List<double>(len);
CollectionsMarshal.SetCount(t, len);
CollectionsMarshal.SetCount(v, len);
Batch(source.HighValues, source.LowValues, source.CloseValues,
CollectionsMarshal.AsSpan(v), period, multiplier);
source.Times.CopyTo(CollectionsMarshal.AsSpan(t));
return new TSeries(t, v);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static (TSeries Results, Vstop Indicator) Calculate(
TBarSeries source, int period = 7, double multiplier = 3.0)
{
var indicator = new Vstop(period, multiplier);
var results = indicator.Update(source);
return (results, indicator);
}
}
+51
View File
@@ -0,0 +1,51 @@
# VSTOP — Volatility Stop (Wilder's Volatility System)
## Overview
**VSTOP** is an ATR-based trailing stop indicator created by J. Welles Wilder. It determines trend direction using a "Significant Close" (SIC) concept — the highest close during an uptrend or lowest close during a downtrend. The stop-and-reverse (SAR) line trails price at a fixed ATR multiple distance from the SIC.
When price crosses through the SAR level, the trend flips — making it suitable for trend detection, dynamic stop-loss placement, and reversal signals.
## Formula
### Parameters
- **Period** (`p`): ATR lookback window. Default = 7.
- **Multiplier** (`m`): ATR band width. Default = 3.0.
### Calculation Steps
1. **ATR**: Compute Average True Range using Wilder's smoothing (RMA) over `p` bars.
2. **SIC (Significant Close)**:
- Uptrend: $\text{SIC} = \max(\text{SIC}, \text{Close})$
- Downtrend: $\text{SIC} = \min(\text{SIC}, \text{Close})$
3. **SAR**:
- Uptrend: $\text{SAR} = \text{SIC} - m \times \text{ATR}$
- Downtrend: $\text{SAR} = \text{SIC} + m \times \text{ATR}$
4. **Reversal**: If Close crosses SAR → flip direction, reset SIC to current Close, recalculate SAR.
### Initial Trend Direction
The initial trend guess is determined by comparing the first Close value with the Close value at the end of the warmup period. If `Close[period] >= Close[0]`, the initial trend is long (uptrend); otherwise short (downtrend).
## Key Properties
| Property | Value |
|:---------|:------|
| **Outputs** | 1 (SAR value) |
| **Output range** | Same as price |
| **Warmup period** | `p` bars |
| **Category** | Reversals |
| **Similar indicators** | SAR, SuperTrend, ATR Trailing Stop |
## Interpretation
- **SAR below price** → Uptrend; SAR serves as trailing stop for long positions.
- **SAR above price** → Downtrend; SAR serves as trailing stop for short positions.
- **SAR flip** → Trend reversal signal; `IsStop = true`.
- **Higher multiplier** → Wider stop distance, fewer reversals (smoother trend).
- **Lower multiplier** → Tighter stop, more sensitive to reversals.
## References
- Wilder, J. Welles, Jr. *New Concepts in Technical Trading Systems* (1978).
- Skender Stock Indicators: [Volatility Stop](https://dotnet.stockindicators.dev/indicators/VolatilityStop/)
@@ -0,0 +1,68 @@
using TradingPlatform.BusinessLayer;
using Xunit;
namespace QuanTAlib.Tests;
public sealed class VstopIndicatorTests
{
[Fact]
public void Indicator_Creates()
{
var indicator = new VstopIndicator();
Assert.NotNull(indicator);
}
[Fact]
public void DefaultParameters_Match()
{
var indicator = new VstopIndicator();
Assert.Equal(7, indicator.Period);
Assert.Equal(3.0, indicator.Multiplier);
}
[Fact]
public void Indicator_HasLineSeries()
{
var indicator = new VstopIndicator();
indicator.Initialize();
Assert.Single(indicator.LinesSeries);
}
[Fact]
public void ShortName_IncludesParameters()
{
var indicator = new VstopIndicator { Period = 10, Multiplier = 2.5 };
Assert.Contains("VSTOP", indicator.ShortName, StringComparison.Ordinal);
}
[Fact]
public void SeparateWindow_IsFalse()
{
var indicator = new VstopIndicator();
Assert.False(indicator.SeparateWindow);
}
[Fact]
public void ProcessBars_ProducesOutput()
{
var indicator = new VstopIndicator { Period = 5 };
indicator.Initialize();
var now = DateTime.UtcNow;
var gbm = new GBM(100.0, 0.05, 0.2, seed: 42);
for (int i = 0; i < 20; i++)
{
var (_, _, h, l, c, _) = gbm.Next(isNew: true);
indicator.HistoricalData.AddBar(now.AddMinutes(i), c, h, l, c);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
Assert.Single(indicator.LinesSeries);
}
[Fact]
public void SourceCodeLink_IsValid()
{
var indicator = new VstopIndicator();
Assert.Contains("Vstop.cs", indicator.SourceCodeLink, StringComparison.Ordinal);
}
}
+361
View File
@@ -0,0 +1,361 @@
using System.Runtime.CompilerServices;
using Xunit;
namespace QuanTAlib.Tests;
public sealed class VstopTests
{
private readonly GBM _gbm = new(100.0, 0.05, 0.2, seed: 42);
// ── Bucket A: Constructor Tests ──────────────────────────────────────
[Fact]
public void DefaultPeriod_Is7()
{
var ind = new Vstop();
Assert.Equal(7, ind.Period);
}
[Fact]
public void DefaultMultiplier_Is3()
{
var ind = new Vstop();
Assert.Equal(3.0, ind.Multiplier);
}
[Fact]
public void CustomPeriod_IsStored()
{
var ind = new Vstop(period: 14, multiplier: 2.5);
Assert.Equal(14, ind.Period);
Assert.Equal(2.5, ind.Multiplier);
}
[Fact]
public void Period1_Throws() =>
Assert.Throws<ArgumentException>(() => new Vstop(period: 1));
[Fact]
public void ZeroMultiplier_Throws() =>
Assert.Throws<ArgumentException>(() => new Vstop(multiplier: 0));
[Fact]
public void NegativeMultiplier_Throws() =>
Assert.Throws<ArgumentException>(() => new Vstop(multiplier: -1));
// ── Bucket B: Basic Output ──────────────────────────────────────────
[Fact]
public void FirstBar_ReturnsNaN()
{
var ind = new Vstop();
var bar = new TBar(DateTime.UtcNow, 100, 102, 98, 101, 1000);
ind.Update(bar);
Assert.True(double.IsNaN(ind.SarValue));
}
[Fact]
public void AfterWarmup_ReturnsFinite()
{
var ind = new Vstop(period: 3);
for (int i = 0; i < 10; 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(double.IsFinite(ind.SarValue));
}
[Fact]
public void SarValue_MatchesLastValue()
{
var ind = new Vstop(period: 3);
TValue last = default;
for (int i = 0; i < 10; i++)
{
var (_, o, h, l, c, v) = _gbm.Next(isNew: true);
last = ind.Update(new TBar(DateTime.UtcNow.AddMinutes(i), o, h, l, c, v));
}
Assert.Equal(ind.SarValue, last.Value);
}
// ── Bucket C: SAR Position Relative to Price ────────────────────────
[Fact]
public void InUptrend_SarBelowClose()
{
// Construct a strong uptrend
var ind = new Vstop(period: 3, multiplier: 2.0);
double price = 100;
for (int i = 0; i < 20; i++)
{
price += 2; // Steady uptrend
ind.Update(new TBar(DateTime.UtcNow.AddMinutes(i), price, price + 1, price - 0.5, price, 1000));
}
Assert.True(ind.IsLong);
Assert.True(ind.SarValue < price);
}
[Fact]
public void InDowntrend_SarAboveClose()
{
var ind = new Vstop(period: 3, multiplier: 2.0);
double price = 200;
for (int i = 0; i < 20; i++)
{
price -= 2; // Steady downtrend
ind.Update(new TBar(DateTime.UtcNow.AddMinutes(i), price, price + 0.5, price - 1, price, 1000));
}
Assert.False(ind.IsLong);
Assert.True(ind.SarValue > price);
}
// ── Bucket D: Reversal Detection ────────────────────────────────────
[Fact]
public void Reversal_IsStopTrue()
{
var ind = new Vstop(period: 3, multiplier: 1.0);
double price = 100;
// Build uptrend
for (int i = 0; i < 10; i++)
{
price += 2;
ind.Update(new TBar(DateTime.UtcNow.AddMinutes(i), price, price + 0.5, price - 0.5, price, 1000));
}
Assert.True(ind.IsLong);
// Force reversal with large drop
price -= 30;
ind.Update(new TBar(DateTime.UtcNow.AddMinutes(20), price, price + 0.5, price - 0.5, price, 1000));
Assert.True(ind.IsStop);
Assert.False(ind.IsLong);
}
// ── Bucket E: Bar Correction ────────────────────────────────────────
[Fact]
public void BarCorrection_RestoresState()
{
var ind = new Vstop(period: 3, multiplier: 2.0);
for (int i = 0; i < 8; i++)
{
var (_, o, h, l, c, v) = _gbm.Next(isNew: true);
ind.Update(new TBar(DateTime.UtcNow.AddMinutes(i), o, h, l, c, v));
}
bool longBefore = ind.IsLong;
// Update with isNew=false (bar correction)
var (_, o2, h2, l2, c2, v2) = _gbm.Next(isNew: true);
ind.Update(new TBar(DateTime.UtcNow.AddMinutes(8), o2, h2, l2, c2, v2), isNew: false);
// Restore previous state by re-updating with isNew=false
ind.Update(new TBar(DateTime.UtcNow.AddMinutes(8), o2, h2, l2, c2, v2), isNew: false);
// State should be restored from _ps
Assert.Equal(longBefore, ind.IsLong);
}
// ── Bucket F: Reset ─────────────────────────────────────────────────
[Fact]
public void Reset_ClearsState()
{
var ind = new Vstop(period: 3, multiplier: 2.0);
for (int i = 0; i < 10; i++)
{
var (_, o, h, l, c, v) = _gbm.Next(isNew: true);
ind.Update(new TBar(DateTime.UtcNow.AddMinutes(i), o, h, l, c, v));
}
ind.Reset();
Assert.True(double.IsNaN(ind.SarValue));
Assert.False(ind.IsHot);
}
// ── Bucket G: Batch ─────────────────────────────────────────────────
[Fact]
public void Batch_MatchesStreaming()
{
var gbm1 = new GBM(100.0, 0.05, 0.2, seed: 123);
var gbm2 = new GBM(100.0, 0.05, 0.2, seed: 123);
const int N = 50;
// Streaming
var streamInd = new Vstop(period: 5, multiplier: 2.0);
double[] streamOut = 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;
}
// Batch
double[] highs = new double[N], lows = new double[N], closes = new double[N];
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: 5, multiplier: 2.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);
}
}
}
[Fact]
public void BatchTBarSeries_ReturnsCorrectLength()
{
var source = new TBarSeries();
for (int i = 0; i < 30; i++)
{
var (_, o, h, l, c, v) = _gbm.Next(isNew: true);
source.Add(new TBar(DateTime.UtcNow.AddMinutes(i), o, h, l, c, v));
}
var result = Vstop.Batch(source, period: 5, multiplier: 2.0);
Assert.Equal(30, result.Count);
}
// ── Bucket H: Events ────────────────────────────────────────────────
[Fact]
public void PubEvent_Fires()
{
var ind = new Vstop(period: 3);
int count = 0;
ind.Pub += (_, in _) => count++;
for (int i = 0; i < 5; 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.Equal(5, count);
}
// ── Bucket I: NaN Handling ───────────────────────────────────────────
[Fact]
public void NaN_Input_ReturnsNaN()
{
var ind = new Vstop(period: 3);
var bar = new TBar(DateTime.UtcNow, double.NaN, double.NaN, double.NaN, double.NaN, 0);
ind.Update(bar);
Assert.True(double.IsNaN(ind.SarValue));
}
[Fact]
public void NaN_AfterValid_SubstitutesLastValid()
{
var ind = new Vstop(period: 3);
// Feed valid data first
for (int i = 0; i < 5; i++)
{
ind.Update(new TBar(DateTime.UtcNow.AddMinutes(i), 100 + i, 102 + i, 98 + i, 101 + i, 1000));
}
// Now feed partial NaN — should substitute
ind.Update(new TBar(DateTime.UtcNow.AddMinutes(10), double.NaN, 110, 95, 105, 1000));
// Should not crash — NaN high substituted with last valid
Assert.True(double.IsFinite(ind.Last.Value) || double.IsNaN(ind.Last.Value));
}
// ── Bucket J: Multiplier Sensitivity ────────────────────────────────
[Fact]
public void HigherMultiplier_WiderStop()
{
var gbm1 = new GBM(100.0, 0.05, 0.2, seed: 77);
var gbm2 = new GBM(100.0, 0.05, 0.2, seed: 77);
var ind1 = new Vstop(period: 5, multiplier: 1.0);
var ind2 = new Vstop(period: 5, multiplier: 3.0);
for (int i = 0; i < 20; 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));
}
if (double.IsFinite(ind1.SarValue) && double.IsFinite(ind2.SarValue) && ind1.IsLong && ind2.IsLong)
{
// Higher multiplier → SAR further from SIC → wider stop
double gap1 = Math.Abs(ind1.SarValue - ind1.Last.Value);
double gap2 = Math.Abs(ind2.SarValue - ind2.Last.Value);
// Both gaps should be non-negative
Assert.True(gap1 >= 0 && gap2 >= 0, "Both gaps should be non-negative");
}
}
// ── Bucket K: Calculate Method ──────────────────────────────────────
[Fact]
public void Calculate_ReturnsTupleWithIndicator()
{
var source = new TBarSeries();
for (int i = 0; i < 20; i++)
{
var (_, o, h, l, c, v) = _gbm.Next(isNew: true);
source.Add(new TBar(DateTime.UtcNow.AddMinutes(i), o, h, l, c, v));
}
var (results, indicator) = Vstop.Calculate(source, period: 5, multiplier: 2.0);
Assert.Equal(20, results.Count);
Assert.NotNull(indicator);
Assert.True(indicator.IsHot);
}
// ── Bucket L: Prime Method ──────────────────────────────────────────
[Fact]
public void Prime_SetsState()
{
var source = new TBarSeries();
for (int i = 0; i < 15; i++)
{
var (_, o, h, l, c, v) = _gbm.Next(isNew: true);
source.Add(new TBar(DateTime.UtcNow.AddMinutes(i), o, h, l, c, v));
}
var ind = new Vstop(period: 5);
ind.Prime(source);
Assert.True(ind.IsHot);
Assert.True(double.IsFinite(ind.SarValue));
}
// ── Bucket M: Streaming Consistency ─────────────────────────────────
[Fact]
public void StreamingAfterPrime_IsDeterministic()
{
var gbm1 = new GBM(100.0, 0.05, 0.2, seed: 99);
var gbm2 = new GBM(100.0, 0.05, 0.2, seed: 99);
// Build source for priming (first 20 bars)
var source = new TBarSeries();
for (int i = 0; i < 20; i++)
{
var (_, o, h, l, c, v) = gbm1.Next(isNew: true);
source.Add(new TBar(DateTime.UtcNow.AddMinutes(i), o, h, l, c, v));
}
// Full streaming
var fullInd = new Vstop(period: 5);
for (int i = 0; i < 20; i++)
{
var (_, o, h, l, c, v) = gbm2.Next(isNew: true);
fullInd.Update(new TBar(DateTime.UtcNow.AddMinutes(i), o, h, l, c, v));
}
// Primed
var primedInd = new Vstop(period: 5);
primedInd.Prime(source);
Assert.Equal(fullInd.SarValue, primedInd.SarValue, precision: 10);
Assert.Equal(fullInd.IsLong, primedInd.IsLong);
}
}
@@ -0,0 +1,196 @@
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);
}
}