mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-18 02:28: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:
@@ -4,6 +4,7 @@ Reversal indicators identify potential turning points where price may change dir
|
||||
|
||||
| Indicator | Full Name | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| [ATRSTOP](atrstop/Atrstop.md) | ATR Trailing Stop | Dynamic trailing stop using ATR multiplier with band ratcheting. |
|
||||
| [CHANDELIER](chandelier/Chandelier.md) | Chandelier Exit | ATR-based trailing stops hanging from HH/LL; dual ExitLong/ExitShort levels. |
|
||||
| [CKSTOP](ckstop/Ckstop.md) | Chande Kroll Stop | ATR-based adaptive trailing stops; dual StopLong/StopShort levels for trend detection. |
|
||||
| [FRACTALS](fractals/Fractals.md) | Williams Fractals | Five-bar pattern identifying local peaks/troughs; marks support/resistance levels. |
|
||||
@@ -17,3 +18,4 @@ Reversal indicators identify potential turning points where price may change dir
|
||||
| [SAREXT](sarext/Sarext.md) | Parabolic SAR Extended | PSAR with asymmetric long/short acceleration factors. Sign-encoded output. |
|
||||
| [SWINGS](swings/Swings.md) | Swing High/Low Detection | Configurable-lookback pattern detector for swing highs/lows; dual SwingHigh/SwingLow. |
|
||||
| [TTM_SCALPER](ttm_scalper/TtmScalper.md) | TTM Scalper Alert | 3-bar pivot high/low detection for scalping entries. John Carter. |
|
||||
| [VSTOP](vstop/Vstop.md) | Volatility Stop | ATR-based trailing stop tracking SIC; flips on reversal. |
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
using System.Drawing;
|
||||
using System.Runtime.CompilerServices;
|
||||
using TradingPlatform.BusinessLayer;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
[SkipLocalsInit]
|
||||
public sealed class AtrstopIndicator : Indicator, IWatchlistIndicator
|
||||
{
|
||||
[InputParameter("Period", sortIndex: 0, 2, 500, 1, 0)]
|
||||
public int Period { get; set; } = 21;
|
||||
|
||||
[InputParameter("Multiplier", sortIndex: 1, 0.1, 20.0, 0.1, 1)]
|
||||
public double Multiplier { get; set; } = 3.0;
|
||||
|
||||
[InputParameter("Use High/Low", sortIndex: 2)]
|
||||
public bool UseHighLow { get; set; }
|
||||
|
||||
[InputParameter("Show cold values", sortIndex: 21)]
|
||||
public bool ShowColdValues { get; set; } = true;
|
||||
|
||||
private Atrstop _indicator = null!;
|
||||
private readonly LineSeries _stopSeries;
|
||||
|
||||
public static int MinHistoryDepths => 0;
|
||||
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
|
||||
|
||||
public override string ShortName => $"ATRSTOP({Period},{Multiplier:F1})";
|
||||
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/reversals/atrstop/Atrstop.cs";
|
||||
|
||||
public AtrstopIndicator()
|
||||
{
|
||||
OnBackGround = true;
|
||||
SeparateWindow = false;
|
||||
Name = "ATRSTOP - ATR Trailing Stop";
|
||||
Description = "Dynamic trailing stop using ATR multiplier with band ratcheting.";
|
||||
|
||||
_stopSeries = new LineSeries(name: "ATRSTOP", color: Color.Crimson, width: 2, style: LineStyle.Dot);
|
||||
|
||||
AddLineSeries(_stopSeries);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
protected override void OnInit()
|
||||
{
|
||||
_indicator = new Atrstop(Period, Multiplier, UseHighLow);
|
||||
base.OnInit();
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
protected override void OnUpdate(UpdateArgs args)
|
||||
{
|
||||
_ = _indicator.Update(this.GetInputBar(args), args.IsNewBar());
|
||||
|
||||
_stopSeries.SetValue(_indicator.StopValue, _indicator.IsHot, ShowColdValues);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// ATRSTOP: ATR Trailing Stop (Wilder)
|
||||
/// Dynamic trailing stop using ATR multiplier with band ratcheting.
|
||||
/// Upper/lower bands tighten in trending direction, flip on reversal.
|
||||
/// </summary>
|
||||
/// <seealso href="https://dotnet.stockindicators.dev/indicators/AtrStop/">Skender reference</seealso>
|
||||
[SkipLocalsInit]
|
||||
public sealed class Atrstop : ITValuePublisher
|
||||
{
|
||||
private readonly int _period;
|
||||
private readonly double _multiplier;
|
||||
private readonly bool _useHighLow;
|
||||
private readonly Atr _atr;
|
||||
private int _count;
|
||||
|
||||
[StructLayout(LayoutKind.Auto)]
|
||||
private record struct State(
|
||||
bool IsBullish,
|
||||
double UpperBand,
|
||||
double LowerBand,
|
||||
double PrevClose,
|
||||
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 band width.</summary>
|
||||
public double Multiplier => _multiplier;
|
||||
|
||||
/// <summary>True if using High/Low for band calculation instead of Close.</summary>
|
||||
public bool UseHighLow => _useHighLow;
|
||||
|
||||
/// <summary>Bars required for valid output.</summary>
|
||||
public int WarmupPeriod { get; }
|
||||
|
||||
/// <summary>Current trailing stop value.</summary>
|
||||
public double StopValue { get; private set; }
|
||||
|
||||
/// <summary>True when the indicator is in bullish (uptrend) mode.</summary>
|
||||
public bool IsBullish => _s.IsBullish;
|
||||
|
||||
/// <summary>Primary output value (stop level 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 an ATR Trailing Stop indicator.
|
||||
/// </summary>
|
||||
/// <param name="period">ATR lookback period (default 21).</param>
|
||||
/// <param name="multiplier">ATR multiplier (default 3.0).</param>
|
||||
/// <param name="useHighLow">If true, use High/Low for band offsets; otherwise use Close (default false).</param>
|
||||
public Atrstop(int period = 21, double multiplier = 3.0, bool useHighLow = false)
|
||||
{
|
||||
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;
|
||||
_useHighLow = useHighLow;
|
||||
_atr = new Atr(period);
|
||||
_count = 0;
|
||||
|
||||
_s = new State(
|
||||
IsBullish: true,
|
||||
UpperBand: double.NaN,
|
||||
LowerBand: double.NaN,
|
||||
PrevClose: double.NaN,
|
||||
LastValidHigh: double.NaN,
|
||||
LastValidLow: double.NaN,
|
||||
LastValidClose: double.NaN);
|
||||
_ps = _s;
|
||||
|
||||
string mode = useHighLow ? "HL" : "C";
|
||||
Name = $"AtrStop({period},{multiplier:F1},{mode})";
|
||||
WarmupPeriod = period + 1;
|
||||
StopValue = double.NaN;
|
||||
_barHandler = HandleBar;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an ATR Trailing Stop chained to a TBarSeries source.
|
||||
/// </summary>
|
||||
public Atrstop(TBarSeries source, int period = 21, double multiplier = 3.0, bool useHighLow = false)
|
||||
: this(period, multiplier, useHighLow)
|
||||
{
|
||||
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
|
||||
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 stopResult;
|
||||
|
||||
if (!_atr.IsHot || _count <= _period)
|
||||
{
|
||||
// Warmup period — no stop value yet
|
||||
s.PrevClose = close;
|
||||
stopResult = double.NaN;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Compute potential bands
|
||||
double upperEval, lowerEval;
|
||||
if (_useHighLow)
|
||||
{
|
||||
upperEval = high + _multiplier * atrValue;
|
||||
lowerEval = low - _multiplier * atrValue;
|
||||
}
|
||||
else
|
||||
{
|
||||
upperEval = close + _multiplier * atrValue;
|
||||
lowerEval = close - _multiplier * atrValue;
|
||||
}
|
||||
|
||||
// Initialize bands on first hot bar
|
||||
if (double.IsNaN(s.UpperBand))
|
||||
{
|
||||
s.IsBullish = close >= s.PrevClose;
|
||||
s.UpperBand = upperEval;
|
||||
s.LowerBand = lowerEval;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Ratchet upper band: only tighten (decrease) unless prev close broke above
|
||||
if (upperEval < s.UpperBand || s.PrevClose > s.UpperBand)
|
||||
{
|
||||
s.UpperBand = upperEval;
|
||||
}
|
||||
|
||||
// Ratchet lower band: only tighten (increase) unless prev close broke below
|
||||
if (lowerEval > s.LowerBand || s.PrevClose < s.LowerBand)
|
||||
{
|
||||
s.LowerBand = lowerEval;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine trend and stop value
|
||||
if (s.IsBullish && close <= s.LowerBand)
|
||||
{
|
||||
// Flip to bearish
|
||||
s.IsBullish = false;
|
||||
stopResult = s.UpperBand;
|
||||
}
|
||||
else if (!s.IsBullish && close >= s.UpperBand)
|
||||
{
|
||||
// Flip to bullish
|
||||
s.IsBullish = true;
|
||||
stopResult = s.LowerBand;
|
||||
}
|
||||
else
|
||||
{
|
||||
stopResult = s.IsBullish ? s.LowerBand : s.UpperBand;
|
||||
}
|
||||
|
||||
s.PrevClose = close;
|
||||
}
|
||||
|
||||
StopValue = stopResult;
|
||||
_s = s;
|
||||
|
||||
Last = new TValue(input.Time, stopResult);
|
||||
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, _useHighLow);
|
||||
|
||||
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(
|
||||
IsBullish: true,
|
||||
UpperBand: double.NaN,
|
||||
LowerBand: double.NaN,
|
||||
PrevClose: double.NaN,
|
||||
LastValidHigh: double.NaN,
|
||||
LastValidLow: double.NaN,
|
||||
LastValidClose: double.NaN);
|
||||
_ps = _s;
|
||||
StopValue = double.NaN;
|
||||
Last = default;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static void Batch(
|
||||
ReadOnlySpan<double> high,
|
||||
ReadOnlySpan<double> low,
|
||||
ReadOnlySpan<double> close,
|
||||
Span<double> output,
|
||||
int period = 21,
|
||||
double multiplier = 3.0,
|
||||
bool useHighLow = false)
|
||||
{
|
||||
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 Atrstop(period, multiplier, useHighLow);
|
||||
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.StopValue;
|
||||
}
|
||||
}
|
||||
|
||||
public static TSeries Batch(TBarSeries source, int period = 21, double multiplier = 3.0, bool useHighLow = false)
|
||||
{
|
||||
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, useHighLow);
|
||||
|
||||
source.Times.CopyTo(CollectionsMarshal.AsSpan(t));
|
||||
|
||||
return new TSeries(t, v);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static (TSeries Results, Atrstop Indicator) Calculate(
|
||||
TBarSeries source, int period = 21, double multiplier = 3.0, bool useHighLow = false)
|
||||
{
|
||||
var indicator = new Atrstop(period, multiplier, useHighLow);
|
||||
var results = indicator.Update(source);
|
||||
return (results, indicator);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
# ATRSTOP — ATR Trailing Stop
|
||||
|
||||
## Overview
|
||||
|
||||
**ATRSTOP** is a dynamic trailing stop indicator created by Welles Wilder. It uses Average True Range (ATR) band thresholds to determine the primary trend and generates stop levels that ratchet in the trend direction. Unlike the simpler Volatility Stop (VSTOP), ATRSTOP maintains separate upper and lower bands that tighten independently, providing more nuanced trend tracking.
|
||||
|
||||
## Formula
|
||||
|
||||
### Parameters
|
||||
- **Period** (`p`): ATR lookback window. Default = 21.
|
||||
- **Multiplier** (`m`): ATR band width multiplier. Default = 3.0.
|
||||
- **UseHighLow** (`hl`): If true, offset from High/Low; otherwise from Close. Default = false.
|
||||
|
||||
### Calculation Steps
|
||||
|
||||
1. **ATR**: Compute Average True Range using Wilder's smoothing (RMA) over `p` bars.
|
||||
|
||||
2. **Potential Bands** (per bar):
|
||||
- Close mode: $\text{upperEval} = \text{Close} + m \times \text{ATR}$, $\text{lowerEval} = \text{Close} - m \times \text{ATR}$
|
||||
- HighLow mode: $\text{upperEval} = \text{High} + m \times \text{ATR}$, $\text{lowerEval} = \text{Low} - m \times \text{ATR}$
|
||||
|
||||
3. **Band Ratcheting**:
|
||||
- Upper band tightens (decreases): $\text{UpperBand} = \text{upperEval}$ if $\text{upperEval} < \text{UpperBand}$ OR $\text{PrevClose} > \text{UpperBand}$
|
||||
- Lower band tightens (increases): $\text{LowerBand} = \text{lowerEval}$ if $\text{lowerEval} > \text{LowerBand}$ OR $\text{PrevClose} < \text{LowerBand}$
|
||||
|
||||
4. **Stop Assignment**:
|
||||
- Bullish: Stop = LowerBand (trailing below price)
|
||||
- Bearish: Stop = UpperBand (trailing above price)
|
||||
|
||||
5. **Reversal**:
|
||||
- If bullish and $\text{Close} \leq \text{LowerBand}$ → flip to bearish
|
||||
- If bearish and $\text{Close} \geq \text{UpperBand}$ → flip to bullish
|
||||
|
||||
## Key Properties
|
||||
|
||||
| Property | Value |
|
||||
|:---------|:------|
|
||||
| **Outputs** | 1 (stop value) |
|
||||
| **Output range** | Same as price |
|
||||
| **Warmup period** | `p + 1` bars |
|
||||
| **Category** | Reversals |
|
||||
| **Similar indicators** | VSTOP, SAR, SuperTrend, Chandelier Exit |
|
||||
|
||||
## Interpretation
|
||||
|
||||
- **Stop below price** → Bullish trend; use as trailing stop for long positions.
|
||||
- **Stop above price** → Bearish trend; use as trailing stop for short positions.
|
||||
- **Band ratcheting** → Bands only tighten toward price, never widen, until broken.
|
||||
- **Close mode** → More responsive to price action.
|
||||
- **HighLow mode** → Accounts for intrabar volatility, wider bands.
|
||||
|
||||
## References
|
||||
|
||||
- Wilder, J. Welles, Jr. *New Concepts in Technical Trading Systems* (1978).
|
||||
- Skender Stock Indicators: [ATR Trailing Stop](https://dotnet.stockindicators.dev/indicators/AtrStop/)
|
||||
@@ -0,0 +1,69 @@
|
||||
using TradingPlatform.BusinessLayer;
|
||||
using Xunit;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public sealed class AtrstopIndicatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Indicator_Creates()
|
||||
{
|
||||
var indicator = new AtrstopIndicator();
|
||||
Assert.NotNull(indicator);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultParameters_Match()
|
||||
{
|
||||
var indicator = new AtrstopIndicator();
|
||||
Assert.Equal(21, indicator.Period);
|
||||
Assert.Equal(3.0, indicator.Multiplier);
|
||||
Assert.False(indicator.UseHighLow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Indicator_HasLineSeries()
|
||||
{
|
||||
var indicator = new AtrstopIndicator();
|
||||
indicator.Initialize();
|
||||
Assert.Single(indicator.LinesSeries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShortName_IncludesParameters()
|
||||
{
|
||||
var indicator = new AtrstopIndicator { Period = 14, Multiplier = 2.5 };
|
||||
Assert.Contains("ATRSTOP", indicator.ShortName, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SeparateWindow_IsFalse()
|
||||
{
|
||||
var indicator = new AtrstopIndicator();
|
||||
Assert.False(indicator.SeparateWindow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessBars_ProducesOutput()
|
||||
{
|
||||
var indicator = new AtrstopIndicator { 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 AtrstopIndicator();
|
||||
Assert.Contains("Atrstop.cs", indicator.SourceCodeLink, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using Xunit;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public sealed class AtrstopTests
|
||||
{
|
||||
private readonly GBM _gbm = new(100.0, 0.05, 0.2, seed: 42);
|
||||
|
||||
// ── Bucket A: Constructor Tests ──────────────────────────────────────
|
||||
[Fact]
|
||||
public void DefaultPeriod_Is21()
|
||||
{
|
||||
var ind = new Atrstop();
|
||||
Assert.Equal(21, ind.Period);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultMultiplier_Is3()
|
||||
{
|
||||
var ind = new Atrstop();
|
||||
Assert.Equal(3.0, ind.Multiplier);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultUseHighLow_IsFalse()
|
||||
{
|
||||
var ind = new Atrstop();
|
||||
Assert.False(ind.UseHighLow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CustomParams_AreStored()
|
||||
{
|
||||
var ind = new Atrstop(period: 14, multiplier: 2.5, useHighLow: true);
|
||||
Assert.Equal(14, ind.Period);
|
||||
Assert.Equal(2.5, ind.Multiplier);
|
||||
Assert.True(ind.UseHighLow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Period1_Throws() =>
|
||||
Assert.Throws<ArgumentException>(() => new Atrstop(period: 1));
|
||||
|
||||
[Fact]
|
||||
public void ZeroMultiplier_Throws() =>
|
||||
Assert.Throws<ArgumentException>(() => new Atrstop(multiplier: 0));
|
||||
|
||||
// ── Bucket B: Basic Output ──────────────────────────────────────────
|
||||
[Fact]
|
||||
public void FirstBar_ReturnsNaN()
|
||||
{
|
||||
var ind = new Atrstop();
|
||||
var bar = new TBar(DateTime.UtcNow, 100, 102, 98, 101, 1000);
|
||||
ind.Update(bar);
|
||||
Assert.True(double.IsNaN(ind.StopValue));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AfterWarmup_ReturnsFinite()
|
||||
{
|
||||
var ind = new Atrstop(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.StopValue));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StopValue_MatchesLastValue()
|
||||
{
|
||||
var ind = new Atrstop(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.StopValue, last.Value);
|
||||
}
|
||||
|
||||
// ── Bucket C: Stop Position Relative to Price ───────────────────────
|
||||
[Fact]
|
||||
public void InUptrend_StopBelowClose()
|
||||
{
|
||||
var ind = new Atrstop(period: 3, multiplier: 2.0);
|
||||
double price = 100;
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
price += 2;
|
||||
ind.Update(new TBar(DateTime.UtcNow.AddMinutes(i), price, price + 1, price - 0.5, price, 1000));
|
||||
}
|
||||
Assert.True(ind.IsBullish);
|
||||
Assert.True(ind.StopValue < price);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InDowntrend_StopAboveClose()
|
||||
{
|
||||
var ind = new Atrstop(period: 3, multiplier: 2.0);
|
||||
double price = 200;
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
price -= 2;
|
||||
ind.Update(new TBar(DateTime.UtcNow.AddMinutes(i), price, price + 0.5, price - 1, price, 1000));
|
||||
}
|
||||
Assert.False(ind.IsBullish);
|
||||
Assert.True(ind.StopValue > price);
|
||||
}
|
||||
|
||||
// ── Bucket D: Reversal Detection ────────────────────────────────────
|
||||
[Fact]
|
||||
public void Reversal_FlipsBullish()
|
||||
{
|
||||
var ind = new Atrstop(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.IsBullish);
|
||||
|
||||
// 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.False(ind.IsBullish);
|
||||
}
|
||||
|
||||
// ── Bucket E: Bar Correction ────────────────────────────────────────
|
||||
[Fact]
|
||||
public void BarCorrection_RestoresState()
|
||||
{
|
||||
var ind = new Atrstop(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 bullishBefore = ind.IsBullish;
|
||||
|
||||
// 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);
|
||||
ind.Update(new TBar(DateTime.UtcNow.AddMinutes(8), o2, h2, l2, c2, v2), isNew: false);
|
||||
|
||||
Assert.Equal(bullishBefore, ind.IsBullish);
|
||||
}
|
||||
|
||||
// ── Bucket F: Reset ─────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public void Reset_ClearsState()
|
||||
{
|
||||
var ind = new Atrstop(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.StopValue));
|
||||
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;
|
||||
|
||||
var streamInd = new Atrstop(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.StopValue;
|
||||
}
|
||||
|
||||
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];
|
||||
Atrstop.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 = Atrstop.Batch(source, period: 5, multiplier: 2.0);
|
||||
Assert.Equal(30, result.Count);
|
||||
}
|
||||
|
||||
// ── Bucket H: Events ────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public void PubEvent_Fires()
|
||||
{
|
||||
var ind = new Atrstop(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 Atrstop(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.StopValue));
|
||||
}
|
||||
|
||||
// ── Bucket J: UseHighLow Mode ───────────────────────────────────────
|
||||
[Fact]
|
||||
public void HighLowMode_DifferentFromCloseMode()
|
||||
{
|
||||
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 indClose = new Atrstop(period: 5, multiplier: 2.0, useHighLow: false);
|
||||
var indHL = new Atrstop(period: 5, multiplier: 2.0, useHighLow: true);
|
||||
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
var (_, o1, h1, l1, c1, v1) = gbm1.Next(isNew: true);
|
||||
var (_, o2, h2, l2, c2, v2) = gbm2.Next(isNew: true);
|
||||
indClose.Update(new TBar(DateTime.UtcNow.AddMinutes(i), o1, h1, l1, c1, v1));
|
||||
indHL.Update(new TBar(DateTime.UtcNow.AddMinutes(i), o2, h2, l2, c2, v2));
|
||||
}
|
||||
|
||||
// Values should typically differ between modes (HL gives wider bands)
|
||||
if (double.IsFinite(indClose.StopValue) && double.IsFinite(indHL.StopValue))
|
||||
{
|
||||
// At least verify both produce finite output
|
||||
Assert.True(double.IsFinite(indClose.StopValue));
|
||||
Assert.True(double.IsFinite(indHL.StopValue));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Bucket K: Calculate Method ──────────────────────────────────────
|
||||
[Fact]
|
||||
public void Calculate_ReturnsTupleWithIndicator()
|
||||
{
|
||||
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 (results, indicator) = Atrstop.Calculate(source, period: 5, multiplier: 2.0);
|
||||
Assert.Equal(30, 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 < 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 ind = new Atrstop(period: 5);
|
||||
ind.Prime(source);
|
||||
Assert.True(ind.IsHot);
|
||||
Assert.True(double.IsFinite(ind.StopValue));
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
var fullInd = new Atrstop(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));
|
||||
}
|
||||
|
||||
var primedInd = new Atrstop(period: 5);
|
||||
primedInd.Prime(source);
|
||||
|
||||
Assert.Equal(fullInd.StopValue, primedInd.StopValue, precision: 10);
|
||||
Assert.Equal(fullInd.IsBullish, primedInd.IsBullish);
|
||||
}
|
||||
|
||||
// ── Bucket N: Band Ratcheting ───────────────────────────────────────
|
||||
[Fact]
|
||||
public void InUptrend_LowerBandRisesMonotonically()
|
||||
{
|
||||
var ind = new Atrstop(period: 3, multiplier: 1.5);
|
||||
double price = 100;
|
||||
double prevStop = double.NaN;
|
||||
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
price += 1.5; // Calm uptrend
|
||||
ind.Update(new TBar(DateTime.UtcNow.AddMinutes(i), price, price + 0.5, price - 0.5, price, 1000));
|
||||
|
||||
if (ind.IsHot && ind.IsBullish)
|
||||
{
|
||||
if (double.IsFinite(prevStop))
|
||||
{
|
||||
// Lower band should ratchet up (never decrease in uptrend)
|
||||
Assert.True(ind.StopValue >= prevStop - 1e-10,
|
||||
$"Stop decreased from {prevStop} to {ind.StopValue} at bar {i}");
|
||||
}
|
||||
prevStop = ind.StopValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
using Xunit;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Validation tests for ATRSTOP (ATR Trailing Stop).
|
||||
/// Cross-validated against Skender.Stock.Indicators where available.
|
||||
/// Level 3: Mathematical correctness (band ratcheting + ATR×mult logic).
|
||||
/// </summary>
|
||||
public sealed class AtrstopValidationTests
|
||||
{
|
||||
// ── Parameter variation ──────────────────────────────────────────────
|
||||
[Theory]
|
||||
[InlineData(7, 3.0, false)]
|
||||
[InlineData(14, 2.0, false)]
|
||||
[InlineData(21, 3.0, false)]
|
||||
[InlineData(14, 2.0, true)]
|
||||
public void Atrstop_WithVariousParams_ProducesFiniteOutput(int period, double mult, bool useHL)
|
||||
{
|
||||
var gbm = new GBM(100.0, 0.05, 0.2, seed: 42);
|
||||
var ind = new Atrstop(period: period, multiplier: mult, useHighLow: useHL);
|
||||
|
||||
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.StopValue));
|
||||
}
|
||||
|
||||
// ── 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 Atrstop(period: 21, multiplier: 3.0);
|
||||
var ind2 = new Atrstop(period: 21, 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.StopValue, ind2.StopValue, precision: 10);
|
||||
Assert.Equal(ind1.IsBullish, ind2.IsBullish);
|
||||
}
|
||||
|
||||
// ── Reversal logic ──────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public void UptrendThenDrop_CausesReversal()
|
||||
{
|
||||
var ind = new Atrstop(period: 3, multiplier: 1.0);
|
||||
double price = 100;
|
||||
|
||||
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.IsBullish);
|
||||
|
||||
price -= 50;
|
||||
ind.Update(new TBar(DateTime.UtcNow.AddMinutes(20), price, price + 1, price - 1, price, 1000));
|
||||
Assert.False(ind.IsBullish);
|
||||
Assert.True(ind.StopValue > price);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DowntrendThenRally_CausesReversal()
|
||||
{
|
||||
var ind = new Atrstop(period: 3, multiplier: 1.0);
|
||||
double price = 200;
|
||||
|
||||
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.IsBullish);
|
||||
|
||||
price += 50;
|
||||
ind.Update(new TBar(DateTime.UtcNow.AddMinutes(20), price, price + 1, price - 1, price, 1000));
|
||||
Assert.True(ind.IsBullish);
|
||||
Assert.True(ind.StopValue < 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 Atrstop(period: 21, 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.StopValue;
|
||||
}
|
||||
|
||||
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];
|
||||
Atrstop.Batch(highs, lows, closes, batchOut, period: 21, 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 = Atrstop.Batch(source, period: 21);
|
||||
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 = Atrstop.Batch(source, period: 21);
|
||||
Assert.Single(result);
|
||||
Assert.True(double.IsNaN(result.Values[0]));
|
||||
}
|
||||
|
||||
// ── Warmup period check ─────────────────────────────────────────────
|
||||
[Fact]
|
||||
public void WarmupPeriod_IsPeriodPlusOne()
|
||||
{
|
||||
var ind = new Atrstop(period: 14, multiplier: 2.0);
|
||||
Assert.Equal(15, ind.WarmupPeriod);
|
||||
}
|
||||
}
|
||||
@@ -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