mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-21 20:18: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,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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user