mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-20 11:38:05 +00:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user